Skip to main content

acme_proxy/webadmin/pages/
misc.rs

1//! `/ui/`, `/ui/profiles` and `/ui/nonces` — the overview and the two small
2//! surfaces.
3
4use axum::extract::State;
5use axum::response::Html;
6use serde::Deserialize;
7use serde_json::{Map, Value};
8use std::time::Duration;
9
10use crate::admin;
11use crate::sqlite::account::Account;
12use crate::sqlite::eab::Eab;
13use crate::sqlite::nonce::Nonce;
14use crate::sqlite::order::{Order, OrderQuery};
15use crate::webadmin::AdminState;
16use crate::webadmin::handlers::misc::profile_rows;
17use crate::webadmin::pages::auth::{PageSession, PageSessionWrite};
18use crate::webadmin::pages::error::PageError;
19use crate::webadmin::pages::{chrome, flash, respond, respond_fragment};
20
21#[derive(Debug, Deserialize, Default)]
22pub struct CleanupForm {
23    /// Blank means `nonce.ttl_seconds`, matching the JSON API's absent member.
24    #[serde(default, rename = "ttlSeconds")]
25    pub ttl_seconds: String,
26}
27
28/// `GET /ui/` — the overview.
29///
30/// Four counts and the endpoint list. The counts come from the same `search`
31/// calls the lists use, asked for a single row: the totals are computed by the
32/// database, so this is four `COUNT(*)`s rather than four full reads.
33pub async fn get_index(
34    State(state): State<AdminState>,
35    session: PageSession,
36) -> Result<Html<String>, PageError> {
37    let (_, accounts) = Account::search(None, 1, 0, &state.database).await?;
38    let (_, orders) = Order::search(
39        &OrderQuery {
40            limit: 1,
41            ..OrderQuery::default()
42        },
43        &state.database,
44    )
45    .await?;
46    let (_, eab) = Eab::search(1, 0, &state.database).await?;
47    let nonces = Nonce::count(&state.database).await?;
48
49    let mut context = chrome(&session, "index", "Overview");
50    context.insert(
51        "stats".to_string(),
52        serde_json::json!({
53            "accounts": accounts,
54            "orders": orders,
55            "eab": eab,
56            "nonces": nonces,
57        }),
58    );
59    context.insert("profiles".to_string(), Value::Array(profile_rows(&state)));
60
61    // The overview is a whole page or nothing: there is no fragment of it worth
62    // swapping on its own.
63    respond(&state, false, "index.html", "index.html", context)
64}
65
66/// `GET /ui/profiles` — the endpoints this process is serving.
67pub async fn list_profiles(
68    State(state): State<AdminState>,
69    session: PageSession,
70) -> Result<Html<String>, PageError> {
71    let profiles = profile_rows(&state);
72    // Computed here rather than filtered in the template: a warning this
73    // load-bearing should be a value a Rust test can assert on.
74    let any_bypass = profiles
75        .iter()
76        .any(|profile| profile["challengeBypass"] == Value::Bool(true));
77
78    let mut context = chrome(&session, "profiles", "Profiles");
79    context.insert("profiles".to_string(), Value::Array(profiles));
80    context.insert("any_bypass".to_string(), Value::Bool(any_bypass));
81
82    respond(
83        &state,
84        false,
85        "profiles/list.html",
86        "profiles/_table.html",
87        context,
88    )
89}
90
91/// `GET /ui/nonces` — how many rows the table holds.
92pub async fn get_nonces(
93    State(state): State<AdminState>,
94    session: PageSession,
95) -> Result<Html<String>, PageError> {
96    let count = Nonce::count(&state.database).await?;
97
98    let mut context = chrome(&session, "nonces", "Nonces");
99    context.insert("count".to_string(), Value::from(count));
100    context.insert(
101        "ttl_seconds".to_string(),
102        Value::from(state.config.nonce.ttl_seconds),
103    );
104
105    respond(
106        &state,
107        session.hx,
108        "nonces/index.html",
109        "nonces/_panel.html",
110        context,
111    )
112}
113
114/// `POST /ui/nonces/cleanup` — sweep now, rather than waiting for the reaper.
115pub async fn cleanup_nonces(
116    State(state): State<AdminState>,
117    session: PageSessionWrite,
118    axum::Form(form): axum::Form<CleanupForm>,
119) -> Result<Html<String>, PageError> {
120    let seconds = match form.ttl_seconds.trim() {
121        "" => state.config.nonce.ttl_seconds,
122        raw => raw.parse::<u64>().map_err(|_| {
123            PageError::from(crate::webadmin::error::AdminError::bad_request(format!(
124                "`{raw}` is not a number of seconds"
125            )))
126        })?,
127    };
128
129    let removed =
130        admin::cleanup_nonces(Duration::from_secs(seconds), state.database.clone()).await?;
131    tracing::info!(event = "admin_nonces_cleaned",
132                   outcome = "success",
133                   surface = "ui",
134                   rows_removed = removed,
135                   ttl_seconds = seconds,
136                   username = %session.auth.user.username);
137
138    let count = Nonce::count(&state.database).await?;
139    let mut context = Map::new();
140    context.insert(
141        "csrf_token".to_string(),
142        Value::String(session.auth.session.csrf_token.clone()),
143    );
144    context.insert("count".to_string(), Value::from(count));
145    context.insert(
146        "ttl_seconds".to_string(),
147        Value::from(state.config.nonce.ttl_seconds),
148    );
149    context.insert(
150        "flash".to_string(),
151        flash("ok", format!("Swept {removed} nonce(s).")),
152    );
153
154    respond_fragment(&state, "nonces/_panel.html", context)
155}