Skip to main content

acme_proxy_admin/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::Value;
8
9use crate::webadmin::AdminState;
10use crate::webadmin::handlers::Caller;
11use crate::webadmin::handlers::misc::{apply_cleanup_nonces, profile_rows};
12use crate::webadmin::pages::auth::{PageSession, PageSessionWrite};
13use crate::webadmin::pages::error::PageError;
14use crate::webadmin::pages::{chrome, flash, respond, respond_fragment};
15use acme_proxy_store::account::Account;
16use acme_proxy_store::audit::AuditEntry;
17use acme_proxy_store::audit::AuditQuery;
18use acme_proxy_store::eab::Eab;
19use acme_proxy_store::job::Job;
20use acme_proxy_store::job::JobQuery;
21use acme_proxy_store::nonce::Nonce;
22use acme_proxy_store::order::Order;
23use acme_proxy_store::order::OrderQuery;
24use acme_proxy_store::status::JobStatus;
25
26/// The form of `POST /ui/nonces/cleanup`.
27#[derive(Debug, Deserialize, Default)]
28pub struct CleanupForm {
29    /// Blank means `nonce.ttl_seconds`, matching the JSON API's absent member.
30    #[serde(default, rename = "ttlSeconds")]
31    pub ttl_seconds: String,
32}
33
34/// How far ahead the overview's "expiring" tile looks. The expiry page's own
35/// "urgent" badge threshold, so the tile and the red rows it opens agree.
36const ATTENTION_EXPIRY_DAYS: u64 = 7;
37
38/// `GET /ui/` — the overview.
39///
40/// What needs attention, then the totals, then the endpoint list. Every number
41/// comes from the same query its list page runs, asked for a single row: the
42/// totals are computed by the database, so this is a handful of `COUNT(*)`s
43/// rather than as many full reads.
44pub async fn get_index(
45    State(state): State<AdminState>,
46    session: PageSession,
47) -> Result<Html<String>, PageError> {
48    let (_, accounts) = Account::search(None, None, 1, 0, &state.database).await?;
49    let (_, orders) = Order::search(
50        &OrderQuery {
51            limit: 1,
52            ..OrderQuery::default()
53        },
54        &state.database,
55    )
56    .await?;
57    let (_, eab) = Eab::search(1, 0, &state.database).await?;
58    let nonces = Nonce::count(&state.database).await?;
59
60    // What needs somebody, before what merely exists. Each is the total of a
61    // query a list page already runs, asked for one row, and each tile links
62    // to that list -- so a number here is never one the operator cannot open.
63    let (_, failed_jobs) = Job::search(
64        &JobQuery {
65            kind: None,
66            status: Some(JobStatus::Failed),
67            limit: 1,
68            offset: 0,
69        },
70        &state.database,
71    )
72    .await?;
73    // The whole window, replaced certificates included, so the number agrees
74    // with the list the tile opens rather than with a filter it does not set.
75    let (_, expiring_soon, _) = acme_proxy_store::expiring::list_expiring(
76        &acme_proxy_store::expiring::ExpiringQuery {
77            profile: None,
78            before: acme_proxy_store::expiring::expiring_horizon(ATTENTION_EXPIRY_DAYS),
79            include_superseded: true,
80            limit: 1,
81            offset: 0,
82        },
83        state.database.clone(),
84    )
85    .await?;
86    let (_, refusals) = AuditEntry::search(
87        &AuditQuery {
88            outcome: Some("failure".to_string()),
89            since: Some(acme_proxy_store::nonce::now_secs().saturating_sub(24 * 60 * 60)),
90            limit: 1,
91            ..AuditQuery::default()
92        },
93        &state.database,
94    )
95    .await?;
96    let profiles = profile_rows(&state);
97    let any_bypass = profiles
98        .iter()
99        .any(|profile| profile["challengeBypass"] == Value::Bool(true));
100
101    let mut context = chrome(&session, "index", "Overview");
102    context.insert(
103        "stats".to_string(),
104        serde_json::json!({
105            "accounts": accounts,
106            "orders": orders,
107            "eab": eab,
108            "nonces": nonces,
109            "failedJobs": failed_jobs,
110            "expiringSoon": expiring_soon,
111            "expiryDays": ATTENTION_EXPIRY_DAYS,
112            "refusals": refusals,
113        }),
114    );
115    context.insert("any_bypass".to_string(), Value::Bool(any_bypass));
116    context.insert("profiles".to_string(), Value::Array(profiles));
117
118    // The overview is a whole page or nothing: there is no fragment of it worth
119    // swapping on its own.
120    respond(&state, false, "index.html", "index.html", context)
121}
122
123/// `GET /ui/profiles` — the endpoints this process is serving.
124pub async fn list_profiles(
125    State(state): State<AdminState>,
126    session: PageSession,
127) -> Result<Html<String>, PageError> {
128    let profiles = profile_rows(&state);
129    // Computed here rather than filtered in the template: a warning this
130    // load-bearing should be a value a Rust test can assert on.
131    let any_bypass = profiles
132        .iter()
133        .any(|profile| profile["challengeBypass"] == Value::Bool(true));
134
135    let mut context = chrome(&session, "profiles", "Profiles");
136    context.insert("profiles".to_string(), Value::Array(profiles));
137    context.insert("any_bypass".to_string(), Value::Bool(any_bypass));
138
139    respond(
140        &state,
141        false,
142        "profiles/list.html",
143        "profiles/_table.html",
144        context,
145    )
146}
147
148/// `GET /ui/nonces` — how many rows the table holds.
149pub async fn get_nonces(
150    State(state): State<AdminState>,
151    session: PageSession,
152) -> Result<Html<String>, PageError> {
153    let count = Nonce::count(&state.database).await?;
154
155    let mut context = chrome(&session, "nonces", "Nonces");
156    context.insert("count".to_string(), Value::from(count));
157    context.insert(
158        "ttl_seconds".to_string(),
159        Value::from(state.config.nonce.ttl_seconds),
160    );
161
162    respond(
163        &state,
164        session.hx,
165        "nonces/index.html",
166        "nonces/_panel.html",
167        context,
168    )
169}
170
171/// `POST /ui/nonces/cleanup` — sweep now, rather than waiting for the reaper.
172pub async fn cleanup_nonces(
173    State(state): State<AdminState>,
174    session: PageSessionWrite,
175    request_context: acme_proxy_core::audit::RequestContext,
176    axum::Form(form): axum::Form<CleanupForm>,
177) -> Result<Html<String>, PageError> {
178    let seconds = match form.ttl_seconds.trim() {
179        "" => state.config.nonce.ttl_seconds,
180        raw => raw.parse::<u64>().map_err(|_| {
181            PageError::from(crate::webadmin::error::AdminError::bad_request(format!(
182                "`{raw}` is not a number of seconds"
183            )))
184        })?,
185    };
186
187    let removed = apply_cleanup_nonces(
188        &state,
189        &Caller::ui(&session.auth, &request_context),
190        seconds,
191    )
192    .await?;
193
194    let count = Nonce::count(&state.database).await?;
195    let mut context = super::fragment_context(&session.auth);
196    context.insert("count".to_string(), Value::from(count));
197    context.insert(
198        "ttl_seconds".to_string(),
199        Value::from(state.config.nonce.ttl_seconds),
200    );
201    context.insert(
202        "flash".to_string(),
203        flash("ok", format!("Swept {removed} nonce(s).")),
204    );
205
206    respond_fragment(&state, "nonces/_panel.html", context)
207}