use std::path::PathBuf;
use serde::Serialize;
use crate::error::AdminError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Theme {
#[default]
Auto,
Light,
Dark,
}
#[derive(Debug, Clone, Serialize)]
pub struct NavLink {
pub label: String,
pub url: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct UiSettings {
pub(crate) site_title: String,
pub(crate) accent_color: String,
pub(crate) logo_url: Option<String>,
pub(crate) theme: Theme,
pub(crate) custom_css_url: Option<String>,
pub(crate) footer_text: Option<String>,
pub(crate) page_size: u64,
pub(crate) nav_links: Vec<NavLink>,
pub(crate) web_fonts: bool,
#[serde(skip)]
pub(crate) template_dir: Option<PathBuf>,
}
impl Default for UiSettings {
fn default() -> Self {
Self {
site_title: "Administration".to_owned(),
accent_color: "#4f46e5".to_owned(),
logo_url: None,
theme: Theme::Auto,
custom_css_url: None,
footer_text: None,
page_size: 25,
nav_links: Vec::new(),
web_fonts: true,
template_dir: None,
}
}
}
impl UiSettings {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn from_env() -> Result<Self, AdminError> {
Self::from_lookup(|key| std::env::var(key).ok())
}
pub fn from_lookup(lookup: impl Fn(&str) -> Option<String>) -> Result<Self, AdminError> {
let mut settings = Self::default();
if let Some(v) = lookup("ADMIN_SITE_TITLE") {
settings.site_title = v;
}
if let Some(v) = lookup("ADMIN_ACCENT_COLOR") {
settings = settings.accent_color(&v)?;
}
settings.logo_url = lookup("ADMIN_LOGO_URL");
if let Some(v) = lookup("ADMIN_THEME") {
settings.theme = match v.to_ascii_lowercase().as_str() {
"auto" => Theme::Auto,
"light" => Theme::Light,
"dark" => Theme::Dark,
other => {
return Err(AdminError::Config(format!(
"ADMIN_THEME must be auto, light or dark, got `{other}`"
)));
}
};
}
settings.custom_css_url = lookup("ADMIN_CUSTOM_CSS_URL");
settings.footer_text = lookup("ADMIN_FOOTER_TEXT");
if let Some(v) = lookup("ADMIN_PAGE_SIZE") {
settings.page_size = v.parse::<u64>().ok().filter(|n| *n > 0).ok_or_else(|| {
AdminError::Config("ADMIN_PAGE_SIZE must be a positive integer".into())
})?;
}
if let Some(v) = lookup("ADMIN_NAV_LINKS") {
for pair in v.split(',').filter(|p| !p.trim().is_empty()) {
let (label, url) = pair.split_once('=').ok_or_else(|| {
AdminError::Config("ADMIN_NAV_LINKS entries must look like Label=/url".into())
})?;
settings = settings.nav_link(label.trim(), url.trim());
}
}
if let Some(v) = lookup("ADMIN_WEB_FONTS") {
settings.web_fonts = match v.to_ascii_lowercase().as_str() {
"true" | "1" | "yes" => true,
"false" | "0" | "no" => false,
_ => {
return Err(AdminError::Config(
"ADMIN_WEB_FONTS must be true or false".into(),
));
}
};
}
settings.template_dir = lookup("ADMIN_TEMPLATE_DIR").map(PathBuf::from);
Ok(settings)
}
#[must_use]
pub fn site_title(mut self, title: impl Into<String>) -> Self {
self.site_title = title.into();
self
}
pub fn accent_color(mut self, color: &str) -> Result<Self, AdminError> {
let hex = color.strip_prefix('#').unwrap_or("");
if !(matches!(hex.len(), 3 | 6) && hex.chars().all(|c| c.is_ascii_hexdigit())) {
return Err(AdminError::Config(format!(
"accent colour must be #rgb or #rrggbb, got `{color}`"
)));
}
self.accent_color = color.to_owned();
Ok(self)
}
#[must_use]
pub fn web_fonts(mut self, enabled: bool) -> Self {
self.web_fonts = enabled;
self
}
#[must_use]
pub fn logo_url(mut self, url: impl Into<String>) -> Self {
self.logo_url = Some(url.into());
self
}
#[must_use]
pub fn theme(mut self, theme: Theme) -> Self {
self.theme = theme;
self
}
#[must_use]
pub fn custom_css_url(mut self, url: impl Into<String>) -> Self {
self.custom_css_url = Some(url.into());
self
}
#[must_use]
pub fn footer_text(mut self, text: impl Into<String>) -> Self {
self.footer_text = Some(text.into());
self
}
#[must_use]
pub fn page_size(mut self, rows: u64) -> Self {
self.page_size = rows.max(1);
self
}
#[must_use]
pub fn nav_link(mut self, label: impl Into<String>, url: impl Into<String>) -> Self {
self.nav_links.push(NavLink {
label: label.into(),
url: url.into(),
});
self
}
#[must_use]
pub fn template_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.template_dir = Some(dir.into());
self
}
}
pub use std::time::Duration;
pub use tracing::Level as LogLevel;
#[derive(Clone)]
pub struct Settings {
pub secret_key: String,
pub database: DatabaseSettings,
pub server: ServerSettings,
pub auth: AuthSettings,
pub admin: AdminSettings,
pub api: ApiSettings,
pub docs: DocsSettings,
pub home: HomeSettings,
pub uploads: UploadSettings,
pub mail: MailSettings,
pub log_level: LogLevel,
}
impl Default for Settings {
fn default() -> Self {
Self {
secret_key: String::new(),
database: DatabaseSettings::default(),
server: ServerSettings::default(),
auth: AuthSettings::default(),
admin: AdminSettings::default(),
api: ApiSettings::default(),
docs: DocsSettings::default(),
home: HomeSettings::default(),
uploads: UploadSettings::default(),
mail: MailSettings::default(),
log_level: LogLevel::INFO,
}
}
}
impl std::fmt::Debug for Settings {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Settings")
.field("secret_key", &"<hidden>")
.field("database", &self.database)
.field("server", &self.server)
.field("auth", &self.auth)
.field("admin", &self.admin)
.field("api", &self.api)
.field("docs", &self.docs)
.field("home", &self.home)
.field("uploads", &self.uploads)
.field("mail", &self.mail)
.field("log_level", &self.log_level)
.finish()
}
}
#[derive(Clone, Default)]
pub struct DatabaseSettings {
pub url: String,
pub max_connections: Option<u32>,
pub min_connections: Option<u32>,
pub acquire_timeout: Option<Duration>,
}
impl std::fmt::Debug for DatabaseSettings {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let scheme = self.url.split_once(':').map_or("", |(scheme, _)| scheme);
f.debug_struct("DatabaseSettings")
.field("url", &format_args!("{scheme}:<hidden>"))
.field("max_connections", &self.max_connections)
.field("min_connections", &self.min_connections)
.field("acquire_timeout", &self.acquire_timeout)
.finish()
}
}
#[derive(Debug, Clone)]
pub struct ServerSettings {
pub bind: String,
pub request_timeout: Option<Duration>,
pub cors_origins: Vec<String>,
pub client_ip_header: Option<String>,
pub public_url: Option<String>,
}
impl Default for ServerSettings {
fn default() -> Self {
Self {
bind: "127.0.0.1:3000".to_owned(),
request_timeout: Some(Duration::from_secs(30)),
cors_origins: Vec::new(),
client_ip_header: None,
public_url: None,
}
}
}
#[derive(Debug, Clone)]
pub struct AuthSettings {
pub methods: Vec<AuthMethod>,
pub signup: Signup,
pub signup_groups: Vec<String>,
pub token_lifetime: Duration,
pub totp_required: bool,
pub cookie_secure: bool,
pub login_max_attempts: u32,
pub login_lockout: Duration,
pub api_keys: bool,
}
impl Default for AuthSettings {
fn default() -> Self {
Self {
methods: vec![AuthMethod::Password],
signup: Signup::Closed,
signup_groups: Vec::new(),
token_lifetime: Duration::from_secs(3600),
totp_required: false,
cookie_secure: true,
login_max_attempts: 5,
login_lockout: Duration::from_secs(300),
api_keys: true,
}
}
}
#[derive(Debug, Clone)]
pub enum AuthMethod {
Password,
MagicLink,
Google(OAuthClient),
Microsoft {
client: OAuthClient,
tenant: String,
},
GitHub(OAuthClient),
Facebook(OAuthClient),
Oidc(OidcProvider),
}
impl AuthMethod {
#[must_use]
pub fn name(&self) -> &str {
match self {
Self::Password => "password",
Self::MagicLink => "magic-link",
Self::Google(_) => "google",
Self::Microsoft { .. } => "microsoft",
Self::GitHub(_) => "github",
Self::Facebook(_) => "facebook",
Self::Oidc(provider) => &provider.name,
}
}
}
#[derive(Clone)]
pub struct OAuthClient {
pub client_id: String,
pub client_secret: String,
}
impl OAuthClient {
#[must_use]
pub fn new(client_id: impl Into<String>, client_secret: impl Into<String>) -> Self {
Self {
client_id: client_id.into(),
client_secret: client_secret.into(),
}
}
pub fn from_env(prefix: &str) -> Result<Self, AdminError> {
Ok(Self {
client_id: env(&format!("{prefix}_CLIENT_ID"))?,
client_secret: env(&format!("{prefix}_CLIENT_SECRET"))?,
})
}
}
impl std::fmt::Debug for OAuthClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OAuthClient")
.field("client_id", &self.client_id)
.field("client_secret", &"<hidden>")
.finish()
}
}
#[derive(Debug, Clone)]
pub struct OidcProvider {
pub name: String,
pub label: String,
pub issuer: String,
pub client: OAuthClient,
pub trust_email: bool,
}
impl OidcProvider {
#[must_use]
pub fn new(
name: impl Into<String>,
label: impl Into<String>,
issuer: impl Into<String>,
client: OAuthClient,
) -> Self {
Self {
name: name.into(),
label: label.into(),
issuer: issuer.into(),
client,
trust_email: false,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum Signup {
#[default]
Closed,
Domains(Vec<String>),
Open,
}
#[derive(Debug, Clone)]
pub struct AdminSettings {
pub path: String,
pub site_title: String,
pub accent_color: String,
pub logo_url: Option<String>,
pub theme: Theme,
pub custom_css_url: Option<String>,
pub footer_text: Option<String>,
pub page_size: u64,
pub nav_links: Vec<NavLink>,
pub web_fonts: bool,
pub template_dir: Option<PathBuf>,
}
impl Default for AdminSettings {
fn default() -> Self {
let ui = UiSettings::default();
Self {
path: "/admin".to_owned(),
site_title: ui.site_title,
accent_color: ui.accent_color,
logo_url: ui.logo_url,
theme: ui.theme,
custom_css_url: ui.custom_css_url,
footer_text: ui.footer_text,
page_size: ui.page_size,
nav_links: ui.nav_links,
web_fonts: ui.web_fonts,
template_dir: ui.template_dir,
}
}
}
impl AdminSettings {
pub(crate) fn ui(&self) -> Result<UiSettings, AdminError> {
if self.page_size == 0 {
return Err(AdminError::Config(
"admin.page_size must be at least 1".into(),
));
}
let accent = UiSettings::default()
.accent_color(&self.accent_color)
.map_err(|e| AdminError::Config(format!("admin.accent_color: {}", config_text(e))))?
.accent_color;
Ok(UiSettings {
site_title: self.site_title.clone(),
accent_color: accent,
logo_url: self.logo_url.clone(),
theme: self.theme,
custom_css_url: self.custom_css_url.clone(),
footer_text: self.footer_text.clone(),
page_size: self.page_size,
nav_links: self.nav_links.clone(),
web_fonts: self.web_fonts,
template_dir: self.template_dir.clone(),
})
}
}
#[derive(Debug, Clone)]
pub struct ApiSettings {
pub path: String,
}
impl Default for ApiSettings {
fn default() -> Self {
Self {
path: "/api".to_owned(),
}
}
}
#[derive(Clone)]
pub struct DocsSettings {
pub enabled: bool,
pub username: Option<String>,
pub password: Option<String>,
pub title: Option<String>,
pub version: String,
pub description: Option<String>,
pub swagger_js_url: Option<String>,
pub swagger_css_url: Option<String>,
pub redoc_js_url: Option<String>,
}
impl Default for DocsSettings {
fn default() -> Self {
Self {
enabled: true,
username: None,
password: None,
title: None,
version: "0.1.0".to_owned(),
description: None,
swagger_js_url: None,
swagger_css_url: None,
redoc_js_url: None,
}
}
}
impl std::fmt::Debug for DocsSettings {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DocsSettings")
.field("enabled", &self.enabled)
.field("username", &self.username)
.field("password", &self.password.as_ref().map(|_| "<hidden>"))
.field("title", &self.title)
.field("version", &self.version)
.finish_non_exhaustive()
}
}
#[derive(Debug, Clone)]
pub struct HomeSettings {
pub enabled: bool,
pub title: Option<String>,
pub tagline: Option<String>,
}
impl Default for HomeSettings {
fn default() -> Self {
Self {
enabled: true,
title: None,
tagline: None,
}
}
}
#[derive(Clone, Default)]
pub struct MailSettings {
pub url: Option<String>,
pub from: Option<String>,
}
impl std::fmt::Debug for MailSettings {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MailSettings")
.field("url", &self.url.as_ref().map(|_| "<hidden>"))
.field("from", &self.from)
.finish()
}
}
#[derive(Debug, Clone)]
pub struct UploadSettings {
pub storage_url: Option<String>,
pub max_file_bytes: u64,
pub max_chunk_bytes: u64,
}
impl Default for UploadSettings {
fn default() -> Self {
Self {
storage_url: None,
max_file_bytes: crate::uploads::DEFAULT_MAX_FILE_BYTES,
max_chunk_bytes: crate::uploads::DEFAULT_MAX_CHUNK_BYTES as u64,
}
}
}
fn load_dotenv() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
let _ = dotenvy::dotenv();
});
}
pub fn env(key: &str) -> Result<String, AdminError> {
env_opt(key).ok_or_else(|| AdminError::Config(format!("{key} is not set (add it to .env)")))
}
#[must_use]
pub fn env_opt(key: &str) -> Option<String> {
load_dotenv();
std::env::var(key).ok().filter(|value| !value.is_empty())
}
pub fn env_or<T: std::str::FromStr + 'static>(key: &str, default: T) -> Result<T, AdminError> {
let Some(text) = env_opt(key) else {
return Ok(default);
};
let normalised = if std::any::TypeId::of::<T>() == std::any::TypeId::of::<bool>() {
match text.trim().to_ascii_lowercase().as_str() {
"1" | "yes" | "true" | "on" => "true".to_owned(),
"0" | "no" | "false" | "off" => "false".to_owned(),
_ => text.clone(),
}
} else {
text.trim().to_owned()
};
normalised
.parse::<T>()
.map_err(|_| AdminError::Config(format!("{key} has an invalid value `{text}`")))
}
fn config_text(error: AdminError) -> String {
match error {
AdminError::Config(message) => message,
other => other.to_string(),
}
}