use axum::body::Body;
use axum::extract::{Form, Path, Query, State};
use axum::http::{Response, StatusCode};
use axum::response::{IntoResponse, Redirect};
use axum::Extension;
use serde::Deserialize;
use tera::Context;
use super::super::auth;
use super::super::org_host::{self, HostError};
use super::{inject_op_brand, render, urlencoding_lite, ConsoleState};
use crate::core::Column as _;
use crate::sql::FetcherPool as _;
#[derive(Deserialize)]
pub(super) struct HostsQuery {
#[serde(default)]
error: Option<String>,
#[serde(default)]
notice: Option<String>,
}
#[derive(Deserialize)]
pub(super) struct AddForm {
#[serde(default)]
hostname: String,
}
#[derive(Deserialize)]
pub(super) struct HostActionForm {
#[serde(default)]
hostname: String,
#[serde(default)]
enabled: Option<String>,
}
pub(super) async fn org_hosts_view(
State(state): State<ConsoleState>,
Path(slug): Path<String>,
Extension(op): Extension<auth::Operator>,
Query(q): Query<HostsQuery>,
) -> Response<Body> {
if !org_exists(&state, &slug).await {
return (StatusCode::NOT_FOUND, format!("org `{slug}` not found")).into_response();
}
let hosts = match org_host::list_for_org(&state.registry, &slug).await {
Ok(h) => h,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
let mut ctx = Context::new();
inject_op_brand(&mut ctx, &state.op_brand);
ctx.insert("section", "orgs");
ctx.insert("operator_username", &op.username);
ctx.insert("slug", &slug);
ctx.insert("hosts", &hosts);
ctx.insert("error", &q.error);
ctx.insert("notice", &q.notice);
render(&state, "op_org_hosts.html", &ctx)
}
pub(super) async fn org_hosts_add(
State(state): State<ConsoleState>,
Path(slug): Path<String>,
Extension(op): Extension<auth::Operator>,
Form(form): Form<AddForm>,
) -> Response<Body> {
match org_host::add_host(&state.registry, &slug, &form.hostname).await {
Ok(row) => {
audit(&state, &slug, &op, "host_add", &row.hostname).await;
back(&slug, None, Some(&format!("added `{}`", row.hostname)))
}
Err(e) => back(&slug, Some(&explain(&e, &slug)), None),
}
}
pub(super) async fn org_hosts_remove(
State(state): State<ConsoleState>,
Path(slug): Path<String>,
Extension(op): Extension<auth::Operator>,
Form(form): Form<HostActionForm>,
) -> Response<Body> {
match org_host::remove_host(&state.registry, &slug, &form.hostname).await {
Ok(()) => {
audit(&state, &slug, &op, "host_remove", &form.hostname).await;
back(&slug, None, Some(&format!("removed `{}`", form.hostname)))
}
Err(e) => back(&slug, Some(&explain(&e, &slug)), None),
}
}
pub(super) async fn org_hosts_toggle(
State(state): State<ConsoleState>,
Path(slug): Path<String>,
Extension(op): Extension<auth::Operator>,
Form(form): Form<HostActionForm>,
) -> Response<Body> {
let enable = form.enabled.is_some();
match org_host::set_host_enabled(&state.registry, &slug, &form.hostname, enable).await {
Ok(()) => {
let verb = if enable {
"host_enable"
} else {
"host_disable"
};
audit(&state, &slug, &op, verb, &form.hostname).await;
let word = if enable { "enabled" } else { "disabled" };
back(&slug, None, Some(&format!("{word} `{}`", form.hostname)))
}
Err(e) => back(&slug, Some(&explain(&e, &slug)), None),
}
}
async fn org_exists(state: &ConsoleState, slug: &str) -> bool {
super::super::Org::objects()
.where_(super::super::Org::slug.eq(slug.to_owned()))
.fetch(&state.registry)
.await
.is_ok_and(|rows: Vec<super::super::Org>| !rows.is_empty())
}
fn back(slug: &str, error: Option<&str>, notice: Option<&str>) -> Response<Body> {
use std::fmt::Write as _;
let mut url = format!("/orgs/{}/hosts", urlencoding_lite(slug));
if let Some(e) = error {
let _ = write!(url, "?error={}", urlencoding_lite(e));
} else if let Some(n) = notice {
let _ = write!(url, "?notice={}", urlencoding_lite(n));
}
Redirect::to(&url).into_response()
}
fn explain(e: &HostError, slug: &str) -> String {
match e {
HostError::Invalid(h) => format!(
"`{h}` is not a bare hostname — no scheme, no port, no path, no trailing dot. \
Use `shop.example.com`"
),
HostError::Taken(h) => format!(
"`{h}` is already registered. A hostname resolves to exactly one tenant, so \
remove it from the tenant that holds it first"
),
HostError::IsBaseHost(h) => format!(
"`{h}` is `{slug}`'s base host and has no row to remove. Change it on the edit \
page instead"
),
HostError::NoSuchOrg(s) => {
format!("there is no tenant `{s}` — it may have been purged, or the link is stale")
}
HostError::NotFound => "no such host on this tenant".to_owned(),
HostError::Driver(d) => format!("the registry rejected the change: {d}"),
}
}
async fn audit(state: &ConsoleState, slug: &str, op: &auth::Operator, verb: &str, host: &str) {
let mut extra = serde_json::Map::new();
extra.insert(
"hostname".into(),
serde_json::Value::String(host.to_owned()),
);
let operator_id = op.id.get().copied().unwrap_or_default();
super::emit_op_audit(&state.registry, slug, operator_id, verb, extra).await;
}