use topcoat::{
context::Cx,
router::{
Body,
error::{bad_request, forbidden, not_found, see_other},
path_param_segment,
},
view::BoxView,
};
use super::{
super::{
forms::{FormChrome, parse_form_body, render_form_page, truthy},
gate::{gate, landing_url},
write::{commit_then, commit_write},
},
fetch::{composite_pk_error, find_by_key},
};
use crate::{
BoxFuture,
db::db,
form::{FieldErrors, FormValues},
notification::{Notification, set_notification},
policy::Ability,
resource::{
ActionEntry, Committed, ErasedInput, HeaderEntry, InputSpec, Mounted, Places,
RESERVED_KEYS, Resource, run_after_commit,
},
table::{action_options_url, bulk_key},
topcoat_compat::async_page,
};
#[derive(Clone, Copy)]
enum Target {
Record,
Bulk,
}
pub(crate) fn resource_delete<R: Resource>(cx: &Cx, body: Body) -> BoxView<'_> {
async_page(async move {
let resource = gate::<R>(cx)?;
mutate(
cx,
&resource,
body,
Target::Record,
Some(ActionEntry::delete()),
)
.await
})
}
pub(crate) fn resource_bulk_delete<R: Resource>(cx: &Cx, body: Body) -> BoxView<'_> {
async_page(async move {
let resource = gate::<R>(cx)?;
let action = Some(ActionEntry::bulk_delete());
mutate(cx, &resource, body, Target::Bulk, action).await
})
}
pub(crate) fn resource_record_action<R: Resource>(cx: &Cx, body: Body) -> BoxView<'_> {
async_page(async move {
let resource = gate::<R>(cx)?;
let action = custom_action(cx, &resource, Places::RECORD);
mutate(cx, &resource, body, Target::Record, action).await
})
}
pub(crate) fn resource_list_action<R: Resource>(cx: &Cx, body: Body) -> BoxView<'_> {
async_page(async move {
let resource = gate::<R>(cx)?;
let name = path_param_segment(cx, "action");
if let Some(header) = resource.header_actions.find(name).copied() {
if !header_allowed(cx, &resource, &header) {
return Err(forbidden().into());
}
let after: AfterCommit = after_header_commit::<R>;
return run_header(cx, body, header, &resource.url, Some(after)).await;
}
let action = custom_action(cx, &resource, Places::BULK);
mutate(cx, &resource, body, Target::Bulk, action).await
})
}
pub(super) fn header_allowed<R: Resource>(
cx: &Cx,
resource: &Mounted<R>,
header: &HeaderEntry,
) -> bool {
resource.can(cx, Ability::ViewAny)
&& resource.can(
cx,
Ability::RunHeader {
action: header.name,
},
)
&& (header.can_run)(cx)
}
fn custom_action<R: Resource>(
cx: &Cx,
resource: &Mounted<R>,
places: Places,
) -> Option<ActionEntry<R>> {
let name = path_param_segment(cx, "action");
resource
.actions
.find(name)
.filter(|a| a.places.intersects(places))
.copied()
}
pub(crate) type AfterCommit = for<'c> fn(&'c Cx, &'static str) -> BoxFuture<'c, ()>;
fn after_header_commit<'c, R: Resource>(cx: &'c Cx, name: &'static str) -> BoxFuture<'c, ()> {
Box::pin(run_after_commit::<R>(cx, Committed::ran(name)))
}
struct Posted {
ids: Option<String>,
pending: Pending,
}
async fn read_post(
cx: &Cx,
body: Body,
name: &str,
confirm: bool,
input: &InputSpec,
) -> Result<Posted, topcoat::Error> {
let parts = parse_form_body(cx, body).await?;
crate::csrf::verify(cx, parts.last(crate::csrf::FIELD_NAME))?;
if confirm && !parts.last("confirm").is_some_and(truthy) {
return Err(bad_request(format!("{name} requires confirmation")).into());
}
let pending = read_input(cx, name, input, &parts.pairs).await?;
Ok(Posted {
ids: parts.last("ids").map(str::to_string),
pending,
})
}
async fn mutate<'a, R: Resource>(
cx: &'a Cx,
resource: &Mounted<R>,
body: Body,
target: Target,
action: Option<ActionEntry<R>>,
) -> Result<BoxView<'a>, topcoat::Error> {
let resource_wide = action.map_or(Ability::ViewAny, |action| action.resource_wide);
if !resource.can(cx, resource_wide) {
return Err(forbidden().into());
}
let Some(action) = action else {
return Err(not_found().into());
};
let Posted {
ids: posted_ids,
pending,
} = read_post(cx, body, action.name, action.confirm, &action.input).await?;
let ids = match target {
Target::Record => vec![path_param_segment(cx, "id").to_string()],
Target::Bulk => {
let ids = parse_bulk_ids(posted_ids.as_deref().unwrap_or_default(), MAX_BULK_IDS);
if ids.is_empty() {
set_notification(cx, Notification::error("Select at least one row first"));
return Err(see_other(landing_url(cx, &resource.url)).into());
}
if ids.len() > MAX_BULK_IDS {
return Err(bad_request(format!("too many ids (max {MAX_BULK_IDS})")).into());
}
ids
}
};
let mut db = db(cx);
let mut tx = db.transaction().await.map_err(crate::error::unavailable)?;
let rows = load_targets(cx, resource, &ids, target, &mut tx).await?;
if rows.iter().any(|row| !resource.can(cx, Ability::View(row))) {
return Err(forbidden().into());
}
let (rows, refused): (Vec<R::Model>, Vec<R::Model>) = rows
.into_iter()
.partition(|row| (action.can_run)(resource, cx, row));
if rows.is_empty() {
return Err(refuse(cx, resource, &action, target, refused.len()));
}
let skipped = (!refused.is_empty()).then(|| {
let skipped = refused.len();
let selected = rows.len() + skipped;
format!(" ({skipped} of {selected} skipped)")
});
let label = (action.label)(cx);
let page = |page: InputPage, label: String| {
let title = match target {
Target::Record => format!("{label}: {}", resource.record_title(&rows[0], &ids[0])),
Target::Bulk => {
let count = rows.len();
let noun = if count == 1 { "record" } else { "records" };
format!(
"{label}: {count} {noun}{}",
skipped.as_deref().unwrap_or_default()
)
}
};
let mut hidden = Vec::new();
if let (Target::Bulk, Some(ids)) = (target, &posted_ids) {
hidden.push(("ids".to_string(), ids.clone()));
}
let chrome = page.chrome(
action.name,
title,
label,
action.confirm,
hidden,
&resource.url,
);
(page, chrome)
};
let input = match pending {
Pending::Ready(input) => input,
Pending::Submitted {
input,
values: posted,
} => {
let errors = (action.input.schema)()
.recheck_relationships(cx, &posted, &FormValues::new(), &mut tx)
.await;
if !errors.is_empty() {
let (page, chrome) = page(
InputPage {
values: posted,
errors,
},
label,
);
drop(tx);
return page.render(cx, &action.input, chrome).await;
}
input
}
Pending::Page(input_page) => {
let (input_page, chrome) = page(input_page, label);
drop(tx);
return input_page.render(cx, &action.input, chrome).await;
}
};
let mut note = (action.success)(cx, rows.len());
note.push_str(skipped.as_deref().unwrap_or_default());
let written = (action.run)(cx, &rows, input, &mut tx).await.map(|()| rows);
commit_write(
cx,
resource,
tx,
written,
action.acted,
note,
action.failure,
)
.await
}
pub(crate) async fn run_header<'a>(
cx: &'a Cx,
body: Body,
action: HeaderEntry,
home: &str,
after: Option<AfterCommit>,
) -> Result<BoxView<'a>, topcoat::Error> {
let Posted { pending, .. } =
read_post(cx, body, action.name, action.confirm, &action.input).await?;
let render = |page: InputPage| {
let label = (action.label)(cx);
let chrome = page.chrome(
action.name,
label.clone(),
label,
action.confirm,
Vec::new(),
home,
);
(page, chrome)
};
let (input, submitted) = match pending {
Pending::Ready(input) => (input, None),
Pending::Submitted { input, values } => (input, Some(values)),
Pending::Page(page) => {
let (page, chrome) = render(page);
return page.render(cx, &action.input, chrome).await;
}
};
let mut db = db(cx);
let mut tx = db.transaction().await.map_err(crate::error::unavailable)?;
if let Some(values) = submitted {
let errors = (action.input.schema)()
.recheck_relationships(cx, &values, &FormValues::new(), &mut tx)
.await;
if !errors.is_empty() {
drop(tx);
let (page, chrome) = render(InputPage { values, errors });
return page.render(cx, &action.input, chrome).await;
}
}
let written = (action.run)(cx, input, &mut tx).await;
let note = (action.success)(cx);
commit_then(
cx,
tx,
written,
|()| async move {
if let Some(after) = after {
after(cx, action.name).await;
}
},
note,
"run the action",
home,
)
.await
}
pub(super) struct InputPage {
pub(super) values: FormValues,
pub(super) errors: FieldErrors,
}
pub(super) enum Pending {
Ready(ErasedInput),
Submitted {
input: ErasedInput,
values: FormValues,
},
Page(InputPage),
}
pub(super) async fn read_input(
cx: &Cx,
name: &str,
spec: &InputSpec,
posted: &[(String, String)],
) -> Result<Pending, topcoat::Error> {
if !spec.takes_input {
let input = (spec.parse)(cx, &FormValues::new()).map_err(|_| {
crate::error::declaration(format!(
"action '{name}' declares no input field but refuses an empty input"
))
})?;
return Ok(Pending::Ready(input));
}
let schema = (spec.schema)();
let input = schema.read_input(posted, &RESERVED_KEYS)?;
let mut errors = FieldErrors::new();
let parsed = match (spec.parse)(cx, &input) {
Ok(parsed) => Some(parsed),
Err(refused) => {
for error in refused {
errors.push(error);
}
None
}
};
schema
.check_controls(cx, &input, &FormValues::new(), &mut errors)
.await;
if let Some(key) = schema.unplaced_error(&errors) {
return Err(crate::error::declaration(format!(
"action '{name}' refused key `{key}`, which no input control renders"
)));
}
match parsed {
Some(parsed) if errors.is_empty() => Ok(Pending::Submitted {
input: parsed,
values: input,
}),
_ => Ok(Pending::Page(InputPage {
values: input,
errors,
})),
}
}
impl InputPage {
fn chrome(
&self,
name: &str,
title: String,
label: String,
confirm: bool,
hidden: Vec<(String, String)>,
home: &str,
) -> FormChrome {
let mut carried = Vec::new();
if confirm {
carried.push(("confirm".to_string(), "1".to_string()));
}
carried.extend(hidden);
FormChrome::action(
title,
label,
confirm,
carried,
home.to_string(),
action_options_url(home, name),
)
}
pub(super) async fn render<'a>(
self,
cx: &'a Cx,
spec: &InputSpec,
chrome: FormChrome,
) -> topcoat::Result<BoxView<'a>> {
render_form_page(
cx,
&(spec.schema)(),
chrome,
&self.values,
&self.errors,
&Default::default(),
)
.await
}
}
async fn load_targets<R: Resource>(
cx: &Cx,
resource: &Mounted<R>,
ids: &[String],
target: Target,
tx: &mut toasty::Transaction<'_>,
) -> Result<Vec<R::Model>, topcoat::Error> {
if let Target::Record = target {
return Ok(vec![find_by_key(cx, resource, &ids[0], tx).await?]);
}
let keys: Vec<&str> = ids.iter().map(String::as_str).collect();
let Some(pk_filter) = crate::toasty_compat::pk::pk_in_expr::<R::Model>(&keys) else {
if let Some(error) = composite_pk_error(resource) {
return Err(error);
}
return Err(not_found().into());
};
let rows = resource
.scoped_query(cx)?
.filter(pk_filter)
.exec(tx)
.await
.map_err(crate::error::unavailable)?;
if rows.len() != ids.len() {
return Err(not_found().into());
}
Ok(rows)
}
fn refuse<R: Resource>(
cx: &Cx,
resource: &Mounted<R>,
action: &ActionEntry<R>,
target: Target,
refused: usize,
) -> topcoat::Error {
match target {
Target::Record => forbidden().into(),
Target::Bulk => {
let noun = if refused == 1 { "record" } else { "records" };
set_notification(
cx,
Notification::error(format!(
"{}: {refused} selected {noun} cannot take this action; nothing was changed",
(action.label)(cx)
)),
);
see_other(landing_url(cx, &resource.url)).into()
}
}
}
pub(super) const MAX_BULK_IDS: usize = 400;
pub(super) fn parse_bulk_ids(raw: &str, max: usize) -> Vec<String> {
let mut ids: Vec<String> = Vec::new();
let mut seen = std::collections::HashSet::new();
let parts = raw
.split(',')
.map(str::trim)
.filter(|part| !part.is_empty());
for part in parts {
let key = bulk_key(part);
if seen.insert(key.clone()) {
ids.push(key);
if ids.len() > max {
break;
}
}
}
ids
}
#[cfg(test)]
mod tests;