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;
#[derive(Debug, Deserialize, Default)]
pub struct RevokeForm {
#[serde(default)]
pub reason: String,
}
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());
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,
)
}
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,
)
}
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))?;
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(),
),
(
axum::http::header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{filename}\""),
),
],
pem,
)
.into_response())
}
pub async fn revoke_order(
State(state): State<AdminState>,
Path(id): Path<String>,
request_context: acme_proxy_core::audit::RequestContext,
session: PageSessionWrite,
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."
),
),
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),
};
let mut context = card_context(&id, &state, &session).await?;
context.insert("flash".to_string(), banner);
respond_fragment(&state, "orders/_card.html", context)
}
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(_) => {}
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))
}
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)
}
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))
}