use axum::Router;
use std::sync::Arc;
use tower_sessions::cookie::time::Duration;
use super::error_build::BuildError;
use super::runique_app::RuniqueApp;
use super::staging::{AdminStaging, CoreStaging, MiddlewareStaging, StaticStaging};
use super::templates::TemplateLoader;
use crate::admin::build_admin_router;
use crate::auth::{
PasswordResetAdapter, PasswordResetConfig, PasswordResetStaging, session::UserEntity,
};
use crate::config::RuniqueConfig;
use crate::engine::RuniqueEngine;
use crate::macros::add_urls;
use crate::middleware::HostPolicy;
#[cfg(feature = "orm")]
use crate::middleware::session::session_db::RuniqueSessionStore;
use crate::utils::aliases::new;
use crate::utils::runique_log::{RuniqueLog, log_init};
use axum::http::{HeaderName, HeaderValue};
use tower_http::{services::ServeDir, set_header::SetResponseHeaderLayer};
#[cfg(feature = "orm")]
use crate::db::DatabaseConfig;
#[cfg(feature = "orm")]
use sea_orm::DatabaseConnection;
#[doc = include_str!("../../doc-tests/builder/builder_basic.md")]
pub struct RuniqueAppBuilder {
config: RuniqueConfig,
core: CoreStaging,
middleware: MiddlewareStaging,
statics: StaticStaging,
router: Option<Router>,
admin: AdminStaging,
password_reset: Option<PasswordResetStaging>,
}
impl RuniqueAppBuilder {
pub fn new(config: RuniqueConfig) -> Self {
let middleware = MiddlewareStaging::from_config(&config);
Self {
config,
core: CoreStaging::new(),
middleware,
statics: StaticStaging::new(),
router: None,
admin: AdminStaging::new(),
password_reset: None,
}
}
pub fn core(mut self, f: impl FnOnce(CoreStaging) -> CoreStaging) -> Self {
self.core = f(self.core);
self
}
#[cfg(feature = "orm")]
pub fn with_database(mut self, db: DatabaseConnection) -> Self {
self.core = self.core.with_database(db);
self
}
#[cfg(feature = "orm")]
pub fn with_database_config(mut self, config: DatabaseConfig) -> Self {
self.core = self.core.with_database_config(config);
self
}
pub fn with_log(mut self, f: impl FnOnce(RuniqueLog) -> RuniqueLog) -> Self {
self.config.log = f(RuniqueLog::new());
self
}
pub fn routes(mut self, router: Router) -> Self {
self.router = Some(router);
self
}
pub fn middleware(mut self, f: impl FnOnce(MiddlewareStaging) -> MiddlewareStaging) -> Self {
self.middleware = f(self.middleware);
self
}
pub fn with_session_duration(mut self, duration: Duration) -> Self {
self.middleware = self.middleware.with_session_duration(duration);
self
}
pub fn with_error_handler(mut self, enable: bool) -> Self {
self.middleware = self.middleware.with_debug_errors(enable);
self
}
pub fn static_files(mut self, f: impl FnOnce(StaticStaging) -> StaticStaging) -> Self {
self.statics = f(self.statics);
self
}
pub fn with_mailer(self, config: crate::utils::mailer::MailerConfig) -> Self {
crate::utils::mailer::mailer_init(config);
self
}
pub fn with_mailer_from_env(self) -> Self {
crate::utils::mailer::mailer_init_from_env();
self
}
pub fn statics(mut self) -> Self {
self.statics = self.statics.enable();
self
}
pub fn no_statics(mut self) -> Self {
self.statics = self.statics.disable();
self
}
pub fn with_admin(mut self, f: impl FnOnce(AdminStaging) -> AdminStaging) -> Self {
self.admin = f(self.admin.enable());
self
}
pub fn with_password_reset<E: UserEntity + 'static>(
mut self,
f: impl FnOnce(PasswordResetConfig) -> PasswordResetConfig,
) -> Self {
let config = f(PasswordResetConfig::default());
self.password_reset = Some(PasswordResetStaging {
handler: Box::new(PasswordResetAdapter::<E>::new()),
config,
});
self
}
pub async fn build(mut self) -> Result<RuniqueApp, BuildError> {
self.config.log.init_subscriber();
self.validate()?;
if !self.all_ready() {
return Err(BuildError::validation(
"One or more components are not ready for construction",
));
}
#[cfg(feature = "orm")]
let db = self.core.connect().await?;
let config = self.config;
let url_registry = self.core.url_registry;
let mut middleware = self.middleware;
let statics_enabled = self.statics.enabled;
let router = self.router;
let tera = new(TemplateLoader::init(&config, url_registry.clone())
.map_err(|e| BuildError::template(e.to_string()))?);
let config = new(config);
log_init(config.log.clone());
crate::utils::password::password_init(config.password.clone());
let engine = new(RuniqueEngine {
config: (*config).clone(),
tera: tera.clone(),
#[cfg(feature = "orm")]
db: new(db),
features: {
let mut f = middleware.features.clone();
f.exclusive_login = middleware.exclusive_login;
f
},
url_registry,
security_csp: {
let mut policy = middleware.security_policy.take().unwrap_or_default();
if self.admin.enabled {
policy.merge_htmx_hashes();
}
new(policy)
},
security_hosts: new(HostPolicy::new(
middleware.allowed_hosts.clone(),
middleware.features.enable_host_validation,
)),
session_store: std::sync::LazyLock::new(|| std::sync::RwLock::new(None)),
session_db_store: std::sync::LazyLock::new(|| std::sync::RwLock::new(None)),
});
add_urls(&engine);
let router = router.unwrap_or_default();
let router = if let Some(pr) = self.password_reset {
let pr_router = pr.handler.build_router(std::sync::Arc::new(pr.config));
router.merge(pr_router)
} else {
router
};
let router = if self.admin.enabled {
let admin_prefix = self.admin.config.prefix.trim_end_matches('/').to_string();
let robots_txt = self.admin.robots_txt;
let admin_router = build_admin_router(self.admin, engine.db.clone());
add_urls(&engine);
let mut r = router.merge(admin_router);
if robots_txt {
r = r.route(
"/robots.txt",
axum::routing::get(move || {
let body = format!("User-agent: *\nDisallow: {}/\n", admin_prefix);
async move { body }
}),
);
}
r
} else {
router
};
let _exclusive_login = middleware.exclusive_login;
let (router, session_store) =
middleware.apply_to_router(router, config, engine.clone(), tera);
if let Some(store) = session_store {
if let Ok(mut guard) = engine.session_store.write() {
*guard = Some(store);
}
}
#[cfg(feature = "orm")]
{
let db_store = RuniqueSessionStore::new(engine.db.clone());
if let Ok(mut guard) = engine.session_db_store.write() {
*guard = Some(Arc::new(db_store));
}
}
let router = if statics_enabled {
Self::attach_static_files(router, &engine.config)
} else {
router
};
Ok(RuniqueApp { engine, router })
}
fn validate(&self) -> Result<(), BuildError> {
self.core.validate()?;
self.middleware.validate()?;
self.statics.validate()?;
self.admin.validate()?;
self.cross_validate()?;
Ok(())
}
fn cross_validate(&self) -> Result<(), BuildError> {
Ok(())
}
fn all_ready(&self) -> bool {
self.core.is_ready()
&& self.middleware.is_ready()
&& self.statics.is_ready()
&& self.admin.is_ready()
}
fn attach_static_files(mut router: Router, config: &RuniqueConfig) -> Router {
let static_headers = tower::ServiceBuilder::new()
.layer(SetResponseHeaderLayer::if_not_present(
HeaderName::from_static("x-content-type-options"),
HeaderValue::from_static("nosniff"),
))
.layer(SetResponseHeaderLayer::if_not_present(
HeaderName::from_static("strict-transport-security"),
HeaderValue::from_static("max-age=31536000; includeSubDomains; preload"),
))
.layer(SetResponseHeaderLayer::if_not_present(
HeaderName::from_static("x-frame-options"),
HeaderValue::from_static("DENY"),
))
.layer(SetResponseHeaderLayer::if_not_present(
HeaderName::from_static("referrer-policy"),
HeaderValue::from_static("strict-origin-when-cross-origin"),
));
router = router
.nest_service(
&config.static_files.static_url,
static_headers
.clone()
.service(ServeDir::new(&config.static_files.staticfiles_dirs)),
)
.nest_service(
&config.static_files.media_url,
static_headers
.clone()
.service(ServeDir::new(&config.static_files.media_root)),
);
if !config.static_files.static_runique_url.is_empty() {
router = router.nest_service(
&config.static_files.static_runique_url,
static_headers.service(ServeDir::new(&config.static_files.static_runique_path)),
);
}
router
}
}