acme-proxy-admin 0.6.1

The operation layer and web admin panel of acme-proxy (internal crate, no semver promise)
Documentation
//! `/ui/orders` — the order list, one order with its authorizations, and the
//! two things an operator can do to it.

use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::{Html, IntoResponse, Response};
use serde::Deserialize;
use serde_json::{Map, Value};

use crate::admin;
use crate::webadmin::AdminState;
use crate::webadmin::error::AdminError;
use crate::webadmin::handlers::Caller;
use crate::webadmin::handlers::orders::{
    OrderListParams, Revoked, apply_delete_order, apply_revoke_order, render_orders,
};
use crate::webadmin::handlers::paging::PageParams;
use crate::webadmin::pages::auth::{PageSession, PageSessionWrite};
use crate::webadmin::pages::error::{PageError, redirect};
use crate::webadmin::pages::{
    ListFilters, chrome, flash, flash_error, page_value, pager, respond, respond_fragment,
    vocabulary,
};
use acme_proxy_store::order::Order;

/// The revoke control posts a `<select>`, whose empty option means "no reason".
#[derive(Debug, Deserialize, Default)]
pub struct RevokeForm {
    /// Empty when the operator left the reason at "unspecified"; `serde` would
    /// otherwise refuse to parse `reason=` into an `Option<u32>`.
    #[serde(default)]
    pub reason: String,
}

/// `GET /ui/orders?profile=&accountId=&status=&identifier=&identifierContains=&certSerial=&limit=&offset=`
pub async fn list_orders(
    State(state): State<AdminState>,
    Query(params): Query<OrderListParams>,
    session: PageSession,
) -> Result<Html<String>, PageError> {
    let page = PageParams::from(params.limit, params.offset).resolve(&state.config);
    let filters = ListFilters::new()
        .with("profile", params.profile.as_deref())
        .with("status", params.status.as_deref())
        .with("accountId", params.account_id.as_deref())
        .with("identifier", params.identifier.as_deref())
        .with("identifierContains", params.identifier_contains.as_deref())
        .with("certSerial", params.cert_serial.as_deref());
    // The API's own query, refusals and codes included, rendered as a page
    // rather than as JSON.
    let query = crate::webadmin::handlers::orders::order_query(params, page)?;
    let (orders, total) = Order::search(&query, &state.database).await?;
    let items = render_orders(&orders, &state).await?;

    let mut context = chrome(&session, "orders", "Orders");
    context.insert("page".to_string(), page_value(items, total));
    context.insert(
        "pager".to_string(),
        pager(page, total, "/ui/orders", &filters.pairs(), "#orders-table"),
    );
    context.insert("filters".to_string(), filters.to_value());
    context.insert(
        "statuses".to_string(),
        vocabulary(acme_proxy_store::status::OrderStatus::ALL, |status| {
            status.as_str()
        }),
    );
    context.insert(
        "profiles".to_string(),
        Value::Array(crate::webadmin::handlers::misc::profile_rows(&state)),
    );

    respond(
        &state,
        session.hx,
        "orders/list.html",
        "orders/_table.html",
        context,
    )
}

/// `GET /ui/orders/{id}`
pub async fn get_order(
    State(state): State<AdminState>,
    Path(id): Path<String>,
    session: PageSession,
) -> Result<Html<String>, PageError> {
    let detail = load(&id, &state).await?;

    let mut context = chrome(&session, "orders", "Order");
    context.insert("detail".to_string(), detail);
    context.insert(
        "live_certificate".to_string(),
        Value::Bool(live_certificate(&id, &state).await?),
    );

    respond(
        &state,
        session.hx,
        "orders/detail.html",
        "orders/_card.html",
        context,
    )
}

/// `GET /ui/orders/{id}/chain.pem` — the issued chain as a file.
///
/// A `GET`, so it stays out of `mutating_page_endpoints()` deliberately rather
/// than by omission: it reads, it carries no CSRF token, and `PageSession` is
/// the read-side extractor. It is still behind a session — a certificate is
/// public once issued, but *which* orders exist is not.
///
/// The browser cannot follow the ACME `certificate` URL the card used to print
/// (signed POST-as-GET only), which is the whole reason this route exists.
pub async fn download_chain(
    State(state): State<AdminState>,
    Path(id): Path<String>,
    _session: PageSession,
) -> Result<Response, PageError> {
    let order = Order::find_by_id(&id, &state.database)
        .await?
        .ok_or_else(|| not_found(&id))?;

    // A `404` rather than an empty file: an order that never reached issuance
    // has no chain, and handing back zero bytes named `.pem` would look like a
    // broken certificate rather than an absent one.
    let filename = format!("{}.pem", order.id);
    let pem = order.certificate.ok_or_else(|| {
        PageError::not_found(format!("order {id} has no certificate to download"))
    })?;

    Ok((
        [
            (
                axum::http::header::CONTENT_TYPE,
                "application/pem-certificate-chain".to_string(),
            ),
            (
                // Built from the *stored* id rather than the path-supplied one:
                // this interpolates into a header, and the stored value is a
                // generated identifier where the path segment is whatever the
                // client typed. The lookup above would have 404'd on anything
                // exotic, so this is belt and braces — but the cheap kind.
                axum::http::header::CONTENT_DISPOSITION,
                format!("attachment; filename=\"{filename}\""),
            ),
        ],
        pem,
    )
        .into_response())
}

/// `POST /ui/orders/{id}/revoke`
///
/// The operator-side equivalent of `POST /revokeCert`, and it resolves *that
/// order's own* profile's revocation route — revoking through whichever
/// profile happened to be first would write the serial into the wrong CA's
/// ledger. Like every request, it never reaches a backend: a local CA's
/// revocation is a ledger row the worker signs, anything else a queued job.
///
/// ## Why a refusal is usually a banner and not a page
///
/// A `409` here means the row is in a state that does not allow what was asked
/// (`already_revoked`, `order_not_issued`): the answer belongs beside the
/// button, with the order still on screen. A `5xx` is not about this order at
/// all, so it replaces the page. The rule is "the row's state is a banner, the
/// server's problem is a page".
pub async fn revoke_order(
    State(state): State<AdminState>,
    Path(id): Path<String>,
    request_context: acme_proxy_core::audit::RequestContext,
    session: PageSessionWrite,
    // A plain `Form`, not `Option<Form>`: axum implements the optional
    // extractor for `Json` but not for `Form`, and every caller here is a
    // browser form that always sends a body.
    axum::Form(form): axum::Form<RevokeForm>,
) -> Result<Html<String>, PageError> {
    let reason = match form.reason.trim() {
        "" => None,
        raw => Some(raw.parse::<u32>().map_err(|_| {
            PageError::from(AdminError::bad_request(format!(
                "revocation reason `{raw}` is not a number"
            )))
        })?),
    };

    let caller = Caller::ui(&session.auth, &request_context);
    let banner = match apply_revoke_order(&state, &caller, &id, reason).await {
        Ok(Revoked::Now(_)) => flash("ok", "Certificate revoked."),
        Ok(Revoked::Queued(job)) => flash(
            "ok",
            format!(
                "Revocation queued as job {job}; the worker performs it. \
                 Follow it under Jobs."
            ),
        ),
        // No order to show a card for, or not about this order at all.
        Err(error) if error.status == StatusCode::NOT_FOUND || error.status.is_server_error() => {
            return Err(error.into());
        }
        Err(error) => flash_error(error.code, error.message),
    };

    // Re-read rather than reuse: the revocation stamped columns the card shows,
    // and re-rendering from the pre-revocation row would tell the operator
    // nothing happened.
    let mut context = card_context(&id, &state, &session).await?;
    context.insert("flash".to_string(), banner);
    respond_fragment(&state, "orders/_card.html", context)
}

/// `DELETE /ui/orders/{id}`
pub async fn delete_order(
    State(state): State<AdminState>,
    Path(id): Path<String>,
    session: PageSessionWrite,
    request_context: acme_proxy_core::audit::RequestContext,
) -> Result<Response, PageError> {
    match apply_delete_order(&state, &Caller::ui(&session.auth, &request_context), &id).await {
        Ok(_) => {}
        // The card, with the refusal beside the button that was pressed: the
        // order is still there, and revoking it is one panel up.
        Err(error) if error.status == StatusCode::CONFLICT => {
            let context = card_context(&id, &state, &session).await?;
            return super::refuse_with_card(&state, "orders/_card.html", context, &error);
        }
        Err(error) => return Err(error.into()),
    }

    Ok(redirect("/ui/orders", session.hx))
}

/// The card's context as a mutation re-renders it — the order re-read, since
/// the mutation may have stamped columns the card shows.
async fn card_context(
    id: &str,
    state: &AdminState,
    session: &PageSessionWrite,
) -> Result<Map<String, Value>, PageError> {
    let detail = load(id, state).await?;
    let mut context = super::fragment_context(&session.auth);
    context.insert("detail".to_string(), detail);
    context.insert(
        "live_certificate".to_string(),
        Value::Bool(live_certificate(id, state).await?),
    );
    Ok(context)
}

/// Whether the order holds a live certificate, which disables its delete
/// button. The handler refuses regardless; this only spares the operator a
/// button that can only say no.
async fn live_certificate(id: &str, state: &AdminState) -> Result<bool, PageError> {
    let Some(order_id) = acme_proxy_store::id::parse(id) else {
        return Ok(false);
    };
    Ok(Order::count_live_certificates(order_id, &state.database).await? > 0)
}

async fn load(id: &str, state: &AdminState) -> Result<Value, PageError> {
    let detail = admin::load_order_detail(id, state.database.clone())
        .await?
        .ok_or_else(|| not_found(id))?;
    Ok(admin::render_order_detail_json(
        &detail,
        &state.config.server.base_url,
    ))
}

fn not_found(id: &str) -> PageError {
    PageError::not_found(crate::admin::subject::Subject::Order.missing(id))
}