use std::collections::{HashMap, HashSet};
use topcoat::{
Result,
context::Cx,
router::{Body, error::forbidden},
view::BoxView,
};
use super::{
super::{actions::load_editable, gate::gate, relations::render_relations},
related::{open_related, render_related},
render::{Carried, FormChrome, render_form_page},
};
pub(crate) use crate::upload::FormParts;
use crate::{
db::{WriteTx, db},
form::{FieldErrors, FormValue, FormValues, RecordForm},
panel::state::uploader,
policy::Ability,
resource::{Mounted, Resource},
topcoat_compat::async_page,
upload::Capture,
};
pub(crate) async fn parse_form_body(cx: &Cx, body: Body) -> Result<FormParts, topcoat::Error> {
let capture = if uploader(cx).is_some() {
Capture::Persistable
} else {
Capture::None
};
crate::upload::parse_body(cx, body, capture).await
}
pub(super) fn reject_unknown_form_keys(
schema: &crate::schema::Schema,
unclaimed: Vec<String>,
) -> Result<(), topcoat::Error> {
let unknown: Vec<String> = unclaimed
.into_iter()
.filter(|key| {
let file = key
.strip_prefix("clear_")
.or_else(|| key.strip_prefix("keep_"));
key != crate::csrf::FIELD_NAME
&& key != super::render::VERSION_FIELD
&& key != crate::form::ANOTHER_FIELD
&& !file
.is_some_and(|file| schema.fields().any(|f| f.is_file() && f.name() == file))
})
.collect();
if unknown.is_empty() {
Ok(())
} else {
Err(topcoat::router::error::bad_request(format!(
"unknown field(s): {}",
unknown.join(", ")
))
.into())
}
}
pub(crate) fn truthy(v: &str) -> bool {
v == "1" || v == "true"
}
#[derive(Debug, Default)]
pub(super) struct FileFlags {
pub(super) keep: Vec<String>,
pub(super) clear: Vec<String>,
}
impl FileFlags {
pub(super) fn clears(&self) -> bool {
self.clear.last().is_some_and(|flag| truthy(flag))
}
}
pub(super) fn file_flags(
schema: &crate::schema::Schema,
parts: &FormParts,
) -> HashMap<String, FileFlags> {
schema
.fields()
.filter(|field| field.is_file())
.map(|field| {
let name = field.name();
let posted = |key: String| -> Vec<String> {
parts
.pairs
.iter()
.filter(|(posted, _)| *posted == key)
.map(|(_, value)| value.clone())
.collect()
};
let flags = FileFlags {
keep: posted(format!("keep_{name}")),
clear: posted(format!("clear_{name}")),
};
(name.to_string(), flags)
})
.collect()
}
pub(super) fn drop_client_typed_uploads(
schema: &crate::schema::Schema,
file_part_names: &HashSet<String>,
values: &mut FormValues,
) {
for field in schema.fields().filter(|field| field.is_file()) {
if !file_part_names.contains(field.name()) {
values.remove(field.name());
}
}
}
pub(super) fn blank(values: &FormValues, key: &str) -> bool {
values.text(key).is_none_or(|value| value.trim().is_empty())
}
pub(super) async fn restore_pending_uploads(
cx: &Cx,
schema: &crate::schema::Schema,
flags: &HashMap<String, FileFlags>,
values: &mut FormValues,
) -> HashSet<String> {
let mut restored = HashSet::new();
for (name, flags) in flags {
let Some(file) = schema
.fields()
.find(|field| field.name() == name)
.and_then(|field| field.as_file())
.filter(|file| !file.multiple)
else {
continue;
};
if !blank(values, name) || flags.clears() {
continue;
}
let Some(candidate) = flags
.keep
.last()
.and_then(|carried| crate::upload::carry::open(cx, name, carried.trim()))
else {
continue;
};
let taken = file.takes_path(candidate);
if !taken || candidate.is_empty() || !crate::upload::holds(uploader(cx), candidate).await {
continue;
}
values.insert(name.clone(), candidate);
restored.insert(name.clone());
}
restored
}
#[derive(Debug)]
pub(super) struct FilePost {
name: String,
order: Vec<String>,
carried: Vec<String>,
clear: HashSet<String>,
added: Vec<String>,
}
impl FilePost {
fn resolve(&self, held: &[String]) -> Vec<String> {
let known: HashSet<&str> = held
.iter()
.chain(&self.carried)
.map(String::as_str)
.collect();
let mut seen = HashSet::new();
let mut paths = Vec::new();
let posted = self
.order
.iter()
.filter(|path| known.contains(path.as_str()));
for path in posted.chain(held).chain(&self.carried) {
if !self.clear.contains(path) && seen.insert(path.as_str()) {
paths.push(path.clone());
}
}
paths.extend(self.added.iter().cloned());
paths
}
}
#[derive(Debug, Default)]
pub(super) struct FilePosts(Vec<FilePost>);
impl FilePosts {
pub(super) fn resolve(
&self,
schema: &crate::schema::Schema,
stored: &FormValues,
values: &mut FormValues,
errors: &mut FieldErrors,
) {
for post in &self.0 {
let paths = post.resolve(stored.list(&post.name).unwrap_or_default());
let field = schema.fields().find(|field| field.name() == post.name);
let refusal = field.and_then(|field| {
let file = field.as_file()?;
file.count_refusal(&field.label(), paths.len())
});
if let Some(refusal) = refusal.filter(|_| !errors.contains_key(&post.name)) {
errors.add(post.name.clone(), refusal);
}
values.insert(&post.name, FormValue::List(paths));
}
}
pub(super) fn pending(&self) -> impl Iterator<Item = (String, String)> + '_ {
self.0.iter().flat_map(|post| {
let paths = post.carried.iter().chain(&post.added);
paths
.filter(|path| !post.clear.contains(*path))
.map(|path| (post.name.clone(), path.clone()))
})
}
}
pub(super) fn refuse_too_many_files(
schema: &crate::schema::Schema,
files: &mut HashMap<String, Vec<crate::upload::StagedUpload>>,
) -> FieldErrors {
let mut errors = FieldErrors::new();
for field in schema.fields() {
let Some(file) = field.as_file().filter(|file| file.multiple) else {
continue;
};
let chosen = files.get(field.name()).map_or(0, Vec::len);
if let Some(refusal) = file.count_refusal(&field.label(), chosen) {
files.remove(field.name());
errors.add(field.name(), refusal);
}
}
errors
}
pub(super) async fn gather_file_lists(
cx: &Cx,
schema: &crate::schema::Schema,
flags: &HashMap<String, FileFlags>,
file_part_names: &HashSet<String>,
values: &FormValues,
) -> FilePosts {
let mut posts = Vec::new();
for field in schema.fields() {
let Some(file) = field.as_file().filter(|file| file.multiple) else {
continue;
};
let name = field.name();
let Some(flags) = flags.get(name) else {
continue;
};
if !file_part_names.contains(name) && flags.keep.is_empty() && flags.clear.is_empty() {
continue;
}
let mut order = Vec::new();
let mut carried = Vec::new();
for kept in &flags.keep {
let kept = kept.trim();
match crate::upload::carry::open(cx, name, kept) {
Some(path) => {
let held = !path.is_empty()
&& file.takes_path(path)
&& crate::upload::holds(uploader(cx), path).await;
if held {
carried.push(path.to_string());
}
}
None => order.push(kept.to_string()),
}
}
posts.push(FilePost {
name: name.to_string(),
order,
carried,
clear: flags
.clear
.iter()
.map(|path| path.trim().to_string())
.collect(),
added: values.list(name).unwrap_or_default().to_vec(),
});
}
FilePosts(posts)
}
pub(super) async fn rerender_invalid_form<'a, R: Resource>(
cx: &'a Cx,
resource: &Mounted<R>,
tx: WriteTx<'_>,
chrome: FormChrome<'a>,
values: &FormValues,
errors: &FieldErrors,
carried: &Carried,
) -> Result<BoxView<'a>> {
drop(tx);
render_form_page(cx, &resource.form, chrome, values, errors, carried).await
}
pub(crate) fn resource_edit<R: Resource>(cx: &Cx, _body: Body) -> BoxView<'_> {
async_page(async move {
let resource = gate::<R>(cx)?;
let mut db = db(cx)?;
let record = load_editable(cx, &resource, &mut db).await?;
if !resource.can(cx, Ability::Update(&record)) {
return Err(forbidden().into());
}
crate::csrf::ensure_token(cx);
let values = <R::Form as RecordForm>::hydrate(cx, &record);
let relations = render_relations(cx, &resource, &record);
let related = open_related(cx, &resource, Some(&record), &mut db).await?;
let html = render_form_page(
cx,
&resource.form,
FormChrome::edit(cx, &resource, &record)
.with_relations(relations)
.with_related(render_related(cx, &related)),
&values,
&FieldErrors::new(),
&Carried::new(),
)
.await?;
Ok(html)
})
}
#[cfg(test)]
mod tests;