diff options
| author | Richard Qin <richard.qin.x@gmail.com> | 2026-04-11 05:36:22 +0800 |
|---|---|---|
| committer | Richard Qin <richard.qin.x@gmail.com> | 2026-04-11 05:36:22 +0800 |
| commit | 4ef9c771444943008bb990343127a7d659fd6df2 (patch) | |
| tree | 560b1c93f1950ddc5540999eae9b8d446e384470 /src | |
| parent | 5efb7400b96fa112c38009b18e2a48620f328148 (diff) | |
| download | rwebbrowser-4ef9c771444943008bb990343127a7d659fd6df2.tar.gz rwebbrowser-4ef9c771444943008bb990343127a7d659fd6df2.tar.xz | |
feat: implement SSLSocket for encrypted connections and add terminal size detection utility
Diffstat (limited to 'src')
| -rw-r--r-- | src/core/browser.cc | 32 | ||||
| -rw-r--r-- | src/net/http_client.cc | 135 | ||||
| -rw-r--r-- | src/net/ssl_socket.cc | 118 | ||||
| -rw-r--r-- | src/render/renderer.cc | 38 | ||||
| -rw-r--r-- | src/tui/terminal.cc | 71 |
5 files changed, 328 insertions, 66 deletions
diff --git a/src/core/browser.cc b/src/core/browser.cc index 0636de6..650ebcf 100644 --- a/src/core/browser.cc +++ b/src/core/browser.cc @@ -54,44 +54,48 @@ int Browser::run( std::string_view initial_url ) const std::string rendered_text = renderer.render( dom_root ); std::vector<std::string> lines; - std::stringstream ss(rendered_text); + std::stringstream ss( rendered_text ); std::string line; - while (std::getline(ss, line, '\n')) { - lines.push_back(line); + while ( std::getline( ss, line, '\n' ) ) { + lines.push_back( line ); } + std::cout << "DEBUG: Status Code: " << response.status_code << "\r\n"; + std::cout << "DEBUG: Raw Body Length: " << response.body.length() << "\r\n"; + // TUI Event Loop const tui::Terminal terminal; std::size_t scroll_y = 0; - const std::size_t screen_height = 24; - while (true) { + while ( true ) { terminal.clear_screen(); - for (std::size_t i = 0; i < screen_height - 1; ++i) { + const auto [term_width, term_height] = terminal.get_size(); + const std::size_t usable_height = ( term_height > 1 ) ? ( term_height - 1 ) : 1; + + for ( std::size_t i = 0; i < usable_height; ++i ) { const std::size_t line_idx = scroll_y + i; - if (line_idx < lines.size()) { + if ( line_idx < lines.size() ) { std::cout << lines[line_idx] << "\r\n"; } else { std::cout << "~\r\n"; } } - std::cout << "\033[7m" << " URL: " << initial_url - << " | [j] Down [k] Up [q] Quit " << "\033[0m"; + std::cout << "\033[7m" << " URL: " << initial_url << " | [j] Down [k] Up [q] Quit " << "\033[0m"; std::cout.flush(); const char c = terminal.read_key(); - if (c == 'q') { + if ( c == 'q' ) { break; // Break loop, terminal destructor will restore terminal } - if (c == 'j') { - if (scroll_y + screen_height - 1 < lines.size()) { + if ( c == 'j' ) { + if ( scroll_y + usable_height < lines.size() ) { scroll_y++; // Scroll down } - } else if (c == 'k') { - if (scroll_y > 0) { + } else if ( c == 'k' ) { + if ( scroll_y > 0 ) { scroll_y--; // Scroll up } } diff --git a/src/net/http_client.cc b/src/net/http_client.cc index b31073c..b31976f 100644 --- a/src/net/http_client.cc +++ b/src/net/http_client.cc @@ -17,6 +17,7 @@ */ #include "ricochet/net/http_client.hh" +#include "ricochet/net/ssl_socket.hh" #include "ricochet/net/sys/address.hh" #include "ricochet/net/sys/socket.hh" @@ -37,13 +38,17 @@ struct ParsedUrl std::expected<ParsedUrl, std::string> parse_url( std::string_view url ) { - const std::string_view http_prefix = "http://"; + std::string_view prefix = "http://"; - if ( !url.starts_with( http_prefix ) ) { - return std::unexpected( "Only 'http://' URLs are supported in MVP. (e.g., http://example.com)" ); + if ( url.starts_with( "https://" ) ) { + prefix = "https://"; + } else if ( url.starts_with( "http://" ) ) { + prefix = "http://"; + } else { + return std::unexpected( "Only 'http://' or 'https://' URLs are supported." ); } - url.remove_prefix( http_prefix.length() ); + url.remove_prefix( prefix.length() ); auto slash_pos = url.find( '/' ); ParsedUrl result; @@ -57,6 +62,77 @@ std::expected<ParsedUrl, std::string> parse_url( std::string_view url ) return result; } + +std::string dechunk( std::string_view body ) // NOLINT(readability-convert-member-functions-to-static) +{ + std::string result; + std::size_t pos = 0; + while ( pos < body.size() ) { + // find hex length end with \r\n + auto line_end = body.find( "\r\n", pos ); + if ( line_end == std::string_view::npos ) { + break; + } + + // parse hex length + const std::string len_str( body.substr( pos, line_end - pos ) ); + unsigned int chunk_size = 0; + try { + chunk_size = std::stoul( len_str, nullptr, 16 ); + } catch ( ... ) { + break; + } + + if ( chunk_size == 0 ) { + break; // end of stream + } + + // extract data block (skip \r\n) + pos = line_end + 2; + if ( pos + chunk_size > body.size() ) { + break; + } + result.append( body.substr( pos, chunk_size ) ); + + // skip data block's \r\n + pos += chunk_size + 2; + } + return result; +} + +std::string remove_html_comments( std::string html ) // NOLINT(readability-convert-member-functions-to-static) +{ + std::size_t start_pos = 0; + while ( ( start_pos = html.find( "<!--", start_pos ) ) != std::string::npos ) { + auto end_pos = html.find( "-->", start_pos ); + if ( end_pos != std::string::npos ) { + html.erase( start_pos, end_pos - start_pos + 3 ); + } else { + html.erase( start_pos ); + break; + } + } + return html; +} + +std::string remove_tag_blocks( std::string html, const std::string& tag_name ) // NOLINT +{ + const std::string open_tag = "<" + tag_name; + const std::string close_tag = "</" + tag_name + ">"; + std::size_t start_pos = 0; + + while ( ( start_pos = html.find( open_tag, start_pos ) ) != std::string::npos ) { + const std::size_t end_pos = html.find( close_tag, start_pos ); + if ( end_pos != std::string::npos ) { + html.erase( start_pos, end_pos - start_pos + close_tag.length() ); + } else { + html.erase( start_pos ); + break; + } + } + return html; +} + } // namespace std::expected<HttpResponse, std::string> @@ -69,30 +145,46 @@ HttpClient::fetch( // NOLINT(readability-convert-member-functions-to-static) } auto [host, path] = parsed_url.value(); + const bool is_https = url.starts_with( "https://" ); + const std::string service = is_https ? "https" : "http"; try { - const Address server_addr( host, "http" ); - - TCPSocket socket; - socket.connect( server_addr ); + const Address server_addr( host, service ); const std::string request = std::format( "GET {} HTTP/1.1\r\n" "Host: {}\r\n" - "User-Agent: Ricochet/0.1.0\r\n" + "User-Agent: Mozilla/5.0 (Ricochet/0.1.0)\r\n" + "Accept: text/html\r\n" + "Accept-Encoding: identity\r\n" "Connection: close\r\n\r\n", path, host ); - size_t bytes_written = 0; - while ( bytes_written < request.size() ) { - bytes_written += socket.write( request.substr( bytes_written ) ); - } - std::string raw_response; - while ( !socket.eof() ) { - std::string buffer; - socket.read( buffer ); - raw_response += buffer; + if ( is_https ) { + SSLSocket ssl_socket; + ssl_socket.connect( server_addr, host ); + size_t bytes_written = 0; + while ( bytes_written < request.size() ) { + bytes_written += ssl_socket.write( request.substr( bytes_written ) ); + } + while ( !ssl_socket.eof() ) { + std::string buffer; + ssl_socket.read( buffer ); + raw_response += buffer; + } + } else { + TCPSocket tcp_socket; + tcp_socket.connect( server_addr ); + size_t bytes_written = 0; + while ( bytes_written < request.size() ) { + bytes_written += tcp_socket.write( request.substr( bytes_written ) ); + } + while ( !tcp_socket.eof() ) { + std::string buffer; + tcp_socket.read( buffer ); + raw_response += buffer; + } } HttpResponse response; @@ -111,6 +203,13 @@ HttpClient::fetch( // NOLINT(readability-convert-member-functions-to-static) response.body = raw_response; } + if ( raw_response.contains( "Transfer-Encoding: chunked" ) ) { + response.body = dechunk( response.body ); + } + + response.body = remove_html_comments( response.body ); + response.body = remove_tag_blocks( response.body, "script" ); + response.body = remove_tag_blocks( response.body, "style" ); return response; } catch ( const std::exception& e ) { return std::unexpected( std::string( "Network Error: " ) + e.what() ); diff --git a/src/net/ssl_socket.cc b/src/net/ssl_socket.cc new file mode 100644 index 0000000..a31b564 --- /dev/null +++ b/src/net/ssl_socket.cc @@ -0,0 +1,118 @@ +/* + * Ricochet - A minimalist CLI web browser for fast, distraction-free navigation. + * Copyright (C) 2026 Richard Qin + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +#include "ricochet/net/ssl_socket.hh" +#include <iostream> +#include <openssl/err.h> +#include <openssl/ssl.h> +#include <stdexcept> + +namespace ricochet::net { + +// --- RAII Deleters --- + +void SslCtxDeleter::operator()( SSL_CTX* ptr ) const +{ + if ( ptr != nullptr ) { + SSL_CTX_free( ptr ); + } +} + +void SslDeleter::operator()( SSL* ptr ) const +{ + if ( ptr != nullptr ) { + SSL_free( ptr ); + } +} + +// SSLSocket Implementation + +SSLSocket::SSLSocket() +{ + const SSL_METHOD* method = TLS_client_method(); + ctx_.reset( SSL_CTX_new( method ) ); + if ( !ctx_ ) { + throw std::runtime_error( "Failed to create SSL_CTX" ); + } + + if ( SSL_CTX_set_default_verify_paths( ctx_.get() ) != 1 ) { + throw std::runtime_error( "Failed to load default CA certificates" ); + } +} + +void SSLSocket::connect( const Address& address, const std::string& hostname ) +{ + // TCP Connect + underlying_socket_.connect( address ); + + ssl_.reset( SSL_new( ctx_.get() ) ); + if ( !ssl_ ) { + throw std::runtime_error( "Failed to create SSL object" ); + } + + // Setup SNI (Server Name Indication) + if ( SSL_set_tlsext_host_name( ssl_.get(), hostname.c_str() ) != 1 ) { + // Some old servers do not support SNI, here we can ignore the error or throw an exception, depending on the + // requirements + std::cerr << "Warning: Failed to set SNI" << '\n'; + } + + SSL_set_fd( ssl_.get(), underlying_socket_.fd_num() ); + + if ( SSL_connect( ssl_.get() ) != 1 ) { + throw std::runtime_error( "TLS Handshake failed!" ); + } +} + +std::size_t SSLSocket::write( std::string_view data ) +{ + if ( !ssl_ ) { + return 0; + } + + const int bytes_written = SSL_write( ssl_.get(), data.data(), static_cast<int>( data.size() ) ); + if ( bytes_written <= 0 ) { + throw std::runtime_error( "SSL write failed" ); + } + return static_cast<std::size_t>( bytes_written ); +} + +void SSLSocket::read( std::string& buffer ) +{ + if ( !ssl_ ) { + return; + } + + std::array<char, 4096> buf {}; + // Read decrypted data from the encrypted channel + const int bytes_read = SSL_read( ssl_.get(), buf.data(), static_cast<int>( buf.size() ) ); + + if ( bytes_read > 0 ) { + buffer.append( buf.data(), static_cast<std::size_t>( bytes_read ) ); + } else { + // Read 0 or negative, representing the server closed the connection (EOF) + eof_ = true; + } +} + +bool SSLSocket::eof() const +{ + return eof_; +} + +} // namespace ricochet::net
\ No newline at end of file diff --git a/src/render/renderer.cc b/src/render/renderer.cc index 0a91d49..2115ee1 100644 --- a/src/render/renderer.cc +++ b/src/render/renderer.cc @@ -23,6 +23,25 @@ namespace ricochet::render { namespace { +std::string decode_entities( std::string text ) // NOLINT(readability-convert-member-functions-to-static) +{ + constexpr std::array<std::pair<std::string_view, std::string_view>, 24> entities + = { { { " ", " " }, { "<", "<" }, { ">", ">" }, { "&", "&" }, { """, "\"" }, + { "©", "©" }, { "®", "®" }, { "™", "™" }, { "€", "€" }, { "£", "£" }, + { "¥", "¥" }, { "¢", "¢" }, { "§", "§" }, { "¶", "¶" }, { "…", "…" }, + { "†", "†" }, { "‡", "‡" }, { "•", "•" }, { "′", "′" }, { "″", "″" }, + { "‹", "‹" }, { "›", "›" }, { "‾", "‾" }, { "⁄", "⁄" } } }; + + for ( const auto& [entity, replacement] : entities ) { + std::size_t pos = 0; + while ( ( pos = text.find( entity, pos ) ) != std::string::npos ) { + text.replace( pos, entity.length(), replacement ); + pos += replacement.length(); + } + } + return text; +} + void render_node( const parser::DomNode& node, std::string& output ) // NOLINT(misc-no-recursion) { if ( node.tag_name == "head" || node.tag_name == "style" || node.tag_name == "script" || node.tag_name == "meta" @@ -31,21 +50,27 @@ void render_node( const parser::DomNode& node, std::string& output ) // NOLINT(m } if ( node.tag_name.empty() ) { - output += node.text_content; + output += decode_entities( node.text_content ); return; } - if ( node.tag_name == "h1" ) { + + const bool is_header + = ( node.tag_name == "h1" || node.tag_name == "h2" || node.tag_name == "h3" || node.tag_name == "h4" ); + + if ( is_header ) { output += "\n\n\033[1;31m"; } else if ( node.tag_name == "a" ) { std::cout << "\033[4;34m"; - } else if ( node.tag_name == "p" || node.tag_name == "div" ) { - output += "\n"; + } else if ( node.tag_name == "p" || node.tag_name == "div" || is_header ) { + if ( !output.empty() && output.back() != '\n' ) { + output += "\n"; + } } for ( const auto& child : node.children ) { render_node( child, output ); } - if ( node.tag_name == "h1" || node.tag_name == "a" ) { + if ( is_header || node.tag_name == "a" ) { output += "\033[0m"; } @@ -56,7 +81,8 @@ void render_node( const parser::DomNode& node, std::string& output ) // NOLINT(m } // namespace -std::string Renderer::render( const parser::DomNode& node ) const // NOLINT(readability-convert-member-functions-to-static) +std::string Renderer::render( // NOLINT(readability-convert-member-functions-to-static) + const parser::DomNode& node ) const { std::string output; render_node( node, output ); diff --git a/src/tui/terminal.cc b/src/tui/terminal.cc index 8fdb585..75c6c1f 100644 --- a/src/tui/terminal.cc +++ b/src/tui/terminal.cc @@ -19,44 +19,59 @@ #include "ricochet/tui/terminal.hh" #include <cstdlib> #include <iostream> +#include <sys/ioctl.h> #include <unistd.h> namespace ricochet::tui { -Terminal::Terminal() { - // get terminal attributes - tcgetattr(STDIN_FILENO, &original_termios_); - - struct termios raw = original_termios_; - - // close echo, close canonical mode - // close terminal signals (ISIG disable Ctrl+C and Ctrl+Z, let the program handle it) - raw.c_lflag &= ~(ECHO | ICANON | ISIG); - - // close software flow control (Ctrl+S, Ctrl+Q) - raw.c_iflag &= ~(IXON | ICRNL); - - // apply new terminal attributes - tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw); +Terminal::Terminal() +{ + // get terminal attributes + tcgetattr( STDIN_FILENO, &original_termios_ ); + + struct termios raw = original_termios_; + + // close echo, close canonical mode + // close terminal signals (ISIG disable Ctrl+C and Ctrl+Z, let the program handle it) + raw.c_lflag &= ~( ECHO | ICANON | ISIG ); + + // close software flow control (Ctrl+S, Ctrl+Q) + raw.c_iflag &= ~( IXON | ICRNL ); + + // apply new terminal attributes + tcsetattr( STDIN_FILENO, TCSAFLUSH, &raw ); +} + +Terminal::~Terminal() +{ + // RAII: restore terminal attributes when program exits + tcsetattr( STDIN_FILENO, TCSAFLUSH, &original_termios_ ); } -Terminal::~Terminal() { - // RAII: restore terminal attributes when program exits - tcsetattr(STDIN_FILENO, TCSAFLUSH, &original_termios_); +char Terminal::read_key() const // NOLINT(readability-convert-member-functions-to-static) +{ + char c = '\0'; + // read 1 byte + if ( read( STDIN_FILENO, &c, 1 ) == -1 ) { + return '\0'; + } + return c; } -char Terminal::read_key() const { // NOLINT(readability-convert-member-functions-to-static) - char c = '\0'; - // read 1 byte - if (read(STDIN_FILENO, &c, 1) == -1) { - return '\0'; - } - return c; +void Terminal::clear_screen() const // NOLINT(readability-convert-member-functions-to-static) +{ + // ANSI escape codes: \x1b[2J clear screen, \x1b[H move cursor to (1,1) + std::cout << "\x1b[2J\x1b[H"; } -void Terminal::clear_screen() const { // NOLINT(readability-convert-member-functions-to-static) - // ANSI escape codes: \x1b[2J clear screen, \x1b[H move cursor to (1,1) - std::cout << "\x1b[2J\x1b[H"; +std::pair<std::size_t, std::size_t> Terminal::get_size() // NOLINT(readability-convert-member-functions-to-static) + const +{ + struct winsize w {}; + if ( ioctl( STDOUT_FILENO, TIOCGWINSZ, &w ) == -1 ) { + return { 80, 24 }; + } + return { static_cast<std::size_t>( w.ws_col ), static_cast<std::size_t>( w.ws_row ) }; } } // namespace ricochet::tui
\ No newline at end of file |
