refactor: make discrete backend and frontend

This commit is contained in:
2025-01-27 17:10:34 +02:00
parent 493a636a7d
commit 74b9b00de1
70 changed files with 1309 additions and 1016 deletions

42
backend/Cargo.toml Normal file
View File

@@ -0,0 +1,42 @@
[package]
name = "backend"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
thiserror.workspace = true
axum = "0.8"
tokio = { workspace = true, features = ["rt-multi-thread"] }
tower.workspace = true
tower-http = { workspace = true, features = ["cors"] }
serde.workspace = true
serde_json = "1"
uuid.workspace = true
sqlx = { version = "0.8", features = [
"runtime-tokio",
"macros",
"postgres",
"uuid",
"chrono",
"migrate",
] }
chrono = { version = "0.4", features = ["serde", "clock"] }
secrecy = { version = "0.10", features = ["serde"] }
validator = "0.20"
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"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["registry", "env-filter"] }
tracing-bunyan-formatter = { version = "0.3", default-features = false }
tracing-log = "0.2"
anyhow = "1"
[lints]
workspace = true

9
backend/config/base.toml Normal file
View File

@@ -0,0 +1,9 @@
[application]
port = 8000
[database]
host = "127.0.0.1"
port = 5432
username = "postgres"
password = "password"
database_name = "maze_ascension"

View File

@@ -0,0 +1,5 @@
[application]
host = "127.0.0.1"
[database]
require_ssl = false

View File

@@ -0,0 +1,5 @@
[application]
host = "0.0.0.0"
[database]
require_ssl = true

View File

@@ -0,0 +1,11 @@
-- Add down migration script here
-- Drop indexes first
DROP INDEX IF EXISTS idx_scores_user_score;
DROP INDEX IF EXISTS idx_users_login;
-- Drop tables in reverse order of creation
DROP TABLE IF EXISTS scores;
DROP TABLE IF EXISTS users;

View File

@@ -0,0 +1,28 @@
-- Add up migration script here
-- Enable UUID support
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- Users table with login codes
CREATE TABLE IF NOT EXISTS "user" (
id uuid PRIMARY KEY DEFAULT gen_random_uuid (),
username varchar(255) NOT NULL UNIQUE,
code varchar(255) NOT NULL UNIQUE,
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Scores table with detailed game stats
CREATE TABLE IF NOT EXISTS score (
id bigserial PRIMARY KEY,
user_id uuid NOT NULL,
score integer NOT NULL,
floor_reached integer NOT NULL,
play_time_seconds integer NOT NULL,
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES "user" (id)
);
-- Indexes for performance
CREATE INDEX idx_user_login ON "user" (code);
CREATE INDEX idx_scores_user_score ON score (user_id, score DESC);

109
backend/src/config.rs Normal file
View File

@@ -0,0 +1,109 @@
use std::fmt::Display;
use secrecy::{ExposeSecret, SecretString};
use serde::Deserialize;
use serde_aux::field_attributes::deserialize_number_from_string;
use sqlx::{
postgres::{PgConnectOptions, PgSslMode},
ConnectOptions,
};
#[derive(Debug, Deserialize, Clone)]
pub struct Settings {
pub database: DatabaseSettings,
pub application: ApplicationSettings,
}
#[derive(Debug, Deserialize, Clone)]
pub struct DatabaseSettings {
pub username: String,
pub password: SecretString,
#[serde(deserialize_with = "deserialize_number_from_string")]
pub port: u16,
pub host: String,
pub database_name: String,
pub require_ssl: bool,
}
#[derive(Debug, Deserialize, Clone)]
pub struct ApplicationSettings {
#[serde(deserialize_with = "deserialize_number_from_string")]
pub port: u16,
pub host: String,
}
#[derive(Debug, Clone)]
pub enum Environment {
Local,
Production,
}
impl DatabaseSettings {
#[must_use]
pub fn without_db(&self) -> PgConnectOptions {
let ssl_mode = if self.require_ssl {
PgSslMode::Require
} else {
PgSslMode::Prefer
};
PgConnectOptions::new()
.host(&self.host)
.username(&self.username)
.password(self.password.expose_secret())
.port(self.port)
.ssl_mode(ssl_mode)
}
#[must_use]
pub fn with_db(&self) -> PgConnectOptions {
self.without_db()
.database(&self.database_name)
.log_statements(tracing_log::log::LevelFilter::Trace)
}
}
pub fn get_config() -> Result<Settings, config::ConfigError> {
let base_path = std::env::current_dir().expect("Failed to determine current directory");
let config_directory = base_path.join("backend").join("config");
let env: Environment = std::env::var("APP_ENVIRONMENT")
.unwrap_or_else(|_| "local".into())
.try_into()
.expect("Failed to parse APP_ENVIRONMENT");
let env_filename = format!("{}.toml", &env);
let settings = config::Config::builder()
.add_source(config::File::from(config_directory.join("base.toml")))
.add_source(config::File::from(config_directory.join(env_filename)))
.add_source(
config::Environment::with_prefix("APP")
.prefix_separator("_")
.separator("__"),
)
.build()?;
settings.try_deserialize::<Settings>()
}
impl Display for Environment {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Local => write!(f, "local"),
Self::Production => write!(f, "production"),
}
}
}
impl TryFrom<String> for Environment {
type Error = String;
fn try_from(value: String) -> Result<Self, Self::Error> {
match value.to_lowercase().as_str() {
"local" => Ok(Self::Local),
"production" => Ok(Self::Production),
other => Err(format!(
"{other} is not supported environment. \
Use either `local` or `production`."
)),
}
}
}

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

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

36
backend/src/db/users.rs Normal file
View File

@@ -0,0 +1,36 @@
use sqlx::PgPool;
use thiserror::Error;
use crate::domain::user::{error::UserError, new_user::NewUser};
#[derive(Debug, Error)]
pub enum ServerUserError {
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
#[error("Database error: {0}")]
User(#[from] UserError),
}
#[tracing::instrument(name = "Saving new user details in the database", skip(pool, new_user))]
pub async fn insert_user(pool: &PgPool, new_user: &NewUser) -> Result<(), ServerUserError> {
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") => {
ServerUserError::User(UserError::UsernameTaken(new_user.username.to_string()))
}
_ => ServerUserError::Database(e),
}
})?;
Ok(())
}

View File

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

View File

@@ -0,0 +1,22 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum UserError {
#[error("Username validation failed: {0}")]
UsernameValidation(String),
#[error("Code hashing failed: {0}")]
HashingError(String),
#[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

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

View File

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

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, Clone)]
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
}
}

View File

@@ -0,0 +1,73 @@
use rand::{seq::SliceRandom, thread_rng, Rng};
use std::{fmt::Display, str::FromStr};
use unicode_segmentation::UnicodeSegmentation;
use super::error::UserError;
#[derive(Debug, Clone)]
pub struct Username(String);
impl TryFrom<String> for Username {
type Error = UserError;
fn try_from(value: String) -> Result<Self, Self::Error> {
let is_empty_or_whitespace = value.trim().is_empty();
let is_too_long = value.graphemes(true).count() > 256;
let forbidden_characters = ['/', '(', ')', '"', '<', '>', '\\', '{', '}'];
let contains_forbidden_characters =
value.chars().any(|c| forbidden_characters.contains(&c));
if is_empty_or_whitespace || is_too_long || contains_forbidden_characters {
return Err(UserError::UsernameValidation(value));
}
Ok(Self(value))
}
}
impl Default for Username {
fn default() -> Self {
let adjectives = [
"swift", "bright", "clever", "brave", "mighty", "noble", "wise", "calm", "kind",
"bold", "quick", "sharp", "smart", "keen", "fair",
];
let nouns = [
"wolf", "eagle", "lion", "hawk", "bear", "tiger", "fox", "owl", "deer", "seal",
"raven", "crane", "dove", "swan", "falcon",
];
let mut rng = thread_rng();
let adjective = adjectives.choose(&mut rng).unwrap_or(&"swift");
let noun = nouns.choose(&mut rng).unwrap_or(&"wolf");
let number = rng.gen_range(100..1000);
let username = format!("{adjective}_{noun}_{number}");
Self(username)
}
}
impl FromStr for Username {
type Err = UserError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::try_from(s.to_owned())
}
}
impl AsRef<str> for Username {
fn as_ref(&self) -> &str {
&self.0
}
}
impl Display for Username {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<Username> for String {
fn from(value: Username) -> Self {
value.0
}
}

88
backend/src/error/app.rs Normal file
View File

@@ -0,0 +1,88 @@
use axum::{http::StatusCode, response::IntoResponse, Json};
use serde::Serialize;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AppError {
// Authentication/Authorization errors
#[error("Unauthorized")]
Unauthorized,
#[error("Forbidden")]
Forbidden,
// Validation errors
#[error("Validation error: {0}")]
Validation(String),
// Resource errors
#[error("Resource not found: {0}")]
NotFound(String),
#[error("{resource} already exists: {id}")]
AlreadyExists { resource: &'static str, id: String },
// Database errors
#[error("Database error")]
Database(#[from] sqlx::Error),
// Internal errors
#[error("Internal server error")]
Internal(#[from] anyhow::Error),
}
#[derive(Debug, Serialize)]
pub struct ErrorResponse {
pub error: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<String>,
}
impl IntoResponse for AppError {
fn into_response(self) -> axum::response::Response {
let (status, error_message, details) = match self {
// Auth errors
Self::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized".to_string(), None),
Self::Forbidden => (StatusCode::FORBIDDEN, "Forbidden".to_string(), None),
// Validation errors
Self::Validation(msg) => (
StatusCode::BAD_REQUEST,
"Validation error".to_string(),
Some(msg),
),
// Resource errors
Self::NotFound(resource) => (
StatusCode::NOT_FOUND,
"Resource not found".to_string(),
Some(resource),
),
Self::AlreadyExists { resource, id } => (
StatusCode::CONFLICT,
format!("{resource} already exists"),
Some(id),
),
// Database/Internal errors
Self::Database(e) => {
tracing::error!("Database error: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Internal server error".to_string(),
None,
)
}
Self::Internal(e) => {
tracing::error!("Internal error: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Internal server error".to_string(),
None,
)
}
};
let body = Json(ErrorResponse {
error: error_message,
details,
});
(status, body).into_response()
}
}

1
backend/src/error/mod.rs Normal file
View File

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

8
backend/src/lib.rs Normal file
View File

@@ -0,0 +1,8 @@
pub mod config;
pub mod db;
pub mod domain;
pub mod error;
pub mod routes;
pub mod server;
pub mod startup;
pub mod telemetry;

18
backend/src/main.rs Normal file
View File

@@ -0,0 +1,18 @@
use backend::{
config::get_config,
server::Server,
telemetry::{get_subscriber, init_subscriber},
};
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
// Generate the list of routes in your Leptos App
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 = Server::build(config).await?;
application.run_until_stopped().await?;
Ok(())
}

View File

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

View File

@@ -0,0 +1,72 @@
use anyhow::anyhow;
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
use secrecy::ExposeSecret;
use serde::{Deserialize, Serialize};
use crate::{
db::users::{insert_user, ServerUserError},
domain::user::{error::UserError, new_user::NewUser},
error::app::AppError,
startup::AppState,
};
#[derive(Debug, Deserialize)]
pub struct FormData {
pub username: String,
}
#[derive(Debug, Serialize)]
pub struct Response {
pub username: String,
pub code: String,
}
#[tracing::instrument(
name = "Creating new user",
skip(state, payload),
fields(
username= %payload.username,
)
)]
pub async fn register(
State(state): State<AppState>,
Json(payload): Json<FormData>,
) -> Result<impl IntoResponse, impl IntoResponse> {
let new_user = payload
.try_into()
.map_err(|e: UserError| AppError::Validation(e.to_string()))?;
match insert_user(&state.pool, &new_user).await {
Ok(()) => Ok((StatusCode::CREATED, Json(Response::from(new_user)))),
Err(ServerUserError::User(UserError::UsernameTaken(username))) => {
Err(AppError::AlreadyExists {
resource: "User",
id: username,
})
}
Err(e) => {
tracing::error!("Failed to register user: {}", e);
Err(AppError::Internal(anyhow!(e)))
}
}
}
impl TryFrom<FormData> for NewUser {
type Error = UserError;
fn try_from(value: FormData) -> Result<Self, Self::Error> {
let username = value.username.try_into()?;
Ok(Self {
username,
..Default::default()
})
}
}
impl From<NewUser> for Response {
fn from(value: NewUser) -> Self {
Self {
username: value.username.into(),
code: value.code.expose_secret().into(),
}
}
}

View File

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

View File

@@ -0,0 +1,5 @@
use axum::{http::StatusCode, response::IntoResponse};
pub async fn health_check() -> impl IntoResponse {
StatusCode::OK
}

51
backend/src/routes/mod.rs Normal file
View File

@@ -0,0 +1,51 @@
mod api;
mod health_check;
use axum::{
body::Bytes,
extract::MatchedPath,
http::{HeaderMap, Request},
response::Response,
routing::get,
Router,
};
use health_check::health_check;
use std::time::Duration;
use tower_http::{classify::ServerErrorsFailureClass, trace::TraceLayer};
use tracing::{info_span, Span};
use uuid::Uuid;
use crate::startup::AppState;
pub fn route(state: AppState) -> Router {
Router::new()
.route("/health_check", get(health_check))
.nest("/api", api::routes())
.with_state(state)
// Tracing layer
.layer(
TraceLayer::new_for_http()
.make_span_with(|request: &Request<_>| {
let matched_path = request
.extensions()
.get::<MatchedPath>()
.map(MatchedPath::as_str);
info_span!(
"http-request",
method = ?request.method(),
matched_path,
some_other_field = tracing::field::Empty,
request_id=%Uuid::new_v4(),
)
})
.on_request(|_request: &Request<_>, _span: &Span| {})
.on_response(|_response: &Response<_>, _latency: Duration, _span: &Span| {})
.on_body_chunk(|_chunk: &Bytes, _latency: Duration, _span: &Span| {})
.on_eos(
|_trailers: Option<&HeaderMap>, _stream_duration: Duration, _span: &Span| {},
)
.on_failure(
|_error: ServerErrorsFailureClass, _latency: Duration, _span: &Span| {},
),
)
}

38
backend/src/server.rs Normal file
View File

@@ -0,0 +1,38 @@
use tokio::{net::TcpListener, task::JoinHandle};
use crate::{
config::Settings,
routes::route,
startup::{get_connection_pool, App},
};
#[derive(Debug)]
pub struct Server {
port: u16,
server: JoinHandle<Result<(), std::io::Error>>,
}
impl Server {
pub async fn build(config: Settings) -> Result<Self, std::io::Error> {
let pool = get_connection_pool(&config.database);
// Use application's address configuration
let addr = format!("{}:{}", config.application.host, config.application.port);
let listener = TcpListener::bind(addr).await?;
let port = listener.local_addr()?.port();
let app_state = App { pool }.into();
let server = tokio::spawn(async move { axum::serve(listener, route(app_state)).await });
Ok(Self { port, server })
}
#[must_use]
#[inline]
pub const fn port(&self) -> u16 {
self.port
}
pub async fn run_until_stopped(self) -> Result<(), std::io::Error> {
self.server.await?
}
}

17
backend/src/startup.rs Normal file
View File

@@ -0,0 +1,17 @@
use sqlx::{postgres::PgPoolOptions, PgPool};
use std::sync::Arc;
use crate::config::DatabaseSettings;
pub type AppState = Arc<App>;
#[derive(Debug)]
pub struct App {
pub pool: PgPool,
}
#[must_use]
#[inline]
pub fn get_connection_pool(config: &DatabaseSettings) -> PgPool {
PgPoolOptions::new().connect_lazy_with(config.with_db())
}

25
backend/src/telemetry.rs Normal file
View File

@@ -0,0 +1,25 @@
use tracing::{subscriber::set_global_default, Subscriber};
use tracing_bunyan_formatter::{BunyanFormattingLayer, JsonStorageLayer};
use tracing_log::LogTracer;
use tracing_subscriber::{fmt::MakeWriter, layer::SubscriberExt, EnvFilter, Registry};
pub fn get_subscriber<Sink>(
name: &str,
env_filter: &str,
sink: Sink,
) -> impl Subscriber + Sync + Send
where
Sink: for<'a> MakeWriter<'a> + Send + Sync + 'static,
{
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| env_filter.into());
let formatting_layer = BunyanFormattingLayer::new(name.into(), sink);
Registry::default()
.with(env_filter)
.with(JsonStorageLayer)
.with(formatting_layer)
}
pub fn init_subscriber(subscriber: impl Subscriber + Sync + Send) {
LogTracer::init().expect("Failed to set logger");
set_global_default(subscriber).expect("Failed to set subscriber.");
}