1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
//! HTML rendering of form fields via Tera with fallback to internal templates.
use crate::forms::base::FormField;
use crate::utils::{
aliases::{ATera, FieldsMap},
trad::tf,
};
use tracing::warn;
/// Renders a form's fields to HTML via Tera: global errors first, then each
/// field in order, then any registered JS files (see [`FormRenderer::add_js`]).
#[derive(Clone)]
pub struct FormRenderer {
tera: ATera,
pub js_files: Vec<String>,
csp_nonce: Option<String>,
}
impl FormRenderer {
/// Creates a renderer bound to the given Tera instance, with no registered
/// JS files and no CSP nonce set.
pub fn new(tera: ATera) -> Self {
Self {
tera,
js_files: Vec::new(),
csp_nonce: None,
}
}
/// Sets the CSP nonce applied to the `<script>` tag when the registered
/// JS files are rendered.
pub fn set_nonce(&mut self, nonce: impl Into<String>) {
self.csp_nonce = Some(nonce.into());
}
/// Registers JS files to render after the form's fields. Each path must be
/// relative, end in `.js`, and contain no `../` traversal; a path that
/// fails these checks is skipped with a warning instead of erroring.
pub fn add_js(&mut self, files: &[&str]) {
for file in files {
if let Some(reason) = Self::validate_js_path(file) {
warn!(file = %file, reason = reason, "Skipping JS file");
continue;
}
self.js_files.push(file.to_string());
}
}
fn validate_js_path(file: &str) -> Option<&'static str> {
if !file.ends_with(".js") {
return Some("File does not have .js extension");
}
if file.starts_with('/') || file.starts_with('\\') {
return Some("Absolute paths are not allowed");
}
if file.contains("../") {
return Some("Path traversal (../) is not allowed");
}
None
}
/// Renders the full form: global `errors` first, then each field in
/// `fields`, then the registered JS files, all joined with newlines.
/// Fails on the first field that errors during rendering.
pub fn render(&self, fields: &FieldsMap, errors: &[String]) -> Result<String, String> {
let log_render = crate::utils::runique_log::get_log()
.forms
.as_ref()
.and_then(|f| f.render);
if let Some(level) = log_render {
crate::runique_log!(
level,
fields = fields.len(),
global_errors = errors.len(),
"render start"
);
}
let mut html = Vec::new();
// Render global errors first
if !errors.is_empty() {
let mut _context = tera::Context::new();
_context.insert("errors", errors);
html.push(
errors
.iter()
.map(|err| format!("<div class=\"form-error\">{}</div>", err))
.collect::<Vec<String>>()
.join("\n"),
);
}
for field in fields.values() {
match field.render(&self.tera) {
Ok(rendered) => {
if let Some(level) = log_render {
crate::runique_log!(level, field = %field.name(), "rendered ok");
}
html.push(rendered);
}
Err(e) => {
if let Some(level) = log_render {
crate::runique_log!(level, field = %field.name(), error = %e, "render error");
}
return Err(tf("forms.finalize_error", &[field.name(), &e]).to_owned());
}
}
}
// Scripts last: the form's JS goes after the fields it drives (defer anyway).
let js_html = self.render_js()?;
if !js_html.is_empty() {
html.push(js_html);
}
Ok(html.join("\n"))
}
pub(crate) fn render_js(&self) -> Result<String, String> {
if self.js_files.is_empty() {
return Ok(String::new());
}
let template_name = "js_files.html";
if !self
.tera
.get_template_names()
.any(|name| name == template_name)
{
return Err(tf("forms.template_missing", &[template_name]).to_owned());
}
let mut context = tera::Context::new();
context.insert("js_files", &self.js_files);
if let Some(ref nonce) = self.csp_nonce {
context.insert("csp_nonce", nonce);
}
self.tera
.render(template_name, &context)
.map_err(|e| tf("forms.render_js_error", &[&e]).to_owned())
}
/// Renders a single field to HTML directly, bypassing the global-error
/// block and JS file output that a full [`FormRenderer::render`] produces.
pub fn render_field(&self, field: &dyn FormField) -> Result<String, String> {
field.render(&self.tera)
}
}