register users

This commit is contained in:
2025-07-16 18:37:26 +02:00
parent 990f196984
commit be362326aa
48 changed files with 1002 additions and 64 deletions

94
backend/Cargo.lock generated
View File

@@ -64,6 +64,18 @@ version = "1.0.98"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
[[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 = "arrayvec"
version = "0.7.6"
@@ -234,6 +246,15 @@ dependencies = [
"wyz",
]
[[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"
@@ -457,6 +478,15 @@ dependencies = [
"zeroize",
]
[[package]]
name = "deranged"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e"
dependencies = [
"powerfmt",
]
[[package]]
name = "derive_more"
version = "2.0.1"
@@ -1226,6 +1256,12 @@ dependencies = [
"zeroize",
]
[[package]]
name = "num-conv"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]]
name = "num-integer"
version = "0.1.46"
@@ -1306,6 +1342,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 = "pem-rfc7468"
version = "0.7.0"
@@ -1369,6 +1416,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@@ -1825,6 +1878,7 @@ checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
dependencies = [
"base64",
"bytes",
"chrono",
"crc",
"crossbeam-queue",
"either",
@@ -1845,6 +1899,7 @@ dependencies = [
"sha2",
"smallvec",
"thiserror",
"time",
"tokio",
"tokio-stream",
"tracing",
@@ -1901,6 +1956,7 @@ dependencies = [
"bitflags",
"byteorder",
"bytes",
"chrono",
"crc",
"digest",
"dotenvy",
@@ -1928,6 +1984,7 @@ dependencies = [
"sqlx-core",
"stringprep",
"thiserror",
"time",
"tracing",
"uuid",
"whoami",
@@ -1943,6 +2000,7 @@ dependencies = [
"base64",
"bitflags",
"byteorder",
"chrono",
"crc",
"dotenvy",
"etcetera",
@@ -1966,6 +2024,7 @@ dependencies = [
"sqlx-core",
"stringprep",
"thiserror",
"time",
"tracing",
"uuid",
"whoami",
@@ -1978,6 +2037,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
dependencies = [
"atoi",
"chrono",
"flume",
"futures-channel",
"futures-core",
@@ -1991,6 +2051,7 @@ dependencies = [
"serde_urlencoded",
"sqlx-core",
"thiserror",
"time",
"tracing",
"url",
"uuid",
@@ -2112,6 +2173,37 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "time"
version = "0.3.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40"
dependencies = [
"deranged",
"itoa",
"num-conv",
"powerfmt",
"serde",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c"
[[package]]
name = "time-macros"
version = "0.2.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tinystr"
version = "0.8.1"
@@ -2423,8 +2515,10 @@ name = "warren"
version = "0.1.0"
dependencies = [
"anyhow",
"argon2",
"axum",
"axum_typed_multipart",
"chrono",
"derive_more",
"dotenv",
"mime_guess",

View File

@@ -13,15 +13,17 @@ path = "src/bin/backend/main.rs"
[dependencies]
anyhow = "1.0.98"
argon2 = "0.5.3"
axum = { version = "0.8.4", features = ["multipart", "query"] }
axum_typed_multipart = "0.16.3"
chrono = "0.4.41"
derive_more = { version = "2.0.1", features = ["display"] }
dotenv = "0.15.0"
mime_guess = "2.0.5"
regex = "1.11.1"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio", "uuid"] }
sqlx = { version = "0.8.6", features = ["chrono", "postgres", "runtime-tokio", "time", "uuid"] }
thiserror = "2.0.12"
tokio = { version = "1.46.1", features = ["full"] }
tower = "0.5.2"

View File

@@ -0,0 +1,9 @@
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT GEN_RANDOM_UUID(),
name VARCHAR NOT NULL,
email VARCHAR NOT NULL,
hash VARCHAR NOT NULL,
admin BOOLEAN NOT NULL DEFAULT FALSE,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -0,0 +1 @@
ALTER TABLE users ADD CONSTRAINT users_email_key UNIQUE (email);

28
backend/plan Normal file
View File

@@ -0,0 +1,28 @@
# Structure
## users
- id
- name
- email
- hash
- admin
- updated_at
- created_at
## warrens
- id
- name
- path
- allow_children
- updated_at
- created_at
## user_warrens
- user_id
- warren_id
- can_create_children (the user-specific flag, the warren's `allow_children` takes precedence so that both flags have to be enabled)
- can_list_files (see and traverse the layout of the warren's directories and files)
- can_read_files (read contents of the warren's files)
- can_modify_files (edit contents of the warren's files)
- can_delete_files (delete files and directories)
- can_delete_warren (delete the warren and all its contents)

View File

@@ -33,12 +33,14 @@ async fn main() -> anyhow::Result<()> {
let fs_service = domain::warren::service::file_system::Service::new(fs, metrics, notifier);
let warren_service = domain::warren::service::warren::Service::new(
postgres,
postgres.clone(),
metrics,
notifier,
fs_service.clone(),
);
let auth_service = domain::warren::service::auth::Service::new(postgres, metrics, notifier);
let server_config = HttpServerConfig::new(
&config.server_address,
&config.server_port,
@@ -46,7 +48,7 @@ async fn main() -> anyhow::Result<()> {
config.static_frontend_dir.as_deref(),
);
let http_server = HttpServer::new(warren_service, server_config).await?;
let http_server = HttpServer::new(warren_service, auth_service, server_config).await?;
http_server.run().await?;
Ok(())

View File

@@ -1,2 +1,3 @@
pub mod file;
pub mod user;
pub mod warren;

View File

@@ -0,0 +1,102 @@
mod requests;
pub use requests::*;
use std::sync::LazyLock;
use chrono::NaiveDateTime;
use derive_more::Display;
use regex::Regex;
use thiserror::Error;
use uuid::Uuid;
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, sqlx::FromRow)]
pub struct User {
id: Uuid,
name: UserName,
email: UserEmail,
hash: String,
admin: bool,
updated_at: NaiveDateTime,
created_at: NaiveDateTime,
}
impl User {
pub fn id(&self) -> &Uuid {
&self.id
}
pub fn name(&self) -> &UserName {
&self.name
}
pub fn email(&self) -> &UserEmail {
&self.email
}
pub fn admin(&self) -> bool {
self.admin
}
pub fn updated_at(&self) -> &NaiveDateTime {
&self.updated_at
}
pub fn created_at(&self) -> &NaiveDateTime {
&self.created_at
}
}
/// A valid username
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Display, sqlx::Type)]
#[sqlx(transparent)]
pub struct UserName(String);
#[derive(Clone, Debug, Error)]
pub enum UserNameError {
#[error("A username must not be empty")]
Empty,
}
impl UserName {
pub fn new(raw: &str) -> Result<Self, UserNameError> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(UserNameError::Empty);
}
Ok(Self(trimmed.to_string()))
}
}
/// A valid email
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Display, sqlx::Type)]
#[sqlx(transparent)]
pub struct UserEmail(String);
#[derive(Clone, Debug, Error)]
pub enum UserEmailError {
#[error("A user's email must be a valid email")]
Invalid,
#[error("A user's email must not be empty")]
Empty,
}
static USER_EMAIL_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[^@]+@[^@]+\.[^@]+$").expect("Email regex"));
impl UserEmail {
pub fn new(raw: &str) -> Result<Self, UserEmailError> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(UserEmailError::Empty);
}
if !USER_EMAIL_REGEX.is_match(&trimmed) {
return Err(UserEmailError::Invalid);
}
Ok(Self(raw.to_string()))
}
}

View File

@@ -0,0 +1,89 @@
use thiserror::Error;
use super::{UserEmail, UserName};
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct RegisterUserRequest {
name: UserName,
email: UserEmail,
password: UserPassword,
}
#[derive(Debug, Error)]
pub enum RegisterUserError {
#[error(transparent)]
Unknown(#[from] anyhow::Error),
}
/// A valid user password
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct UserPassword(String);
#[derive(Clone, Debug, Error)]
pub enum UserPasswordError {
#[error("A user's password must not be empty")]
Empty,
#[error("A user's password must not start with a whitespace")]
LeadingWhitespace,
#[error("A user's password must not end with a whitespace")]
TrailingWhitespace,
#[error("A user's password must be longer")]
TooShort,
#[error("A user's password must be shorter")]
TooLong,
}
impl UserPassword {
const MIN_LENGTH: usize = 12;
const MAX_LENGTH: usize = 32;
pub fn new(raw: &str) -> Result<Self, UserPasswordError> {
if raw.is_empty() {
return Err(UserPasswordError::Empty);
}
if raw.trim_start().len() != raw.len() {
return Err(UserPasswordError::LeadingWhitespace);
}
if raw.trim_end().len() != raw.len() {
return Err(UserPasswordError::TrailingWhitespace);
}
if raw.len() < Self::MIN_LENGTH {
return Err(UserPasswordError::TooShort);
}
if raw.len() > Self::MAX_LENGTH {
return Err(UserPasswordError::TooLong);
}
Ok(Self(raw.to_string()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl RegisterUserRequest {
pub fn new(name: UserName, email: UserEmail, password: UserPassword) -> Self {
Self {
name,
email,
password,
}
}
pub fn name(&self) -> &UserName {
&self.name
}
pub fn email(&self) -> &UserEmail {
&self.email
}
pub fn password(&self) -> &UserPassword {
&self.password
}
}

View File

@@ -48,3 +48,8 @@ pub trait FileSystemMetrics: Clone + Send + Sync + 'static {
fn record_entry_rename_success(&self) -> impl Future<Output = ()> + Send;
fn record_entry_rename_failure(&self) -> impl Future<Output = ()> + Send;
}
pub trait AuthMetrics: Clone + Send + Sync + 'static {
fn record_user_registration_success(&self) -> impl Future<Output = ()> + Send;
fn record_user_registration_failure(&self) -> impl Future<Output = ()> + Send;
}

View File

@@ -12,6 +12,7 @@ use super::models::{
DeleteDirectoryError, DeleteDirectoryRequest, DeleteFileError, DeleteFileRequest, File,
FilePath, ListFilesError, ListFilesRequest, RenameEntryError, RenameEntryRequest,
},
user::{RegisterUserError, RegisterUserRequest, User},
warren::{
CreateWarrenDirectoryError, CreateWarrenDirectoryRequest, DeleteWarrenDirectoryError,
DeleteWarrenDirectoryRequest, DeleteWarrenFileError, DeleteWarrenFileRequest,
@@ -91,3 +92,10 @@ pub trait FileSystemService: Clone + Send + Sync + 'static {
request: RenameEntryRequest,
) -> impl Future<Output = Result<FilePath, RenameEntryError>> + Send;
}
pub trait AuthService: Clone + Send + Sync + 'static {
fn register_user(
&self,
request: RegisterUserRequest,
) -> impl Future<Output = Result<User, RegisterUserError>> + Send;
}

View File

@@ -1,5 +1,6 @@
use crate::domain::warren::models::{
file::{AbsoluteFilePath, File, FilePath},
user::User,
warren::Warren,
};
@@ -68,3 +69,7 @@ pub trait FileSystemNotifier: Clone + Send + Sync + 'static {
new_path: &FilePath,
) -> impl Future<Output = ()> + Send;
}
pub trait AuthNotifier: Clone + Send + Sync + 'static {
fn user_registered(&self, user: &User) -> impl Future<Output = ()> + Send;
}

View File

@@ -4,6 +4,7 @@ use crate::domain::warren::models::{
DeleteDirectoryError, DeleteDirectoryRequest, DeleteFileError, DeleteFileRequest, File,
FilePath, ListFilesError, ListFilesRequest, RenameEntryError, RenameEntryRequest,
},
user::{RegisterUserError, RegisterUserRequest, User},
warren::{FetchWarrenError, FetchWarrenRequest, ListWarrensError, ListWarrensRequest, Warren},
};
@@ -48,3 +49,10 @@ pub trait FileSystemRepository: Clone + Send + Sync + 'static {
request: RenameEntryRequest,
) -> impl Future<Output = Result<FilePath, RenameEntryError>> + Send;
}
pub trait AuthRepository: Clone + Send + Sync + 'static {
fn register_user(
&self,
request: RegisterUserRequest,
) -> impl Future<Output = Result<User, RegisterUserError>> + Send;
}

View File

@@ -0,0 +1,51 @@
use crate::domain::warren::{
models::user::{RegisterUserError, RegisterUserRequest, User},
ports::{AuthMetrics, AuthNotifier, AuthRepository, AuthService},
};
#[derive(Debug, Clone)]
pub struct Service<R, M, N>
where
R: AuthRepository,
M: AuthMetrics,
N: AuthNotifier,
{
repository: R,
metrics: M,
notifier: N,
}
impl<R, M, N> Service<R, M, N>
where
R: AuthRepository,
M: AuthMetrics,
N: AuthNotifier,
{
pub fn new(repository: R, metrics: M, notifier: N) -> Self {
Self {
repository,
metrics,
notifier,
}
}
}
impl<R, M, N> AuthService for Service<R, M, N>
where
R: AuthRepository,
M: AuthMetrics,
N: AuthNotifier,
{
async fn register_user(&self, request: RegisterUserRequest) -> Result<User, RegisterUserError> {
let result = self.repository.register_user(request).await;
if let Ok(user) = result.as_ref() {
self.metrics.record_user_registration_success().await;
self.notifier.user_registered(user).await;
} else {
self.metrics.record_user_registration_failure().await;
}
result
}
}

View File

@@ -1,2 +1,3 @@
pub mod auth;
pub mod file_system;
pub mod warren;

View File

@@ -1,6 +1,7 @@
use crate::{
domain::warren::models::{
file::{CreateDirectoryError, DeleteDirectoryError, DeleteFileError, ListFilesError},
user::RegisterUserError,
warren::{
CreateWarrenDirectoryError, DeleteWarrenDirectoryError, DeleteWarrenFileError,
FetchWarrenError, ListWarrenFilesError, ListWarrensError, RenameWarrenEntryError,
@@ -116,12 +117,20 @@ impl From<ListWarrensError> for ApiError {
impl From<RenameWarrenEntryError> for ApiError {
fn from(value: RenameWarrenEntryError) -> Self {
ApiError::InternalServerError(value.to_string())
Self::InternalServerError(value.to_string())
}
}
impl From<UploadWarrenFilesError> for ApiError {
fn from(value: UploadWarrenFilesError) -> Self {
ApiError::InternalServerError(value.to_string())
Self::InternalServerError(value.to_string())
}
}
impl From<RegisterUserError> for ApiError {
fn from(value: RegisterUserError) -> Self {
match value {
RegisterUserError::Unknown(error) => Self::InternalServerError(error.to_string()),
}
}
}

View File

@@ -0,0 +1,13 @@
mod register;
use register::register;
use axum::{Router, routing::post};
use crate::{
domain::warren::ports::{AuthService, WarrenService},
inbound::http::AppState,
};
pub fn routes<WS: WarrenService, AS: AuthService>() -> Router<AppState<WS, AS>> {
Router::new().route("/register", post(register))
}

View File

@@ -0,0 +1,94 @@
use axum::{Json, extract::State, http::StatusCode};
use serde::Deserialize;
use thiserror::Error;
use crate::{
domain::warren::{
models::user::{
RegisterUserRequest, UserEmail, UserEmailError, UserName, UserNameError, UserPassword,
UserPasswordError,
},
ports::{AuthService, WarrenService},
},
inbound::http::{
AppState,
responses::{ApiError, ApiSuccess},
},
};
#[derive(Debug, Clone, Error)]
pub enum ParseRegisterUserHttpRequestError {
#[error(transparent)]
UserName(#[from] UserNameError),
#[error(transparent)]
UserEmail(#[from] UserEmailError),
#[error(transparent)]
UserPassword(#[from] UserPasswordError),
}
impl From<ParseRegisterUserHttpRequestError> for ApiError {
fn from(value: ParseRegisterUserHttpRequestError) -> Self {
match value {
ParseRegisterUserHttpRequestError::UserName(err) => match err {
UserNameError::Empty => {
Self::BadRequest("The username must not be empty".to_string())
}
},
ParseRegisterUserHttpRequestError::UserEmail(err) => match err {
UserEmailError::Invalid => Self::BadRequest("Invalid email".to_string()),
UserEmailError::Empty => {
Self::BadRequest("The user email must not be empty".to_string())
}
},
ParseRegisterUserHttpRequestError::UserPassword(err) => match err {
UserPasswordError::Empty => {
Self::BadRequest("The user password must not be empty".to_string())
}
UserPasswordError::LeadingWhitespace => Self::BadRequest(
"The user password must not start with a whitespace".to_string(),
),
UserPasswordError::TrailingWhitespace => {
Self::BadRequest("The user password must not end with a whitespace".to_string())
}
UserPasswordError::TooShort => {
Self::BadRequest("The user password must be longer".to_string())
}
UserPasswordError::TooLong => {
Self::BadRequest("The user password must be shorter".to_string())
}
},
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RegisterUserHttpRequestBody {
name: String,
email: String,
password: String,
}
impl RegisterUserHttpRequestBody {
fn try_into_domain(self) -> Result<RegisterUserRequest, ParseRegisterUserHttpRequestError> {
let name = UserName::new(&self.name)?;
let email = UserEmail::new(&self.email)?;
let password = UserPassword::new(&self.password)?;
Ok(RegisterUserRequest::new(name, email, password))
}
}
pub async fn register<WS: WarrenService, AS: AuthService>(
State(state): State<AppState<WS, AS>>,
Json(request): Json<RegisterUserHttpRequestBody>,
) -> Result<ApiSuccess<()>, ApiError> {
let domain_request = request.try_into_domain()?;
state
.auth_service
.register_user(domain_request)
.await
.map(|_| ApiSuccess::new(StatusCode::CREATED, ()))
.map_err(ApiError::from)
}

View File

@@ -1 +1,2 @@
pub mod auth;
pub mod warrens;

View File

@@ -9,7 +9,7 @@ use crate::{
file::{AbsoluteFilePathError, FilePath, FilePathError},
warren::CreateWarrenDirectoryRequest,
},
ports::WarrenService,
ports::{AuthService, WarrenService},
},
inbound::http::{
AppState,
@@ -62,13 +62,10 @@ impl CreateWarrenDirectoryHttpRequestBody {
}
}
pub async fn create_warren_directory<WS>(
State(state): State<AppState<WS>>,
pub async fn create_warren_directory<WS: WarrenService, AS: AuthService>(
State(state): State<AppState<WS, AS>>,
Json(request): Json<CreateWarrenDirectoryHttpRequestBody>,
) -> Result<ApiSuccess<()>, ApiError>
where
WS: WarrenService,
{
) -> Result<ApiSuccess<()>, ApiError> {
let domain_request = request.try_into_domain()?;
state

View File

@@ -9,7 +9,7 @@ use crate::{
file::{AbsoluteFilePathError, FilePath, FilePathError},
warren::DeleteWarrenDirectoryRequest,
},
ports::WarrenService,
ports::{AuthService, WarrenService},
},
inbound::http::{
AppState,
@@ -64,13 +64,10 @@ impl DeleteWarrenDirectoryHttpRequestBody {
}
}
pub async fn delete_warren_directory<WS>(
State(state): State<AppState<WS>>,
pub async fn delete_warren_directory<WS: WarrenService, AS: AuthService>(
State(state): State<AppState<WS, AS>>,
Json(request): Json<DeleteWarrenDirectoryHttpRequestBody>,
) -> Result<ApiSuccess<()>, ApiError>
where
WS: WarrenService,
{
) -> Result<ApiSuccess<()>, ApiError> {
let domain_request = request.try_into_domain()?;
state

View File

@@ -9,7 +9,7 @@ use crate::{
file::{AbsoluteFilePathError, FilePath, FilePathError},
warren::DeleteWarrenFileRequest,
},
ports::WarrenService,
ports::{AuthService, WarrenService},
},
inbound::http::{
AppState,
@@ -62,13 +62,10 @@ impl DeleteWarrenFileHttpRequestBody {
}
}
pub async fn delete_warren_file<WS>(
State(state): State<AppState<WS>>,
pub async fn delete_warren_file<WS: WarrenService, AS: AuthService>(
State(state): State<AppState<WS, AS>>,
Json(request): Json<DeleteWarrenFileHttpRequestBody>,
) -> Result<ApiSuccess<()>, ApiError>
where
WS: WarrenService,
{
) -> Result<ApiSuccess<()>, ApiError> {
let domain_request = request.try_into_domain()?;
state

View File

@@ -6,7 +6,7 @@ use uuid::Uuid;
use crate::{
domain::warren::{
models::warren::{FetchWarrenRequest, Warren},
ports::WarrenService,
ports::{AuthService, WarrenService},
},
inbound::http::{
AppState,
@@ -49,8 +49,8 @@ impl FetchWarrenHttpRequestBody {
}
}
pub async fn fetch_warren<WS: WarrenService>(
State(state): State<AppState<WS>>,
pub async fn fetch_warren<WS: WarrenService, AS: AuthService>(
State(state): State<AppState<WS, AS>>,
Json(request): Json<FetchWarrenHttpRequestBody>,
) -> Result<ApiSuccess<FetchWarrenResponseData>, ApiError> {
let domain_request = request.try_into_domain()?;

View File

@@ -9,7 +9,7 @@ use crate::{
file::{AbsoluteFilePathError, File, FileMimeType, FilePath, FilePathError, FileType},
warren::ListWarrenFilesRequest,
},
ports::WarrenService,
ports::{AuthService, WarrenService},
},
inbound::http::{
AppState,
@@ -94,8 +94,8 @@ impl From<Vec<File>> for ListWarrenFilesResponseData {
}
}
pub async fn list_warren_files<WS: WarrenService>(
State(state): State<AppState<WS>>,
pub async fn list_warren_files<WS: WarrenService, AS: AuthService>(
State(state): State<AppState<WS, AS>>,
Json(request): Json<ListWarrenFilesHttpRequestBody>,
) -> Result<ApiSuccess<ListWarrenFilesResponseData>, ApiError> {
let domain_request = request.try_into_domain()?;

View File

@@ -5,7 +5,7 @@ use uuid::Uuid;
use crate::{
domain::warren::{
models::warren::{ListWarrensRequest, Warren},
ports::WarrenService,
ports::{AuthService, WarrenService},
},
inbound::http::{
AppState,
@@ -41,8 +41,8 @@ impl From<&Vec<Warren>> for ListWarrensResponseData {
}
}
pub async fn list_warrens<WS: WarrenService>(
State(state): State<AppState<WS>>,
pub async fn list_warrens<WS: WarrenService, AS: AuthService>(
State(state): State<AppState<WS, AS>>,
) -> Result<ApiSuccess<ListWarrensResponseData>, ApiError> {
let domain_request = ListWarrensRequest::new();

View File

@@ -13,7 +13,10 @@ use axum::{
routing::{delete, get, patch, post},
};
use crate::{domain::warren::ports::WarrenService, inbound::http::AppState};
use crate::{
domain::warren::ports::{AuthService, WarrenService},
inbound::http::AppState,
};
use fetch_warren::fetch_warren;
use list_warren_files::list_warren_files;
@@ -26,7 +29,7 @@ use delete_warren_file::delete_warren_file;
use rename_warren_entry::rename_warren_entry;
use upload_warren_files::upload_warren_files;
pub fn routes<WS: WarrenService>() -> Router<AppState<WS>> {
pub fn routes<WS: WarrenService, AS: AuthService>() -> Router<AppState<WS, AS>> {
Router::new()
.route("/", get(list_warrens))
.route("/", post(fetch_warren))

View File

@@ -12,7 +12,7 @@ use crate::{
},
warren::RenameWarrenEntryRequest,
},
ports::WarrenService,
ports::{AuthService, WarrenService},
},
inbound::http::{
AppState,
@@ -78,8 +78,8 @@ impl From<ParseRenameWarrenEntryHttpRequestError> for ApiError {
}
}
pub async fn rename_warren_entry<WS: WarrenService>(
State(state): State<AppState<WS>>,
pub async fn rename_warren_entry<WS: WarrenService, AS: AuthService>(
State(state): State<AppState<WS, AS>>,
Json(request): Json<RenameWarrenEntryHttpRequestBody>,
) -> Result<ApiSuccess<()>, ApiError> {
let domain_request = request.try_into_domain()?;

View File

@@ -9,7 +9,7 @@ use crate::{
file::{AbsoluteFilePathError, FileName, FileNameError, FilePath, FilePathError},
warren::{UploadFile, UploadFileList, UploadFileListError, UploadWarrenFilesRequest},
},
ports::WarrenService,
ports::{AuthService, WarrenService},
},
inbound::http::{
AppState,
@@ -92,13 +92,10 @@ impl From<ParseUploadWarrenFilesHttpRequestError> for ApiError {
}
}
pub async fn upload_warren_files<WS>(
State(state): State<AppState<WS>>,
pub async fn upload_warren_files<WS: WarrenService, AS: AuthService>(
State(state): State<AppState<WS, AS>>,
TypedMultipart(multipart): TypedMultipart<UploadWarrenFilesHttpRequestBody>,
) -> Result<ApiSuccess<()>, ApiError>
where
WS: WarrenService,
{
) -> Result<ApiSuccess<()>, ApiError> {
let domain_request = multipart.try_into_domain()?;
state

View File

@@ -6,17 +6,18 @@ use std::sync::Arc;
use anyhow::Context;
use axum::{Router, http::HeaderValue};
use handlers::auth;
use handlers::warrens;
use tokio::net::TcpListener;
use tower::ServiceBuilder;
use tower_http::{
cors::CorsLayer,
services::ServeDir,
trace::{DefaultOnBodyChunk, DefaultOnFailure, DefaultOnRequest, DefaultOnResponse},
trace::{DefaultOnFailure, DefaultOnRequest, DefaultOnResponse},
};
use tracing::Level;
use crate::domain::warren::ports::WarrenService;
use crate::domain::warren::ports::{AuthService, WarrenService};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HttpServerConfig<'a> {
@@ -46,8 +47,9 @@ impl<'a> HttpServerConfig<'a> {
}
#[derive(Debug, Clone)]
pub struct AppState<WS: WarrenService> {
pub struct AppState<WS: WarrenService, AS: AuthService> {
warren_service: Arc<WS>,
auth_service: Arc<AS>,
}
pub struct HttpServer {
@@ -56,8 +58,9 @@ pub struct HttpServer {
}
impl HttpServer {
pub async fn new<WS: WarrenService>(
pub async fn new<WS: WarrenService, AS: AuthService>(
warren_service: WS,
auth_service: AS,
config: HttpServerConfig<'_>,
) -> anyhow::Result<Self> {
let cors_layer = cors_layer(&config)?;
@@ -73,6 +76,7 @@ impl HttpServer {
let state = AppState {
warren_service: Arc::new(warren_service),
auth_service: Arc::new(auth_service),
};
let mut router = Router::new()
@@ -118,6 +122,8 @@ fn cors_layer(config: &HttpServerConfig<'_>) -> anyhow::Result<CorsLayer> {
Ok(layer)
}
fn api_routes<WS: WarrenService>() -> Router<AppState<WS>> {
Router::new().nest("/warrens", warrens::routes())
fn api_routes<WS: WarrenService, AS: AuthService>() -> Router<AppState<WS, AS>> {
Router::new()
.nest("/auth", auth::routes())
.nest("/warrens", warrens::routes())
}

View File

@@ -1,4 +1,4 @@
use crate::domain::warren::ports::{FileSystemMetrics, WarrenMetrics};
use crate::domain::warren::ports::{AuthMetrics, FileSystemMetrics, WarrenMetrics};
#[derive(Debug, Clone, Copy)]
pub struct MetricsDebugLogger;
@@ -122,3 +122,12 @@ impl FileSystemMetrics for MetricsDebugLogger {
tracing::debug!("[Metrics] Entry rename failed");
}
}
impl AuthMetrics for MetricsDebugLogger {
async fn record_user_registration_success(&self) {
tracing::debug!("[Metrics] User registration succeeded");
}
async fn record_user_registration_failure(&self) {
tracing::debug!("[Metrics] User registration failed");
}
}

View File

@@ -1,9 +1,10 @@
use crate::domain::warren::{
models::{
file::{File, FilePath},
user::User,
warren::Warren,
},
ports::{FileSystemNotifier, WarrenNotifier},
ports::{AuthNotifier, FileSystemNotifier, WarrenNotifier},
};
#[derive(Debug, Clone, Copy)]
@@ -112,3 +113,9 @@ impl FileSystemNotifier for NotifierDebugLogger {
tracing::debug!("[Notifier] Renamed file {} to {}", old_path, new_path);
}
}
impl AuthNotifier for NotifierDebugLogger {
async fn user_registered(&self, user: &User) {
tracing::debug!("[Notifier] Registered user {}", user.name());
}
}

View File

@@ -1,6 +1,10 @@
use std::str::FromStr;
use anyhow::{Context, anyhow};
use argon2::{
Argon2,
password_hash::{PasswordHasher, SaltString, rand_core::OsRng},
};
use sqlx::{
ConnectOptions as _, Connection as _, PgConnection, PgPool,
postgres::{PgConnectOptions, PgPoolOptions},
@@ -8,10 +12,13 @@ use sqlx::{
use uuid::Uuid;
use crate::domain::warren::{
models::warren::{
FetchWarrenError, FetchWarrenRequest, ListWarrensError, ListWarrensRequest, Warren,
models::{
user::{RegisterUserError, RegisterUserRequest, User, UserEmail, UserName, UserPassword},
warren::{
FetchWarrenError, FetchWarrenRequest, ListWarrensError, ListWarrensRequest, Warren,
},
},
ports::WarrenRepository,
ports::{AuthRepository, WarrenRepository},
};
#[derive(Debug, Clone)]
@@ -98,6 +105,52 @@ impl Postgres {
Ok(warrens)
}
async fn create_user(
&self,
connection: &mut PgConnection,
name: &UserName,
email: &UserEmail,
password: &UserPassword,
is_admin: bool,
) -> anyhow::Result<User> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let password_hash = argon2
.hash_password(password.as_str().as_bytes(), &salt)
.map_err(|_| anyhow!("Failed to hash password"))?
.to_string();
let mut tx = connection.begin().await?;
let user: User = sqlx::query_as(
"INSERT INTO users (
name,
email,
hash,
admin
)
VALUES (
$1,
$2,
$3,
$4
)
RETURNING
*
",
)
.bind(name)
.bind(email)
.bind(password_hash)
.bind(is_admin)
.fetch_one(&mut *tx)
.await?;
tx.commit().await?;
Ok(user)
}
}
impl WarrenRepository for Postgres {
@@ -143,6 +196,29 @@ impl WarrenRepository for Postgres {
}
}
impl AuthRepository for Postgres {
async fn register_user(&self, request: RegisterUserRequest) -> Result<User, RegisterUserError> {
let mut connection = self
.pool
.acquire()
.await
.context("Failed to get a PostgreSQL connection")?;
let user = self
.create_user(
&mut connection,
request.name(),
request.email(),
request.password(),
false,
)
.await
.context(format!("Failed to create user"))?;
Ok(user)
}
}
fn is_not_found_error(err: &sqlx::Error) -> bool {
matches!(err, sqlx::Error::RowNotFound)
}