mirror of
https://github.com/kristoferssolo/echoes-of-ascension.git
synced 2025-12-31 13:42:34 +00:00
feat(user): add registration endpoint
This commit is contained in:
parent
b26c36293c
commit
8b08f428a2
2
Cargo.lock
generated
2
Cargo.lock
generated
@ -2422,6 +2422,7 @@ dependencies = [
|
|||||||
"config 0.15.6",
|
"config 0.15.6",
|
||||||
"leptos",
|
"leptos",
|
||||||
"leptos_axum",
|
"leptos_axum",
|
||||||
|
"rand",
|
||||||
"secrecy",
|
"secrecy",
|
||||||
"serde",
|
"serde",
|
||||||
"serde-aux",
|
"serde-aux",
|
||||||
@ -2432,6 +2433,7 @@ dependencies = [
|
|||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-log 0.2.0",
|
"tracing-log 0.2.0",
|
||||||
|
"unicode-segmentation",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@ -46,6 +46,8 @@ secrecy = { version = "0.10", features = ["serde"] }
|
|||||||
validator = "0.20"
|
validator = "0.20"
|
||||||
config = { version = "0.15", features = ["toml"], default-features = false }
|
config = { version = "0.15", features = ["toml"], default-features = false }
|
||||||
serde-aux = "4"
|
serde-aux = "4"
|
||||||
|
unicode-segmentation = "1"
|
||||||
|
rand = "0.8"
|
||||||
|
|
||||||
# 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.
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||||
|
|
||||||
-- Users table with login codes
|
-- Users table with login codes
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS "user" (
|
||||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid (),
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid (),
|
||||||
username varchar(255) NOT NULL UNIQUE,
|
username varchar(255) NOT NULL UNIQUE,
|
||||||
code varchar(255) NOT NULL UNIQUE,
|
code varchar(255) NOT NULL UNIQUE,
|
||||||
@ -11,18 +11,18 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- Scores table with detailed game stats
|
-- Scores table with detailed game stats
|
||||||
CREATE TABLE IF NOT EXISTS scores (
|
CREATE TABLE IF NOT EXISTS score (
|
||||||
id bigserial PRIMARY KEY,
|
id bigserial PRIMARY KEY,
|
||||||
user_id uuid NOT NULL,
|
user_id uuid NOT NULL,
|
||||||
score integer NOT NULL,
|
score integer NOT NULL,
|
||||||
floor_reached integer NOT NULL,
|
floor_reached integer NOT NULL,
|
||||||
play_time_seconds integer NOT NULL,
|
play_time_seconds integer NOT NULL,
|
||||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
FOREIGN KEY (user_id) REFERENCES "user" (id)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Indexes for performance
|
-- Indexes for performance
|
||||||
CREATE INDEX idx_users_login ON users (code);
|
CREATE INDEX idx_user_login ON "user" (code);
|
||||||
|
|
||||||
CREATE INDEX idx_scores_user_score ON scores (user_id, score DESC);
|
CREATE INDEX idx_scores_user_score ON score (user_id, score DESC);
|
||||||
|
|
||||||
|
|||||||
@ -23,3 +23,5 @@ serde-aux.workspace = true
|
|||||||
config.workspace = true
|
config.workspace = true
|
||||||
secrecy.workspace = true
|
secrecy.workspace = true
|
||||||
uuid.workspace = true
|
uuid.workspace = true
|
||||||
|
unicode-segmentation.workspace = true
|
||||||
|
rand.workspace = true
|
||||||
|
|||||||
@ -1,2 +0,0 @@
|
|||||||
mod user;
|
|
||||||
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
pub enum UserError {
|
|
||||||
#[error("Database error: {0}")]
|
|
||||||
Database(#[from] sqlx::Error),
|
|
||||||
#[error("Username already taken")]
|
|
||||||
UsernameTaken,
|
|
||||||
#[error("User not found")]
|
|
||||||
NotFound,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn create_user() {}
|
|
||||||
3
server/src/domain/mod.rs
Normal file
3
server/src/domain/mod.rs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
pub mod new_user;
|
||||||
|
pub mod user_code;
|
||||||
|
pub mod username;
|
||||||
7
server/src/domain/new_user.rs
Normal file
7
server/src/domain/new_user.rs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
use super::{user_code::UserCode, username::Username};
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct NewUser {
|
||||||
|
pub username: Username,
|
||||||
|
pub code: UserCode,
|
||||||
|
}
|
||||||
33
server/src/domain/user_code.rs
Normal file
33
server/src/domain/user_code.rs
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
59
server/src/domain/username.rs
Normal file
59
server/src/domain/username.rs
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
use rand::{seq::SliceRandom, thread_rng, Rng};
|
||||||
|
use std::str::FromStr;
|
||||||
|
use unicode_segmentation::UnicodeSegmentation;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Username(String);
|
||||||
|
|
||||||
|
impl TryFrom<String> for Username {
|
||||||
|
type Error = String;
|
||||||
|
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(format!("{} is not a valid subscriber name.", 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();
|
||||||
|
let noun = nouns.choose(&mut rng).unwrap();
|
||||||
|
|
||||||
|
let number = rng.gen_range(100..1000);
|
||||||
|
|
||||||
|
let username = format!("{}_{}_{}", adjective, noun, number);
|
||||||
|
|
||||||
|
Self(username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for Username {
|
||||||
|
type Err = String;
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod db;
|
pub mod domain;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
pub mod startup;
|
pub mod startup;
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
mod health_check;
|
mod health_check;
|
||||||
|
mod user;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use app::{shell, App};
|
use app::{shell, App};
|
||||||
@ -7,7 +8,7 @@ use axum::{
|
|||||||
extract::MatchedPath,
|
extract::MatchedPath,
|
||||||
http::{HeaderMap, Request},
|
http::{HeaderMap, Request},
|
||||||
response::Response,
|
response::Response,
|
||||||
routing::get,
|
routing::{get, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use health_check::health_check;
|
use health_check::health_check;
|
||||||
@ -15,6 +16,7 @@ use health_check::health_check;
|
|||||||
use leptos_axum::{generate_route_list, LeptosRoutes};
|
use leptos_axum::{generate_route_list, LeptosRoutes};
|
||||||
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;
|
use crate::startup::AppState;
|
||||||
@ -67,7 +69,9 @@ fn api_routes(state: AppState) -> Router {
|
|||||||
Router::new()
|
Router::new()
|
||||||
.nest(
|
.nest(
|
||||||
"/api/v1",
|
"/api/v1",
|
||||||
Router::new().route("/health_check", get(health_check)),
|
Router::new()
|
||||||
|
.route("/health_check", get(health_check))
|
||||||
|
.route("/register", post(register)),
|
||||||
)
|
)
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|||||||
2
server/src/routes/user/mod.rs
Normal file
2
server/src/routes/user/mod.rs
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
mod register;
|
||||||
|
pub use register::register;
|
||||||
56
server/src/routes/user/register.rs
Normal file
56
server/src/routes/user/register.rs
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
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()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user