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..f7408c6 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.can_see_domain(&d.name)) + .collect()) + } } diff --git a/src/db/domain.rs b/src/db/domain.rs new file mode 100644 index 0000000..59a1a94 --- /dev/null +++ b/src/db/domain.rs @@ -0,0 +1,6 @@ +use serde::Serialize; + +#[derive(Clone, Debug, Default, Serialize)] +pub struct Domain { + pub name: String, +} 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..ca84fd9 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 } @@ -25,12 +36,29 @@ 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_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 + } + + async fn list_domain_users(&self, domain: Option) -> Result, BoxedError> { + self.inner.read().await.list_domain_users(domain).await } } pub trait DatabaseInterface: Clone + Send + Sync + 'static { + fn create_domain( + &mut self, + domain: &str, + ) -> impl Future, BoxedError>> + Send; + fn get_domain( + &self, + domain: &str, + ) -> impl Future, BoxedError>> + Send; + fn get_user( &self, user: &UserRef, @@ -39,13 +67,20 @@ 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>; - fn list_users( + ) -> impl Future, BoxedError>> + Send; + + fn list_all_domains(&self) -> impl Future, BoxedError>> + Send; + #[expect(unused)] + fn list_all_users(&self) -> impl Future, BoxedError>> + Send; + + /// List users on a specific domain. + /// + /// 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 62b98a3..e170db0 100644 --- a/src/db/memory.rs +++ b/src/db/memory.rs @@ -1,13 +1,15 @@ 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, Group, Operation, User, UserRef}; #[derive(Clone, Debug, Default)] pub struct MemoryDatabase { - pub users: Vec, + // We store data in tables like in SQL + pub domains: Vec, #[expect(unused)] pub groups: Vec, + pub users: Vec, } impl MemoryDatabase { @@ -17,17 +19,46 @@ impl MemoryDatabase { } impl DatabaseInterface for MemoryDatabase { + fn create_domain( + &mut self, + domain: &str, + ) -> impl Future, BoxedError>> { + if domain.is_empty() { + return ready(Ok(Err(DomainCreationError::InvalidDomain( + domain.to_string(), + )))); + } + + 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(), + }); + + ready(Ok(Ok(()))) + } + + 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()))) + } + 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()))); - } - } - - ready(Ok(None)) + ready(Ok(self + .users + .iter() + .find(|u| u.username == req_user.username && u.domain == req_user.domain) + .cloned())) } async fn create_user( @@ -35,10 +66,18 @@ impl DatabaseInterface for MemoryDatabase { user: User, ) -> Result, BoxedError> { let user_ref = user.user_ref(); + if self.get_user(&user_ref).await?.is_some() { return Ok(Err(UserCreationError::UserAlreadyExists(user_ref))); } + // 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()))); + } + self.users.push(user); Ok(Ok(())) } @@ -52,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)); }; @@ -65,19 +103,23 @@ impl DatabaseInterface for MemoryDatabase { self.create_user(new_user).await } - fn list_users( + 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())) + } + + fn list_domain_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() - } else { - self.users.clone() - }; - ready(Ok(users)) + ready(Ok(self + .users + .iter() + .filter(|u| u.domain == domain) + .cloned() + .collect())) } } 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/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..b7c8bcd 100644 --- a/src/db/user.rs +++ b/src/db/user.rs @@ -80,6 +80,16 @@ 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) + } + + 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/src/http/domain.rs b/src/http/domain.rs new file mode 100644 index 0000000..8bccc5b --- /dev/null +++ b/src/http/domain.rs @@ -0,0 +1,81 @@ +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; +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() +} + +#[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/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 98a20ed..def0f22 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -1,26 +1,25 @@ 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 domain; +mod home; mod login; mod logout; mod session; use session::HttpSessionManager; +mod user; impl AxumListener for Listener { type Io = AbstractStreamKind; @@ -98,52 +97,14 @@ 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)) + .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(); } - -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_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/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/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 diff --git a/templates/domain.html b/templates/domain.html new file mode 100644 index 0000000..758ad65 --- /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 584fad4..d1f71b2 100644 --- a/templates/home.html +++ b/templates/home.html @@ -6,9 +6,26 @@ + {% if can_create_domain %} +
+

Create domain

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

Active domains you can see

+ +
{% 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 }}