From afca951b11e38ba229ee986a06242c5a358e9342 Mon Sep 17 00:00:00 2001 From: Richard Qin Date: Sun, 12 Apr 2026 23:49:02 +0800 Subject: feat: implement HTTP POST support for form submissions and add input value persistence --- include/ricochet/net/http_client.hh | 4 +- include/ricochet/render/renderer.hh | 3 + src/core/browser.cc | 136 +++++++++++++++++++++++------------- src/net/http_client.cc | 31 ++++++-- src/render/renderer.cc | 33 +++++++-- 5 files changed, 144 insertions(+), 63 deletions(-) diff --git a/include/ricochet/net/http_client.hh b/include/ricochet/net/http_client.hh index 2dd7419..45c92e8 100644 --- a/include/ricochet/net/http_client.hh +++ b/include/ricochet/net/http_client.hh @@ -51,7 +51,9 @@ public: * @param url Target URL, for example "http://example.com" * @return Returns HttpResponse on success, returns an error message string on failure */ - std::expected fetch( std::string_view url ) const; + std::expected fetch( std::string_view url, + std::string_view method = "GET", + std::string_view body = "" ) const; private: }; diff --git a/include/ricochet/render/renderer.hh b/include/ricochet/render/renderer.hh index 8c49bbd..dda3ac5 100644 --- a/include/ricochet/render/renderer.hh +++ b/include/ricochet/render/renderer.hh @@ -19,6 +19,7 @@ #pragma once #include "ricochet/parser/dom_tree.hh" +#include namespace ricochet::render { @@ -27,6 +28,8 @@ struct InputField std::string name; std::string action; std::string method; + std::string value {}; + std::string type {}; }; struct RenderResult { diff --git a/src/core/browser.cc b/src/core/browser.cc index 0f6d9d2..e6c63d4 100644 --- a/src/core/browser.cc +++ b/src/core/browser.cc @@ -37,6 +37,13 @@ struct PageData std::string title; }; +struct HttpRequest +{ + std::string url {}; + std::string method = "GET"; + std::string body {}; +}; + namespace { std::string extract_title( const parser::DomNode& root_node ) @@ -79,13 +86,12 @@ std::string extract_title( const parser::DomNode& root_node ) return ""; } -PageData load_page( std::string_view url ) +PageData load_page( const HttpRequest& req ) { const net::HttpClient client; - auto response_result = client.fetch( url ); + auto response_result = client.fetch( req.url, req.method, req.body ); if ( !response_result.has_value() ) { - return { - .lines = { "[!] Failed to load: " + std::string( url ) }, .links = {}, .inputs = {}, .title = "Error" }; + return { .lines = { "[!] Failed to load: " + req.url }, .links = {}, .inputs = {}, .title = "Error" }; } const parser::HtmlLexer lexer; @@ -150,7 +156,7 @@ void draw_view( const tui::Terminal& terminal, } std::string footer - = " URL: " + std::string( url ) + " | [j/k]Scroll [/]Jump [f]Follow [i]Input [H/L]History [q]Quit "; + = " URL: " + std::string( url ) + " | [j/k]Scroll [/]Jump [f]Follow [i]Input [r]Refresh [H/L]History [q]Quit "; if ( footer.size() < width ) { footer.append( width - footer.size(), ' ' ); } else if ( footer.size() > width && width > 3 ) { @@ -364,66 +370,96 @@ std::string get_terminal_input( const tui::Terminal& terminal, const std::string return input; } +InputAction submit_form( const render::InputField& target_input, + const std::vector& inputs, + const std::string& current_url, + std::vector& history, + std::size_t& history_idx ) +{ + std::string body; + for ( const auto& in : inputs ) { + if ( in.action == target_input.action && !in.name.empty() && in.type != "submit" && in.type != "button" ) { + std::string encoded = in.value; + for ( char& ch : encoded ) { + if ( ch == ' ' ) { + ch = '+'; + } + } + if ( !body.empty() ) { + body += "&"; + } + body += in.name + "=" + encoded; + } + } + const std::string action + = target_input.action.empty() ? current_url : normalize_url( target_input.action, current_url ); + HttpRequest next_req { .url = action, .method = "GET", .body = "" }; + if ( target_input.method == "post" || target_input.method == "POST" ) { + next_req.method = "POST"; + next_req.body = body; + } else { + const char sep = action.contains( '?' ) ? '&' : '?'; + next_req.url += sep + body; + } + history.resize( history_idx + 1 ); + history.push_back( next_req ); + history_idx++; + return InputAction::Navigate; +} + +void update_input_value( PageData& page_data, std::size_t index, const std::string& query ) +{ + page_data.inputs[index - 1].value = query; + const std::string marker = "[I" + std::to_string( index ) + ":"; + for ( auto& line : page_data.lines ) { + const std::size_t pos = line.find( marker ); + if ( pos != std::string::npos ) { + const std::size_t end = line.find( "]", pos ); + if ( end != std::string::npos ) { + line.replace( pos, end - pos + 1, "[I" + std::to_string( index ) + ":" + query + "]" ); + } + } + } +} + InputAction handle_form_input( const tui::Terminal& terminal, - const PageData& page_data, - std::string& current_url, - std::vector& history, + PageData& page_data, + const std::string& current_url, + std::vector& history, std::size_t& history_idx ) { const std::string num_input = get_terminal_input( terminal, "Select Input [#]:" ); if ( num_input.empty() ) { return InputAction::None; } - std::size_t index = 0; try { index = std::stoul( num_input ); } catch ( ... ) { - (void)0; - } - if ( index == 0 || index > page_data.inputs.size() ) { return InputAction::None; } - - const std::string query = get_terminal_input( terminal, "Enter Text [I" + std::to_string( index ) + "]:" ); - if ( query.empty() ) { + if ( index == 0 || index > page_data.inputs.size() ) { return InputAction::None; } const auto& target_input = page_data.inputs[index - 1]; - std::string encoded_query = query; - for ( char& ch : encoded_query ) { - if ( ch == ' ' ) { - ch = '+'; - } - } - - std::string action = target_input.action; - if ( action.empty() ) { - action = current_url; - } else { - action = normalize_url( action, current_url ); + if ( target_input.type == "submit" || target_input.type == "button" ) { + return submit_form( target_input, page_data.inputs, current_url, history, history_idx ); } - std::string param_name = target_input.name; - if ( param_name.empty() ) { - param_name = "q"; + const std::string query = get_terminal_input( terminal, "Enter Text [I" + std::to_string( index ) + "]:" ); + if ( query.empty() ) { + return InputAction::None; } - - const char sep = action.contains( '?' ) ? '&' : '?'; - const std::string target_url = action + sep + param_name + "=" + encoded_query; - - history.resize( history_idx + 1 ); - history.push_back( target_url ); - history_idx++; - return InputAction::Navigate; + update_input_value( page_data, index, query ); + return InputAction::None; } InputAction process_key( char c, const tui::Terminal& terminal, - const PageData& page_data, - std::string& current_url, - std::vector& history, + PageData& page_data, + std::string current_url, + std::vector& history, std::size_t& history_idx, // NOLINT(bugprone-easily-swappable-parameters) std::size_t& scroll_y ) // NOLINT(bugprone-easily-swappable-parameters) { @@ -447,12 +483,15 @@ InputAction process_key( char c, history_idx++; return InputAction::Navigate; } + if ( c == 'r' ) { + return InputAction::Navigate; + } if ( c == '/' ) { const std::string next_url = get_url_input( terminal ); if ( !next_url.empty() ) { history.resize( history_idx + 1 ); - history.push_back( next_url ); + history.push_back( HttpRequest { .url = next_url, .method = "GET", .body = "" } ); history_idx++; return InputAction::Navigate; } @@ -464,7 +503,7 @@ InputAction process_key( char c, handle_follow_link( terminal, page_data.links, current_url, nav ); if ( nav && current_url != old_url ) { history.resize( history_idx + 1 ); - history.push_back( current_url ); + history.push_back( HttpRequest { .url = current_url, .method = "GET", .body = "" } ); history_idx++; return InputAction::Navigate; } @@ -513,19 +552,20 @@ std::vector wrap_lines( const std::vector& original_li int Browser::run( std::string_view initial_url ) { - std::vector history; - history.emplace_back( initial_url ); + std::vector history; + history.emplace_back( HttpRequest { .url = std::string( initial_url ), .method = "GET", .body = "" } ); std::size_t history_idx = 0; const tui::Terminal terminal; terminal.show_cursor( false ); while ( true ) { - std::string current_url = history[history_idx]; + const HttpRequest current_req = history[history_idx]; + const std::string current_url = current_req.url; terminal.clear_screen(); - std::cout << "-> Fetching: " << current_url << "...\r\n"; + std::cout << "-> Fetching: " << current_req.url << "...\r\n"; std::cout.flush(); - auto page_data = load_page( current_url ); + auto page_data = load_page( current_req ); auto [term_width, term_height] = terminal.get_size(); const std::size_t wrap_width = ( term_width > 1 ) ? ( term_width - 1 ) : 80; diff --git a/src/net/http_client.cc b/src/net/http_client.cc index 143e067..0d75a7f 100644 --- a/src/net/http_client.cc +++ b/src/net/http_client.cc @@ -25,6 +25,7 @@ #include #include #include +#include namespace ricochet::net { @@ -169,22 +170,31 @@ struct FetchResult FetchResult do_network_request( const std::string& host, const std::string& service, // NOLINT(bugprone-easily-swappable-parameters) const std::string& path, // NOLINT(bugprone-easily-swappable-parameters) - bool is_https ) + bool is_https, + const std::string& method, // NOLINT(bugprone-easily-swappable-parameters) + const std::string& body ) // NOLINT(bugprone-easily-swappable-parameters) { FetchResult res; try { const Address server_addr( host, service ); - const std::string request - = std::format( "GET {} HTTP/1.1\r\n" + std::string request + = std::format( "{} {} HTTP/1.1\r\n" "Host: {}\r\n" "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/124.0.0.0 Safari/537.36\r\n" "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\r\n" "Accept-Language: en-US,en;q=0.9\r\n" "DNT: 1\r\n" - "Connection: close\r\n\r\n", + "Connection: close\r\n", + method, path, host ); + if ( !body.empty() ) { + request += std::format( "Content-Length: {}\r\n" + "Content-Type: application/x-www-form-urlencoded\r\n", + body.length() ); + } + request += "\r\n" + body; if ( is_https ) { SSLSocket ssl_socket; @@ -217,10 +227,13 @@ FetchResult do_network_request( const std::string& host, } // namespace -std::expected HttpClient::fetch( std::string_view url ) const +std::expected HttpClient::fetch( std::string_view url, // NOLINT + std::string_view method, + std::string_view body ) const { - (void)this; // 完美绕过 static 方法警告,满足最严苛的 tidy 规则 std::string current_url( url ); + std::string current_method( method ); + std::string current_body( body ); for ( int redirect_count = 0; redirect_count < 5; ++redirect_count ) { const auto parsed_url = parse_url( current_url ); @@ -232,7 +245,7 @@ std::expected HttpClient::fetch( std::string_view url const bool is_https = current_url.starts_with( "https://" ); const std::string service = is_https ? "https" : "http"; - const FetchResult net_res = do_network_request( host, service, path, is_https ); + const FetchResult net_res = do_network_request( host, service, path, is_https, current_method, current_body ); if ( !net_res.error.empty() ) { return std::unexpected( net_res.error ); } @@ -258,6 +271,10 @@ std::expected HttpClient::fetch( std::string_view url const std::string next_url = extract_redirect_url( raw_response, is_https, host ); if ( !next_url.empty() ) { current_url = next_url; + if ( response.status_code == 301 || response.status_code == 302 || response.status_code == 303 ) { + current_method = "GET"; + current_body.clear(); + } continue; } } diff --git a/src/render/renderer.cc b/src/render/renderer.cc index 8708206..4b152b7 100644 --- a/src/render/renderer.cc +++ b/src/render/renderer.cc @@ -154,6 +154,30 @@ bool is_heading_tag( std::string_view tag ) return tag == "h1" || tag == "h2" || tag == "h3" || tag == "h4"; } +void render_input_node( const parser::DomNode& node, + RenderResult& result, + const FormContext& ctx, + std::string& output ) +{ + std::string type = extract_attr( node.tag_name, "type" ); + const std::size_t space_pos = node.tag_name.find( ' ' ); + const std::string base_tag + = ( space_pos == std::string::npos ) ? node.tag_name : node.tag_name.substr( 0, space_pos ); + if ( base_tag == "button" && type.empty() ) { + type = "submit"; + } + const std::string default_val = extract_attr( node.tag_name, "value" ); + result.inputs.push_back( { extract_attr( node.tag_name, "name" ), ctx.action, ctx.method, default_val, type } ); + + std::string hint = build_input_hint( node.tag_name ); + if ( base_tag == "button" ) { + hint = "Submit"; + } + if ( hint != "hidden" ) { + output += " \033[7;33m[I" + std::to_string( result.inputs.size() ) + ":" + hint + "]\033[0m "; + } +} + void render_node( const parser::DomNode& node, // NOLINT(misc-no-recursion) RenderResult& result, FormContext ctx ) @@ -179,7 +203,7 @@ void render_node( const parser::DomNode& node, // NOLINT(misc-no-recursion) const bool is_header = is_heading_tag( base_tag ); const bool is_link = ( base_tag == "a" ); const bool is_list_item = ( base_tag == "li" ); - const bool is_input = ( base_tag == "input" || base_tag == "textarea" ); + const bool is_input = ( base_tag == "input" || base_tag == "textarea" || base_tag == "button" ); if ( is_header ) { output += "\n\n\033[1;31m"; @@ -188,12 +212,7 @@ void render_node( const parser::DomNode& node, // NOLINT(misc-no-recursion) } else if ( is_list_item ) { output += "\n • "; } else if ( is_input ) { - result.inputs.push_back( { extract_attr( node.tag_name, "name" ), ctx.action, ctx.method } ); - const std::string hint = build_input_hint( node.tag_name ); - - if ( hint != "hidden" ) { - output += " \033[7;33m[I" + std::to_string( result.inputs.size() ) + ":" + hint + "]\033[0m "; - } + render_input_node( node, result, ctx, output ); } else if ( base_tag == "p" || base_tag == "div" || is_header ) { if ( !output.empty() && output.back() != '\n' ) { output += "\n"; -- cgit v1.2.3