feat: Implement JSON-backed database

This commit is contained in:
selfhoster selfhoster 2026-09-19 14:17:41 +02:00
commit 9933990fd3
12 changed files with 179 additions and 18 deletions

4
Cargo.lock generated
View file

@ -169,6 +169,9 @@ name = "camino"
version = "1.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d"
dependencies = [
"serde_core",
]
[[package]]
name = "cc"
@ -530,6 +533,7 @@ dependencies = [
"minijinja",
"minijinja-embed",
"serde",
"serde_json",
"static-serve",
"tokio",
"tokio-util",

View file

@ -7,7 +7,7 @@ edition = "2024"
argh = "0.1.19"
axum = { version = "0.8.9", optional = true, features = ["macros"] }
axum-extra = { version = "0.12.6", features = ["cookie"], optional = true }
camino = "1.2.5"
camino = { version = "1.2.5", features = ["serde1"] }
dn_escape = { path = "vendor/dn_escape" }
futures-util = { version = "0.3.34", features = ["sink"] }
http = { version = "1.5.0", optional = true }
@ -15,6 +15,7 @@ ldap3_proto = "0.8.1"
minijinja = { version = "2.24.0", optional = true }
minijinja-embed = { version = "2.24.0", optional = true }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
static-serve = { version = "0.6.3", optional = true }
tokio = { version = "1.53.1", features = ["macros", "net", "rt", "time", "sync"] }
tokio-util = { version = "0.7.19", features = ["codec"] }

View file

@ -1,8 +1,13 @@
use argh::FromArgs;
use camino::Utf8PathBuf;
/// Run the llldap server
#[derive(FromArgs)]
pub struct CliArgs {
/// path to the JSON file for the database
#[argh(option)]
pub db: Option<Utf8PathBuf>,
/// address or socket to listen on for LDAP connections
#[argh(option, default = "String::from(\"127.0.0.1:3389\")")]
pub listen_ldap: String,

View file

@ -1,6 +1,6 @@
use serde::Serialize;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, Serialize)]
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct Domain {
pub name: String,
}

145
src/db/filesystem.rs Normal file
View file

@ -0,0 +1,145 @@
use camino::{Utf8Path, Utf8PathBuf};
use serde::{Deserialize, Serialize};
use serde_json::Error as JsonError;
use std::fmt;
use std::io::Error as IOError;
use crate::db::error::{BoxedError, DomainCreationError, UserCreationError};
use crate::db::{Database, DatabaseInterface, Domain, MemoryDatabase, User, UserRef};
#[derive(Debug)]
pub enum FilesystemDatabaseError {
ReadFileIO(Utf8PathBuf, IOError),
ReadFileJson(Utf8PathBuf, JsonError),
WriteFileIO(Utf8PathBuf, IOError),
WriteFileJson(Utf8PathBuf, JsonError),
}
impl fmt::Display for FilesystemDatabaseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}",
match self {
Self::ReadFileIO(path, e) => format!("Failed to read database file {path}: {e}"),
Self::ReadFileJson(path, e) =>
format!("Failed to parse database JSON file {path}: {e}"),
Self::WriteFileIO(path, e) => format!("Failed to write database file {path}: {e}"),
Self::WriteFileJson(path, e) =>
format!("Failed to convert database to JSON file {path}: {e}"),
}
)
}
}
impl std::error::Error for FilesystemDatabaseError {}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct FilesystemDatabase {
// TODO: Once we have common validation steps in place across DB backends, we can reduce cloning.
// For now, we clone the DB on every write operation, and update it when saving to disk
// succeeds.
pub inner: MemoryDatabase,
pub path: Utf8PathBuf,
}
impl FilesystemDatabase {
pub async fn from_path(path: impl AsRef<Utf8Path>) -> Result<Database<Self>, BoxedError> {
let path = path.as_ref();
let s = tokio::fs::read(path)
.await
.map_err(|e| Box::new(FilesystemDatabaseError::ReadFileIO(path.to_path_buf(), e)))?;
let inner: MemoryDatabase = serde_json::from_slice(&s)
.map_err(|e| Box::new(FilesystemDatabaseError::ReadFileJson(path.to_path_buf(), e)))?;
Ok(Database::new(Self {
inner,
path: path.to_path_buf(),
}))
}
pub async fn save(&mut self) -> Result<(), BoxedError> {
let s = serde_json::to_string(&self)
.map_err(|e| Box::new(FilesystemDatabaseError::WriteFileJson(self.path.clone(), e)))?;
Ok(tokio::fs::write(&self.path, &s)
.await
.map_err(|e| Box::new(FilesystemDatabaseError::WriteFileIO(self.path.clone(), e)))?)
}
}
impl DatabaseInterface for FilesystemDatabase {
async fn create_domain(
&mut self,
domain: &str,
) -> Result<Result<(), DomainCreationError>, BoxedError> {
let mut new_db = self.inner.clone();
if let Err(e) = new_db.create_domain(domain).await? {
return Ok(Err(e));
}
self.save().await?;
self.inner = new_db;
Ok(Ok(()))
}
fn get_domain(&self, domain: &str) -> impl Future<Output = Result<Option<Domain>, BoxedError>> {
self.inner.get_domain(domain)
}
fn get_user(
&self,
req_user: &UserRef,
) -> impl Future<Output = Result<Option<User>, BoxedError>> {
self.inner.get_user(req_user)
}
async fn create_user(
&mut self,
user: User,
) -> Result<Result<(), UserCreationError>, BoxedError> {
let mut new_db = self.inner.clone();
if let Err(e) = new_db.create_user(user).await? {
return Ok(Err(e));
}
self.save().await?;
self.inner = new_db;
Ok(Ok(()))
}
async fn try_create_user(
&mut self,
new_user: User,
current_user: &User,
) -> Result<Result<(), UserCreationError>, BoxedError> {
let mut new_db = self.inner.clone();
if let Err(e) = new_db.try_create_user(new_user, current_user).await? {
return Ok(Err(e));
}
self.save().await?;
self.inner = new_db;
Ok(Ok(()))
}
fn list_all_domains(&self) -> impl Future<Output = Result<Vec<Domain>, BoxedError>> {
self.inner.list_all_domains()
}
fn list_all_users(&self) -> impl Future<Output = Result<Vec<User>, BoxedError>> {
self.inner.list_all_users()
}
fn list_domain_users(
&self,
domain: Option<String>,
) -> impl Future<Output = Result<Vec<User>, BoxedError>> {
self.inner.list_domain_users(domain)
}
}

View file

@ -1,7 +1,7 @@
#[derive(Clone, Debug)]
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Group {
#[expect(unused)]
name: String,
#[expect(unused)]
domain: String,
}

View file

@ -62,7 +62,7 @@ pub trait DatabaseInterface: Clone + Send + Sync + 'static {
fn get_user(
&self,
user: &UserRef,
) -> impl std::future::Future<Output = Result<Option<User>, BoxedError>> + Send;
) -> impl Future<Output = Result<Option<User>, BoxedError>> + Send;
async fn create_user(
&mut self,
user: User,
@ -83,5 +83,5 @@ pub trait DatabaseInterface: Clone + Send + Sync + 'static {
fn list_domain_users(
&self,
domain: Option<String>,
) -> impl std::future::Future<Output = Result<Vec<User>, BoxedError>> + Send;
) -> impl Future<Output = Result<Vec<User>, BoxedError>> + Send;
}

View file

@ -1,13 +1,14 @@
use std::future::{Future, ready};
use serde::{Deserialize, Serialize};
use std::future::ready;
use crate::db::error::{BoxedError, DomainCreationError, UserCreationError};
use crate::db::{Database, DatabaseInterface, Domain, Group, Operation, User, UserRef};
#[derive(Clone, Debug, Default)]
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct MemoryDatabase {
// We store data in tables like in SQL
pub domains: Vec<Domain>,
#[expect(unused)]
pub groups: Vec<Group>,
pub users: Vec<User>,
}

View file

@ -3,6 +3,8 @@ pub use common::Database;
mod domain;
pub use domain::Domain;
pub mod error;
mod filesystem;
pub use filesystem::FilesystemDatabase;
mod group;
pub use group::Group;
mod interface;

View file

@ -1,4 +1,4 @@
use serde::Serialize;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug)]
pub enum Operation {
@ -7,12 +7,11 @@ pub enum Operation {
ListUsers(Option<String>),
}
#[derive(Clone, Debug, PartialEq, Serialize)]
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
pub enum Role {
/// Can do anything
Admin,
/// Can only read data across all vhosts
#[expect(unused)]
ReadonlyAdmin,
/// Can do anything on a domain, except removing
/// oneself as a domain admin.
@ -20,7 +19,6 @@ pub enum Role {
/// Can not give away roles other than DomainModerator/User
DomainAdmin(String),
/// Can create users and reset passwords on a domain
#[expect(unused)]
DomainModerator(String),
/// Can only edit own profile
User,

View file

@ -1,4 +1,4 @@
use serde::Serialize;
use serde::{Deserialize, Serialize};
use std::fmt;
@ -54,7 +54,7 @@ impl fmt::Display for UserRef {
}
}
#[derive(Clone, Debug, Serialize)]
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct User {
/// Username, without the domain part. Once set, cannot be edited.
pub username: String,

View file

@ -12,7 +12,7 @@ mod stream;
#[cfg(feature = "http")]
use crate::http::http_listen;
use cli::CliArgs;
use db::{Database, DatabaseInterface, MemoryDatabase, Role, User};
use db::{Database, DatabaseInterface, FilesystemDatabase, MemoryDatabase, Role, User};
use error::GlobalError;
use ldap::ldap_handler;
use listener::ListenerPath;
@ -69,6 +69,11 @@ async fn main() -> Result<(), GlobalError> {
let ldap_listener = ListenerPath::new(&cli.listen_ldap)?.listener().await?;
// let mut db = if let Some(db_path) = &cli.db {
// FilesystemDatabase::from_path(db_path).await.unwrap()
// } else {
// MemoryDatabase::new()
// };
let mut db = MemoryDatabase::new();
create_dummy_users(&mut db).await;