From 4ef9c771444943008bb990343127a7d659fd6df2 Mon Sep 17 00:00:00 2001 From: Richard Qin Date: Sat, 11 Apr 2026 05:36:22 +0800 Subject: feat: implement SSLSocket for encrypted connections and add terminal size detection utility --- src/net/http_client.cc | 135 ++++++++++++++++++++++++++++++++++++++++++------- src/net/ssl_socket.cc | 118 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 235 insertions(+), 18 deletions(-) create mode 100644 src/net/ssl_socket.cc (limited to 'src/net') 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 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 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 ); + 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 = ""; + 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 @@ -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 . + */ + +#include "ricochet/net/ssl_socket.hh" +#include +#include +#include +#include + +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( data.size() ) ); + if ( bytes_written <= 0 ) { + throw std::runtime_error( "SSL write failed" ); + } + return static_cast( bytes_written ); +} + +void SSLSocket::read( std::string& buffer ) +{ + if ( !ssl_ ) { + return; + } + + std::array buf {}; + // Read decrypted data from the encrypted channel + const int bytes_read = SSL_read( ssl_.get(), buf.data(), static_cast( buf.size() ) ); + + if ( bytes_read > 0 ) { + buffer.append( buf.data(), static_cast( 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 -- cgit v1.2.3