use serde::Serialize;
#[derive(crate::Model, Debug, Clone, Serialize)]
#[rustango(
table = "rustango_org_hosts",
scope = "registry",
display = "hostname",
admin(
list_display = "hostname, org_id, enabled, created_at",
ordering = "hostname",
)
)]
pub struct OrgHost {
#[rustango(primary_key)]
pub id: crate::sql::Auto<i64>,
#[rustango(fk = "rustango_orgs", on = "id", index)]
pub org_id: i64,
#[rustango(max_length = 255, unique, index)]
pub hostname: String,
pub enabled: bool,
#[rustango(auto_now_add)]
pub created_at: crate::sql::Auto<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Clone, Serialize)]
pub struct TenantHost {
pub hostname: String,
pub is_base: bool,
pub enabled: bool,
pub id: Option<i64>,
}
#[derive(Debug)]
pub enum HostError {
Invalid(String),
Taken(String),
IsBaseHost(String),
NoSuchOrg(String),
NotFound,
Driver(crate::sql::ExecError),
}
impl std::fmt::Display for HostError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Invalid(h) => write!(f, "`{h}` is not a bare hostname"),
Self::Taken(h) => write!(f, "`{h}` is already registered"),
Self::IsBaseHost(h) => {
write!(f, "`{h}` is this tenant's base host and cannot be removed")
}
Self::NoSuchOrg(s) => write!(f, "no tenant named `{s}`"),
Self::NotFound => write!(f, "no such host"),
Self::Driver(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for HostError {}
impl From<crate::sql::ExecError> for HostError {
fn from(e: crate::sql::ExecError) -> Self {
Self::Driver(e)
}
}
pub fn normalize_hostname(raw: &str) -> Result<String, HostError> {
let h = raw.trim().to_ascii_lowercase();
let bad = h.is_empty()
|| h.len() > 255
|| h.contains("://")
|| h.contains('/')
|| h.contains(':')
|| h.contains(char::is_whitespace)
|| h.starts_with('.')
|| h.ends_with('.')
|| !h
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-');
if bad {
return Err(HostError::Invalid(raw.to_owned()));
}
Ok(h)
}
use crate::core::Column as _;
use crate::sql::{FetcherPool as _, Pool};
pub async fn list_for_org(registry: &Pool, org_slug: &str) -> Result<Vec<TenantHost>, HostError> {
let Some(org) = super::Org::objects()
.where_(super::Org::slug.eq(org_slug.to_owned()))
.first(registry)
.await?
else {
return Err(HostError::NoSuchOrg(org_slug.to_owned()));
};
let org_id = org.id.get().copied().unwrap_or_default();
let mut out = Vec::new();
if let Some(base) = org.host_pattern.as_deref().filter(|h| !h.trim().is_empty()) {
out.push(TenantHost {
hostname: base.to_owned(),
is_base: true,
enabled: true,
id: None,
});
}
let extras: Vec<OrgHost> = OrgHost::objects()
.where_(OrgHost::org_id.eq(org_id))
.order_by(&[("hostname", false)])
.fetch(registry)
.await?;
out.extend(extras.into_iter().map(|h| TenantHost {
hostname: h.hostname,
is_base: false,
enabled: h.enabled,
id: h.id.get().copied(),
}));
Ok(out)
}
pub async fn add_host(
registry: &Pool,
org_slug: &str,
hostname: &str,
) -> Result<OrgHost, HostError> {
let host = normalize_hostname(hostname)?;
let Some(org) = super::Org::objects()
.where_(super::Org::slug.eq(org_slug.to_owned()))
.first(registry)
.await?
else {
return Err(HostError::NoSuchOrg(org_slug.to_owned()));
};
if host_claimed(registry, &host, None).await? {
return Err(HostError::Taken(host));
}
let mut row = OrgHost {
id: crate::sql::Auto::Unset,
org_id: org.id.get().copied().unwrap_or_default(),
hostname: host,
enabled: true,
created_at: crate::sql::Auto::Unset,
};
row.insert_pool(registry).await?;
super::invalidate_host_cache();
Ok(row)
}
pub(crate) async fn host_claimed(
registry: &Pool,
host: &str,
except_org: Option<i64>,
) -> Result<bool, crate::sql::ExecError> {
let base: Vec<super::Org> = super::Org::objects()
.where_(super::Org::host_pattern.iexact(host))
.fetch(registry)
.await?;
if other_org(&base, except_org) {
return Ok(true);
}
let extra: Vec<OrgHost> = OrgHost::objects()
.where_(OrgHost::hostname.iexact(host))
.fetch(registry)
.await?;
Ok(extra
.iter()
.any(|h| except_org.is_none() || Some(h.org_id) != except_org))
}
pub(crate) async fn prefix_claimed(
registry: &Pool,
prefix: &str,
except_org: Option<i64>,
) -> Result<bool, crate::sql::ExecError> {
let rows: Vec<super::Org> = super::Org::objects()
.where_(super::Org::path_prefix.iexact(prefix))
.fetch(registry)
.await?;
Ok(other_org(&rows, except_org))
}
pub(crate) async fn port_claimed(
registry: &Pool,
port: i32,
except_org: Option<i64>,
) -> Result<bool, crate::sql::ExecError> {
let rows: Vec<super::Org> = super::Org::objects()
.where_(super::Org::port.eq(Some(port)))
.fetch(registry)
.await?;
Ok(other_org(&rows, except_org))
}
fn other_org(rows: &[super::Org], except_org: Option<i64>) -> bool {
rows.iter()
.any(|o| except_org.is_none() || o.id.get().copied() != except_org)
}
pub async fn remove_host(registry: &Pool, org_slug: &str, hostname: &str) -> Result<(), HostError> {
let host = normalize_hostname(hostname)?;
let Some(org) = super::Org::objects()
.where_(super::Org::slug.eq(org_slug.to_owned()))
.first(registry)
.await?
else {
return Err(HostError::NoSuchOrg(org_slug.to_owned()));
};
if org.host_pattern.as_deref() == Some(host.as_str()) {
return Err(HostError::IsBaseHost(host));
}
let org_id = org.id.get().copied().unwrap_or_default();
let Some(row) = OrgHost::objects()
.where_(OrgHost::hostname.eq(host))
.where_(OrgHost::org_id.eq(org_id))
.first(registry)
.await?
else {
return Err(HostError::NotFound);
};
row.delete_pool(registry).await?;
super::invalidate_host_cache();
Ok(())
}
pub async fn set_host_enabled(
registry: &Pool,
org_slug: &str,
hostname: &str,
enabled: bool,
) -> Result<(), HostError> {
let host = normalize_hostname(hostname)?;
let Some(org) = super::Org::objects()
.where_(super::Org::slug.eq(org_slug.to_owned()))
.first(registry)
.await?
else {
return Err(HostError::NoSuchOrg(org_slug.to_owned()));
};
if org.host_pattern.as_deref() == Some(host.as_str()) {
return Err(HostError::IsBaseHost(host));
}
let org_id = org.id.get().copied().unwrap_or_default();
let Some(mut row) = OrgHost::objects()
.where_(OrgHost::hostname.eq(host))
.where_(OrgHost::org_id.eq(org_id))
.first(registry)
.await?
else {
return Err(HostError::NotFound);
};
row.enabled = enabled;
row.save_pool(registry).await?;
super::invalidate_host_cache();
Ok(())
}
pub async fn generation(registry: &Pool) -> Result<Generation, HostError> {
use crate::core::aggregates::{count_all, max, sum};
use crate::core::{AggregateQuery, Model as _, WhereExpr};
use crate::sql::fetch_aggregate_pool;
let q = AggregateQuery {
model: OrgHost::SCHEMA,
joins: Vec::new(),
where_clause: WhereExpr::And(Vec::new()),
group_by: Vec::new(),
aggregates: vec![
("n".into(), count_all().into()),
(
"n_on".into(),
count_all().filter(OrgHost::enabled.eq(true)).into(),
),
("max_id".into(), max("id").into()),
(
"on_id_sum".into(),
sum("id").filter(OrgHost::enabled.eq(true)).into(),
),
],
aliases: Vec::new(),
having: None,
order_by: Vec::new(),
limit: None,
offset: None,
source: None,
};
let rows: Vec<(Option<i64>, Option<i64>, Option<i64>, Option<i64>)> =
fetch_aggregate_pool(registry, &q).await?;
let (n, n_on, max_id, on_id_sum) = rows.into_iter().next().unwrap_or((None, None, None, None));
Ok(Generation {
count: n.unwrap_or(0),
enabled: n_on.unwrap_or(0),
max_id: max_id.unwrap_or(0),
enabled_id_sum: on_id_sum.unwrap_or(0),
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Generation {
pub count: i64,
pub enabled: i64,
pub max_id: i64,
pub enabled_id_sum: i64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_a_bare_hostname_and_lowercases_it() {
assert_eq!(normalize_hostname("Example.COM").unwrap(), "example.com");
assert_eq!(
normalize_hostname(" a.b.example.com ").unwrap(),
"a.b.example.com"
);
assert_eq!(
normalize_hostname("my-site.co.uk").unwrap(),
"my-site.co.uk"
);
assert_eq!(normalize_hostname("localhost").unwrap(), "localhost");
}
#[test]
fn rejects_anything_that_is_not_a_bare_host() {
for bad in [
"",
" ",
"https://example.com", "example.com/", "example.com/blog", "example.com:8080", "exa mple.com", ".example.com", "example.com.", "exam_ple.com", "exam?ple.com",
] {
assert!(
normalize_hostname(bad).is_err(),
"{bad:?} should be rejected"
);
}
}
#[test]
fn rejects_an_over_long_hostname() {
assert!(normalize_hostname(&"a".repeat(256)).is_err());
assert!(normalize_hostname(&"a".repeat(255)).is_ok());
}
#[test]
fn base_host_is_flagged_so_callers_can_refuse_a_remove() {
let base = TenantHost {
hostname: "acme.example.com".into(),
is_base: true,
enabled: true,
id: None,
};
assert!(base.is_base);
assert!(base.id.is_none());
}
}