Skip to main content

acme_proxy_admin/webadmin/pages/
orders.rs

1//! `/ui/orders` — the order list, one order with its authorizations, and the
2//! two things an operator can do to it.
3
4use axum::extract::{Path, Query, State};
5use axum::http::StatusCode;
6use axum::response::{Html, IntoResponse, Response};
7use serde::Deserialize;
8use serde_json::{Map, Value};
9
10use crate::admin;
11use crate::webadmin::AdminState;
12use crate::webadmin::error::AdminError;
13use crate::webadmin::handlers::Caller;
14use crate::webadmin::handlers::orders::{
15    OrderListParams, Revoked, apply_delete_order, apply_revoke_order, render_orders,
16};
17use crate::webadmin::handlers::paging::PageParams;
18use crate::webadmin::pages::auth::{PageSession, PageSessionWrite};
19use crate::webadmin::pages::error::{PageError, redirect};
20use crate::webadmin::pages::{
21    ListFilters, chrome, flash, flash_error, page_value, pager, respond, respond_fragment,
22    vocabulary,
23};
24use acme_proxy_store::order::Order;
25
26/// The revoke control posts a `<select>`, whose empty option means "no reason".
27#[derive(Debug, Deserialize, Default)]
28pub struct RevokeForm {
29    /// Empty when the operator left the reason at "unspecified"; `serde` would
30    /// otherwise refuse to parse `reason=` into an `Option<u32>`.
31    #[serde(default)]
32    pub reason: String,
33}
34
35/// `GET /ui/orders?profile=&accountId=&status=&identifier=&identifierContains=&certSerial=&limit=&offset=`
36pub async fn list_orders(
37    State(state): State<AdminState>,
38    Query(params): Query<OrderListParams>,
39    session: PageSession,
40) -> Result<Html<String>, PageError> {
41    let page = PageParams::from(params.limit, params.offset).resolve(&state.config);
42    let filters = ListFilters::new()
43        .with("profile", params.profile.as_deref())
44        .with("status", params.status.as_deref())
45        .with("accountId", params.account_id.as_deref())
46        .with("identifier", params.identifier.as_deref())
47        .with("identifierContains", params.identifier_contains.as_deref())
48        .with("certSerial", params.cert_serial.as_deref());
49    // The API's own query, refusals and codes included, rendered as a page
50    // rather than as JSON.
51    let query = crate::webadmin::handlers::orders::order_query(params, page)?;
52    let (orders, total) = Order::search(&query, &state.database).await?;
53    let items = render_orders(&orders, &state).await?;
54
55    let mut context = chrome(&session, "orders", "Orders");
56    context.insert("page".to_string(), page_value(items, total));
57    context.insert(
58        "pager".to_string(),
59        pager(page, total, "/ui/orders", &filters.pairs(), "#orders-table"),
60    );
61    context.insert("filters".to_string(), filters.to_value());
62    context.insert(
63        "statuses".to_string(),
64        vocabulary(acme_proxy_store::status::OrderStatus::ALL, |status| {
65            status.as_str()
66        }),
67    );
68    context.insert(
69        "profiles".to_string(),
70        Value::Array(crate::webadmin::handlers::misc::profile_rows(&state)),
71    );
72
73    respond(
74        &state,
75        session.hx,
76        "orders/list.html",
77        "orders/_table.html",
78        context,
79    )
80}
81
82/// `GET /ui/orders/{id}`
83pub async fn get_order(
84    State(state): State<AdminState>,
85    Path(id): Path<String>,
86    session: PageSession,
87) -> Result<Html<String>, PageError> {
88    let detail = load(&id, &state).await?;
89
90    let mut context = chrome(&session, "orders", "Order");
91    context.insert("detail".to_string(), detail);
92    context.insert(
93        "live_certificate".to_string(),
94        Value::Bool(live_certificate(&id, &state).await?),
95    );
96
97    respond(
98        &state,
99        session.hx,
100        "orders/detail.html",
101        "orders/_card.html",
102        context,
103    )
104}
105
106/// `GET /ui/orders/{id}/chain.pem` — the issued chain as a file.
107///
108/// A `GET`, so it stays out of `mutating_page_endpoints()` deliberately rather
109/// than by omission: it reads, it carries no CSRF token, and `PageSession` is
110/// the read-side extractor. It is still behind a session — a certificate is
111/// public once issued, but *which* orders exist is not.
112///
113/// The browser cannot follow the ACME `certificate` URL the card used to print
114/// (signed POST-as-GET only), which is the whole reason this route exists.
115pub async fn download_chain(
116    State(state): State<AdminState>,
117    Path(id): Path<String>,
118    _session: PageSession,
119) -> Result<Response, PageError> {
120    let order = Order::find_by_id(&id, &state.database)
121        .await?
122        .ok_or_else(|| not_found(&id))?;
123
124    // A `404` rather than an empty file: an order that never reached issuance
125    // has no chain, and handing back zero bytes named `.pem` would look like a
126    // broken certificate rather than an absent one.
127    let filename = format!("{}.pem", order.id);
128    let pem = order.certificate.ok_or_else(|| {
129        PageError::not_found(format!("order {id} has no certificate to download"))
130    })?;
131
132    Ok((
133        [
134            (
135                axum::http::header::CONTENT_TYPE,
136                "application/pem-certificate-chain".to_string(),
137            ),
138            (
139                // Built from the *stored* id rather than the path-supplied one:
140                // this interpolates into a header, and the stored value is a
141                // generated identifier where the path segment is whatever the
142                // client typed. The lookup above would have 404'd on anything
143                // exotic, so this is belt and braces — but the cheap kind.
144                axum::http::header::CONTENT_DISPOSITION,
145                format!("attachment; filename=\"{filename}\""),
146            ),
147        ],
148        pem,
149    )
150        .into_response())
151}
152
153/// `POST /ui/orders/{id}/revoke`
154///
155/// The operator-side equivalent of `POST /revokeCert`, and it resolves *that
156/// order's own* profile's revocation route — revoking through whichever
157/// profile happened to be first would write the serial into the wrong CA's
158/// ledger. Like every request, it never reaches a backend: a local CA's
159/// revocation is a ledger row the worker signs, anything else a queued job.
160///
161/// ## Why a refusal is usually a banner and not a page
162///
163/// A `409` here means the row is in a state that does not allow what was asked
164/// (`already_revoked`, `order_not_issued`): the answer belongs beside the
165/// button, with the order still on screen. A `5xx` is not about this order at
166/// all, so it replaces the page. The rule is "the row's state is a banner, the
167/// server's problem is a page".
168pub async fn revoke_order(
169    State(state): State<AdminState>,
170    Path(id): Path<String>,
171    request_context: acme_proxy_core::audit::RequestContext,
172    session: PageSessionWrite,
173    // A plain `Form`, not `Option<Form>`: axum implements the optional
174    // extractor for `Json` but not for `Form`, and every caller here is a
175    // browser form that always sends a body.
176    axum::Form(form): axum::Form<RevokeForm>,
177) -> Result<Html<String>, PageError> {
178    let reason = match form.reason.trim() {
179        "" => None,
180        raw => Some(raw.parse::<u32>().map_err(|_| {
181            PageError::from(AdminError::bad_request(format!(
182                "revocation reason `{raw}` is not a number"
183            )))
184        })?),
185    };
186
187    let caller = Caller::ui(&session.auth, &request_context);
188    let banner = match apply_revoke_order(&state, &caller, &id, reason).await {
189        Ok(Revoked::Now(_)) => flash("ok", "Certificate revoked."),
190        Ok(Revoked::Queued(job)) => flash(
191            "ok",
192            format!(
193                "Revocation queued as job {job}; the worker performs it. \
194                 Follow it under Jobs."
195            ),
196        ),
197        // No order to show a card for, or not about this order at all.
198        Err(error) if error.status == StatusCode::NOT_FOUND || error.status.is_server_error() => {
199            return Err(error.into());
200        }
201        Err(error) => flash_error(error.code, error.message),
202    };
203
204    // Re-read rather than reuse: the revocation stamped columns the card shows,
205    // and re-rendering from the pre-revocation row would tell the operator
206    // nothing happened.
207    let mut context = card_context(&id, &state, &session).await?;
208    context.insert("flash".to_string(), banner);
209    respond_fragment(&state, "orders/_card.html", context)
210}
211
212/// `DELETE /ui/orders/{id}`
213pub async fn delete_order(
214    State(state): State<AdminState>,
215    Path(id): Path<String>,
216    session: PageSessionWrite,
217    request_context: acme_proxy_core::audit::RequestContext,
218) -> Result<Response, PageError> {
219    match apply_delete_order(&state, &Caller::ui(&session.auth, &request_context), &id).await {
220        Ok(_) => {}
221        // The card, with the refusal beside the button that was pressed: the
222        // order is still there, and revoking it is one panel up.
223        Err(error) if error.status == StatusCode::CONFLICT => {
224            let context = card_context(&id, &state, &session).await?;
225            return super::refuse_with_card(&state, "orders/_card.html", context, &error);
226        }
227        Err(error) => return Err(error.into()),
228    }
229
230    Ok(redirect("/ui/orders", session.hx))
231}
232
233/// The card's context as a mutation re-renders it — the order re-read, since
234/// the mutation may have stamped columns the card shows.
235async fn card_context(
236    id: &str,
237    state: &AdminState,
238    session: &PageSessionWrite,
239) -> Result<Map<String, Value>, PageError> {
240    let detail = load(id, state).await?;
241    let mut context = super::fragment_context(&session.auth);
242    context.insert("detail".to_string(), detail);
243    context.insert(
244        "live_certificate".to_string(),
245        Value::Bool(live_certificate(id, state).await?),
246    );
247    Ok(context)
248}
249
250/// Whether the order holds a live certificate, which disables its delete
251/// button. The handler refuses regardless; this only spares the operator a
252/// button that can only say no.
253async fn live_certificate(id: &str, state: &AdminState) -> Result<bool, PageError> {
254    let Some(order_id) = acme_proxy_store::id::parse(id) else {
255        return Ok(false);
256    };
257    Ok(Order::count_live_certificates(order_id, &state.database).await? > 0)
258}
259
260async fn load(id: &str, state: &AdminState) -> Result<Value, PageError> {
261    let detail = admin::load_order_detail(id, state.database.clone())
262        .await?
263        .ok_or_else(|| not_found(id))?;
264    Ok(admin::render_order_detail_json(
265        &detail,
266        &state.config.server.base_url,
267    ))
268}
269
270fn not_found(id: &str) -> PageError {
271    PageError::not_found(crate::admin::subject::Subject::Order.missing(id))
272}