pub mod account;
pub mod accounts;
pub mod assets;
pub mod audit;
pub mod auth;
pub mod eab;
pub mod error;
pub mod expiring;
pub mod filter;
pub mod jobs;
pub mod misc;
pub mod operators;
pub mod orders;
pub mod session;
pub mod templates;
pub mod upstream_orders;
pub use auth::{
PageAdminRead, PageAdminWrite, PageAuth, PageSelfServiceWrite, PageSession, PageSessionWrite,
};
pub use error::PageError;
use axum::Router;
use axum::response::{Html, IntoResponse, Response};
use axum::routing::{get, post};
use serde_json::{Map, Value, json};
use crate::webadmin::AdminState;
use crate::webadmin::error::AdminError;
use crate::webadmin::handlers::paging::Page;
pub(crate) fn pages_router() -> Router<AdminState> {
Router::new()
.route(
"/ui/login",
get(session::get_login).post(session::post_login),
)
.route(
"/ui/login/mfa",
get(session::get_login_mfa).post(session::post_login_mfa),
)
.route("/ui/static/{file}", get(assets::get_asset))
.route("/ui/", get(misc::get_index))
.route("/ui/account", get(account::get_account))
.route("/ui/account/mfa/totp", post(account::begin_totp))
.route("/ui/account/mfa/totp/confirm", post(account::confirm_totp))
.route("/ui/account/mfa/totp/disable", post(account::disable_totp))
.route(
"/ui/account/mfa/recovery-codes",
post(account::regenerate_recovery_codes),
)
.route("/ui/account/password", post(account::change_password))
.route("/ui/account/contact", post(account::change_contact))
.route(
"/ui/account/sessions/{id}/revoke",
post(account::revoke_own_session),
)
.route("/ui/logout", post(session::post_logout))
.route("/ui/operators", get(operators::list_operators))
.route("/ui/operators/{username}", get(operators::get_operator))
.route(
"/ui/operators/{username}/disable",
post(operators::disable_operator),
)
.route(
"/ui/operators/{username}/enable",
post(operators::enable_operator),
)
.route(
"/ui/operators/{username}/totp/reset",
post(operators::reset_operator_totp),
)
.route(
"/ui/operators/{username}/contact",
post(operators::set_operator_contact),
)
.route(
"/ui/operators/{username}/role",
post(operators::set_operator_role),
)
.route(
"/ui/operators/{username}/sessions/{id}/revoke",
post(operators::revoke_operator_session),
)
.route("/ui/accounts", get(accounts::list_accounts))
.route(
"/ui/accounts/{id}",
get(accounts::get_account).delete(accounts::delete_account),
)
.route(
"/ui/accounts/{id}/orders",
get(accounts::list_account_orders),
)
.route(
"/ui/accounts/{id}/contact",
post(accounts::post_account_contact),
)
.route(
"/ui/accounts/{id}/deactivate",
post(accounts::deactivate_account),
)
.route("/ui/expiring", get(expiring::list_expiring))
.route("/ui/audit", get(audit::list_audit))
.route("/ui/audit/{id}", get(audit::get_audit))
.route("/ui/orders", get(orders::list_orders))
.route(
"/ui/orders/{id}",
get(orders::get_order).delete(orders::delete_order),
)
.route("/ui/orders/{id}/revoke", post(orders::revoke_order))
.route("/ui/orders/{id}/chain.pem", get(orders::download_chain))
.route("/ui/jobs", get(jobs::list_jobs))
.route("/ui/jobs/{id}", get(jobs::get_job))
.route("/ui/jobs/{id}/cancel", post(jobs::cancel_job))
.route("/ui/jobs/{id}/run", post(jobs::run_job))
.route(
"/ui/upstream-orders",
get(upstream_orders::list_upstream_orders),
)
.route(
"/ui/upstream-orders/{id}",
get(upstream_orders::get_upstream_order),
)
.route("/ui/eab", get(eab::list_eab).post(eab::create_eab))
.route("/ui/eab/{kid}", get(eab::get_eab).delete(eab::delete_eab))
.route("/ui/eab/{kid}/revoke", post(eab::revoke_eab))
.route("/ui/nonces", get(misc::get_nonces))
.route("/ui/nonces/cleanup", post(misc::cleanup_nonces))
.route("/ui/profiles", get(misc::list_profiles))
.route(
"/ui/profiles/{name}/filter",
get(filter::get_profile_filter),
)
}
pub(crate) fn chrome<S: PageAuth>(
session: &S,
nav: &'static str,
title: &str,
) -> Map<String, Value> {
let mut context = fragment_context(session.auth());
context.insert("nav".to_string(), Value::String(nav.to_string()));
context.insert("title".to_string(), Value::String(title.to_string()));
context
}
pub(crate) fn fragment_context(
auth: &crate::webadmin::session::Authenticated,
) -> Map<String, Value> {
let mut context = Map::new();
context.insert(
"csrf_token".to_string(),
Value::String(auth.session.csrf_token.clone()),
);
context.insert(
"user".to_string(),
crate::admin::render_admin_user_json(&auth.user),
);
context.insert(
"can_write".to_string(),
Value::Bool(auth.user.role() >= acme_proxy_store::admin_user::AdminRole::Operator),
);
context
}
pub(crate) fn respond(
state: &AdminState,
hx: bool,
page: &str,
fragment: &str,
context: Map<String, Value>,
) -> Result<Html<String>, PageError> {
let name = if hx { fragment } else { page };
templates::render(
&state.templates,
name,
minijinja::Value::from_serialize(Value::Object(context)),
)
}
pub(crate) fn respond_fragment(
state: &AdminState,
fragment: &str,
context: Map<String, Value>,
) -> Result<Html<String>, PageError> {
templates::render(
&state.templates,
fragment,
minijinja::Value::from_serialize(Value::Object(context)),
)
}
#[must_use]
pub(crate) fn flash(kind: &str, message: impl Into<String>) -> Value {
json!({ "kind": kind, "message": message.into() })
}
#[must_use]
pub(crate) fn flash_error(code: &str, message: impl Into<String>) -> Value {
json!({ "kind": "error", "message": message.into(), "code": code })
}
pub(crate) fn refuse_with_card(
state: &AdminState,
fragment: &str,
mut context: Map<String, Value>,
error: &AdminError,
) -> Result<Response, PageError> {
let message = if error.status == axum::http::StatusCode::UNAUTHORIZED {
"That password is not correct.".to_string()
} else {
error.message.clone()
};
context.insert("flash".to_string(), flash_error(error.code, message));
let body = respond_fragment(state, fragment, context)?;
let mut response = error.clone().into_response();
*response.body_mut() = body.into_response().into_body();
response.headers_mut().insert(
axum::http::header::CONTENT_TYPE,
axum::http::HeaderValue::from_static("text/html; charset=utf-8"),
);
Ok(response)
}
#[must_use]
pub(crate) fn page_value(items: Vec<Value>, total: i64) -> Value {
json!({ "items": items, "total": total })
}
#[must_use]
pub(crate) fn vocabulary<T: Copy>(all: &[T], name: impl Fn(T) -> &'static str) -> Value {
Value::Array(all.iter().map(|value| Value::from(name(*value))).collect())
}
#[derive(Debug, Default)]
pub(crate) struct ListFilters(Vec<(&'static str, String)>);
impl ListFilters {
#[must_use]
pub(crate) fn new() -> Self {
Self::default()
}
#[must_use]
pub(crate) fn with(mut self, key: &'static str, value: Option<&str>) -> Self {
self.0.push((key, value.unwrap_or_default().to_string()));
self
}
#[must_use]
pub(crate) fn pairs(&self) -> Vec<(&str, &str)> {
self.0
.iter()
.map(|(key, value)| (*key, value.as_str()))
.collect()
}
#[must_use]
pub(crate) fn to_value(&self) -> Value {
Value::Object(
self.0
.iter()
.map(|(key, value)| ((*key).to_string(), Value::String(value.clone())))
.collect(),
)
}
}
pub(crate) fn pager(
page: Page,
total: i64,
path: &str,
filters: &[(&str, &str)],
target: &str,
) -> Value {
let url = |offset: i64| {
let mut query = url::form_urlencoded::Serializer::new(String::new());
query.append_pair("limit", &page.limit.to_string());
query.append_pair("offset", &offset.to_string());
for (key, value) in filters {
if !value.is_empty() {
query.append_pair(key, value);
}
}
format!("{path}?{}", query.finish())
};
let next = page.offset.saturating_add(page.limit);
json!({
"total": total,
"limit": page.limit,
"offset": page.offset,
"from": if total == 0 { 0 } else { page.offset.saturating_add(1) },
"to": next.min(total).max(0),
"prev": (page.offset > 0).then(|| url(page.offset.saturating_sub(page.limit).max(0))),
"next": (next < total).then(|| url(next)),
"target": target,
"push": true,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn page(limit: i64, offset: i64) -> Page {
Page { limit, offset }
}
#[test]
fn the_first_page_of_several_offers_next_and_not_previous() {
let value = pager(page(50, 0), 312, "/ui/accounts", &[], "#accounts-table");
assert_eq!(value["from"], 1);
assert_eq!(value["to"], 50);
assert_eq!(value["total"], 312);
assert!(value["prev"].is_null());
assert_eq!(value["next"], "/ui/accounts?limit=50&offset=50");
}
#[test]
fn the_last_page_offers_previous_and_not_next() {
let value = pager(page(50, 300), 312, "/ui/accounts", &[], "#accounts-table");
assert_eq!(value["from"], 301);
assert_eq!(value["to"], 312);
assert_eq!(value["prev"], "/ui/accounts?limit=50&offset=250");
assert!(value["next"].is_null());
}
#[test]
fn the_filters_survive_a_page_step_and_are_encoded() {
let value = pager(
page(10, 0),
40,
"/ui/orders",
&[("profile", "le"), ("status", ""), ("accountId", "a b&c")],
"#orders-table",
);
let next = value["next"].as_str().unwrap();
assert!(next.contains("profile=le"));
assert!(!next.contains("status="));
assert!(next.contains("accountId=a+b%26c"));
}
#[test]
fn an_empty_result_set_renders_no_controls() {
let value = pager(page(50, 0), 0, "/ui/eab", &[], "#eab-table");
assert_eq!(value["total"], 0);
assert_eq!(value["from"], 0);
assert_eq!(value["to"], 0);
assert!(value["prev"].is_null());
assert!(value["next"].is_null());
}
#[test]
fn an_extreme_window_saturates_instead_of_overflowing() {
let value = pager(page(i64::MAX, i64::MAX / 2), 10, "/ui/orders", &[], "#t");
assert_eq!(value["to"], 10);
assert!(value["next"].is_null());
assert!(value["prev"].is_string());
}
#[test]
fn list_filters_echo_every_key_and_the_pager_carries_the_set_ones() {
let filters = ListFilters::new()
.with("certSerial", Some("0a0b"))
.with("orderId", None);
let echoed = filters.to_value();
assert_eq!(echoed["certSerial"], "0a0b");
assert_eq!(echoed["orderId"], "");
let value = pager(page(1, 0), 5, "/ui/audit", &filters.pairs(), "#t");
let next = value["next"].as_str().unwrap();
assert!(next.contains("certSerial=0a0b"), "{next}");
assert!(!next.contains("orderId"), "{next}");
assert_eq!(value["push"], true);
}
#[test]
fn a_single_full_page_offers_neither_control() {
let value = pager(page(50, 0), 50, "/ui/accounts", &[], "#accounts-table");
assert!(value["prev"].is_null());
assert!(value["next"].is_null());
}
#[test]
fn flashes_carry_their_kind_and_a_code_only_when_there_was_one() {
let ok = flash("ok", "saved");
assert_eq!(ok["kind"], "ok");
assert!(ok.get("code").is_none());
let error = flash_error("already_revoked", "already revoked");
assert_eq!(error["kind"], "error");
assert_eq!(error["code"], "already_revoked");
}
}