use crate::forms::base::{CommonFieldConfig, FieldConfig, FormField};
use crate::forms::fields::file::is_staged_upload;
use crate::utils::aliases::ATera;
use crate::utils::trad::{t, tf};
use async_trait::async_trait;
use base64::{Engine, engine::general_purpose::STANDARD};
use serde::Serialize;
#[derive(Clone, Serialize, Debug)]
pub struct BinaryField {
pub base: FieldConfig,
pub max_size: Option<u64>,
}
impl BinaryField {
pub fn new(name: &str) -> Self {
Self {
base: FieldConfig::new(name, "binary", "base_file.html"),
max_size: None,
}
}
pub fn max_size(mut self, bytes: u64) -> Self {
self.max_size = Some(bytes);
self
}
pub fn label(mut self, label: &str) -> Self {
self.base.label = label.to_string();
self
}
pub fn required(mut self) -> Self {
self.set_required(true, None);
self
}
}
pub fn decode_binary(value: &str) -> Option<Vec<u8>> {
STANDARD.decode(value.trim()).ok()
}
impl CommonFieldConfig for BinaryField {
fn get_field_config(&self) -> &FieldConfig {
&self.base
}
fn get_field_config_mut(&mut self) -> &mut FieldConfig {
&mut self.base
}
}
#[async_trait]
impl FormField for BinaryField {
fn cap_max_size(&mut self, bytes: u64) {
if self.max_size.is_none_or(|s| s > bytes) {
self.max_size = Some(bytes);
}
}
fn set_submitted_value(&mut self, value: &str) {
let value = value.trim();
if !value.is_empty() && !is_staged_upload(value) {
tracing::warn!(field = %self.base.name, "binary field: submitted value is not a staged upload, ignored");
return;
}
self.set_value(value);
}
async fn validate(&mut self) -> bool {
let val = self.base.value.trim().to_string();
if self.base.is_required.choice && val.is_empty() {
let msg = self
.base
.is_required
.message
.clone()
.unwrap_or_else(|| t("forms.file_required").to_string());
self.set_error(msg);
return false;
}
if let Some(max) = self.max_size
&& is_staged_upload(&val)
&& let Ok(meta) = tokio::fs::metadata(&val).await
&& meta.len() > max
{
let mb = |bytes: u64| format!("{:.1}", bytes as f64 / (1024.0 * 1024.0));
let name = std::path::Path::new(&val)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default()
.to_string();
let _ = tokio::fs::remove_file(&val).await;
self.base.value.clear();
self.set_error(tf(
"forms.file_too_large",
&[name.as_str(), mb(meta.len()).as_str(), mb(max).as_str()],
));
return false;
}
self.clear_error();
true
}
async fn finalize(&mut self) -> Result<(), String> {
let val = self.base.value.trim().to_string();
if !is_staged_upload(&val) {
return Ok(());
}
let bytes = tokio::fs::read(&val)
.await
.map_err(|e| format!("read upload '{val}': {e}"))?;
if let Err(e) = tokio::fs::remove_file(&val).await {
tracing::warn!(path = %val, error = %e, "binary field: staged upload removal failed");
}
self.base.value = STANDARD.encode(bytes);
Ok(())
}
fn render(&self, tera: &ATera) -> Result<String, String> {
let mut context = self.base_context();
let mut field = self.base.clone();
field.value.clear();
context.insert("field", &field);
context.insert("is_file", &true);
if let Some(size) = self.max_size {
context.insert("max_size", &size);
context.insert("max_size_mb", &(size as f64 / (1024.0 * 1024.0)));
}
tera.render(&self.base.template_name, &context)
.map_err(|e| {
tf(
"forms.finalize_error",
&[&self.base.template_name, &e.to_string()],
)
.to_string()
})
}
}
#[cfg(test)]
mod guarantees {
use super::*;
use std::fs;
#[test]
fn cap_max_size_only_ever_lowers_the_limit() {
let mut field = BinaryField::new("blob").max_size(100);
field.cap_max_size(200);
assert_eq!(field.max_size, Some(100));
field.cap_max_size(100);
assert_eq!(field.max_size, Some(100));
field.cap_max_size(40);
assert_eq!(field.max_size, Some(40));
let mut unbounded = BinaryField::new("blob");
unbounded.cap_max_size(70);
assert_eq!(unbounded.max_size, Some(70));
}
#[tokio::test]
async fn an_upload_of_exactly_the_limit_is_accepted() {
let _g = crate::config::static_files::MEDIA_ENV_LOCK.lock().await;
let root = std::env::temp_dir().join(format!("rq_bin_{}", uuid::Uuid::new_v4()));
let staging = root.join(format!(".staging-{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&staging).unwrap();
unsafe { std::env::set_var("MEDIA_ROOT", root.to_str().unwrap()) };
let exact = staging.join("exact.bin");
fs::write(&exact, [7u8; 8]).unwrap();
let mut field = BinaryField::new("thumb").max_size(8);
field.set_value(&exact.to_string_lossy());
assert!(field.validate().await, "8 bytes in an 8-byte column");
unsafe { std::env::remove_var("MEDIA_ROOT") };
let _ = fs::remove_dir_all(&root);
}
#[test]
fn render_gives_the_limit_and_never_the_bytes() {
let mut tera = tera::Tera::default();
tera.add_raw_template(
"base_file.html",
"{{ max_size_mb }}|{{ field.value }}|{{ is_file }}",
)
.unwrap();
let mut field = BinaryField::new("blob").max_size(1_572_864);
field.set_value("c2VjcmV0");
assert_eq!(
field.render(&std::sync::Arc::new(tera)).unwrap(),
"1.5||true"
);
}
}