use std::time::Duration;
#[derive(Debug, Clone)]
pub struct RouteConfig {
pub login_url: String,
pub logout_url: String,
pub admin_url: String,
pub audit_url: String,
pub static_url: String,
pub brand_url: String,
pub change_password_url: String,
pub impersonation_handoff_url: String,
pub basic_auth_realm: String,
pub tenant_session_ttl: Duration,
pub operator_session_ttl: Duration,
pub impersonation_ttl: Duration,
}
impl Default for RouteConfig {
fn default() -> Self {
Self {
login_url: "/login".to_owned(),
logout_url: "/logout".to_owned(),
admin_url: "/admin".to_owned(),
audit_url: "/audit".to_owned(),
static_url: "/_static".to_owned(),
brand_url: "/_brand".to_owned(),
change_password_url: "/change-password".to_owned(),
impersonation_handoff_url: "/_impersonation_handoff".to_owned(),
basic_auth_realm: "Rustango Admin".to_owned(),
tenant_session_ttl: Duration::from_secs(7 * 24 * 60 * 60),
operator_session_ttl: Duration::from_secs(7 * 24 * 60 * 60),
impersonation_ttl: Duration::from_secs(60 * 60),
}
}
}
impl RouteConfig {
#[must_use]
pub fn friendly() -> Self {
Self::default()
}
#[must_use]
pub fn legacy() -> Self {
Self {
login_url: "/__login".to_owned(),
logout_url: "/__logout".to_owned(),
admin_url: "/__admin".to_owned(),
audit_url: "/__audit".to_owned(),
static_url: "/__static__".to_owned(),
brand_url: "/__brand__".to_owned(),
change_password_url: "/__change-password".to_owned(),
impersonation_handoff_url: "/__impersonation_handoff".to_owned(),
..Default::default()
}
}
#[must_use]
pub fn audit_full_url(&self) -> String {
format!("{}{}", self.admin_url, self.audit_url)
}
#[must_use]
pub fn brand_asset_url(
&self,
slug: &str,
path: Option<&str>,
storage: &crate::storage::BoxedStorage,
) -> Option<String> {
super::branding::brand_asset_url_at(self.brand_prefix(), slug, path, storage)
}
pub(crate) fn brand_prefix(&self) -> &str {
self.brand_url.trim_end_matches('/')
}
#[must_use]
pub(crate) fn under_prefix(&self, prefix: &str) -> Self {
let p = prefix.trim_end_matches('/');
let join = |url: &str| format!("{p}{url}");
Self {
login_url: join(&self.login_url),
logout_url: join(&self.logout_url),
admin_url: join(&self.admin_url),
static_url: join(&self.static_url),
change_password_url: join(&self.change_password_url),
impersonation_handoff_url: join(&self.impersonation_handoff_url),
brand_url: join(self.brand_prefix()),
..self.clone()
}
}
}
pub(crate) fn cookie_path<'a>(org: &'a super::Org, path: &str) -> &'a str {
org.path_prefix
.as_deref()
.filter(|p| path_is_under(path, p))
.map_or("/", |p| p.trim_end_matches('/'))
}
pub(crate) fn path_is_under(path: &str, prefix: &str) -> bool {
let p = prefix.trim_end_matches('/');
!p.is_empty()
&& path
.strip_prefix(p)
.is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn brand_asset_url_uses_brand_url() {
let storage: crate::storage::BoxedStorage =
std::sync::Arc::new(crate::storage::InMemoryStorage::new());
let url = |r: &RouteConfig| r.brand_asset_url("acme", Some("logo.png"), &storage);
assert_eq!(
url(&RouteConfig::default()).as_deref(),
Some("/_brand/acme/logo.png")
);
assert_eq!(
url(&RouteConfig::legacy()).as_deref(),
Some("/__brand__/acme/logo.png")
);
let slash = RouteConfig {
brand_url: "/_brand/".to_owned(),
..RouteConfig::default()
};
assert_eq!(url(&slash).as_deref(), Some("/_brand/acme/logo.png"));
}
#[test]
fn cookie_path_follows_the_request() {
let org = crate::tenancy::Org {
path_prefix: Some("/acme".into()),
..crate::testkit::org()
};
assert_eq!(cookie_path(&org, "/acme/__login"), "/acme");
assert_eq!(cookie_path(&org, "/acmecorp/__login"), "/");
assert_eq!(cookie_path(&crate::testkit::org(), "/acme/x"), "/");
}
#[test]
fn a_path_prefix_covers_its_segment_only() {
assert!(path_is_under("/acme", "/acme"));
assert!(path_is_under("/acme/admin", "/acme"));
assert!(!path_is_under("/acmecorp/admin", "/acme"));
assert!(!path_is_under("/admin", "/acme"));
let r = RouteConfig::default().under_prefix("/acme");
assert_eq!(r.admin_url, "/acme/admin");
assert_eq!(r.impersonation_handoff_url, "/acme/_impersonation_handoff");
assert_eq!(r.brand_url, "/acme/_brand");
}
#[test]
fn prefixed_brand_asset_url() {
let r = RouteConfig::default().under_prefix("/acme");
let local: crate::storage::BoxedStorage =
std::sync::Arc::new(crate::storage::InMemoryStorage::new());
assert_eq!(
r.brand_asset_url("acme", Some("logo.png"), &local)
.as_deref(),
Some("/acme/_brand/acme/logo.png")
);
let cdn: crate::storage::BoxedStorage = std::sync::Arc::new(
crate::storage::LocalStorage::new(std::env::temp_dir())
.with_base_url("https://cdn.example.com/b"),
);
assert_eq!(
r.brand_asset_url("acme", Some("logo.png"), &cdn).as_deref(),
Some("https://cdn.example.com/b/acme/logo.png")
);
}
#[test]
fn defaults_now_match_friendly() {
let r = RouteConfig::default();
assert_eq!(r.login_url, "/login");
assert_eq!(r.logout_url, "/logout");
assert_eq!(r.admin_url, "/admin");
assert_eq!(r.audit_url, "/audit");
assert_eq!(r.static_url, "/_static");
assert_eq!(r.brand_url, "/_brand");
assert_eq!(r.change_password_url, "/change-password");
assert_eq!(r.impersonation_handoff_url, "/_impersonation_handoff");
assert_eq!(r.basic_auth_realm, "Rustango Admin");
}
#[test]
fn friendly_is_alias_for_default() {
let f = RouteConfig::friendly();
let d = RouteConfig::default();
assert_eq!(f.login_url, d.login_url);
assert_eq!(f.admin_url, d.admin_url);
assert_eq!(f.audit_url, d.audit_url);
assert_eq!(f.static_url, d.static_url);
assert_eq!(f.brand_url, d.brand_url);
assert_eq!(f.change_password_url, d.change_password_url);
}
#[test]
fn legacy_preset_keeps_underscores() {
let r = RouteConfig::legacy();
assert_eq!(r.login_url, "/__login");
assert_eq!(r.logout_url, "/__logout");
assert_eq!(r.admin_url, "/__admin");
assert_eq!(r.audit_url, "/__audit");
assert_eq!(r.static_url, "/__static__");
assert_eq!(r.brand_url, "/__brand__");
assert_eq!(r.change_password_url, "/__change-password");
assert_eq!(r.impersonation_handoff_url, "/__impersonation_handoff");
}
#[test]
fn audit_full_url_joins_prefixes() {
assert_eq!(RouteConfig::default().audit_full_url(), "/admin/audit");
assert_eq!(RouteConfig::legacy().audit_full_url(), "/__admin/__audit");
}
#[test]
fn ttls_have_sensible_defaults() {
let r = RouteConfig::default();
assert_eq!(r.tenant_session_ttl.as_secs(), 7 * 24 * 60 * 60);
assert_eq!(r.operator_session_ttl.as_secs(), 7 * 24 * 60 * 60);
assert_eq!(r.impersonation_ttl.as_secs(), 60 * 60);
}
}