Skip to main content

bliss_dom/
form.rs

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