diff --git a/src/browser.rs b/src/browser.rs index b2c07ac..643f2b8 100644 --- a/src/browser.rs +++ b/src/browser.rs @@ -1,6 +1,6 @@ use crate::bookmarks::BookmarkStore; use crate::html_parser::HtmlParser; -use crate::network::NetworkClient; +use crate::network::{FetchResult, NetworkClient}; use crate::types::{FormFieldType, InputMode, LoadState, RenderedLine}; const MAX_HISTORY: usize = 200; @@ -15,8 +15,8 @@ pub struct Browser { // Page content pub lines: Vec, pub scroll: usize, - pub link_focus: Option, // line index of focused interactive element - pub link_count: usize, // total numbered links on page + pub link_focus: Option, // line index of focused interactive element + pub link_count: usize, // total numbered links on page // Source view pub raw_html: String, @@ -29,7 +29,7 @@ pub struct Browser { pub search_input: String, pub search_matches: Vec, pub search_pos: usize, - pub goto_input: String, // for go-to-link-number mode + pub goto_input: String, // for go-to-link-number mode pub status_msg: String, pub viewport_height: usize, @@ -79,9 +79,7 @@ impl Browser { search_pos: 0, goto_input: String::new(), - status_msg: String::from( - "Welcome to kirim — press 'g' to open a URL, '?' for help" - ), + status_msg: String::from("Welcome to kirim — press 'g' to open a URL, '?' for help"), viewport_height: 24, viewport_width: 80, tick: 0, @@ -110,16 +108,36 @@ impl Browser { fn navigate_internal(&mut self, url: &str, push_history: bool) { let url = url.trim().to_string(); - if url.is_empty() { return; } + if url.is_empty() { + return; + } + + self.begin_navigation(&url); + let result = self.net.fetch(&url); + self.complete_navigation(&url, push_history, result); + } + + fn submit_form_request(&mut self, action: &str, method: &str, field_name: &str, value: &str) { + self.begin_navigation(action); + let result = self.net.submit_form(action, method, field_name, value); + self.complete_navigation(action, true, result); + } + fn begin_navigation(&mut self, url: &str) { self.state = LoadState::Loading; self.status_msg = format!("Loading {}…", url); self.scroll = 0; self.link_focus = None; self.search_matches.clear(); - self.viewing_source = false; + } - match self.net.fetch(&url) { + fn complete_navigation( + &mut self, + url: &str, + push_history: bool, + result: anyhow::Result, + ) { + match result { Ok(result) => { self.current_url = result.url.clone(); self.url_input = self.current_url.clone(); @@ -127,14 +145,15 @@ impl Browser { let parser = HtmlParser::new(self.viewport_width.saturating_sub(2)); self.lines = parser.parse(&result.body, &result.url); - self.link_count = self.lines.iter() + self.link_count = self + .lines + .iter() .filter_map(|l| l.link_num) .max() .unwrap_or(0); - // Extract - self.page_title = extract_title(&result.body) - .unwrap_or_else(|| self.current_url.clone()); + self.page_title = + extract_title(&result.body).unwrap_or_else(|| self.current_url.clone()); if self.lines.is_empty() { self.lines = vec![RenderedLine::plain("(empty page)")]; @@ -152,9 +171,7 @@ impl Browser { } self.state = LoadState::Idle; - self.status_msg = format!( - "{} [{} links]", self.current_url, self.link_count - ); + self.status_msg = format!("{} [{} links]", self.current_url, self.link_count); } Err(e) => { self.state = LoadState::Error(e.to_string()); @@ -187,11 +204,17 @@ impl Browser { pub fn reload(&mut self) { let url = self.current_url.clone(); - if !url.is_empty() { self.navigate_internal(&url, false); } + if !url.is_empty() { + self.navigate_internal(&url, false); + } } - pub fn can_go_back(&self) -> bool { self.history_pos > 1 } - pub fn can_go_forward(&self) -> bool { self.history_pos < self.history.len() } + pub fn can_go_back(&self) -> bool { + self.history_pos > 1 + } + pub fn can_go_forward(&self) -> bool { + self.history_pos < self.history.len() + } // ── Source view ─────────────────────────────────────────────────────────── @@ -199,7 +222,8 @@ impl Browser { self.viewing_source = !self.viewing_source; self.scroll = 0; if self.viewing_source { - self.lines = self.raw_html + self.lines = self + .raw_html .lines() .map(|l| RenderedLine::plain(l.to_string())) .collect(); @@ -207,7 +231,12 @@ impl Browser { } else { let parser = HtmlParser::new(self.viewport_width.saturating_sub(2)); self.lines = parser.parse(&self.raw_html, &self.current_url); - self.link_count = self.lines.iter().filter_map(|l| l.link_num).max().unwrap_or(0); + self.link_count = self + .lines + .iter() + .filter_map(|l| l.link_num) + .max() + .unwrap_or(0); self.status_msg = self.current_url.clone(); } } @@ -271,7 +300,9 @@ impl Browser { /// Jump to link by its displayed number. pub fn goto_link_num(&mut self, n: usize) { - if n == 0 { return; } + if n == 0 { + return; + } for (i, line) in self.lines.iter().enumerate() { if line.link_num == Some(n) { self.link_focus = Some(i); @@ -295,7 +326,10 @@ impl Browser { fn update_link_status(&mut self, idx: usize) { if let Some(ref href) = self.lines[idx].link_href.clone() { - let num = self.lines[idx].link_num.map(|n| format!("[{}] ", n)).unwrap_or_default(); + let num = self.lines[idx] + .link_num + .map(|n| format!("[{}] ", n)) + .unwrap_or_default(); self.status_msg = format!("{}→ {}", num, href); } } @@ -309,15 +343,17 @@ impl Browser { } if let Some(ref field) = line.form_field { match field.field_type { - FormFieldType::Text | FormFieldType::Search => { + FormFieldType::Search => { self.active_form_line = Some(idx); self.form_text.clear(); self.input_mode = InputMode::FormField; - self.status_msg = "Type and press Enter to submit (Esc=cancel)".to_string(); + self.status_msg = + "Type and press Enter to submit (Esc=cancel)".to_string(); } - FormFieldType::Submit | FormFieldType::Button => { + FormFieldType::Submit => { self.submit_form_from_line(idx); } + FormFieldType::Text | FormFieldType::Button => {} } } } @@ -335,7 +371,10 @@ impl Browser { pub fn search(&mut self, query: &str) { let q = query.to_lowercase(); - self.search_matches = self.lines.iter().enumerate() + self.search_matches = self + .lines + .iter() + .enumerate() .filter(|(_, l)| l.text().to_lowercase().contains(&q)) .map(|(i, _)| i) .collect(); @@ -346,23 +385,35 @@ impl Browser { self.status_msg = if self.search_matches.is_empty() { format!("No matches for \"{}\"", query) } else { - format!("{} match{} for \"{}\" (n=next N=prev)", + format!( + "{} match{} for \"{}\" (n=next N=prev)", self.search_matches.len(), - if self.search_matches.len() == 1 { "" } else { "es" }, - query) + if self.search_matches.len() == 1 { + "" + } else { + "es" + }, + query + ) }; } pub fn search_next(&mut self) { - if self.search_matches.is_empty() { return; } + if self.search_matches.is_empty() { + return; + } self.search_pos = (self.search_pos + 1) % self.search_matches.len(); let idx = self.search_matches[self.search_pos]; self.ensure_visible(idx); } pub fn search_prev(&mut self) { - if self.search_matches.is_empty() { return; } - self.search_pos = self.search_pos.checked_sub(1) + if self.search_matches.is_empty() { + return; + } + self.search_pos = self + .search_pos + .checked_sub(1) .unwrap_or(self.search_matches.len() - 1); let idx = self.search_matches[self.search_pos]; self.ensure_visible(idx); @@ -396,7 +447,9 @@ impl Browser { pub fn commit_search(&mut self) { let q = self.search_input.clone(); self.input_mode = InputMode::Normal; - if !q.is_empty() { self.search(&q); } + if !q.is_empty() { + self.search(&q); + } } pub fn commit_goto_num(&mut self) { @@ -423,9 +476,9 @@ impl Browser { let mut input_name = "q".to_string(); let mut input_value = self.form_text.clone(); for i in (0..self.lines.len()).filter(|&i| i != submit_line) { - if let Some(ref f) = self.lines[i].form_field { + if let Some(f) = &self.lines[i].form_field { if f.form_action == submit_field.form_action - && matches!(f.field_type, FormFieldType::Text | FormFieldType::Search) + && matches!(f.field_type, FormFieldType::Search) { input_name = f.name.clone(); if self.active_form_line == Some(i) { @@ -435,22 +488,27 @@ impl Browser { } } } - let url = build_form_url(&submit_field.form_action, &submit_field.form_method, &input_name, &input_value); self.input_mode = InputMode::Normal; self.active_form_line = None; self.form_text.clear(); - self.navigate(&url); + self.submit_form_request( + &submit_field.form_action, + &submit_field.form_method, + &input_name, + &input_value, + ); } pub fn submit_active_form(&mut self) { - let Some(idx) = self.active_form_line else { return }; + let Some(idx) = self.active_form_line else { + return; + }; let field = self.lines[idx].form_field.clone(); let Some(f) = field else { return }; - let url = build_form_url(&f.form_action, &f.form_method, &f.name, &self.form_text); + let value = std::mem::take(&mut self.form_text); self.input_mode = InputMode::Normal; self.active_form_line = None; - self.form_text.clear(); - self.navigate(&url); + self.submit_form_request(&f.form_action, &f.form_method, &f.name, &value); } pub fn cancel_form_input(&mut self) { @@ -463,7 +521,9 @@ impl Browser { // ── Bookmarks ───────────────────────────────────────────────────────────── pub fn add_bookmark(&mut self) { - if self.current_url.is_empty() { return; } + if self.current_url.is_empty() { + return; + } let title = self.page_title.clone(); let url = self.current_url.clone(); self.bookmarks.add(url, title); @@ -483,7 +543,8 @@ impl Browser { pub fn bookmark_up(&mut self) { if !self.bookmarks.items.is_empty() { - self.bookmark_cursor = self.bookmark_cursor + self.bookmark_cursor = self + .bookmark_cursor .checked_sub(1) .unwrap_or(self.bookmarks.items.len() - 1); } @@ -553,7 +614,10 @@ impl Browser { self.mouse_line = Some(page_line); self.hover_url = self.lines[page_line].link_href.clone(); if let Some(ref url) = self.hover_url { - let num = self.lines[page_line].link_num.map(|n| format!("[{}] ", n)).unwrap_or_default(); + let num = self.lines[page_line] + .link_num + .map(|n| format!("[{}] ", n)) + .unwrap_or_default(); self.status_msg = format!("{}→ {}", num, url); } else { self.status_msg = self.current_url.clone(); @@ -567,10 +631,15 @@ impl Browser { pub fn on_click(&mut self, screen_row: u16, col: u16) { if screen_row < self.content_top { // Toolbar: back(col<4), forward(col<8), reload(col<12), URL bar otherwise - if col < 4 { self.go_back(); } - else if col < 8 { self.go_forward(); } - else if col < 12 { self.reload(); } - else { self.open_url_bar(); } + if col < 4 { + self.go_back(); + } else if col < 8 { + self.go_forward(); + } else if col < 12 { + self.reload(); + } else { + self.open_url_bar(); + } return; } let rel = (screen_row - self.content_top) as usize; @@ -581,61 +650,54 @@ impl Browser { self.navigate(&href); } else if let Some(ref field) = line.form_field { match field.field_type { - FormFieldType::Text | FormFieldType::Search => { + FormFieldType::Search => { self.link_focus = Some(page_line); self.active_form_line = Some(page_line); self.form_text.clear(); self.input_mode = InputMode::FormField; self.status_msg = "Type and press Enter (Esc=cancel)".to_string(); } - FormFieldType::Submit | FormFieldType::Button => { + FormFieldType::Submit => { self.submit_form_from_line(page_line); } + FormFieldType::Text | FormFieldType::Button => {} } } } } - pub fn on_scroll_down(&mut self) { self.scroll_down(3); } - pub fn on_scroll_up(&mut self) { self.scroll_up(3); } + pub fn on_scroll_down(&mut self) { + self.scroll_down(3); + } + pub fn on_scroll_up(&mut self) { + self.scroll_up(3); + } - pub fn tick(&mut self) { self.tick = self.tick.wrapping_add(1); } + pub fn tick(&mut self) { + self.tick = self.tick.wrapping_add(1); + } pub fn scroll_percent(&self) -> u16 { if self.lines.len() <= self.viewport_height { 100 } else { let max = self.lines.len().saturating_sub(self.viewport_height); - if max == 0 { 100 } else { ((self.scroll as f64 / max as f64) * 100.0).round() as u16 } + if max == 0 { + 100 + } else { + ((self.scroll as f64 / max as f64) * 100.0).round() as u16 + } } } pub fn loading_frame(&self) -> char { - const FRAMES: &[char] = &['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏']; + const FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; FRAMES[self.tick % FRAMES.len()] } } // ── Helpers ─────────────────────────────────────────────────────────────────── -fn build_form_url(action: &str, _method: &str, field_name: &str, value: &str) -> String { - let encoded = url_encode(value); - let sep = if action.contains('?') { "&" } else { "?" }; - format!("{}{}{}={}", action, sep, field_name, encoded) -} - -fn url_encode(s: &str) -> String { - s.chars().map(|c| match c { - 'A'..='Z'|'a'..='z'|'0'..='9'|'-'|'_'|'.'|'~' => c.to_string(), - ' ' => "+".to_string(), - c => { - let mut buf = [0u8; 4]; - let bytes = c.encode_utf8(&mut buf).as_bytes(); - bytes.iter().map(|b| format!("%{:02X}", b)).collect() - } - }).collect() -} - fn extract_title(html: &str) -> Option<String> { let lower = html.to_lowercase(); let start = lower.find("<title>")? + 7; diff --git a/src/html_parser.rs b/src/html_parser.rs index 60ce575..ffada25 100644 --- a/src/html_parser.rs +++ b/src/html_parser.rs @@ -193,8 +193,8 @@ impl HtmlParser { for btn_id in all_tag(doc, id, "button") { if let Some(n) = doc.tree.get(btn_id) { if let Node::Element(be) = n.value() { - let bt = be.attr("type").unwrap_or("submit"); - if bt == "submit" || bt == "button" { + let bt = be.attr("type").unwrap_or("submit").to_ascii_lowercase(); + if !matches!(bt.as_str(), "button" | "reset") { let label = plain_text(doc, btn_id); let label = label.trim(); if !label.is_empty() { @@ -520,18 +520,48 @@ fn raw_text(doc: &Html, id: NodeId) -> String { } pub fn resolve_url(href: &str, base: &str) -> String { - if href.starts_with("http://") || href.starts_with("https://") { return href.to_string(); } - if href.starts_with("//") { - if let Some(s) = base.split("://").next() { return format!("{}:{}", s, href); } + if href.starts_with("http://") || href.starts_with("https://") { + return href.to_string(); } - if href.starts_with('/') { - if let Ok(p) = url::Url::parse(base) { - return format!("{}://{}{}", p.scheme(), p.host_str().unwrap_or(""), href); + if href.starts_with("//") { + if let Some(scheme) = base.split("://").next() { + return format!("{}:{}", scheme, href); } } - if href.starts_with('#') || href.is_empty() { return base.to_string(); } - if let Ok(p) = url::Url::parse(base) { - if let Ok(r) = p.join(href) { return r.to_string(); } + if href.starts_with('#') || href.is_empty() { + return base.to_string(); + } + if let Ok(base) = url::Url::parse(base) { + if let Ok(resolved) = base.join(href) { + return resolved.to_string(); + } } href.to_string() } + +#[cfg(test)] +mod tests { + use super::{resolve_url, HtmlParser}; + use crate::types::FormFieldType; + + #[test] + fn resolves_root_relative_urls_without_losing_ports() { + assert_eq!( + resolve_url("/search", "https://example.com:8443/docs/page"), + "https://example.com:8443/search" + ); + } + + #[test] + fn renders_only_submit_buttons_as_form_actions() { + let parser = HtmlParser::new(80); + let lines = parser.parse( + r#"<form><button type="button">Dismiss</button><button type="reset">Reset</button><button type="SUBMIT">Submit</button></form>"#, + "https://example.com/", + ); + let fields: Vec<_> = lines.iter().filter_map(|line| line.form_field.as_ref()).collect(); + + assert_eq!(fields.len(), 1); + assert_eq!(fields[0].field_type, FormFieldType::Submit); + } +} diff --git a/src/network.rs b/src/network.rs index 865e3e2..2eb253c 100644 --- a/src/network.rs +++ b/src/network.rs @@ -1,6 +1,6 @@ use anyhow::{anyhow, Result}; -use reqwest::blocking::Client; -use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, ACCEPT_LANGUAGE, HeaderName}; +use reqwest::blocking::{Client, Response}; +use reqwest::header::{HeaderMap, HeaderName, HeaderValue, ACCEPT, ACCEPT_LANGUAGE}; use std::time::Duration; pub struct NetworkClient { @@ -11,7 +11,6 @@ pub struct NetworkClient { pub struct FetchResult { pub url: String, pub body: String, - pub content_type: String, } impl NetworkClient { @@ -23,10 +22,7 @@ impl NetworkClient { "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", ), ); - headers.insert( - ACCEPT_LANGUAGE, - HeaderValue::from_static("en-US,en;q=0.5"), - ); + headers.insert(ACCEPT_LANGUAGE, HeaderValue::from_static("en-US,en;q=0.5")); // Some sites check this — pretend we're coming from a neutral referrer headers.insert( HeaderName::from_static("dnt"), @@ -35,9 +31,7 @@ impl NetworkClient { let client = Client::builder() // A modern Firefox UA to avoid bot-detection rejections - .user_agent( - "Mozilla/5.0 (X11; Linux x86_64; rv:124.0) Gecko/20100101 Firefox/124.0", - ) + .user_agent("Mozilla/5.0 (X11; Linux x86_64; rv:124.0) Gecko/20100101 Firefox/124.0") .default_headers(headers) .timeout(Duration::from_secs(20)) .redirect(reqwest::redirect::Policy::limited(10)) @@ -49,16 +43,35 @@ impl NetworkClient { pub fn fetch(&self, url: &str) -> Result<FetchResult> { let url = Self::normalize_url(url); - - // ── Intercept known bot-blocking search engines ─────────────────────── let url = redirect_search_engines(&url); - let response = self .client .get(&url) .send() .map_err(|e| anyhow!("Request failed: {}", e))?; + Self::into_fetch_result(response) + } + + pub fn submit_form( + &self, + action: &str, + method: &str, + field_name: &str, + value: &str, + ) -> Result<FetchResult> { + let fields = [(field_name, value)]; + let response = if method.eq_ignore_ascii_case("post") { + self.client.post(action).form(&fields).send() + } else { + self.client.get(action).query(&fields).send() + } + .map_err(|e| anyhow!("Request failed: {}", e))?; + + Self::into_fetch_result(response) + } + + fn into_fetch_result(response: Response) -> Result<FetchResult> { if !response.status().is_success() { return Err(anyhow!( "HTTP {} {}", @@ -67,22 +80,14 @@ impl NetworkClient { )); } - let final_url = response.url().to_string(); - let content_type = response - .headers() - .get("content-type") - .and_then(|v| v.to_str().ok()) - .unwrap_or("text/html") - .to_string(); - + let url = response.url().to_string(); let body = response .text() .map_err(|e| anyhow!("Failed to read response body: {}", e))?; Ok(FetchResult { - url: final_url, + url, body, - content_type, }) } @@ -175,3 +180,88 @@ fn urlencoding_simple(s: &str) -> String { }) .collect() } + +#[cfg(test)] +mod tests { + use super::NetworkClient; + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::sync::mpsc::{self, Receiver}; + use std::thread; + + fn capture_request() -> (String, Receiver<String>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let (sender, receiver) = mpsc::channel(); + + thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = Vec::new(); + let mut buffer = [0; 1024]; + loop { + let read = stream.read(&mut buffer).unwrap(); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + if request_is_complete(&request) { + break; + } + } + sender.send(String::from_utf8(request).unwrap()).unwrap(); + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .unwrap(); + }); + + (format!("http://{address}"), receiver) + } + + fn request_is_complete(request: &[u8]) -> bool { + let Some(header_end) = request.windows(4).position(|window| window == b"\r\n\r\n") else { + return false; + }; + let headers = std::str::from_utf8(&request[..header_end]).unwrap(); + let content_length = headers + .lines() + .find_map(|line| { + line.strip_prefix("Content-Length: ") + .or_else(|| line.strip_prefix("content-length: ")) + }) + .and_then(|value| value.parse::<usize>().ok()) + .unwrap_or(0); + + request.len() >= header_end + 4 + content_length + } + + #[test] + fn submit_form_encodes_get_fields_in_the_query() { + let (base, request) = capture_request(); + NetworkClient::new() + .submit_form( + &format!("{base}/search?existing=1#fragment"), + "get", + "q[]", + "rust lang", + ) + .unwrap(); + + let request = request.recv().unwrap(); + assert!(request.starts_with("GET /search?existing=1&q%5B%5D=rust+lang HTTP/1.1\r\n")); + } + + #[test] + fn submit_form_encodes_post_fields_in_the_body() { + let (base, request) = capture_request(); + NetworkClient::new() + .submit_form(&format!("{base}/submit"), "post", "q[]", "rust lang") + .unwrap(); + + let request = request.recv().unwrap(); + assert!(request.starts_with("POST /submit HTTP/1.1\r\n")); + assert!(request + .to_ascii_lowercase() + .contains("content-type: application/x-www-form-urlencoded")); + assert!(request.ends_with("\r\n\r\nq%5B%5D=rust+lang")); + } +}