Skip to main content

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