diff --git a/backend/Cargo.lock b/backend/Cargo.lock index a3f284b..72356d1 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -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", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 408fa53..2233725 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -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" diff --git a/backend/migrations/20250716125144_create_users_table.sql b/backend/migrations/20250716125144_create_users_table.sql new file mode 100644 index 0000000..05badb1 --- /dev/null +++ b/backend/migrations/20250716125144_create_users_table.sql @@ -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 +); diff --git a/backend/migrations/20250716135209_user_email_unique.sql b/backend/migrations/20250716135209_user_email_unique.sql new file mode 100644 index 0000000..6ab1046 --- /dev/null +++ b/backend/migrations/20250716135209_user_email_unique.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD CONSTRAINT users_email_key UNIQUE (email); diff --git a/backend/plan b/backend/plan new file mode 100644 index 0000000..61081c3 --- /dev/null +++ b/backend/plan @@ -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) diff --git a/backend/src/bin/backend/main.rs b/backend/src/bin/backend/main.rs index 65eb8a3..b3cc844 100644 --- a/backend/src/bin/backend/main.rs +++ b/backend/src/bin/backend/main.rs @@ -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(()) diff --git a/backend/src/lib/domain/warren/models/mod.rs b/backend/src/lib/domain/warren/models/mod.rs index 8078362..64eb083 100644 --- a/backend/src/lib/domain/warren/models/mod.rs +++ b/backend/src/lib/domain/warren/models/mod.rs @@ -1,2 +1,3 @@ pub mod file; +pub mod user; pub mod warren; diff --git a/backend/src/lib/domain/warren/models/user/mod.rs b/backend/src/lib/domain/warren/models/user/mod.rs new file mode 100644 index 0000000..c7a99ad --- /dev/null +++ b/backend/src/lib/domain/warren/models/user/mod.rs @@ -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 { + 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 = + LazyLock::new(|| Regex::new(r"^[^@]+@[^@]+\.[^@]+$").expect("Email regex")); + +impl UserEmail { + pub fn new(raw: &str) -> Result { + 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())) + } +} diff --git a/backend/src/lib/domain/warren/models/user/requests.rs b/backend/src/lib/domain/warren/models/user/requests.rs new file mode 100644 index 0000000..30fbde3 --- /dev/null +++ b/backend/src/lib/domain/warren/models/user/requests.rs @@ -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 { + 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 + } +} diff --git a/backend/src/lib/domain/warren/ports/metrics.rs b/backend/src/lib/domain/warren/ports/metrics.rs index 94df44f..4c74936 100644 --- a/backend/src/lib/domain/warren/ports/metrics.rs +++ b/backend/src/lib/domain/warren/ports/metrics.rs @@ -48,3 +48,8 @@ pub trait FileSystemMetrics: Clone + Send + Sync + 'static { fn record_entry_rename_success(&self) -> impl Future + Send; fn record_entry_rename_failure(&self) -> impl Future + Send; } + +pub trait AuthMetrics: Clone + Send + Sync + 'static { + fn record_user_registration_success(&self) -> impl Future + Send; + fn record_user_registration_failure(&self) -> impl Future + Send; +} diff --git a/backend/src/lib/domain/warren/ports/mod.rs b/backend/src/lib/domain/warren/ports/mod.rs index bd4e97d..73dccf4 100644 --- a/backend/src/lib/domain/warren/ports/mod.rs +++ b/backend/src/lib/domain/warren/ports/mod.rs @@ -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> + Send; } + +pub trait AuthService: Clone + Send + Sync + 'static { + fn register_user( + &self, + request: RegisterUserRequest, + ) -> impl Future> + Send; +} diff --git a/backend/src/lib/domain/warren/ports/notifier.rs b/backend/src/lib/domain/warren/ports/notifier.rs index e6e623e..a2ad17a 100644 --- a/backend/src/lib/domain/warren/ports/notifier.rs +++ b/backend/src/lib/domain/warren/ports/notifier.rs @@ -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 + Send; } + +pub trait AuthNotifier: Clone + Send + Sync + 'static { + fn user_registered(&self, user: &User) -> impl Future + Send; +} diff --git a/backend/src/lib/domain/warren/ports/repository.rs b/backend/src/lib/domain/warren/ports/repository.rs index 68bddc8..edba598 100644 --- a/backend/src/lib/domain/warren/ports/repository.rs +++ b/backend/src/lib/domain/warren/ports/repository.rs @@ -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> + Send; } + +pub trait AuthRepository: Clone + Send + Sync + 'static { + fn register_user( + &self, + request: RegisterUserRequest, + ) -> impl Future> + Send; +} diff --git a/backend/src/lib/domain/warren/service/auth.rs b/backend/src/lib/domain/warren/service/auth.rs new file mode 100644 index 0000000..44dd5dd --- /dev/null +++ b/backend/src/lib/domain/warren/service/auth.rs @@ -0,0 +1,51 @@ +use crate::domain::warren::{ + models::user::{RegisterUserError, RegisterUserRequest, User}, + ports::{AuthMetrics, AuthNotifier, AuthRepository, AuthService}, +}; + +#[derive(Debug, Clone)] +pub struct Service +where + R: AuthRepository, + M: AuthMetrics, + N: AuthNotifier, +{ + repository: R, + metrics: M, + notifier: N, +} + +impl Service +where + R: AuthRepository, + M: AuthMetrics, + N: AuthNotifier, +{ + pub fn new(repository: R, metrics: M, notifier: N) -> Self { + Self { + repository, + metrics, + notifier, + } + } +} + +impl AuthService for Service +where + R: AuthRepository, + M: AuthMetrics, + N: AuthNotifier, +{ + async fn register_user(&self, request: RegisterUserRequest) -> Result { + 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 + } +} diff --git a/backend/src/lib/domain/warren/service/mod.rs b/backend/src/lib/domain/warren/service/mod.rs index a098c04..ae9858f 100644 --- a/backend/src/lib/domain/warren/service/mod.rs +++ b/backend/src/lib/domain/warren/service/mod.rs @@ -1,2 +1,3 @@ +pub mod auth; pub mod file_system; pub mod warren; diff --git a/backend/src/lib/inbound/http/errors.rs b/backend/src/lib/inbound/http/errors.rs index 66940a8..3da8b34 100644 --- a/backend/src/lib/inbound/http/errors.rs +++ b/backend/src/lib/inbound/http/errors.rs @@ -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 for ApiError { impl From for ApiError { fn from(value: RenameWarrenEntryError) -> Self { - ApiError::InternalServerError(value.to_string()) + Self::InternalServerError(value.to_string()) } } impl From for ApiError { fn from(value: UploadWarrenFilesError) -> Self { - ApiError::InternalServerError(value.to_string()) + Self::InternalServerError(value.to_string()) + } +} + +impl From for ApiError { + fn from(value: RegisterUserError) -> Self { + match value { + RegisterUserError::Unknown(error) => Self::InternalServerError(error.to_string()), + } } } diff --git a/backend/src/lib/inbound/http/handlers/auth/mod.rs b/backend/src/lib/inbound/http/handlers/auth/mod.rs new file mode 100644 index 0000000..aaa5141 --- /dev/null +++ b/backend/src/lib/inbound/http/handlers/auth/mod.rs @@ -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() -> Router> { + Router::new().route("/register", post(register)) +} diff --git a/backend/src/lib/inbound/http/handlers/auth/register.rs b/backend/src/lib/inbound/http/handlers/auth/register.rs new file mode 100644 index 0000000..d4d9030 --- /dev/null +++ b/backend/src/lib/inbound/http/handlers/auth/register.rs @@ -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 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 { + 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( + State(state): State>, + Json(request): Json, +) -> Result, 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) +} diff --git a/backend/src/lib/inbound/http/handlers/mod.rs b/backend/src/lib/inbound/http/handlers/mod.rs index 09789d5..1751879 100644 --- a/backend/src/lib/inbound/http/handlers/mod.rs +++ b/backend/src/lib/inbound/http/handlers/mod.rs @@ -1 +1,2 @@ +pub mod auth; pub mod warrens; diff --git a/backend/src/lib/inbound/http/handlers/warrens/create_warren_directory.rs b/backend/src/lib/inbound/http/handlers/warrens/create_warren_directory.rs index 8c5bba6..d1e6217 100644 --- a/backend/src/lib/inbound/http/handlers/warrens/create_warren_directory.rs +++ b/backend/src/lib/inbound/http/handlers/warrens/create_warren_directory.rs @@ -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( - State(state): State>, +pub async fn create_warren_directory( + State(state): State>, Json(request): Json, -) -> Result, ApiError> -where - WS: WarrenService, -{ +) -> Result, ApiError> { let domain_request = request.try_into_domain()?; state diff --git a/backend/src/lib/inbound/http/handlers/warrens/delete_warren_directory.rs b/backend/src/lib/inbound/http/handlers/warrens/delete_warren_directory.rs index 874c962..b223088 100644 --- a/backend/src/lib/inbound/http/handlers/warrens/delete_warren_directory.rs +++ b/backend/src/lib/inbound/http/handlers/warrens/delete_warren_directory.rs @@ -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( - State(state): State>, +pub async fn delete_warren_directory( + State(state): State>, Json(request): Json, -) -> Result, ApiError> -where - WS: WarrenService, -{ +) -> Result, ApiError> { let domain_request = request.try_into_domain()?; state diff --git a/backend/src/lib/inbound/http/handlers/warrens/delete_warren_file.rs b/backend/src/lib/inbound/http/handlers/warrens/delete_warren_file.rs index 1c18641..f37e138 100644 --- a/backend/src/lib/inbound/http/handlers/warrens/delete_warren_file.rs +++ b/backend/src/lib/inbound/http/handlers/warrens/delete_warren_file.rs @@ -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( - State(state): State>, +pub async fn delete_warren_file( + State(state): State>, Json(request): Json, -) -> Result, ApiError> -where - WS: WarrenService, -{ +) -> Result, ApiError> { let domain_request = request.try_into_domain()?; state diff --git a/backend/src/lib/inbound/http/handlers/warrens/fetch_warren.rs b/backend/src/lib/inbound/http/handlers/warrens/fetch_warren.rs index 6b62223..5ae4d40 100644 --- a/backend/src/lib/inbound/http/handlers/warrens/fetch_warren.rs +++ b/backend/src/lib/inbound/http/handlers/warrens/fetch_warren.rs @@ -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( - State(state): State>, +pub async fn fetch_warren( + State(state): State>, Json(request): Json, ) -> Result, ApiError> { let domain_request = request.try_into_domain()?; diff --git a/backend/src/lib/inbound/http/handlers/warrens/list_warren_files.rs b/backend/src/lib/inbound/http/handlers/warrens/list_warren_files.rs index 2502a54..e5267ed 100644 --- a/backend/src/lib/inbound/http/handlers/warrens/list_warren_files.rs +++ b/backend/src/lib/inbound/http/handlers/warrens/list_warren_files.rs @@ -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> for ListWarrenFilesResponseData { } } -pub async fn list_warren_files( - State(state): State>, +pub async fn list_warren_files( + State(state): State>, Json(request): Json, ) -> Result, ApiError> { let domain_request = request.try_into_domain()?; diff --git a/backend/src/lib/inbound/http/handlers/warrens/list_warrens.rs b/backend/src/lib/inbound/http/handlers/warrens/list_warrens.rs index 3f714f8..f09c1f5 100644 --- a/backend/src/lib/inbound/http/handlers/warrens/list_warrens.rs +++ b/backend/src/lib/inbound/http/handlers/warrens/list_warrens.rs @@ -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> for ListWarrensResponseData { } } -pub async fn list_warrens( - State(state): State>, +pub async fn list_warrens( + State(state): State>, ) -> Result, ApiError> { let domain_request = ListWarrensRequest::new(); diff --git a/backend/src/lib/inbound/http/handlers/warrens/mod.rs b/backend/src/lib/inbound/http/handlers/warrens/mod.rs index d49efc5..16b5c4f 100644 --- a/backend/src/lib/inbound/http/handlers/warrens/mod.rs +++ b/backend/src/lib/inbound/http/handlers/warrens/mod.rs @@ -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() -> Router> { +pub fn routes() -> Router> { Router::new() .route("/", get(list_warrens)) .route("/", post(fetch_warren)) diff --git a/backend/src/lib/inbound/http/handlers/warrens/rename_warren_entry.rs b/backend/src/lib/inbound/http/handlers/warrens/rename_warren_entry.rs index da72074..3424743 100644 --- a/backend/src/lib/inbound/http/handlers/warrens/rename_warren_entry.rs +++ b/backend/src/lib/inbound/http/handlers/warrens/rename_warren_entry.rs @@ -12,7 +12,7 @@ use crate::{ }, warren::RenameWarrenEntryRequest, }, - ports::WarrenService, + ports::{AuthService, WarrenService}, }, inbound::http::{ AppState, @@ -78,8 +78,8 @@ impl From for ApiError { } } -pub async fn rename_warren_entry( - State(state): State>, +pub async fn rename_warren_entry( + State(state): State>, Json(request): Json, ) -> Result, ApiError> { let domain_request = request.try_into_domain()?; diff --git a/backend/src/lib/inbound/http/handlers/warrens/upload_warren_files.rs b/backend/src/lib/inbound/http/handlers/warrens/upload_warren_files.rs index 4180375..bb61a96 100644 --- a/backend/src/lib/inbound/http/handlers/warrens/upload_warren_files.rs +++ b/backend/src/lib/inbound/http/handlers/warrens/upload_warren_files.rs @@ -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 for ApiError { } } -pub async fn upload_warren_files( - State(state): State>, +pub async fn upload_warren_files( + State(state): State>, TypedMultipart(multipart): TypedMultipart, -) -> Result, ApiError> -where - WS: WarrenService, -{ +) -> Result, ApiError> { let domain_request = multipart.try_into_domain()?; state diff --git a/backend/src/lib/inbound/http/mod.rs b/backend/src/lib/inbound/http/mod.rs index deb576e..51a009a 100644 --- a/backend/src/lib/inbound/http/mod.rs +++ b/backend/src/lib/inbound/http/mod.rs @@ -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 { +pub struct AppState { warren_service: Arc, + auth_service: Arc, } pub struct HttpServer { @@ -56,8 +58,9 @@ pub struct HttpServer { } impl HttpServer { - pub async fn new( + pub async fn new( warren_service: WS, + auth_service: AS, config: HttpServerConfig<'_>, ) -> anyhow::Result { 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 { Ok(layer) } -fn api_routes() -> Router> { - Router::new().nest("/warrens", warrens::routes()) +fn api_routes() -> Router> { + Router::new() + .nest("/auth", auth::routes()) + .nest("/warrens", warrens::routes()) } diff --git a/backend/src/lib/outbound/metrics_debug_logger.rs b/backend/src/lib/outbound/metrics_debug_logger.rs index 037c7a2..d77d897 100644 --- a/backend/src/lib/outbound/metrics_debug_logger.rs +++ b/backend/src/lib/outbound/metrics_debug_logger.rs @@ -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"); + } +} diff --git a/backend/src/lib/outbound/notifier_debug_logger.rs b/backend/src/lib/outbound/notifier_debug_logger.rs index f93ad53..6fb7d21 100644 --- a/backend/src/lib/outbound/notifier_debug_logger.rs +++ b/backend/src/lib/outbound/notifier_debug_logger.rs @@ -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()); + } +} diff --git a/backend/src/lib/outbound/postgres.rs b/backend/src/lib/outbound/postgres.rs index f8c380f..6b7567a 100644 --- a/backend/src/lib/outbound/postgres.rs +++ b/backend/src/lib/outbound/postgres.rs @@ -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 { + 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 { + 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) } diff --git a/frontend/app.vue b/frontend/app.vue index 456d898..37f6bf8 100644 --- a/frontend/app.vue +++ b/frontend/app.vue @@ -27,4 +27,8 @@ body, width: 100%; height: 100%; } + +* { + font-family: 'TikTok Sans', sans-serif; +} diff --git a/frontend/components/ui/card/Card.vue b/frontend/components/ui/card/Card.vue new file mode 100644 index 0000000..9154a6a --- /dev/null +++ b/frontend/components/ui/card/Card.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/components/ui/card/CardAction.vue b/frontend/components/ui/card/CardAction.vue new file mode 100644 index 0000000..c2beb20 --- /dev/null +++ b/frontend/components/ui/card/CardAction.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/components/ui/card/CardContent.vue b/frontend/components/ui/card/CardContent.vue new file mode 100644 index 0000000..6bff4bc --- /dev/null +++ b/frontend/components/ui/card/CardContent.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/components/ui/card/CardDescription.vue b/frontend/components/ui/card/CardDescription.vue new file mode 100644 index 0000000..2a0a755 --- /dev/null +++ b/frontend/components/ui/card/CardDescription.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/components/ui/card/CardFooter.vue b/frontend/components/ui/card/CardFooter.vue new file mode 100644 index 0000000..1f3648d --- /dev/null +++ b/frontend/components/ui/card/CardFooter.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/components/ui/card/CardHeader.vue b/frontend/components/ui/card/CardHeader.vue new file mode 100644 index 0000000..f693a6c --- /dev/null +++ b/frontend/components/ui/card/CardHeader.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/components/ui/card/CardTitle.vue b/frontend/components/ui/card/CardTitle.vue new file mode 100644 index 0000000..caa7e06 --- /dev/null +++ b/frontend/components/ui/card/CardTitle.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/components/ui/card/index.ts b/frontend/components/ui/card/index.ts new file mode 100644 index 0000000..73d985f --- /dev/null +++ b/frontend/components/ui/card/index.ts @@ -0,0 +1,7 @@ +export { default as Card } from './Card.vue' +export { default as CardAction } from './CardAction.vue' +export { default as CardContent } from './CardContent.vue' +export { default as CardDescription } from './CardDescription.vue' +export { default as CardFooter } from './CardFooter.vue' +export { default as CardHeader } from './CardHeader.vue' +export { default as CardTitle } from './CardTitle.vue' diff --git a/frontend/layouts/auth.vue b/frontend/layouts/auth.vue new file mode 100644 index 0000000..62b973f --- /dev/null +++ b/frontend/layouts/auth.vue @@ -0,0 +1,5 @@ + diff --git a/frontend/layouts/default.vue b/frontend/layouts/default.vue index 47e9116..e93b3ea 100644 --- a/frontend/layouts/default.vue +++ b/frontend/layouts/default.vue @@ -90,9 +90,3 @@ const breadcrumbs = computed(() => getBreadcrumbs(route.path)); - - diff --git a/frontend/lib/api/auth/index.ts b/frontend/lib/api/auth/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/frontend/lib/api/auth/register.ts b/frontend/lib/api/auth/register.ts new file mode 100644 index 0000000..0676ff7 --- /dev/null +++ b/frontend/lib/api/auth/register.ts @@ -0,0 +1,42 @@ +import { toast } from 'vue-sonner'; +import type { ApiResponse } from '~/types/api'; + +export async function registerUser( + username: string, + email: string, + password: string +): Promise<{ success: boolean }> { + const { data, error } = await useFetch>( + getApiUrl('auth/register'), + { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + name: username, + email: email, + password: password, + }), + responseType: 'json', + } + ); + + if (data.value == null) { + toast.error('Register', { + description: error.value?.data ?? 'Failed to register', + }); + + return { + success: false, + }; + } + + toast.success('Register', { + description: `Successfully registered user ${username}`, + }); + + return { + success: true, + }; +} diff --git a/frontend/pages/login.vue b/frontend/pages/login.vue new file mode 100644 index 0000000..db7b80c --- /dev/null +++ b/frontend/pages/login.vue @@ -0,0 +1,52 @@ + + + diff --git a/frontend/pages/register.vue b/frontend/pages/register.vue new file mode 100644 index 0000000..af7e496 --- /dev/null +++ b/frontend/pages/register.vue @@ -0,0 +1,95 @@ + + + diff --git a/frontend/types/api.ts b/frontend/types/api.ts index 2d78009..0615da5 100644 --- a/frontend/types/api.ts +++ b/frontend/types/api.ts @@ -2,3 +2,6 @@ export type ApiResponse = { status: number; data: T; }; + +// TODO: Fix +export type ApiError = string;