From f1a2721cbeb204189066b97c9b7175df66337067 Mon Sep 17 00:00:00 2001 From: selfhoster1312 Date: Mon, 14 Sep 2026 21:05:23 +0200 Subject: [PATCH 1/7] feat: Basic domain creation API --- src/db/domain.rs | 35 +++++++++++++ src/db/error.rs | 24 +++++++++ src/db/interface.rs | 25 +++++++++- src/db/memory.rs | 116 +++++++++++++++++++++++++++++++------------- src/db/mod.rs | 2 + src/main.rs | 18 ++++--- 6 files changed, 176 insertions(+), 44 deletions(-) create mode 100644 src/db/domain.rs diff --git a/src/db/domain.rs b/src/db/domain.rs new file mode 100644 index 0000000..5ea6d43 --- /dev/null +++ b/src/db/domain.rs @@ -0,0 +1,35 @@ +use crate::db::error::UserCreationError; +use crate::db::{Group, User, UserRef}; + +#[derive(Clone, Debug, Default)] +pub struct Domain { + pub name: String, + pub users: Vec, + #[expect(unused)] + pub groups: Vec, +} + +impl Domain { + pub fn get_user(&self, req_user: &UserRef) -> Option { + assert!( + req_user.domain.as_deref().unwrap_or("") == self.name, + "Should only call Domain::get_user on the matching domain. Asked for {:?} on domain {}", + req_user, + self.name + ); + + self.users + .iter() + .find(|u| u.username == req_user.username) + .cloned() + } + + pub fn create_user(&mut self, user: User, user_ref: UserRef) -> Result<(), UserCreationError> { + if self.get_user(&user_ref).is_some() { + return Err(UserCreationError::UserAlreadyExists(user_ref)); + } + + self.users.push(user); + Ok(()) + } +} diff --git a/src/db/error.rs b/src/db/error.rs index ccdcd47..9fdce05 100644 --- a/src/db/error.rs +++ b/src/db/error.rs @@ -6,6 +6,7 @@ pub type BoxedError = Box; #[derive(Debug)] pub enum UserCreationError { + DomainNotFound(String), UserAlreadyExists(UserRef), Permissions, } @@ -13,6 +14,7 @@ pub enum UserCreationError { impl fmt::Display for UserCreationError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::DomainNotFound(domain) => write!(f, "No domain {domain} to create user in"), Self::UserAlreadyExists(user) => write!(f, "User already exists: {user}"), Self::Permissions => write!(f, "You do not have permissions to create this user"), } @@ -20,3 +22,25 @@ impl fmt::Display for UserCreationError { } impl std::error::Error for UserCreationError {} + +#[derive(Debug)] +pub enum DomainCreationError { + DomainAlreadyExists(String), + InvalidDomain(String), +} + +impl fmt::Display for DomainCreationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::DomainAlreadyExists(domain) => { + write!(f, "Cannot create domain {domain} because it already exists") + } + Self::InvalidDomain(domain) => write!( + f, + "Cannot create domain `{domain}` because it's not considered a valid domain" + ), + } + } +} + +impl std::error::Error for DomainCreationError {} diff --git a/src/db/interface.rs b/src/db/interface.rs index 668e03b..ed6d86c 100644 --- a/src/db/interface.rs +++ b/src/db/interface.rs @@ -1,7 +1,18 @@ -use crate::db::error::{BoxedError, UserCreationError}; -use crate::db::{Database, User, UserRef}; +use crate::db::error::{BoxedError, DomainCreationError, UserCreationError}; +use crate::db::{Database, Domain, User, UserRef}; impl DatabaseInterface for Database { + async fn create_domain( + &mut self, + domain: &str, + ) -> Result, BoxedError> { + self.inner.write().await.create_domain(domain).await + } + + async fn get_domain(&self, domain: &str) -> Result, BoxedError> { + self.inner.read().await.get_domain(domain).await + } + async fn get_user(&self, user: &UserRef) -> Result, BoxedError> { self.inner.read().await.get_user(user).await } @@ -31,6 +42,12 @@ impl DatabaseInterface for Database { } pub trait DatabaseInterface: Clone + Send + Sync + 'static { + fn create_domain( + &mut self, + domain: &str, + ) -> impl Future, BoxedError>>; + fn get_domain(&self, domain: &str) -> impl Future, BoxedError>>; + fn get_user( &self, user: &UserRef, @@ -45,6 +62,10 @@ pub trait DatabaseInterface: Clone + Send + Sync + 'static { new_user: User, current_user: &User, ) -> Result, BoxedError>; + + /// Use empty string domain to request global users (TODO: this is not very DX) + /// + /// When domain does not exist, the returned list is empty. fn list_users( &self, domain: Option, diff --git a/src/db/memory.rs b/src/db/memory.rs index 62b98a3..b9b8545 100644 --- a/src/db/memory.rs +++ b/src/db/memory.rs @@ -1,46 +1,90 @@ use std::future::{Future, ready}; -use crate::db::error::{BoxedError, UserCreationError}; -use crate::db::{Database, DatabaseInterface, Group, Operation, User, UserRef}; +use crate::db::error::{BoxedError, DomainCreationError, UserCreationError}; +use crate::db::{Database, DatabaseInterface, Domain, Operation, User, UserRef}; #[derive(Clone, Debug, Default)] pub struct MemoryDatabase { - pub users: Vec, - #[expect(unused)] - pub groups: Vec, + pub domains: Vec, + /// Where global users/groups are registered + pub global_domain: Domain, } impl MemoryDatabase { pub fn new() -> Database { Database::new(Self::default()) } + + pub fn get_domain_mut(&mut self, domain: &str) -> Option<&mut Domain> { + self.domains.iter_mut().find(|d| d.name == domain) + } } impl DatabaseInterface for MemoryDatabase { - fn get_user( - &self, - req_user: &UserRef, - ) -> impl Future, BoxedError>> { - for user in &self.users { - if user.username == req_user.username && user.domain == req_user.domain { - return ready(Ok(Some(user.clone()))); - } + fn create_domain( + &mut self, + domain: &str, + ) -> impl Future, BoxedError>> { + if domain.is_empty() { + return ready(Ok(Err(DomainCreationError::InvalidDomain( + domain.to_string(), + )))); } - ready(Ok(None)) + if self.domains.iter().find(|d| d.name == domain).is_some() { + return ready(Ok(Err(DomainCreationError::DomainAlreadyExists( + domain.to_string(), + )))); + } + + self.domains.push(Domain { + name: domain.to_string(), + users: vec![], + groups: vec![], + }); + + ready(Ok(Ok(()))) } - async fn create_user( + fn get_domain(&self, domain: &str) -> impl Future, BoxedError>> { + let Some(domain) = self.domains.iter().find(|d| d.name == domain) else { + return ready(Ok(None)); + }; + + ready(Ok(Some(domain.clone()))) + } + + async fn get_user(&self, req_user: &UserRef) -> Result, BoxedError> { + let Some(req_domain) = &req_user.domain else { + // If no domain is provided for the user query, look up the global users + return Ok(self.global_domain.get_user(req_user)); + }; + + let Some(domain) = self.get_domain(req_domain).await? else { + return Ok(None); + }; + + Ok(domain.get_user(req_user)) + } + + fn create_user( &mut self, user: User, - ) -> Result, BoxedError> { + ) -> impl Future, BoxedError>> { let user_ref = user.user_ref(); - if self.get_user(&user_ref).await?.is_some() { - return Ok(Err(UserCreationError::UserAlreadyExists(user_ref))); - } - self.users.push(user); - Ok(Ok(())) + let Some(req_domain) = &user.domain else { + // No domain requested, this is a global user creation + return ready(Ok(self.global_domain.create_user(user, user_ref))); + }; + + let Some(domain) = self.get_domain_mut(req_domain) else { + return ready(Ok(Err(UserCreationError::DomainNotFound( + req_domain.clone(), + )))); + }; + + ready(Ok(domain.create_user(user, user_ref))) } async fn try_create_user( @@ -65,19 +109,23 @@ impl DatabaseInterface for MemoryDatabase { self.create_user(new_user).await } - fn list_users( - &self, - domain: Option, - ) -> impl Future, BoxedError>> { - let users = if let Some(domain) = domain { - self.users - .iter() - .filter(|user| user.domain.as_ref() == Some(&domain)) - .cloned() - .collect() + async fn list_users(&self, domain: Option) -> Result, BoxedError> { + if let Some(domain) = domain { + if domain.is_empty() { + Ok(self.global_domain.users.clone()) + } else { + let Some(domain) = self.get_domain(&domain).await? else { + return Ok(vec![]); + }; + Ok(domain.users.clone()) + } } else { - self.users.clone() - }; - ready(Ok(users)) + // Aggregate all users + let mut users = self.global_domain.users.clone(); + for domain in &self.domains { + users.extend(domain.users.clone()); + } + Ok(users) + } } } diff --git a/src/db/mod.rs b/src/db/mod.rs index d77daca..c2544e0 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,5 +1,7 @@ mod common; pub use common::Database; +mod domain; +pub use domain::Domain; pub mod error; mod group; pub use group::Group; diff --git a/src/main.rs b/src/main.rs index ace3805..cb6ee76 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,22 +29,24 @@ async fn create_dummy_users(db: &mut Database) { .await .unwrap() .unwrap(); - for domain in &["a", "b", "c"] { + for letter in &["a", "b", "c"] { + let domain = format!("{letter}.localhost"); + db.create_domain(&domain).await.unwrap().unwrap(); db.create_user(User { - username: domain.to_string(), - domain: Some(format!("{domain}.localhost")), + username: letter.to_string(), + domain: Some(domain.clone()), password: "adminadmin".to_string(), - mail: format!("{domain}@{domain}.localhost"), - role: Role::DomainAdmin(format!("{domain}.localhost")), + mail: format!("{letter}@{domain}"), + role: Role::DomainAdmin(domain.clone()), }) .await .unwrap() .unwrap(); db.create_user(User { - username: format!("user{domain}"), - domain: Some(format!("{domain}.localhost")), + username: format!("user{letter}"), + domain: Some(domain.clone()), password: "adminadmin".to_string(), - mail: format!("user{domain}@{domain}.localhost"), + mail: format!("user{letter}@{domain}"), role: Role::User, }) .await From 58a8c375f0ab1ee31016ee6c6be2039d4f6fdf14 Mon Sep 17 00:00:00 2001 From: selfhoster1312 Date: Sat, 19 Sep 2026 09:25:25 +0200 Subject: [PATCH 2/7] refactor: Store domains separately from users --- src/db/domain.rs | 31 --------------- src/db/interface.rs | 17 ++++++--- src/db/memory.rs | 91 ++++++++++++++++++++------------------------- src/http/mod.rs | 6 ++- templates/home.html | 2 +- 5 files changed, 59 insertions(+), 88 deletions(-) diff --git a/src/db/domain.rs b/src/db/domain.rs index 5ea6d43..6cca261 100644 --- a/src/db/domain.rs +++ b/src/db/domain.rs @@ -1,35 +1,4 @@ -use crate::db::error::UserCreationError; -use crate::db::{Group, User, UserRef}; - #[derive(Clone, Debug, Default)] pub struct Domain { pub name: String, - pub users: Vec, - #[expect(unused)] - pub groups: Vec, -} - -impl Domain { - pub fn get_user(&self, req_user: &UserRef) -> Option { - assert!( - req_user.domain.as_deref().unwrap_or("") == self.name, - "Should only call Domain::get_user on the matching domain. Asked for {:?} on domain {}", - req_user, - self.name - ); - - self.users - .iter() - .find(|u| u.username == req_user.username) - .cloned() - } - - pub fn create_user(&mut self, user: User, user_ref: UserRef) -> Result<(), UserCreationError> { - if self.get_user(&user_ref).is_some() { - return Err(UserCreationError::UserAlreadyExists(user_ref)); - } - - self.users.push(user); - Ok(()) - } } diff --git a/src/db/interface.rs b/src/db/interface.rs index ed6d86c..e5d58ec 100644 --- a/src/db/interface.rs +++ b/src/db/interface.rs @@ -36,8 +36,12 @@ impl DatabaseInterface for Database { .await } - async fn list_users(&self, domain: Option) -> Result, BoxedError> { - self.inner.read().await.list_users(domain).await + async fn list_all_users(&self) -> Result, BoxedError> { + self.inner.read().await.list_all_users().await + } + + async fn list_domain_users(&self, domain: Option) -> Result, BoxedError> { + self.inner.read().await.list_domain_users(domain).await } } @@ -63,10 +67,13 @@ pub trait DatabaseInterface: Clone + Send + Sync + 'static { current_user: &User, ) -> Result, BoxedError>; - /// Use empty string domain to request global users (TODO: this is not very DX) + #[expect(unused)] + fn list_all_users(&self) -> impl Future, BoxedError>>; + + /// List users on a specific domain. /// - /// When domain does not exist, the returned list is empty. - fn list_users( + /// A `None` domain requested lists global service users. + fn list_domain_users( &self, domain: Option, ) -> impl std::future::Future, BoxedError>> + Send; diff --git a/src/db/memory.rs b/src/db/memory.rs index b9b8545..1ba13dd 100644 --- a/src/db/memory.rs +++ b/src/db/memory.rs @@ -1,23 +1,21 @@ use std::future::{Future, ready}; use crate::db::error::{BoxedError, DomainCreationError, UserCreationError}; -use crate::db::{Database, DatabaseInterface, Domain, Operation, User, UserRef}; +use crate::db::{Database, DatabaseInterface, Domain, Group, Operation, User, UserRef}; #[derive(Clone, Debug, Default)] pub struct MemoryDatabase { + // We store data in tables like in SQL pub domains: Vec, - /// Where global users/groups are registered - pub global_domain: Domain, + #[expect(unused)] + pub groups: Vec, + pub users: Vec, } impl MemoryDatabase { pub fn new() -> Database { Database::new(Self::default()) } - - pub fn get_domain_mut(&mut self, domain: &str) -> Option<&mut Domain> { - self.domains.iter_mut().find(|d| d.name == domain) - } } impl DatabaseInterface for MemoryDatabase { @@ -39,8 +37,6 @@ impl DatabaseInterface for MemoryDatabase { self.domains.push(Domain { name: domain.to_string(), - users: vec![], - groups: vec![], }); ready(Ok(Ok(()))) @@ -54,37 +50,36 @@ impl DatabaseInterface for MemoryDatabase { ready(Ok(Some(domain.clone()))) } - async fn get_user(&self, req_user: &UserRef) -> Result, BoxedError> { - let Some(req_domain) = &req_user.domain else { - // If no domain is provided for the user query, look up the global users - return Ok(self.global_domain.get_user(req_user)); - }; - - let Some(domain) = self.get_domain(req_domain).await? else { - return Ok(None); - }; - - Ok(domain.get_user(req_user)) + fn get_user( + &self, + req_user: &UserRef, + ) -> impl Future, BoxedError>> { + ready(Ok(self + .users + .iter() + .find(|u| u.username == req_user.username && u.domain == req_user.domain) + .cloned())) } - fn create_user( + async fn create_user( &mut self, user: User, - ) -> impl Future, BoxedError>> { + ) -> Result, BoxedError> { let user_ref = user.user_ref(); - let Some(req_domain) = &user.domain else { - // No domain requested, this is a global user creation - return ready(Ok(self.global_domain.create_user(user, user_ref))); - }; + if self.get_user(&user_ref).await?.is_some() { + return Ok(Err(UserCreationError::UserAlreadyExists(user_ref))); + } - let Some(domain) = self.get_domain_mut(req_domain) else { - return ready(Ok(Err(UserCreationError::DomainNotFound( - req_domain.clone(), - )))); - }; + // If a domain is requested (i.e. not a global user), make sure the domain exists + if let Some(req_domain) = &user.domain + && self.get_domain(req_domain).await?.is_none() + { + return Ok(Err(UserCreationError::DomainNotFound(req_domain.clone()))); + } - ready(Ok(domain.create_user(user, user_ref))) + self.users.push(user); + Ok(Ok(())) } async fn try_create_user( @@ -109,23 +104,19 @@ impl DatabaseInterface for MemoryDatabase { self.create_user(new_user).await } - async fn list_users(&self, domain: Option) -> Result, BoxedError> { - if let Some(domain) = domain { - if domain.is_empty() { - Ok(self.global_domain.users.clone()) - } else { - let Some(domain) = self.get_domain(&domain).await? else { - return Ok(vec![]); - }; - Ok(domain.users.clone()) - } - } else { - // Aggregate all users - let mut users = self.global_domain.users.clone(); - for domain in &self.domains { - users.extend(domain.users.clone()); - } - Ok(users) - } + fn list_all_users(&self) -> impl Future, BoxedError>> { + ready(Ok(self.users.clone())) + } + + fn list_domain_users( + &self, + domain: Option, + ) -> impl Future, BoxedError>> { + ready(Ok(self + .users + .iter() + .filter(|u| u.domain == domain) + .cloned() + .collect())) } } diff --git a/src/http/mod.rs b/src/http/mod.rs index 98a20ed..bd5b2ec 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -115,7 +115,11 @@ pub async fn home( // When the user has no domain (service admin) list all domains let op = Operation::ListUsers(session.user.domain.clone()); let other_users = if session.user.can_perform(&op) { - match state.db.list_users(session.user.domain.clone()).await { + match state + .db + .list_domain_users(session.user.domain.clone()) + .await + { Ok(other_users) => other_users, Err(e) => { return format!("Database error: {e}").into_response(); diff --git a/templates/home.html b/templates/home.html index 584fad4..e9f4925 100644 --- a/templates/home.html +++ b/templates/home.html @@ -8,7 +8,7 @@ {% if other_users %}
-

Other users you have permission to see

+

Other users you have permission to see on your own domain

    {% for user in other_users %}
  • {{ user.mail }}
  • From 8a7e644960ec05fe943e6f825a9820fa46f487f4 Mon Sep 17 00:00:00 2001 From: selfhoster1312 Date: Sat, 19 Sep 2026 10:00:24 +0200 Subject: [PATCH 3/7] feat: List domains user can see on homepage --- Cargo.lock | 12 +++++++++ Cargo.toml | 2 +- src/db/common.rs | 11 +++++++- src/db/domain.rs | 4 ++- src/db/interface.rs | 7 ++++- src/db/memory.rs | 4 +++ src/db/role.rs | 12 ++++++++- src/db/user.rs | 4 +++ src/http/home.rs | 63 +++++++++++++++++++++++++++++++++++++++++++++ src/http/mod.rs | 58 ++++------------------------------------- templates/home.html | 17 ++++++++++++ 11 files changed, 136 insertions(+), 58 deletions(-) create mode 100644 src/http/home.rs diff --git a/Cargo.lock b/Cargo.lock index 545982a..0b55b38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,6 +52,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", + "axum-macros", "bytes", "form_urlencoded", "futures-util", @@ -119,6 +120,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "base64" version = "0.22.1" diff --git a/Cargo.toml b/Cargo.toml index 903b294..2a473bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ edition = "2024" [dependencies] argh = "0.1.19" -axum = { version = "0.8.9", optional = true } +axum = { version = "0.8.9", optional = true, features = ["macros"] } axum-extra = { version = "0.12.6", features = ["cookie"], optional = true } camino = "1.2.5" dn_escape = { path = "vendor/dn_escape" } diff --git a/src/db/common.rs b/src/db/common.rs index 4f278b8..d75ba10 100644 --- a/src/db/common.rs +++ b/src/db/common.rs @@ -3,7 +3,7 @@ use tokio::sync::RwLock; use std::sync::Arc; use crate::db::error::BoxedError; -use crate::db::{DatabaseInterface, UserRef}; +use crate::db::{DatabaseInterface, Domain, User, UserRef}; #[derive(Clone, Debug)] pub struct Database { @@ -36,4 +36,13 @@ impl Database { tracing::debug!("Comparing {} and {}", user.password, password); Ok(user.password == password) } + + pub async fn domains_user_can_see(&self, user: &User) -> Result, BoxedError> { + Ok(self + .list_all_domains() + .await? + .into_iter() + .filter(|d| user.role.can_see_domain(&d.name)) + .collect()) + } } diff --git a/src/db/domain.rs b/src/db/domain.rs index 6cca261..59a1a94 100644 --- a/src/db/domain.rs +++ b/src/db/domain.rs @@ -1,4 +1,6 @@ -#[derive(Clone, Debug, Default)] +use serde::Serialize; + +#[derive(Clone, Debug, Default, Serialize)] pub struct Domain { pub name: String, } diff --git a/src/db/interface.rs b/src/db/interface.rs index e5d58ec..42154e7 100644 --- a/src/db/interface.rs +++ b/src/db/interface.rs @@ -36,6 +36,10 @@ impl DatabaseInterface for Database { .await } + async fn list_all_domains(&self) -> Result, BoxedError> { + self.inner.read().await.list_all_domains().await + } + async fn list_all_users(&self) -> Result, BoxedError> { self.inner.read().await.list_all_users().await } @@ -67,8 +71,9 @@ pub trait DatabaseInterface: Clone + Send + Sync + 'static { current_user: &User, ) -> Result, BoxedError>; + fn list_all_domains(&self) -> impl Future, BoxedError>> + Send; #[expect(unused)] - fn list_all_users(&self) -> impl Future, BoxedError>>; + fn list_all_users(&self) -> impl Future, BoxedError>> + Send; /// List users on a specific domain. /// diff --git a/src/db/memory.rs b/src/db/memory.rs index 1ba13dd..83c3178 100644 --- a/src/db/memory.rs +++ b/src/db/memory.rs @@ -104,6 +104,10 @@ impl DatabaseInterface for MemoryDatabase { self.create_user(new_user).await } + fn list_all_domains(&self) -> impl Future, BoxedError>> { + ready(Ok(self.domains.clone())) + } + fn list_all_users(&self) -> impl Future, BoxedError>> { ready(Ok(self.users.clone())) } diff --git a/src/db/role.rs b/src/db/role.rs index f717813..102933b 100644 --- a/src/db/role.rs +++ b/src/db/role.rs @@ -2,11 +2,12 @@ use serde::Serialize; #[derive(Clone, Debug)] pub enum Operation { + CreateDomain, CreateUser(String), ListUsers(Option), } -#[derive(Clone, Debug, Serialize)] +#[derive(Clone, Debug, PartialEq, Serialize)] pub enum Role { /// Can do anything Admin, @@ -28,6 +29,7 @@ pub enum Role { impl Role { pub fn can_perform(&self, operation: &Operation) -> bool { match operation { + Operation::CreateDomain => self == &Self::Admin, Operation::CreateUser(op_domain) => match self { Self::Admin => true, Self::DomainAdmin(usr_domain) | Self::DomainModerator(usr_domain) => { @@ -44,4 +46,12 @@ impl Role { }, } } + + pub fn can_see_domain(&self, domain: &str) -> bool { + match self { + Self::Admin | Self::ReadonlyAdmin => true, + Self::DomainAdmin(d) | Self::DomainModerator(d) => domain == d, + Self::User => false, + } + } } diff --git a/src/db/user.rs b/src/db/user.rs index 5a325b1..967426a 100644 --- a/src/db/user.rs +++ b/src/db/user.rs @@ -80,6 +80,10 @@ impl User { pub fn can_perform(&self, operation: &Operation) -> bool { self.role.can_perform(operation) } + + pub fn can_create_domain(&self) -> bool { + self.role.can_perform(&Operation::CreateDomain) + } } impl fmt::Display for User { diff --git a/src/http/home.rs b/src/http/home.rs new file mode 100644 index 0000000..595ac17 --- /dev/null +++ b/src/http/home.rs @@ -0,0 +1,63 @@ +use axum::extract::State; +use axum::response::{Html, IntoResponse, Response}; +use axum_extra::extract::cookie::CookieJar; +use http::StatusCode; +use minijinja::context; + +use crate::db::{DatabaseInterface, Operation}; +use crate::http::HttpState; +use crate::http::login::login_page; + +pub async fn home( + State(state): State>, + cookies: CookieJar, +) -> Response { + if let Some(session) = state.sessions.get_session(&cookies) { + // When the user has no domain (service admin) list all domains + let op = Operation::ListUsers(session.user.domain.clone()); + let other_users = if session.user.can_perform(&op) { + match state + .db + .list_domain_users(session.user.domain.clone()) + .await + { + Ok(other_users) => other_users, + Err(e) => { + return format!("Database error: {e}").into_response(); + } + } + } else { + vec![] + }; + + tracing::info!( + "Found {} users on domain {:?}", + other_users.len(), + session.user.domain + ); + + let domains = match state.db.domains_user_can_see(&session.user).await { + Ok(domains) => domains, + Err(e) => { + return format!("Database error: {e}").into_response(); + } + }; + + let ctx = context! { + domains, + user => session.user, + can_create_domain => session.user.can_create_domain(), + other_users, + }; + + let page = state + .templates + .get_template("home.html") + .unwrap() + .render(ctx) + .unwrap(); + (StatusCode::OK, Html(page)).into_response() + } else { + login_page(State(state), None).await + } +} diff --git a/src/http/mod.rs b/src/http/mod.rs index bd5b2ec..1d65472 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -1,22 +1,19 @@ use axum::Router; -use axum::extract::State; -use axum::response::{Html, IntoResponse, Response}; use axum::routing::{get, post}; use axum::serve::Listener as AxumListener; -use axum_extra::extract::cookie::CookieJar; -use http::StatusCode; +use minijinja::Environment; #[cfg(not(feature = "embed"))] use minijinja::path_loader; -use minijinja::{Environment, context}; #[cfg(feature = "embed")] use static_serve::embed_assets; #[cfg(not(feature = "embed"))] use tower_http::services::ServeDir; -use crate::db::{Database, DatabaseInterface, Operation}; +use crate::db::{Database, DatabaseInterface}; use crate::listener::{Listener, ListenerKind}; use crate::stream::{AbstractSocketAddr, AbstractStreamKind}; +mod home; mod login; mod logout; mod session; @@ -98,56 +95,11 @@ pub async fn http_listen(listener: Listener, db: Database< #[cfg(not(feature = "embed"))] let app = { Router::new().nest_service("/assets", ServeDir::new("assets")) }; let app = app - .route("/", get(home)) - .route("/login", get(home)) + .route("/", get(home::home)) + .route("/login", get(home::home)) .route("/login", post(login::post_login)) .route("/logout", get(logout::logout)) .with_state(HttpState::new(db)); axum::serve(listener, app).await.unwrap(); } - -pub async fn home( - State(state): State>, - cookies: CookieJar, -) -> Response { - if let Some(session) = state.sessions.get_session(&cookies) { - // When the user has no domain (service admin) list all domains - let op = Operation::ListUsers(session.user.domain.clone()); - let other_users = if session.user.can_perform(&op) { - match state - .db - .list_domain_users(session.user.domain.clone()) - .await - { - Ok(other_users) => other_users, - Err(e) => { - return format!("Database error: {e}").into_response(); - } - } - } else { - vec![] - }; - - tracing::info!( - "Found {} users on domain {:?}", - other_users.len(), - session.user.domain - ); - - let ctx = context! { - user => session.user, - other_users, - }; - - let page = state - .templates - .get_template("home.html") - .unwrap() - .render(ctx) - .unwrap(); - (StatusCode::OK, Html(page)).into_response() - } else { - login::login_page(State(state), None).await - } -} diff --git a/templates/home.html b/templates/home.html index e9f4925..15eb0da 100644 --- a/templates/home.html +++ b/templates/home.html @@ -6,6 +6,23 @@ + {% if can_create_domain %} +
    +

    Create domain

    +
    + + +
    +
    +
    +

    Active domains you can see

    + +
    + {% endif %} {% if other_users %}

    Other users you have permission to see on your own domain

    From 38700ee4cb5d5743251b5d4e3a223cebd4a987b8 Mon Sep 17 00:00:00 2001 From: selfhoster1312 Date: Sat, 19 Sep 2026 10:38:32 +0200 Subject: [PATCH 4/7] fix: Domain admin can see domain in home --- src/db/common.rs | 2 +- src/db/user.rs | 6 ++++++ templates/home.html | 16 ++++++++-------- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/db/common.rs b/src/db/common.rs index d75ba10..f7408c6 100644 --- a/src/db/common.rs +++ b/src/db/common.rs @@ -42,7 +42,7 @@ impl Database { .list_all_domains() .await? .into_iter() - .filter(|d| user.role.can_see_domain(&d.name)) + .filter(|d| user.can_see_domain(&d.name)) .collect()) } } diff --git a/src/db/user.rs b/src/db/user.rs index 967426a..b7c8bcd 100644 --- a/src/db/user.rs +++ b/src/db/user.rs @@ -84,6 +84,12 @@ impl User { pub fn can_create_domain(&self) -> bool { self.role.can_perform(&Operation::CreateDomain) } + + pub fn can_see_domain(&self, domain: &str) -> bool { + let allowed = self.role.can_see_domain(domain); + tracing::debug!("{} can see domain {}: {}", self.mail, domain, allowed); + allowed + } } impl fmt::Display for User { diff --git a/templates/home.html b/templates/home.html index 15eb0da..13f717e 100644 --- a/templates/home.html +++ b/templates/home.html @@ -14,15 +14,15 @@
    -
    -

    Active domains you can see

    - -
    {% endif %} +
    +

    Active domains you can see

    + +
    {% if other_users %}

    Other users you have permission to see on your own domain

    From ae5d466e992cfcfa0f50cde77b8a79b5bff6d25b Mon Sep 17 00:00:00 2001 From: selfhoster1312 Date: Sat, 19 Sep 2026 10:44:44 +0200 Subject: [PATCH 5/7] feat: Basic domain admin page --- src/db/interface.rs | 5 +++- src/http/domain.rs | 54 +++++++++++++++++++++++++++++++++++++++++++ src/http/mod.rs | 3 +++ templates/domain.html | 30 ++++++++++++++++++++++++ templates/home.html | 2 +- 5 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 src/http/domain.rs create mode 100644 templates/domain.html diff --git a/src/db/interface.rs b/src/db/interface.rs index 42154e7..c109873 100644 --- a/src/db/interface.rs +++ b/src/db/interface.rs @@ -54,7 +54,10 @@ pub trait DatabaseInterface: Clone + Send + Sync + 'static { &mut self, domain: &str, ) -> impl Future, BoxedError>>; - fn get_domain(&self, domain: &str) -> impl Future, BoxedError>>; + fn get_domain( + &self, + domain: &str, + ) -> impl Future, BoxedError>> + Send; fn get_user( &self, diff --git a/src/http/domain.rs b/src/http/domain.rs new file mode 100644 index 0000000..479b5f4 --- /dev/null +++ b/src/http/domain.rs @@ -0,0 +1,54 @@ +use axum::extract::{Path, State}; +use axum::response::{Html, IntoResponse, Response}; +use axum_extra::extract::cookie::CookieJar; +use http::StatusCode; +use minijinja::context; + +use crate::db::{DatabaseInterface, Operation}; +use crate::http::HttpState; +use crate::http::login::login_page; + +pub async fn get_domain( + State(state): State>, + cookies: CookieJar, + Path(domain): Path, +) -> Response { + let Some(session) = state.sessions.get_session(&cookies) else { + return login_page(State(state), None).await.into_response(); + }; + + let domain = match state.db.get_domain(&domain).await { + Ok(Some(domain)) => domain, + Ok(None) => return format!("Domain not found: {domain}").into_response(), + Err(e) => { + return format!("Database error: {e}").into_response(); + } + }; + + let domain_users = match state.db.list_domain_users(Some(domain.name.clone())).await { + Ok(users) => users, + Err(e) => { + return format!("Database error: {e}").into_response(); + } + }; + + // Redundant because someone who can see the domain admin page for the moment + // always can create accounts. + let op = Operation::CreateUser(domain.name.clone()); + let can_create_user = session.user.can_perform(&op); + + let ctx = context! { + can_create_user, + domain, + user => session.user, + users => domain_users, + }; + + let page = state + .templates + .get_template("domain.html") + .unwrap() + .render(ctx) + .unwrap(); + (StatusCode::OK, Html(page)).into_response() +} diff --git a/src/http/mod.rs b/src/http/mod.rs index 1d65472..4692ec9 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -13,6 +13,7 @@ use crate::db::{Database, DatabaseInterface}; use crate::listener::{Listener, ListenerKind}; use crate::stream::{AbstractSocketAddr, AbstractStreamKind}; +mod domain; mod home; mod login; mod logout; @@ -99,6 +100,8 @@ pub async fn http_listen(listener: Listener, db: Database< .route("/login", get(home::home)) .route("/login", post(login::post_login)) .route("/logout", get(logout::logout)) + .route("/domain/{domain}", get(domain::get_domain)) + //.route("/domain", post(domain::create_domain)) .with_state(HttpState::new(db)); axum::serve(listener, app).await.unwrap(); diff --git a/templates/domain.html b/templates/domain.html new file mode 100644 index 0000000..a5c46a4 --- /dev/null +++ b/templates/domain.html @@ -0,0 +1,30 @@ +{% extends 'base.html' %} +{% block main %} +
    + +

    You are logged in as {{ user.username }}

    +
    + Logout +
    + {% if can_create_user %} +
    +

    Create user

    +
    + + + + +
    +
    + {% endif %} +
    +

    Users on {{ domain.name }}

    +
      + {% for user in users %} +
    • {{ user.mail }}
    • + {% endfor %} +
    +
    +
    +{% endblock %} + diff --git a/templates/home.html b/templates/home.html index 13f717e..d1f71b2 100644 --- a/templates/home.html +++ b/templates/home.html @@ -9,7 +9,7 @@ {% if can_create_domain %}

    Create domain

    -
    +
    From 78df25f150f732b1e03bce1a515a9886f59ab27c Mon Sep 17 00:00:00 2001 From: selfhoster1312 Date: Sat, 19 Sep 2026 10:51:23 +0200 Subject: [PATCH 6/7] feat: Basic domain creation form --- src/db/interface.rs | 2 +- src/http/domain.rs | 31 +++++++++++++++++++++++++++++-- src/http/mod.rs | 2 +- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/db/interface.rs b/src/db/interface.rs index c109873..baccc60 100644 --- a/src/db/interface.rs +++ b/src/db/interface.rs @@ -53,7 +53,7 @@ pub trait DatabaseInterface: Clone + Send + Sync + 'static { fn create_domain( &mut self, domain: &str, - ) -> impl Future, BoxedError>>; + ) -> impl Future, BoxedError>> + Send; fn get_domain( &self, domain: &str, diff --git a/src/http/domain.rs b/src/http/domain.rs index 479b5f4..8bccc5b 100644 --- a/src/http/domain.rs +++ b/src/http/domain.rs @@ -1,8 +1,9 @@ -use axum::extract::{Path, State}; -use axum::response::{Html, IntoResponse, Response}; +use axum::extract::{Form, Path, State}; +use axum::response::{Html, IntoResponse, Redirect, Response}; use axum_extra::extract::cookie::CookieJar; use http::StatusCode; use minijinja::context; +use serde::Deserialize; use crate::db::{DatabaseInterface, Operation}; use crate::http::HttpState; @@ -52,3 +53,29 @@ pub async fn get_domain( .unwrap(); (StatusCode::OK, Html(page)).into_response() } + +#[derive(Clone, Debug, Deserialize)] +pub struct DomainCreationForm { + domainname: String, +} + +pub async fn create_domain( + State(mut state): State>, + cookies: CookieJar, + Form(form): Form, +) -> Response { + let Some(session) = state.sessions.get_session(&cookies) else { + return login_page(State(state), None).await.into_response(); + }; + + let op = Operation::CreateDomain; + if !session.user.can_perform(&op) { + return "Not authorized to create a new domain".into_response(); + } + + match state.db.create_domain(&form.domainname).await { + Ok(Ok(())) => Redirect::to(&format!("/domain/{}", form.domainname)).into_response(), + Ok(Err(e)) => format!("Failed to create domain {}: {}", form.domainname, e).into_response(), + Err(e) => format!("Database error: {e}").into_response(), + } +} diff --git a/src/http/mod.rs b/src/http/mod.rs index 4692ec9..9cd0a94 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -101,7 +101,7 @@ pub async fn http_listen(listener: Listener, db: Database< .route("/login", post(login::post_login)) .route("/logout", get(logout::logout)) .route("/domain/{domain}", get(domain::get_domain)) - //.route("/domain", post(domain::create_domain)) + .route("/domain", post(domain::create_domain)) .with_state(HttpState::new(db)); axum::serve(listener, app).await.unwrap(); From e28c584b26f5a65c89d569c54edcd285bb82554e Mon Sep 17 00:00:00 2001 From: selfhoster1312 Date: Sat, 19 Sep 2026 11:13:21 +0200 Subject: [PATCH 7/7] feat: Basic user creation form --- src/db/interface.rs | 5 ++--- src/db/memory.rs | 1 - src/http/mod.rs | 2 ++ src/http/user.rs | 49 +++++++++++++++++++++++++++++++++++++++++++ templates/domain.html | 2 +- 5 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 src/http/user.rs diff --git a/src/db/interface.rs b/src/db/interface.rs index baccc60..ca84fd9 100644 --- a/src/db/interface.rs +++ b/src/db/interface.rs @@ -67,12 +67,11 @@ pub trait DatabaseInterface: Clone + Send + Sync + 'static { &mut self, user: User, ) -> Result, BoxedError>; - #[expect(unused)] - async fn try_create_user( + fn try_create_user( &mut self, new_user: User, current_user: &User, - ) -> Result, BoxedError>; + ) -> impl Future, BoxedError>> + Send; fn list_all_domains(&self) -> impl Future, BoxedError>> + Send; #[expect(unused)] diff --git a/src/db/memory.rs b/src/db/memory.rs index 83c3178..e170db0 100644 --- a/src/db/memory.rs +++ b/src/db/memory.rs @@ -91,7 +91,6 @@ impl DatabaseInterface for MemoryDatabase { // // TODO: for now we don't allow creating service users manually // so we assume there's a domain provided - // TODO: restrict user creation on non-declared domains let Some(new_user_domain) = &new_user.domain else { return Ok(Err(UserCreationError::Permissions)); }; diff --git a/src/http/mod.rs b/src/http/mod.rs index 9cd0a94..def0f22 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -19,6 +19,7 @@ mod login; mod logout; mod session; use session::HttpSessionManager; +mod user; impl AxumListener for Listener { type Io = AbstractStreamKind; @@ -102,6 +103,7 @@ pub async fn http_listen(listener: Listener, db: Database< .route("/logout", get(logout::logout)) .route("/domain/{domain}", get(domain::get_domain)) .route("/domain", post(domain::create_domain)) + .route("/user", post(user::create_user)) .with_state(HttpState::new(db)); axum::serve(listener, app).await.unwrap(); diff --git a/src/http/user.rs b/src/http/user.rs new file mode 100644 index 0000000..d6eb054 --- /dev/null +++ b/src/http/user.rs @@ -0,0 +1,49 @@ +use axum::extract::{Form, State}; +use axum::response::{IntoResponse, Redirect, Response}; +use axum_extra::extract::cookie::CookieJar; +use serde::Deserialize; + +use crate::db::{DatabaseInterface, Role, User}; +use crate::http::HttpState; +use crate::http::login::login_page; + +#[derive(Clone, Debug, Deserialize)] +pub struct UserCreationForm { + pub username: String, + pub domain: String, + pub password: String, +} + +pub async fn create_user( + State(mut state): State>, + cookies: CookieJar, + Form(form): Form, +) -> Response { + let Some(session) = state.sessions.get_session(&cookies) else { + return login_page(State(state), None).await.into_response(); + }; + + // let domain = form.domain; + // let op = Operation::CreateUser(domain.clone()); + // if !session.user.can_perform(&op) { + // return format!("Not authorized to create a new user on domain {domain}").into_response() + // } + + let new_user = User { + mail: format!("{}@{}", form.username, form.domain), + username: form.username.clone(), + domain: Some(form.domain.clone()), + password: form.password, + role: Role::User, + }; + + match state.db.try_create_user(new_user, &session.user).await { + Ok(Ok(())) => Redirect::to(&format!("/domain/{}", form.domain)).into_response(), + Ok(Err(e)) => format!( + "Failed to create user {} on domain {}: {}", + form.username, form.domain, e + ) + .into_response(), + Err(e) => format!("Database error: {e}").into_response(), + } +} diff --git a/templates/domain.html b/templates/domain.html index a5c46a4..758ad65 100644 --- a/templates/domain.html +++ b/templates/domain.html @@ -10,7 +10,7 @@

    Create user

    - +