acme_proxy_admin/webadmin/pages/
orders.rs1use 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#[derive(Debug, Deserialize, Default)]
28pub struct RevokeForm {
29 #[serde(default)]
32 pub reason: String,
33}
34
35pub 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 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
82pub 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
106pub 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 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 axum::http::header::CONTENT_DISPOSITION,
145 format!("attachment; filename=\"{filename}\""),
146 ),
147 ],
148 pem,
149 )
150 .into_response())
151}
152
153pub 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 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 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 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
212pub 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 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
233async 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
250async 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}