Skip to main content

blitz_dom/
form.rs

1use blitz_traits::node_id::NodeId;
2use markup5ever::{LocalName, local_name};
3
4use crate::{
5    BaseDocument, ElementData,
6    traversal::{AncestorTraverser, TreeTraverser},
7};
8use blitz_traits::{
9    navigation::NavigationOptions,
10    net::{Body, Entry, EntryValue, FormData, Method},
11};
12use core::str::FromStr;
13use std::fmt::Display;
14
15/// https://url.spec.whatwg.org/#default-encode-set
16const DEFAULT_ENCODE_SET: percent_encoding::AsciiSet = percent_encoding::CONTROLS
17    // Query Set
18    .add(b' ')
19    .add(b'"')
20    .add(b'#')
21    .add(b'<')
22    .add(b'>')
23    // Path Set
24    .add(b'?')
25    .add(b'`')
26    .add(b'{')
27    .add(b'}');
28
29impl BaseDocument {
30    /// Resets the form owner for a given node by either using an explicit form attribute
31    /// or finding the nearest ancestor form element
32    ///
33    /// # Arguments
34    /// * `node_id` - The ID of the node whose form owner needs to be reset
35    ///
36    /// <https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#reset-the-form-owner>
37    pub fn reset_form_owner(&mut self, node_id: NodeId) {
38        let node = &self.nodes[node_id];
39        let Some(element) = node.element_data() else {
40            return;
41        };
42
43        // First try explicit form attribute
44        let final_owner_id = element
45            .attr(local_name!("form"))
46            .and_then(|owner| self.get_element_by_id(owner))
47            .filter(|owner_id| {
48                self.get_node(*owner_id)
49                    .is_some_and(|node| node.data.is_element_with_tag_name(&local_name!("form")))
50            })
51            .or_else(|| {
52                AncestorTraverser::new(self, node_id).find(|ancestor_id| {
53                    self.nodes[*ancestor_id]
54                        .data
55                        .is_element_with_tag_name(&local_name!("form"))
56                })
57            });
58
59        if let Some(final_owner_id) = final_owner_id {
60            self.controls_to_form.insert(node_id, final_owner_id);
61        }
62    }
63
64    /// Submits a form with the given form node ID and submitter node ID
65    ///
66    /// # Arguments
67    /// * `node_id` - The ID of the form node to submit
68    /// * `submitter_id` - The ID of the node that triggered the submission
69    ///
70    /// <https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#form-submission-algorithm>
71    pub fn submit_form(&self, node_id: NodeId, submitter_id: NodeId) {
72        let node = &self.nodes[node_id];
73        let Some(element) = node.element_data() else {
74            return;
75        };
76
77        let entry = construct_entry_list(self, node_id, submitter_id);
78
79        let method = get_form_attr(
80            self,
81            element,
82            local_name!("method"),
83            submitter_id,
84            local_name!("formmethod"),
85        )
86        .and_then(|method| method.parse::<FormMethod>().ok())
87        .unwrap_or(FormMethod::Get);
88
89        let action = get_form_attr(
90            self,
91            element,
92            local_name!("action"),
93            submitter_id,
94            local_name!("formaction"),
95        )
96        .unwrap_or_default();
97
98        let mut parsed_action = self.resolve_url(action);
99
100        let scheme = parsed_action.scheme();
101
102        let enctype = get_form_attr(
103            self,
104            element,
105            local_name!("enctype"),
106            submitter_id,
107            local_name!("formenctype"),
108        )
109        .and_then(|enctype| enctype.parse::<RequestContentType>().ok())
110        .unwrap_or(RequestContentType::FormUrlEncoded);
111
112        let mut post_resource = Body::Empty;
113
114        match (scheme, method) {
115            ("http" | "https" | "data", FormMethod::Get) => {
116                let pairs = convert_to_list_of_name_value_pairs(entry);
117                let mut query = String::new();
118                url::form_urlencoded::Serializer::new(&mut query).extend_pairs(pairs);
119                parsed_action.set_query(Some(&query));
120            }
121            ("http" | "https", FormMethod::Post) => post_resource = Body::Form(entry),
122            ("mailto", FormMethod::Get) => {
123                let pairs = convert_to_list_of_name_value_pairs(entry);
124                parsed_action.query_pairs_mut().extend_pairs(pairs);
125            }
126            ("mailto", FormMethod::Post) => {
127                let pairs = convert_to_list_of_name_value_pairs(entry);
128                let body = match enctype {
129                    RequestContentType::TextPlain => {
130                        let body = encode_text_plain(&pairs);
131                        percent_encoding::utf8_percent_encode(&body, &DEFAULT_ENCODE_SET)
132                            .to_string()
133                    }
134                    _ => {
135                        let mut body = String::new();
136                        url::form_urlencoded::Serializer::new(&mut body).extend_pairs(pairs);
137                        body
138                    }
139                };
140                let mut query = if let Some(query) = parsed_action.query() {
141                    let mut query = query.to_string();
142                    query.push('&');
143                    query
144                } else {
145                    String::new()
146                };
147                query.push_str("body=");
148                query.push_str(&body);
149                parsed_action.set_query(Some(&query));
150            }
151            _ => {
152                #[cfg(feature = "tracing")]
153                tracing::warn!(
154                    "Scheme {} with method {:?} is not implemented",
155                    scheme,
156                    method
157                );
158                return;
159            }
160        }
161
162        let method = method.try_into().unwrap_or_default();
163
164        let navigation_options =
165            NavigationOptions::new(parsed_action, Some(enctype.to_string()), self.id())
166                .set_document_resource(post_resource)
167                .set_method(method);
168
169        self.navigation_provider.navigate_to(navigation_options)
170    }
171}
172
173/// Constructs a list of form entries from form controls
174///
175/// # Arguments
176/// * `doc` - Reference to the base document
177/// * `form_id` - ID of the form element
178/// * `submitter_id` - ID of the element that triggered form submission
179///
180/// # Returns
181/// Returns an EntryList containing all valid form control entries
182///
183/// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#constructing-the-form-data-set
184fn construct_entry_list(doc: &BaseDocument, form_id: NodeId, submitter_id: NodeId) -> FormData {
185    let mut entry_list = FormData::new();
186
187    let mut create_entry = |name: &str, value: EntryValue| {
188        entry_list.0.push(Entry {
189            name: name.to_string(),
190            value,
191        });
192    };
193
194    fn datalist_ancestor(doc: &BaseDocument, node_id: NodeId) -> bool {
195        AncestorTraverser::new(doc, node_id).any(|node_id| {
196            doc.nodes[node_id]
197                .data
198                .is_element_with_tag_name(&local_name!("datalist"))
199        })
200    }
201
202    // For each element field in controls, in tree order:
203    for control_id in TreeTraverser::new(doc) {
204        let Some(node) = doc.get_node(control_id) else {
205            continue;
206        };
207        let Some(element) = node.element_data() else {
208            continue;
209        };
210
211        // Check if the form owner is same as form_id
212        if doc
213            .controls_to_form
214            .get(&control_id)
215            .map(|owner_id| *owner_id != form_id)
216            .unwrap_or(true)
217        {
218            continue;
219        }
220
221        let element_type = element.attr(local_name!("type"));
222
223        //  If any of the following are true:
224        //   field has a datalist element ancestor;
225        //   field is disabled;
226        //   field is a button but it is not submitter;
227        //   field is an input element whose type attribute is in the Checkbox state and whose checkedness is false; or
228        //   field is an input element whose type attribute is in the Radio Button state and whose checkedness is false,
229        //  then continue.
230        if datalist_ancestor(doc, node.id)
231            || element.attr(local_name!("disabled")).is_some()
232            || (element.name.local == local_name!("button") && node.id != submitter_id)
233            || element.name.local == local_name!("input")
234                && ((matches!(element_type, Some("checkbox" | "radio"))
235                    && !element.checkbox_input_checked().unwrap_or(false))
236                    || matches!(element_type, Some("submit" | "button")))
237        {
238            continue;
239        }
240
241        // If the field element is an input element whose type attribute is in the Image Button state, then:
242        if element_type == Some("image") {
243            // If the field element is not submitter, then continue.
244            if node.id != submitter_id {
245                continue;
246            }
247            // TODO: If the field element has a name attribute specified and its value is not the empty string, let name be that value followed by U+002E (.). Otherwise, let name be the empty string.
248            //   Let namex be the concatenation of name and U+0078 (x).
249            //   Let namey be the concatenation of name and U+0079 (y).
250            //   Let (x, y) be the selected coordinate.
251            //   Create an entry with namex and x, and append it to entry list.
252            //   Create an entry with namey and y, and append it to entry list.
253            //   Continue.
254            continue;
255        }
256
257        // TODO: If the field is a form-associated custom element,
258        //  then perform the entry construction algorithm given field and entry list,
259        //  then continue.
260
261        // If either the field element does not have a name attribute specified, or its name attribute's value is the empty string, then continue.
262        // Let name be the value of the field element's name attribute.
263        let Some(name) = element
264            .attr(local_name!("name"))
265            .filter(|str| !str.is_empty())
266        else {
267            continue;
268        };
269
270        // If the field element is a select element, then for each option
271        // element in the select element's list of options whose selectedness is
272        // true and that is not disabled, create an entry with name and the
273        // value of the option element, and append it to entry list.
274        //
275        // Without this a select fell through to the generic tail below and
276        // submitted its own literal `value` attribute, which a select does not
277        // have. Every form containing a picker posted the wrong body.
278        if element.name.local == local_name!("select") {
279            let options = doc.select_options(control_id);
280            for (index, option_id) in options.iter().enumerate() {
281                let is_selected = element
282                    .select_data()
283                    .map(|data| data.is_selected(index))
284                    // Before layout construction has run there is no live
285                    // state, and the content attribute is all there is.
286                    .unwrap_or_else(|| doc.option_is_selected(*option_id));
287                if is_selected && !doc.option_is_disabled(*option_id) {
288                    create_entry(name, doc.option_value(*option_id).as_str().into());
289                }
290            }
291            continue;
292        }
293
294        // Otherwise, if the field element is an input element whose type attribute is in the Checkbox state or the Radio Button state, then:
295        if element.name.local == local_name!("input")
296            && matches!(element_type, Some("checkbox" | "radio"))
297        {
298            // If the field element has a value attribute specified, then let value be the value of that attribute; otherwise, let value be the string "on".
299            let value = element.attr(local_name!("value")).unwrap_or("on");
300            // Create an entry with name and value, and append it to entry list.
301            create_entry(name, value.into());
302            continue;
303        }
304        // Otherwise, if the field element is an input element whose type attribute is in the File Upload state, then:
305        #[cfg(feature = "file-input")]
306        if element.name.local == local_name!("input") && matches!(element_type, Some("file")) {
307            // If there are no selected files, then create an entry with name and a new File object with an empty name, application/octet-stream as type, and an empty body, and append it to entry list.
308            let Some(files) = element.file_data() else {
309                create_entry(name, EntryValue::EmptyFile);
310                continue;
311            };
312            if files.is_empty() {
313                create_entry(name, EntryValue::EmptyFile);
314            }
315            // Otherwise, for each file in selected files, create an entry with name and a File object representing the file, and append it to entry list.
316            else {
317                for path_buf in files.iter() {
318                    create_entry(name, path_buf.clone().into());
319                }
320            }
321            continue;
322        }
323        //Otherwise, if the field element is an input element whose type attribute is in the Hidden state and name is an ASCII case-insensitive match for "_charset_":
324        if element.name.local == local_name!("input")
325            && element_type == Some("hidden")
326            && name.eq_ignore_ascii_case("_charset_")
327        {
328            // Let charset be the name of encoding.
329            let charset = "UTF-8"; // TODO: Support multiple encodings.
330            // Create an entry with name and charset, and append it to entry list.
331            create_entry(name, charset.into());
332        }
333        // Otherwise, create an entry with name and the value of the field element, and append it to entry list.
334        else if let Some(text) = element.text_input_data() {
335            create_entry(name, text.editor.text().to_string().as_str().into());
336        } else if let Some(value) = element.attr(local_name!("value")) {
337            create_entry(name, value.into());
338        }
339    }
340    entry_list
341}
342
343fn get_form_attr<'a>(
344    doc: &'a BaseDocument,
345    form: &'a ElementData,
346    form_local: impl PartialEq<LocalName>,
347    submitter_id: NodeId,
348    submitter_local: impl PartialEq<LocalName>,
349) -> Option<&'a str> {
350    get_submitter_attr(doc, submitter_id, submitter_local).or_else(|| form.attr(form_local))
351}
352
353fn get_submitter_attr(
354    doc: &BaseDocument,
355    submitter_id: NodeId,
356    local_name: impl PartialEq<LocalName>,
357) -> Option<&str> {
358    doc.get_node(submitter_id)
359        .and_then(|node| node.element_data())
360        .and_then(|element_data| {
361            if element_data.name.local == local_name!("button")
362                && element_data.attr(local_name!("type")) == Some("submit")
363            {
364                element_data.attr(local_name)
365            } else {
366                None
367            }
368        })
369}
370
371#[derive(Debug, Copy, Clone, PartialEq, Eq)]
372enum FormMethod {
373    Get,
374    Post,
375    Dialog,
376}
377impl FromStr for FormMethod {
378    type Err = ();
379    fn from_str(s: &str) -> Result<Self, Self::Err> {
380        Ok(match s.to_lowercase().as_str() {
381            "get" => FormMethod::Get,
382            "post" => FormMethod::Post,
383            "dialog" => FormMethod::Dialog,
384            _ => return Err(()),
385        })
386    }
387}
388impl TryFrom<FormMethod> for Method {
389    type Error = &'static str;
390    fn try_from(method: FormMethod) -> Result<Self, Self::Error> {
391        Ok(match method {
392            FormMethod::Get => Method::GET,
393            FormMethod::Post => Method::POST,
394            FormMethod::Dialog => return Err("Dialog is not an HTTP method"),
395        })
396    }
397}
398/// Supported content types for HTTP requests
399#[derive(Debug, Clone)]
400pub enum RequestContentType {
401    /// application/x-www-form-urlencoded
402    FormUrlEncoded,
403    /// multipart/form-data
404    MultipartFormData,
405    /// text/plain
406    TextPlain,
407}
408
409impl FromStr for RequestContentType {
410    type Err = ();
411    fn from_str(s: &str) -> Result<Self, Self::Err> {
412        Ok(match s {
413            "application/x-www-form-urlencoded" => RequestContentType::FormUrlEncoded,
414            "multipart/form-data" => RequestContentType::MultipartFormData,
415            "text/plain" => RequestContentType::TextPlain,
416            _ => return Err(()),
417        })
418    }
419}
420
421impl Display for RequestContentType {
422    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
423        match self {
424            RequestContentType::FormUrlEncoded => write!(f, "application/x-www-form-urlencoded"),
425            RequestContentType::MultipartFormData => write!(f, "multipart/form-data"),
426            RequestContentType::TextPlain => write!(f, "text/plain"),
427        }
428    }
429}
430
431/// Converts the entry list to a vector of name-value pairs with normalized line endings
432/// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#converting-an-entry-list-to-a-list-of-name-value-pairs
433fn convert_to_list_of_name_value_pairs(form_data: FormData) -> Vec<(String, String)> {
434    form_data
435        .iter()
436        .map(|Entry { name, value }| {
437            let name = normalize_line_endings(name.as_ref());
438            let value = normalize_line_endings(value.as_ref());
439            (name, value)
440        })
441        .collect()
442}
443
444/// Normalizes line endings in a string according to HTML spec
445/// Converts single CR or LF to CRLF pairs according to HTML form submission requirements
446fn normalize_line_endings(input: &str) -> String {
447    // Replace every occurrence of U+000D (CR) not followed by U+000A (LF),
448    // and every occurrence of U+000A (LF) not preceded by U+000D (CR),
449    // in value, by a string consisting of U+000D (CR) and U+000A (LF).
450
451    let mut result = String::with_capacity(input.len());
452    let mut chars = input.chars().peekable();
453
454    while let Some(current) = chars.next() {
455        match (current, chars.peek()) {
456            ('\r', Some('\n')) => {
457                result.push_str("\r\n");
458                chars.next();
459            }
460            ('\r' | '\n', _) => {
461                result.push_str("\r\n");
462            }
463            _ => result.push(current),
464        }
465    }
466
467    result
468}
469
470/// Encodes form data as text/plain according to HTML spec given an slice of name-value pairs
471/// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#text/plain-encoding-algorithm
472fn encode_text_plain<T: AsRef<str>, U: AsRef<str>>(input: &[(T, U)]) -> String {
473    let mut out = String::new();
474    for (name, value) in input {
475        out.push_str(name.as_ref());
476        out.push('=');
477        out.push_str(value.as_ref());
478        out.push_str("\r\n");
479    }
480    out
481}