diff --git a/.cursor/rules/doc.mdc b/.cursor/rules/doc.mdc new file mode 100644 index 00000000..13f6eb1c --- /dev/null +++ b/.cursor/rules/doc.mdc @@ -0,0 +1,7 @@ +--- +description: Documentation style conventions for code snippets, javadocs, and API documentation examples +globs: +alwaysApply: false +--- + +- `route_params` are named `rp` diff --git a/include/boost/http/server/cors.hpp b/include/boost/http/server/cors.hpp index b69cae58..1e77f2ef 100644 --- a/include/boost/http/server/cors.hpp +++ b/include/boost/http/server/cors.hpp @@ -18,31 +18,73 @@ namespace boost { namespace http { +/** Options for CORS middleware configuration. +*/ struct cors_options { + /// Allowed origin, or "*" for any. Empty defaults to "*". std::string origin; + + /// Allowed HTTP methods. Empty defaults to common methods. std::string methods; + + /// Allowed request headers. std::string allowedHeaders; + + /// Response headers exposed to client. std::string exposedHeaders; + + /// Max age for preflight cache. std::chrono::seconds max_age{ 0 }; + + /// Status code for preflight response. status result = status::no_content; + + /// If true, pass preflight to next handler. bool preFlightContinue = false; + + /// If true, allow credentials. bool credentials = false; }; -class cors +/** CORS middleware for handling cross-origin requests. + + This middleware handles Cross-Origin Resource Sharing + (CORS) by setting appropriate response headers and + handling preflight OPTIONS requests. + + @par Example + @code + cors_options opts; + opts.origin = "*"; + opts.methods = "GET,POST,PUT,DELETE"; + opts.credentials = true; + + router.use( cors( opts ) ); + @endcode + + @see cors_options +*/ +class BOOST_HTTP_DECL cors { + cors_options options_; + public: - BOOST_HTTP_DECL - explicit cors( - cors_options options = {}) noexcept; + /** Construct a CORS middleware. - BOOST_HTTP_DECL - route_result - operator()(route_params& p) const; + @param options Configuration options. + */ + explicit cors(cors_options options = {}) noexcept; -private: - cors_options options_; + /** Handle a request. + + Sets CORS headers and handles preflight requests. + + @param rp The route parameters. + + @return A task that completes with the routing result. + */ + route_task operator()(route_params& rp) const; }; } // http diff --git a/include/boost/http/server/encode_url.hpp b/include/boost/http/server/encode_url.hpp new file mode 100644 index 00000000..50f01c40 --- /dev/null +++ b/include/boost/http/server/encode_url.hpp @@ -0,0 +1,48 @@ +// +// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/http +// + +#ifndef BOOST_HTTP_SERVER_ENCODE_URL_HPP +#define BOOST_HTTP_SERVER_ENCODE_URL_HPP + +#include +#include +#include + +namespace boost { +namespace http { + +/** Percent-encode a URL for safe use in HTTP responses. + + Encodes characters that are not safe in URLs using + percent-encoding (e.g. space becomes %20). This is + useful for encoding URLs that will be included in + Location headers or HTML links. + + The following characters are NOT encoded: + - Unreserved: A-Z a-z 0-9 - _ . ~ + - Reserved (allowed in URLs): ! # $ & ' ( ) * + , / : ; = ? @ + + @par Example + @code + std::string url = encode_url( "/path/to/file with spaces.txt" ); + // url == "/path/to/file%20with%20spaces.txt" + @endcode + + @param url The URL or URL component to encode. + + @return A new string with unsafe characters percent-encoded. +*/ +BOOST_HTTP_DECL +std::string +encode_url(core::string_view url); + +} // http +} // boost + +#endif diff --git a/include/boost/http/server/escape_html.hpp b/include/boost/http/server/escape_html.hpp new file mode 100644 index 00000000..1c5374c0 --- /dev/null +++ b/include/boost/http/server/escape_html.hpp @@ -0,0 +1,51 @@ +// +// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/http +// + +#ifndef BOOST_HTTP_SERVER_ESCAPE_HTML_HPP +#define BOOST_HTTP_SERVER_ESCAPE_HTML_HPP + +#include +#include +#include + +namespace boost { +namespace http { + +/** Escape a string for safe inclusion in HTML. + + Replaces characters that have special meaning in HTML + with their corresponding character entity references: + + @li `&` becomes `&` + @li `<` becomes `<` + @li `>` becomes `>` + @li `"` becomes `"` + @li `'` becomes `'` + + This function is used to prevent XSS (Cross-Site Scripting) + attacks when embedding user input in HTML responses. + + @par Example + @code + std::string safe = escape_html( "" ); + // safe == "<script>alert('xss')</script>" + @endcode + + @param s The string to escape. + + @return A new string with HTML special characters escaped. +*/ +BOOST_HTTP_DECL +std::string +escape_html(core::string_view s); + +} // http +} // boost + +#endif diff --git a/include/boost/http/server/etag.hpp b/include/boost/http/server/etag.hpp new file mode 100644 index 00000000..911b2f10 --- /dev/null +++ b/include/boost/http/server/etag.hpp @@ -0,0 +1,84 @@ +// +// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/http +// + +#ifndef BOOST_HTTP_SERVER_ETAG_HPP +#define BOOST_HTTP_SERVER_ETAG_HPP + +#include +#include +#include +#include + +namespace boost { +namespace http { + +/** Options for ETag generation. +*/ +struct etag_options +{ + /// Generate a weak ETag (prefixed with W/). + bool weak = false; +}; + +/** Generate an ETag from content. + + Creates an ETag by computing a hash of the provided + content. The resulting ETag can be used in HTTP + responses to enable caching. + + @par Example + @code + std::string content = "Hello, World!"; + std::string tag = etag( content ); + // tag == "\"d-3/1gIbsr1bCvZ2KQgJ7DpTGR3YH\"" + @endcode + + @param body The content to hash. + + @param opts Options controlling ETag generation. + + @return An ETag string suitable for use in the ETag header. +*/ +BOOST_HTTP_DECL +std::string +etag(core::string_view body, etag_options opts = {}); + +/** Generate an ETag from file metadata. + + Creates an ETag based on a file's size and modification + time. This is more efficient than hashing file content + and is suitable for static file serving. + + @par Example + @code + std::uint64_t size = 1234; + std::uint64_t mtime = 1704067200; // Unix timestamp + std::string tag = etag( size, mtime ); + // tag == "\"4d2-65956a00\"" + @endcode + + @param size The file size in bytes. + + @param mtime The file modification time (typically Unix timestamp). + + @param opts Options controlling ETag generation. + + @return An ETag string suitable for use in the ETag header. +*/ +BOOST_HTTP_DECL +std::string +etag( + std::uint64_t size, + std::uint64_t mtime, + etag_options opts = {}); + +} // http +} // boost + +#endif diff --git a/include/boost/http/server/fresh.hpp b/include/boost/http/server/fresh.hpp new file mode 100644 index 00000000..c403f819 --- /dev/null +++ b/include/boost/http/server/fresh.hpp @@ -0,0 +1,65 @@ +// +// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/http +// + +#ifndef BOOST_HTTP_SERVER_FRESH_HPP +#define BOOST_HTTP_SERVER_FRESH_HPP + +#include +#include +#include + +namespace boost { +namespace http { + +/** Check if a response is fresh for conditional GET. + + Compares the request's conditional headers (`If-None-Match` + and `If-Modified-Since`) against the response's caching + headers (`ETag` and `Last-Modified`) to determine if the + cached response is still valid. + + If this returns `true`, the server should respond with + 304 Not Modified instead of sending the full response body. + + @par Example + @code + // Prepare response headers + rp.res.set( field::etag, "\"abc123\"" ); + rp.res.set( field::last_modified, "Wed, 21 Oct 2024 07:28:00 GMT" ); + + // Check freshness + if( is_fresh( rp.req, rp.res ) ) + { + rp.status( status::not_modified ); + co_return co_await rp.send( "" ); + } + + // Send full response + co_return co_await rp.send( content ); + @endcode + + @param req The HTTP request. + + @param res The HTTP response (with ETag/Last-Modified set). + + @return `true` if the response is fresh (304 should be sent), + `false` if the full response should be sent. + + @see http::field::if_none_match, http::field::if_modified_since +*/ +BOOST_HTTP_DECL +bool +is_fresh( + request const& req, + response const& res) noexcept; + +} // http +} // boost + +#endif diff --git a/include/boost/http/server/mime_db.hpp b/include/boost/http/server/mime_db.hpp new file mode 100644 index 00000000..7e9dab85 --- /dev/null +++ b/include/boost/http/server/mime_db.hpp @@ -0,0 +1,77 @@ +// +// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/http +// + +#ifndef BOOST_HTTP_SERVER_MIME_DB_HPP +#define BOOST_HTTP_SERVER_MIME_DB_HPP + +#include +#include + +namespace boost { +namespace http { + +/** Information about a MIME type. + + This structure contains metadata associated with a + MIME type, including its default charset and whether + the content is typically compressible. +*/ +struct mime_type_entry +{ + /// The MIME type string (e.g. "text/html"). + core::string_view type; + + /// Default charset, or empty if none. + core::string_view charset; + + /// Whether content of this type is typically compressible. + bool compressible = false; +}; + +/** MIME type database utilities. + + This namespace provides lookup functions for the + built-in MIME type database. The database contains + common MIME types with associated metadata like + default charset and compressibility. + + @par Example + @code + // Look up info about a MIME type + auto const* entry = mime_db::lookup( "text/html" ); + if( entry ) + { + std::cout << "charset: " << entry->charset << "\n"; + std::cout << "compressible: " << entry->compressible << "\n"; + } + @endcode + + @see mime_types +*/ +namespace mime_db { + +/** Look up a MIME type in the database. + + Searches the built-in MIME type database for the + specified type string. The lookup is case-insensitive. + + @param type The MIME type to look up (e.g. "text/html"). + + @return A pointer to the entry if found, or `nullptr` + if the type is not in the database. +*/ +BOOST_HTTP_DECL +mime_type_entry const* +lookup(core::string_view type) noexcept; + +} // mime_db +} // http +} // boost + +#endif diff --git a/include/boost/http/server/mime_types.hpp b/include/boost/http/server/mime_types.hpp new file mode 100644 index 00000000..088a8663 --- /dev/null +++ b/include/boost/http/server/mime_types.hpp @@ -0,0 +1,115 @@ +// +// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/http +// + +#ifndef BOOST_HTTP_SERVER_MIME_TYPES_HPP +#define BOOST_HTTP_SERVER_MIME_TYPES_HPP + +#include +#include +#include + +namespace boost { +namespace http { + +/** MIME type lookup utilities. + + This namespace provides functions for looking up MIME + types based on file extensions, and for getting the + default extension for a MIME type. + + @par Example + @code + // Get MIME type for a file + auto type = mime_types::lookup( "index.html" ); + // type == "text/html" + + // Get MIME type for just an extension + auto type2 = mime_types::lookup( ".css" ); + // type2 == "text/css" + + // Get full Content-Type header value + auto ct = mime_types::content_type( "application/json" ); + // ct == "application/json; charset=utf-8" + @endcode + + @see mime_db +*/ +namespace mime_types { + +/** Look up a MIME type by file path or extension. + + Given a file path or extension, returns the corresponding + MIME type. The lookup is case-insensitive. + + @param path_or_ext A file path (e.g. "index.html") or + extension (e.g. ".html" or "html"). + + @return The MIME type string, or an empty string if + the extension is not recognized. +*/ +BOOST_HTTP_DECL +core::string_view +lookup(core::string_view path_or_ext) noexcept; + +/** Return the default extension for a MIME type. + + Given a MIME type, returns the most common file + extension associated with it. + + @param type The MIME type (e.g. "text/html"). + + @return The extension without a leading dot (e.g. "html"), + or an empty string if the type is not recognized. +*/ +BOOST_HTTP_DECL +core::string_view +extension(core::string_view type) noexcept; + +/** Return the default charset for a MIME type. + + @param type The MIME type (e.g. "text/html"). + + @return The charset (e.g. "UTF-8"), or an empty string + if no default charset is defined for the type. +*/ +BOOST_HTTP_DECL +core::string_view +charset(core::string_view type) noexcept; + +/** Build a full Content-Type header value. + + Given a MIME type or file extension, returns a complete + Content-Type header value including charset if applicable. + + @par Example + @code + auto ct = mime_types::content_type( "text/html" ); + // ct == "text/html; charset=utf-8" + + auto ct2 = mime_types::content_type( ".json" ); + // ct2 == "application/json; charset=utf-8" + + auto ct3 = mime_types::content_type( "image/png" ); + // ct3 == "image/png" + @endcode + + @param type_or_ext A MIME type or file extension. + + @return A Content-Type header value with charset if + applicable, or an empty string if not recognized. +*/ +BOOST_HTTP_DECL +std::string +content_type(core::string_view type_or_ext); + +} // mime_types +} // http +} // boost + +#endif diff --git a/include/boost/http/server/range_parser.hpp b/include/boost/http/server/range_parser.hpp new file mode 100644 index 00000000..890d07e9 --- /dev/null +++ b/include/boost/http/server/range_parser.hpp @@ -0,0 +1,98 @@ +// +// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/http +// + +#ifndef BOOST_HTTP_SERVER_RANGE_PARSER_HPP +#define BOOST_HTTP_SERVER_RANGE_PARSER_HPP + +#include +#include +#include +#include + +namespace boost { +namespace http { + +/** A single byte range. + + Represents an inclusive byte range within a resource. + Both `start` and `end` are zero-based byte offsets. +*/ +struct byte_range +{ + /// Start of range (inclusive). + std::int64_t start = 0; + + /// End of range (inclusive). + std::int64_t end = 0; +}; + +/** Result type for range header parsing. +*/ +enum class range_result_type +{ + /// Ranges parsed successfully. + ok, + + /// Range is not satisfiable (416 response). + unsatisfiable, + + /// Range header is malformed (ignore it). + malformed +}; + +/** Result of parsing a Range header. +*/ +struct range_result +{ + /// The parsed ranges (empty if malformed or unsatisfiable). + std::vector ranges; + + /// The result type. + range_result_type type = range_result_type::ok; +}; + +/** Parse an HTTP Range header. + + Parses a Range header value (e.g. "bytes=0-499" or + "bytes=0-499, 1000-1499") and returns the requested + byte ranges adjusted to the resource size. + + @par Example + @code + // Single range + auto result = parse_range( 10000, "bytes=0-499" ); + if( result.type == range_result_type::ok ) + { + // result.ranges[0].start == 0 + // result.ranges[0].end == 499 + } + + // Suffix range (last 500 bytes) + auto result2 = parse_range( 10000, "bytes=-500" ); + // result2.ranges[0].start == 9500 + // result2.ranges[0].end == 9999 + @endcode + + @param size The size of the resource in bytes. + + @param header The Range header value. + + @return A range_result containing the parsed ranges and + result type. + + @see byte_range, range_result, range_result_type +*/ +BOOST_HTTP_DECL +range_result +parse_range(std::int64_t size, core::string_view header); + +} // http +} // boost + +#endif diff --git a/include/boost/http/server/route_handler.hpp b/include/boost/http/server/route_handler.hpp index d1ee0e11..18dd019d 100644 --- a/include/boost/http/server/route_handler.hpp +++ b/include/boost/http/server/route_handler.hpp @@ -12,6 +12,8 @@ #include #include +#include +#include #include #include #include // VFALCO forward declare? @@ -33,6 +35,58 @@ struct acceptor_config //----------------------------------------------- +/** The coroutine task type returned by route handlers. + + Route handlers are coroutines that process HTTP requests and + must return this type. The underlying @ref route_result + (a `system::error_code`) communicates the routing disposition + back to the router: + + @li Return @ref route::next to decline handling and allow + subsequent handlers in the same route to process the request. + + @li Return @ref route::next_route to skip remaining handlers + in the current route and proceed to the next matching route. + + @li Return a non-failing code (one for which + `error_code::failed()` returns `false`) to indicate the + response is complete. The connection will either close + or proceed to the next request. + + @li Return a failing error code to signal an error that + prevents normal processing. + + @par Example + @code + // A handler that serves static files + route_task serve_file(route_params& p) + { + auto path = find_file(p.path); + if(path.empty()) + co_return route::next; // Not found, try next handler + + p.res.set_body_file(path); + co_return {}; // Success + } + + // A handler that requires authentication + route_task require_auth(route_params& p) + { + if(! p.session_data.contains()) + { + p.status(http::status::unauthorized); + co_return {}; + } + co_return route::next; // Authenticated, continue chain + } + @endcode + + @see @ref route_result, @ref route, @ref route_params +*/ +using route_task = capy::task; + +//----------------------------------------------- + /** Parameters object for HTTP route handlers */ struct BOOST_HTTP_SYMBOL_VISIBLE @@ -103,7 +157,134 @@ struct BOOST_HTTP_SYMBOL_VISIBLE route_params& set_body(std::string s); - //http::route_task capy::task + /** Send the HTTP response with the given body. + + This convenience coroutine handles the entire response + lifecycle in a single call, similar to Express.js + `res.send()`. It performs the following steps: + + @li Sets the response body to the provided string. + @li Sets the `Content-Length` header automatically. + @li If `Content-Type` is not already set, detects the + type: bodies starting with `<` are sent as + `text/html; charset=utf-8`, otherwise as + `text/plain; charset=utf-8`. + @li Serializes and transmits the complete response. + + After calling this function the response is complete. + Do not attempt to modify or send additional data. + + @par Example + @code + // Plain text (no leading '<') + route_task hello( route_params& rp ) + { + co_return co_await rp.send( "Hello, World!" ); + } + + // HTML (starts with '<') + route_task greeting( route_params& rp ) + { + co_return co_await rp.send( "

Welcome

" ); + } + + // Explicit Content-Type for JSON + route_task api( route_params& rp ) + { + rp.res.set( http::field::content_type, "application/json" ); + co_return co_await rp.send( R"({"status":"ok"})" ); + } + @endcode + + @param body The content to send as the response body. + + @return A @ref route_task that completes when the response + has been fully transmitted, yielding a @ref route_result + indicating success or failure. + */ + virtual route_task send(std::string_view body) = 0; + + /** Write buffer data to the response body. + + This coroutine writes the provided buffer sequence to + the response output stream. It is used for streaming + responses where the body is sent in chunks. + + The response headers must be set appropriately before + calling this function (e.g., set Transfer-Encoding to + chunked, or set Content-Length if known). + + @par Example + @code + route_task stream_response( route_params& rp ) + { + rp.res.set( field::transfer_encoding, "chunked" ); + + // Write in chunks + std::string chunk1 = "Hello, "; + co_await rp.write( capy::const_buffer( + chunk1.data(), chunk1.size() ) ); + + std::string chunk2 = "World!"; + co_await rp.write( capy::const_buffer( + chunk2.data(), chunk2.size() ) ); + + co_return co_await rp.end(); + } + @endcode + + @param buffers A buffer sequence containing the data to write. + + @return A @ref route_task that completes when the write + operation is finished. + */ + template + route_task + write(Buffers const& buffers) + { + return write_impl(capy::any_bufref(buffers)); + } + + /** Complete a streaming response. + + This coroutine finalizes a streaming response that was + started with @ref write calls. For chunked transfers, + it sends the final chunk terminator. + + @par Example + @code + route_task send_file( route_params& rp ) + { + rp.res.set( field::transfer_encoding, "chunked" ); + + // Stream file contents... + while( ! file.eof() ) + { + auto data = file.read(); + co_await rp.write( capy::const_buffer( + data.data(), data.size() ) ); + } + + co_return co_await rp.end(); + } + @endcode + + @return A @ref route_task that completes when the response + has been fully finalized. + */ + virtual route_task end() = 0; + +protected: + /** Implementation of write with type-erased buffers. + + Derived classes must implement this to perform the + actual write operation. + + @param buffers Type-erased buffer sequence. + + @return A task that completes when the write is done. + */ + virtual route_task write_impl(capy::any_bufref buffers) = 0; }; } // http diff --git a/include/boost/http/server/send_file.hpp b/include/boost/http/server/send_file.hpp new file mode 100644 index 00000000..665ca199 --- /dev/null +++ b/include/boost/http/server/send_file.hpp @@ -0,0 +1,151 @@ +// +// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/http +// + +#ifndef BOOST_HTTP_SERVER_SEND_FILE_HPP +#define BOOST_HTTP_SERVER_SEND_FILE_HPP + +#include +#include +#include +#include +#include + +namespace boost { +namespace http { + +/** Options for sending a file. +*/ +struct send_file_options +{ + /// Enable ETag generation (default: true). + bool etag = true; + + /// Enable Last-Modified header (default: true). + bool last_modified = true; + + /// Max-Age for Cache-Control header in seconds (0 = no cache). + std::uint32_t max_age = 0; + + /// Content-Type to use (empty = auto-detect from extension). + std::string content_type; +}; + +/** Result of send_file_init. +*/ +enum class send_file_result +{ + /// File found and response prepared. + ok, + + /// File not found. + not_found, + + /// Response is fresh (304 Not Modified should be sent). + not_modified, + + /// Error opening or reading file. + error +}; + +/** Information about a file to send. +*/ +struct send_file_info +{ + /// Result of initialization. + send_file_result result = send_file_result::not_found; + + /// File size in bytes. + std::uint64_t size = 0; + + /// Last modification time (system time). + std::uint64_t mtime = 0; + + /// Content-Type to use. + std::string content_type; + + /// ETag value. + std::string etag; + + /// Last-Modified header value. + std::string last_modified; + + /// Range start (for partial content). + std::int64_t range_start = 0; + + /// Range end (for partial content). + std::int64_t range_end = -1; + + /// True if this is a range response. + bool is_range = false; +}; + +/** Initialize headers for sending a file. + + This function prepares the response headers for serving + a static file. It performs the following tasks: + + @li Opens and validates the file + @li Sets Content-Type based on file extension + @li Generates ETag and Last-Modified headers + @li Checks for conditional requests (freshness) + @li Parses Range headers for partial content + + After calling this function, use the returned info to + stream the file content using @ref route_params::write. + + @par Example + @code + route_task serve( route_params& rp, std::string path ) + { + send_file_info info; + send_file_init( info, rp, path ); + + if( info.result == send_file_result::not_modified ) + { + rp.status( status::not_modified ); + co_return co_await rp.send( "" ); + } + + if( info.result != send_file_result::ok ) + co_return route::next; + + // Stream file content... + } + @endcode + + @param info [out] Receives file information and result. + + @param rp The route parameters (for accessing request/response). + + @param path Path to the file to send. + + @param opts Options controlling file sending behavior. +*/ +BOOST_HTTP_DECL +void +send_file_init( + send_file_info& info, + route_params& rp, + core::string_view path, + send_file_options const& opts = {}); + +/** Format Last-Modified time from Unix timestamp. + + @param mtime Unix timestamp (seconds since epoch). + + @return HTTP-date formatted string. +*/ +BOOST_HTTP_DECL +std::string +format_http_date(std::uint64_t mtime); + +} // http +} // boost + +#endif diff --git a/include/boost/http/server/serve_static.hpp b/include/boost/http/server/serve_static.hpp new file mode 100644 index 00000000..648f4a4a --- /dev/null +++ b/include/boost/http/server/serve_static.hpp @@ -0,0 +1,124 @@ +// +// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/http +// + +#ifndef BOOST_HTTP_SERVER_SERVE_STATIC_HPP +#define BOOST_HTTP_SERVER_SERVE_STATIC_HPP + +#include +#include + +namespace boost { +namespace http { + +/** Policy for handling dotfiles in static file serving. +*/ +enum class dotfiles_policy +{ + /// Allow access to dotfiles. + allow, + + /// Deny access to dotfiles (403 Forbidden). + deny, + + /// Ignore dotfiles (pass to next handler). + ignore +}; + +/** Options for the static file server. +*/ +struct serve_static_options +{ + /// How to handle dotfiles. + dotfiles_policy dotfiles = dotfiles_policy::ignore; + + /// Maximum cache age in seconds. + std::uint32_t max_age = 0; + + /// Enable accepting range requests. + bool accept_ranges = true; + + /// Enable etag header generation. + bool etag = true; + + /// Treat client errors as unhandled requests. + bool fallthrough = true; + + /// Enable the immutable directive in cache control headers. + bool immutable = false; + + /// Enable a default index file for directory requests. + bool index = true; + + /// Enable the "Last-Modified" header. + bool last_modified = true; + + /// Enable redirection for directories missing a trailing slash. + bool redirect = true; +}; + +/** Coroutine-based static file server middleware. + + This middleware serves static files from a document root + directory. It handles Content-Type detection, caching + headers, conditional requests, and range requests. + + @par Example + @code + router r; + r.use( serve_static( "/var/www/static" ) ); + @endcode + + @see serve_static_options, dotfiles_policy +*/ +class BOOST_HTTP_DECL serve_static +{ + struct impl; + impl* impl_; + +public: + /** Destructor. + */ + ~serve_static(); + + /** Construct with document root and default options. + + @param root The document root path. + */ + explicit serve_static(core::string_view root); + + /** Construct with document root and options. + + @param root The document root path. + + @param opts Configuration options. + */ + serve_static( + core::string_view root, + serve_static_options const& opts); + + /** Move constructor. + */ + serve_static(serve_static&& other) noexcept; + + /** Handle a request. + + Attempts to serve the requested file from the document root. + Sets appropriate headers and streams the file content. + + @param rp The route parameters. + + @return A task that completes with the routing result. + */ + route_task operator()(route_params& rp) const; +}; + +} // http +} // boost + +#endif diff --git a/include/boost/http/server/statuses.hpp b/include/boost/http/server/statuses.hpp new file mode 100644 index 00000000..d6726356 --- /dev/null +++ b/include/boost/http/server/statuses.hpp @@ -0,0 +1,109 @@ +// +// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/http +// + +#ifndef BOOST_HTTP_SERVER_STATUSES_HPP +#define BOOST_HTTP_SERVER_STATUSES_HPP + +#include +#include + +namespace boost { +namespace http { + +/** HTTP status code utilities. + + This namespace provides utility functions for working with + HTTP status codes beyond the basic enum and string conversion. + These utilities help determine the semantic meaning of status + codes for response handling. + + @par Example + @code + // Check if a response should have no body + if( statuses::is_empty( 204 ) ) + { + // Don't send Content-Length or body + } + + // Check if client should retry + if( statuses::is_retry( 503 ) ) + { + // Schedule retry with backoff + } + @endcode + + @see http::status, http::status_class +*/ +namespace statuses { + +/** Check if a status code indicates an empty response body. + + Responses with these status codes must not include a + message body. This includes: + + @li 204 No Content + @li 205 Reset Content + @li 304 Not Modified + + @param code The HTTP status code to check. + + @return `true` if responses with this code should have + no body, `false` otherwise. +*/ +BOOST_HTTP_DECL +bool +is_empty(unsigned code) noexcept; + +/** Check if a status code indicates a redirect. + + Returns `true` for status codes that indicate the client + should redirect to a different URL. This includes: + + @li 300 Multiple Choices + @li 301 Moved Permanently + @li 302 Found + @li 303 See Other + @li 305 Use Proxy + @li 307 Temporary Redirect + @li 308 Permanent Redirect + + Note: 304 Not Modified is not considered a redirect. + + @param code The HTTP status code to check. + + @return `true` if the code indicates a redirect, + `false` otherwise. +*/ +BOOST_HTTP_DECL +bool +is_redirect(unsigned code) noexcept; + +/** Check if a status code suggests the request may be retried. + + Returns `true` for status codes that indicate a temporary + condition where retrying the request may succeed. This includes: + + @li 502 Bad Gateway + @li 503 Service Unavailable + @li 504 Gateway Timeout + + @param code The HTTP status code to check. + + @return `true` if the request may be retried, + `false` otherwise. +*/ +BOOST_HTTP_DECL +bool +is_retry(unsigned code) noexcept; + +} // statuses +} // http +} // boost + +#endif diff --git a/src/server/cors.cpp b/src/server/cors.cpp index 1a2f923e..02f73558 100644 --- a/src/server/cors.cpp +++ b/src/server/cors.cpp @@ -25,35 +25,34 @@ namespace { struct Vary { - Vary(route_params& p) - : p_(p) + Vary(route_params& rp) + : rp_(rp) { } void set(field f, core::string_view s) { - p_.res.set(f, s); + rp_.res.set(f, s); } void append(field f, core::string_view v) { - auto it = p_.res.find(f); - if (it != p_.res.end()) + auto it = rp_.res.find(f); + if(it != rp_.res.end()) { std::string s = it->value; s += ", "; s += v; - p_.res.set(it, s); + rp_.res.set(it, s); } else { - p_.res.set(f, v); + rp_.res.set(f, v); } } private: - route_params& p_; - std::string v_; + route_params& rp_; }; } // (anon) @@ -109,7 +108,7 @@ static void setCredentials( // Access-Control-Allowed-Headers static void setAllowedHeaders( Vary& v, - route_params const& p, + route_params const& rp, cors_options const& options) { if(! options.allowedHeaders.empty()) @@ -119,7 +118,7 @@ static void setAllowedHeaders( options.allowedHeaders); return; } - auto s = p.res.value_or( + auto s = rp.req.value_or( field::access_control_request_headers, ""); if(! s.empty()) { @@ -153,37 +152,35 @@ static void setMaxAge( options.max_age.count())); } -route_result +route_task cors:: operator()( - route_params& p) const + route_params& rp) const { - Vary v(p); - if(p.req.method() == - method::options) + Vary v(rp); + if(rp.req.method() == method::options) { // preflight - setOrigin(v, p, options_); + setOrigin(v, rp, options_); setMethods(v, options_); setCredentials(v, options_); - setAllowedHeaders(v, p, options_); + setAllowedHeaders(v, rp, options_); setMaxAge(v, options_); setExposeHeaders(v, options_); if(options_.preFlightContinue) - return route::next; + co_return route::next; + // Safari and others need this for 204 or may hang - p.res.set_status(options_.result); - p.res.set_content_length(0); - p.serializer.start(p.res); - // VFALCO FIXME - return {};//route::send; + rp.res.set_status(options_.result); + co_return co_await rp.send(""); } + // actual response - setOrigin(v, p, options_); + setOrigin(v, rp, options_); setCredentials(v, options_); setExposeHeaders(v, options_); - return route::next; + co_return route::next; } } // http diff --git a/src/server/encode_url.cpp b/src/server/encode_url.cpp new file mode 100644 index 00000000..8cb4885c --- /dev/null +++ b/src/server/encode_url.cpp @@ -0,0 +1,82 @@ +// +// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/http +// + +#include + +namespace boost { +namespace http { + +namespace { + +// Check if character needs encoding +// Unreserved + reserved chars that are allowed in URLs +bool +is_safe( char c ) noexcept +{ + // Unreserved: A-Z a-z 0-9 - _ . ~ + if( ( c >= 'A' && c <= 'Z' ) || + ( c >= 'a' && c <= 'z' ) || + ( c >= '0' && c <= '9' ) || + c == '-' || c == '_' || c == '.' || c == '~' ) + return true; + + // Reserved chars allowed in URLs: ! # $ & ' ( ) * + , / : ; = ? @ + switch( c ) + { + case '!': + case '#': + case '$': + case '&': + case '\'': + case '(': + case ')': + case '*': + case '+': + case ',': + case '/': + case ':': + case ';': + case '=': + case '?': + case '@': + return true; + default: + return false; + } +} + +constexpr char hex_chars[] = "0123456789ABCDEF"; + +} // (anon) + +std::string +encode_url( core::string_view url ) +{ + std::string result; + result.reserve( url.size() ); + + for( unsigned char c : url ) + { + if( is_safe( static_cast( c ) ) ) + { + result.push_back( static_cast( c ) ); + } + else + { + result.push_back( '%' ); + result.push_back( hex_chars[c >> 4] ); + result.push_back( hex_chars[c & 0x0F] ); + } + } + + return result; +} + +} // http +} // boost diff --git a/src/server/escape_html.cpp b/src/server/escape_html.cpp new file mode 100644 index 00000000..0c1b2f39 --- /dev/null +++ b/src/server/escape_html.cpp @@ -0,0 +1,50 @@ +// +// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/http +// + +#include + +namespace boost { +namespace http { + +std::string +escape_html( core::string_view s ) +{ + std::string result; + result.reserve( s.size() ); + + for( char c : s ) + { + switch( c ) + { + case '&': + result.append( "&" ); + break; + case '<': + result.append( "<" ); + break; + case '>': + result.append( ">" ); + break; + case '"': + result.append( """ ); + break; + case '\'': + result.append( "'" ); + break; + default: + result.push_back( c ); + break; + } + } + + return result; +} + +} // http +} // boost diff --git a/src/server/etag.cpp b/src/server/etag.cpp new file mode 100644 index 00000000..85d7cfff --- /dev/null +++ b/src/server/etag.cpp @@ -0,0 +1,97 @@ +// +// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/http +// + +#include +#include + +namespace boost { +namespace http { + +namespace { + +// Simple FNV-1a hash for content +std::uint64_t +fnv1a_hash( core::string_view data ) noexcept +{ + constexpr std::uint64_t basis = 14695981039346656037ULL; + constexpr std::uint64_t prime = 1099511628211ULL; + + std::uint64_t hash = basis; + for( unsigned char c : data ) + { + hash ^= c; + hash *= prime; + } + return hash; +} + +// Convert to hex string +void +to_hex( std::uint64_t value, char* out ) noexcept +{ + constexpr char hex[] = "0123456789abcdef"; + for( int i = 15; i >= 0; --i ) + { + out[i] = hex[value & 0xF]; + value >>= 4; + } +} + +} // (anon) + +std::string +etag( core::string_view body, etag_options opts ) +{ + auto const hash = fnv1a_hash( body ); + + std::string result; + if( opts.weak ) + result = "W/\""; + else + result = "\""; + + // Format: size-hash + char buf[32]; + std::snprintf( buf, sizeof( buf ), "%zx-", body.size() ); + result.append( buf ); + + char hex[16]; + to_hex( hash, hex ); + result.append( hex, 16 ); + result.push_back( '"' ); + + return result; +} + +std::string +etag( + std::uint64_t size, + std::uint64_t mtime, + etag_options opts ) +{ + std::string result; + if( opts.weak ) + result = "W/\""; + else + result = "\""; + + // Format: size-mtime (hex) + char buf[64]; + std::snprintf( buf, sizeof( buf ), + "%llx-%llx", + static_cast( size ), + static_cast( mtime ) ); + result.append( buf ); + result.push_back( '"' ); + + return result; +} + +} // http +} // boost diff --git a/src/server/fresh.cpp b/src/server/fresh.cpp new file mode 100644 index 00000000..2b15b6bf --- /dev/null +++ b/src/server/fresh.cpp @@ -0,0 +1,119 @@ +// +// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/http +// + +#include +#include + +namespace boost { +namespace http { + +namespace { + +// Check if ETag matches If-None-Match +// Returns true if they match (response is fresh) +bool +etag_matches( + core::string_view if_none_match, + core::string_view etag ) noexcept +{ + if( if_none_match.empty() || etag.empty() ) + return false; + + // "*" matches any ETag + if( if_none_match == "*" ) + return true; + + // Simple comparison - check if ETag appears in the list + // In full implementation, would need to handle weak vs strong + // and parse comma-separated list properly + + // Remove W/ prefix for comparison if present + auto strip_weak = []( core::string_view s ) -> core::string_view + { + if( s.size() >= 2 && + ( s[0] == 'W' || s[0] == 'w' ) && + s[1] == '/' ) + return s.substr( 2 ); + return s; + }; + + auto const etag_val = strip_weak( etag ); + + // Simple contains check for the ETag value + auto pos = if_none_match.find( etag_val ); + if( pos != core::string_view::npos ) + return true; + + // Also check without weak prefix in if_none_match + auto const inm_stripped = strip_weak( if_none_match ); + if( inm_stripped == etag_val ) + return true; + + return false; +} + +// Parse HTTP date and compare +// Returns true if response's Last-Modified <= request's If-Modified-Since +// For simplicity, doing string comparison (works for RFC 7231 dates) +bool +not_modified_since( + core::string_view if_modified_since, + core::string_view last_modified ) noexcept +{ + if( if_modified_since.empty() || last_modified.empty() ) + return false; + + // HTTP dates in RFC 7231 format are lexicographically comparable + // when in the same format (preferred format) + // For a robust implementation, would parse dates properly + return last_modified <= if_modified_since; +} + +} // (anon) + +bool +is_fresh( + request const& req, + response const& res ) noexcept +{ + // Get conditional request headers + auto const if_none_match = req.value_or( + field::if_none_match, "" ); + auto const if_modified_since = req.value_or( + field::if_modified_since, "" ); + + // If no conditional headers, not fresh + if( if_none_match.empty() && if_modified_since.empty() ) + return false; + + // Get response caching headers + auto const etag = res.value_or( field::etag, "" ); + auto const last_modified = res.value_or( + field::last_modified, "" ); + + // Check ETag first (stronger validator) + if( ! if_none_match.empty() ) + { + if( ! etag.empty() && etag_matches( if_none_match, etag ) ) + return true; + // If If-None-Match present but doesn't match, not fresh + return false; + } + + // Fall back to If-Modified-Since + if( ! if_modified_since.empty() && ! last_modified.empty() ) + { + return not_modified_since( if_modified_since, last_modified ); + } + + return false; +} + +} // http +} // boost diff --git a/src/server/mime_db.cpp b/src/server/mime_db.cpp new file mode 100644 index 00000000..17ac57c7 --- /dev/null +++ b/src/server/mime_db.cpp @@ -0,0 +1,121 @@ +// +// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/http +// + +#include +#include +#include + +namespace boost { +namespace http { +namespace mime_db { + +namespace { + +// Static database of common MIME types +// Sorted by type for binary search +constexpr mime_type_entry db[] = { + { "application/gzip", "", false }, + { "application/javascript", "UTF-8", true }, + { "application/json", "UTF-8", true }, + { "application/octet-stream", "", false }, + { "application/pdf", "", false }, + { "application/rtf", "UTF-8", true }, + { "application/wasm", "", false }, + { "application/x-7z-compressed", "", false }, + { "application/x-bzip", "", false }, + { "application/x-bzip2", "", false }, + { "application/x-tar", "", false }, + { "application/xhtml+xml", "UTF-8", true }, + { "application/xml", "UTF-8", true }, + { "application/zip", "", false }, + { "audio/aac", "", false }, + { "audio/flac", "", false }, + { "audio/mp4", "", false }, + { "audio/mpeg", "", false }, + { "audio/ogg", "", false }, + { "audio/wav", "", false }, + { "audio/webm", "", false }, + { "font/otf", "", false }, + { "font/ttf", "", false }, + { "font/woff", "", false }, + { "font/woff2", "", false }, + { "image/avif", "", false }, + { "image/bmp", "", false }, + { "image/gif", "", false }, + { "image/jpeg", "", false }, + { "image/png", "", false }, + { "image/svg+xml", "UTF-8", true }, + { "image/tiff", "", false }, + { "image/webp", "", false }, + { "image/x-icon", "", false }, + { "text/cache-manifest", "UTF-8", true }, + { "text/calendar", "UTF-8", true }, + { "text/css", "UTF-8", true }, + { "text/csv", "UTF-8", true }, + { "text/html", "UTF-8", true }, + { "text/javascript", "UTF-8", true }, + { "text/markdown", "UTF-8", true }, + { "text/plain", "UTF-8", true }, + { "text/xml", "UTF-8", true }, + { "video/mp4", "", false }, + { "video/mpeg", "", false }, + { "video/ogg", "", false }, + { "video/webm", "", false }, +}; + +constexpr std::size_t db_size = sizeof( db ) / sizeof( db[0] ); + +// Case-insensitive comparison +int +compare_icase( core::string_view a, core::string_view b ) noexcept +{ + auto const n = ( std::min )( a.size(), b.size() ); + for( std::size_t i = 0; i < n; ++i ) + { + auto const ca = static_cast( + std::tolower( static_cast( a[i] ) ) ); + auto const cb = static_cast( + std::tolower( static_cast( b[i] ) ) ); + if( ca < cb ) + return -1; + if( ca > cb ) + return 1; + } + if( a.size() < b.size() ) + return -1; + if( a.size() > b.size() ) + return 1; + return 0; +} + +} // (anon) + +mime_type_entry const* +lookup( core::string_view type ) noexcept +{ + // Binary search + std::size_t lo = 0; + std::size_t hi = db_size; + while( lo < hi ) + { + auto const mid = lo + ( hi - lo ) / 2; + auto const cmp = compare_icase( db[mid].type, type ); + if( cmp < 0 ) + lo = mid + 1; + else if( cmp > 0 ) + hi = mid; + else + return &db[mid]; + } + return nullptr; +} + +} // mime_db +} // http +} // boost diff --git a/src/server/mime_types.cpp b/src/server/mime_types.cpp new file mode 100644 index 00000000..125bf1c3 --- /dev/null +++ b/src/server/mime_types.cpp @@ -0,0 +1,210 @@ +// +// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/http +// + +#include +#include +#include +#include + +namespace boost { +namespace http { +namespace mime_types { + +namespace { + +struct ext_entry +{ + core::string_view ext; + core::string_view type; +}; + +// Sorted by extension for binary search +constexpr ext_entry ext_db[] = { + { "aac", "audio/aac" }, + { "avif", "image/avif" }, + { "bmp", "image/bmp" }, + { "bz", "application/x-bzip" }, + { "bz2", "application/x-bzip2" }, + { "cjs", "application/javascript" }, + { "css", "text/css" }, + { "csv", "text/csv" }, + { "flac", "audio/flac" }, + { "gif", "image/gif" }, + { "gz", "application/gzip" }, + { "htm", "text/html" }, + { "html", "text/html" }, + { "ico", "image/x-icon" }, + { "ics", "text/calendar" }, + { "jpeg", "image/jpeg" }, + { "jpg", "image/jpeg" }, + { "js", "text/javascript" }, + { "json", "application/json" }, + { "m4a", "audio/mp4" }, + { "m4v", "video/mp4" }, + { "manifest", "text/cache-manifest" }, + { "md", "text/markdown" }, + { "mjs", "text/javascript" }, + { "mp3", "audio/mpeg" }, + { "mp4", "video/mp4" }, + { "mpeg", "video/mpeg" }, + { "mpg", "video/mpeg" }, + { "oga", "audio/ogg" }, + { "ogg", "audio/ogg" }, + { "ogv", "video/ogg" }, + { "otf", "font/otf" }, + { "pdf", "application/pdf" }, + { "png", "image/png" }, + { "rtf", "application/rtf" }, + { "svg", "image/svg+xml" }, + { "tar", "application/x-tar" }, + { "tif", "image/tiff" }, + { "tiff", "image/tiff" }, + { "ttf", "font/ttf" }, + { "txt", "text/plain" }, + { "wasm", "application/wasm" }, + { "wav", "audio/wav" }, + { "weba", "audio/webm" }, + { "webm", "video/webm" }, + { "webp", "image/webp" }, + { "woff", "font/woff" }, + { "woff2", "font/woff2" }, + { "xhtml", "application/xhtml+xml" }, + { "xml", "application/xml" }, + { "zip", "application/zip" }, + { "7z", "application/x-7z-compressed" }, +}; + +constexpr std::size_t ext_db_size = sizeof( ext_db ) / sizeof( ext_db[0] ); + +// Case-insensitive comparison +int +compare_icase( core::string_view a, core::string_view b ) noexcept +{ + auto const n = ( std::min )( a.size(), b.size() ); + for( std::size_t i = 0; i < n; ++i ) + { + auto const ca = static_cast( + std::tolower( static_cast( a[i] ) ) ); + auto const cb = static_cast( + std::tolower( static_cast( b[i] ) ) ); + if( ca < cb ) + return -1; + if( ca > cb ) + return 1; + } + if( a.size() < b.size() ) + return -1; + if( a.size() > b.size() ) + return 1; + return 0; +} + +// Extract extension from path +core::string_view +get_extension( core::string_view path ) noexcept +{ + // Find last dot + auto const pos = path.rfind( '.' ); + if( pos == core::string_view::npos ) + return path; // Assume it's just an extension + return path.substr( pos + 1 ); +} + +// Binary search for extension +core::string_view +lookup_ext( core::string_view ext ) noexcept +{ + std::size_t lo = 0; + std::size_t hi = ext_db_size; + while( lo < hi ) + { + auto const mid = lo + ( hi - lo ) / 2; + auto const cmp = compare_icase( ext_db[mid].ext, ext ); + if( cmp < 0 ) + lo = mid + 1; + else if( cmp > 0 ) + hi = mid; + else + return ext_db[mid].type; + } + return {}; +} + +} // (anon) + +core::string_view +lookup( core::string_view path_or_ext ) noexcept +{ + if( path_or_ext.empty() ) + return {}; + + // Skip leading dot if present + if( path_or_ext[0] == '.' ) + path_or_ext.remove_prefix( 1 ); + + auto const ext = get_extension( path_or_ext ); + return lookup_ext( ext ); +} + +core::string_view +extension( core::string_view type ) noexcept +{ + // Linear search for type -> extension + // Could optimize with reverse map if needed + for( std::size_t i = 0; i < ext_db_size; ++i ) + { + if( compare_icase( ext_db[i].type, type ) == 0 ) + return ext_db[i].ext; + } + return {}; +} + +core::string_view +charset( core::string_view type ) noexcept +{ + auto const* entry = mime_db::lookup( type ); + if( entry ) + return entry->charset; + return {}; +} + +std::string +content_type( core::string_view type_or_ext ) +{ + core::string_view type; + + // Check if it looks like an extension + if( ! type_or_ext.empty() && + ( type_or_ext[0] == '.' || + type_or_ext.find( '/' ) == core::string_view::npos ) ) + { + type = lookup( type_or_ext ); + if( type.empty() ) + return {}; + } + else + { + type = type_or_ext; + } + + auto const cs = charset( type ); + if( cs.empty() ) + return std::string( type ); + + std::string result; + result.reserve( type.size() + 10 + cs.size() ); + result.append( type.data(), type.size() ); + result.append( "; charset=" ); + result.append( cs.data(), cs.size() ); + return result; +} + +} // mime_types +} // http +} // boost diff --git a/src/server/range_parser.cpp b/src/server/range_parser.cpp new file mode 100644 index 00000000..e88f2fe9 --- /dev/null +++ b/src/server/range_parser.cpp @@ -0,0 +1,191 @@ +// +// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/http +// + +#include +#include +#include +#include + +namespace boost { +namespace http { + +namespace { + +// Skip whitespace +void +skip_ws( core::string_view& s ) noexcept +{ + while( ! s.empty() && std::isspace( + static_cast( s.front() ) ) ) + s.remove_prefix( 1 ); +} + +// Parse integer +bool +parse_int( core::string_view& s, std::int64_t& out ) noexcept +{ + skip_ws( s ); + if( s.empty() ) + return false; + + auto const* begin = s.data(); + auto const* end = s.data() + s.size(); + auto [ptr, ec] = std::from_chars( begin, end, out ); + if( ec != std::errc() || ptr == begin ) + return false; + + s.remove_prefix( static_cast( ptr - begin ) ); + return true; +} + +// Parse a single range spec: "start-end" or "-suffix" or "start-" +bool +parse_range_spec( + core::string_view& s, + std::int64_t size, + byte_range& out ) +{ + skip_ws( s ); + if( s.empty() ) + return false; + + std::int64_t start = -1; + std::int64_t end = -1; + + // Check for suffix range: "-suffix" + if( s.front() == '-' ) + { + s.remove_prefix( 1 ); + std::int64_t suffix; + if( ! parse_int( s, suffix ) || suffix < 0 ) + return false; + + // Last 'suffix' bytes + if( suffix == 0 ) + return false; + + if( suffix > size ) + suffix = size; + + out.start = size - suffix; + out.end = size - 1; + return true; + } + + // Parse start + if( ! parse_int( s, start ) || start < 0 ) + return false; + + skip_ws( s ); + if( s.empty() || s.front() != '-' ) + return false; + + s.remove_prefix( 1 ); // consume '-' + + // Check for "start-" (open-ended) + skip_ws( s ); + if( s.empty() || s.front() == ',' ) + { + // Open-ended: start to end of file + out.start = start; + out.end = size - 1; + return start < size; + } + + // Parse end + if( ! parse_int( s, end ) || end < 0 ) + return false; + + // Validate + if( start > end ) + return false; + + out.start = start; + out.end = ( std::min )( end, size - 1 ); + + return start < size; +} + +} // (anon) + +range_result +parse_range( std::int64_t size, core::string_view header ) +{ + range_result result; + result.type = range_result_type::malformed; + + if( size <= 0 ) + { + result.type = range_result_type::unsatisfiable; + return result; + } + + // Must start with "bytes=" + skip_ws( header ); + if( header.size() < 6 ) + return result; + + // Case-insensitive "bytes=" check + auto prefix = header.substr( 0, 6 ); + bool is_bytes = true; + for( std::size_t i = 0; i < 5; ++i ) + { + char c = static_cast( std::tolower( + static_cast( prefix[i] ) ) ); + if( c != "bytes"[i] ) + { + is_bytes = false; + break; + } + } + if( ! is_bytes || prefix[5] != '=' ) + return result; + + header.remove_prefix( 6 ); + + // Parse range specs + bool any_satisfiable = false; + + while( ! header.empty() ) + { + skip_ws( header ); + if( header.empty() ) + break; + + byte_range range; + if( parse_range_spec( header, size, range ) ) + { + result.ranges.push_back( range ); + any_satisfiable = true; + } + + skip_ws( header ); + if( ! header.empty() ) + { + if( header.front() == ',' ) + header.remove_prefix( 1 ); + else + break; // Invalid + } + } + + if( result.ranges.empty() ) + { + result.type = range_result_type::unsatisfiable; + } + else if( any_satisfiable ) + { + result.type = range_result_type::ok; + } + + return result; +} + +} // http +} // boost diff --git a/src/server/send_file.cpp b/src/server/send_file.cpp new file mode 100644 index 00000000..cf8dc1e9 --- /dev/null +++ b/src/server/send_file.cpp @@ -0,0 +1,196 @@ +// +// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/http +// + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace boost { +namespace http { + +namespace { + +// Get file stats +bool +get_file_stats( + core::string_view path, + std::uint64_t& size, + std::uint64_t& mtime) +{ + std::error_code ec; + std::filesystem::path p(path.begin(), path.end()); + + auto status = std::filesystem::status(p, ec); + if(ec || ! std::filesystem::is_regular_file(status)) + return false; + + size = static_cast( + std::filesystem::file_size(p, ec)); + if(ec) + return false; + + auto ftime = std::filesystem::last_write_time(p, ec); + if(ec) + return false; + + // Convert to Unix timestamp + auto const sctp = std::chrono::time_point_cast< + std::chrono::system_clock::duration>( + ftime - std::filesystem::file_time_type::clock::now() + + std::chrono::system_clock::now()); + mtime = static_cast( + std::chrono::system_clock::to_time_t(sctp)); + + return true; +} + +} // (anon) + +std::string +format_http_date(std::uint64_t mtime) +{ + std::time_t t = static_cast(mtime); + std::tm tm; +#ifdef _WIN32 + gmtime_s(&tm, &t); +#else + gmtime_r(&t, &tm); +#endif + + char buf[64]; + std::strftime(buf, sizeof(buf), + "%a, %d %b %Y %H:%M:%S GMT", &tm); + return std::string(buf); +} + +void +send_file_init( + send_file_info& info, + route_params& rp, + core::string_view path, + send_file_options const& opts) +{ + info = send_file_info{}; + + // Get file stats + if(! get_file_stats(path, info.size, info.mtime)) + { + info.result = send_file_result::not_found; + return; + } + + // Determine content type + if(! opts.content_type.empty()) + { + info.content_type = opts.content_type; + } + else + { + auto ct = mime_types::content_type(path); + if(ct.empty()) + ct = "application/octet-stream"; + info.content_type = std::move(ct); + } + + // Generate ETag if enabled + if(opts.etag) + { + info.etag = etag(info.size, info.mtime); + rp.res.set(field::etag, info.etag); + } + + // Set Last-Modified if enabled + if(opts.last_modified) + { + info.last_modified = format_http_date(info.mtime); + rp.res.set(field::last_modified, info.last_modified); + } + + // Set Cache-Control + if(opts.max_age > 0) + { + std::string cc = "public, max-age=" + + std::to_string(opts.max_age); + rp.res.set(field::cache_control, cc); + } + + // Check freshness (conditional GET) + if(is_fresh(rp.req, rp.res)) + { + info.result = send_file_result::not_modified; + return; + } + + // Set Content-Type + rp.res.set(field::content_type, info.content_type); + + // Handle Range header + auto range_header = rp.req.value_or(field::range, ""); + if(! range_header.empty()) + { + auto range_result = parse_range( + static_cast(info.size), + range_header); + + if(range_result.type == range_result_type::ok && + ! range_result.ranges.empty()) + { + // Use first range only (simplification) + auto const& range = range_result.ranges[0]; + info.is_range = true; + info.range_start = range.start; + info.range_end = range.end; + + // Set 206 Partial Content + rp.res.set_status(status::partial_content); + + auto const content_length = + range.end - range.start + 1; + rp.res.set_payload_size( + static_cast(content_length)); + + // Content-Range header + std::string cr = "bytes " + + std::to_string(range.start) + "-" + + std::to_string(range.end) + "/" + + std::to_string(info.size); + rp.res.set(field::content_range, cr); + + info.result = send_file_result::ok; + return; + } + + if(range_result.type == range_result_type::unsatisfiable) + { + rp.res.set_status( + status::range_not_satisfiable); + rp.res.set(field::content_range, + "bytes */" + std::to_string(info.size)); + info.result = send_file_result::error; + return; + } + // If malformed, ignore and serve full content + } + + // Full content response + rp.res.set_status(status::ok); + rp.res.set_payload_size(info.size); + info.range_start = 0; + info.range_end = static_cast(info.size) - 1; + info.result = send_file_result::ok; +} + +} // http +} // boost diff --git a/src/server/serve_static.cpp b/src/server/serve_static.cpp new file mode 100644 index 00000000..4e4b7cd1 --- /dev/null +++ b/src/server/serve_static.cpp @@ -0,0 +1,279 @@ +// +// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/http +// + +#include +#include +#include +#include +#include +#include +#include + +namespace boost { +namespace http { + +namespace { + +// Append an HTTP rel-path to a local filesystem path. +void +path_cat( + std::string& result, + core::string_view prefix, + core::string_view suffix) +{ + result = prefix; + +#ifdef BOOST_MSVC + char constexpr path_separator = '\\'; +#else + char constexpr path_separator = '/'; +#endif + if(! result.empty() && result.back() == path_separator) + result.resize(result.size() - 1); + +#ifdef BOOST_MSVC + for(auto& c : result) + if(c == '/') + c = path_separator; +#endif + for(auto const& c : suffix) + { + if(c == '/') + result.push_back(path_separator); + else + result.push_back(c); + } +} + +// Check if path segment is a dotfile +bool +is_dotfile(core::string_view path) noexcept +{ + auto pos = path.rfind('/'); + if(pos == core::string_view::npos) + pos = 0; + else + ++pos; + + if(pos < path.size() && path[pos] == '.') + return true; + + return false; +} + +} // (anon) + +struct serve_static::impl +{ + std::string root; + serve_static_options opts; + + impl( + core::string_view root_, + serve_static_options const& opts_) + : root(root_) + , opts(opts_) + { + } +}; + +serve_static:: +~serve_static() +{ + delete impl_; +} + +serve_static:: +serve_static(core::string_view root) + : serve_static(root, serve_static_options{}) +{ +} + +serve_static:: +serve_static( + core::string_view root, + serve_static_options const& opts) + : impl_(new impl(root, opts)) +{ +} + +serve_static:: +serve_static(serve_static&& other) noexcept + : impl_(other.impl_) +{ + other.impl_ = nullptr; +} + +route_task +serve_static:: +operator()(route_params& rp) const +{ + // Only handle GET and HEAD + if(rp.req.method() != method::get && + rp.req.method() != method::head) + { + if(impl_->opts.fallthrough) + co_return route::next; + + rp.res.set_status(status::method_not_allowed); + rp.res.set(field::allow, "GET, HEAD"); + co_return co_await rp.send(""); + } + + // Get the request path + auto req_path = rp.url.path(); + + // Check for dotfiles + if(is_dotfile(req_path)) + { + switch(impl_->opts.dotfiles) + { + case dotfiles_policy::deny: + rp.res.set_status(status::forbidden); + co_return co_await rp.send("Forbidden"); + + case dotfiles_policy::ignore: + if(impl_->opts.fallthrough) + co_return route::next; + rp.res.set_status(status::not_found); + co_return co_await rp.send("Not Found"); + + case dotfiles_policy::allow: + break; + } + } + + // Build the file path + std::string path; + path_cat(path, impl_->root, req_path); + + // Check if it's a directory + std::error_code fec; + bool is_dir = std::filesystem::is_directory(path, fec); + if(is_dir && ! fec) + { + // Check for trailing slash + if(req_path.empty() || req_path.back() != '/') + { + if(impl_->opts.redirect) + { + // Redirect to add trailing slash + std::string location(req_path); + location += '/'; + rp.res.set_status(status::moved_permanently); + rp.res.set(field::location, location); + co_return co_await rp.send(""); + } + } + + // Try index file + if(impl_->opts.index) + { +#ifdef BOOST_MSVC + path += "\\index.html"; +#else + path += "/index.html"; +#endif + } + } + + // Prepare file response using send_file utilities + send_file_options opts; + opts.etag = impl_->opts.etag; + opts.last_modified = impl_->opts.last_modified; + opts.max_age = impl_->opts.max_age; + + send_file_info info; + send_file_init(info, rp, path, opts); + + // Handle result + switch(info.result) + { + case send_file_result::not_found: + if(impl_->opts.fallthrough) + co_return route::next; + rp.res.set_status(status::not_found); + co_return co_await rp.send("Not Found"); + + case send_file_result::not_modified: + rp.res.set_status(status::not_modified); + co_return co_await rp.send(""); + + case send_file_result::error: + // Range error - headers already set by send_file_init + co_return co_await rp.send(""); + + case send_file_result::ok: + break; + } + + // Set Accept-Ranges if enabled + if(impl_->opts.accept_ranges) + rp.res.set(field::accept_ranges, "bytes"); + + // Set Cache-Control with immutable if configured + if(impl_->opts.immutable && opts.max_age > 0) + { + std::string cc = "public, max-age=" + + std::to_string(opts.max_age) + ", immutable"; + rp.res.set(field::cache_control, cc); + } + + // For HEAD requests, don't send body + if(rp.req.method() == method::head) + co_return co_await rp.send(""); + + // Open and stream the file + capy::file f; + system::error_code ec; + f.open(path.c_str(), capy::file_mode::scan, ec); + if(ec) + { + if(impl_->opts.fallthrough) + co_return route::next; + rp.res.set_status(status::internal_server_error); + co_return co_await rp.send("Internal Server Error"); + } + + // Seek to range start if needed + if(info.is_range && info.range_start > 0) + { + f.seek(static_cast(info.range_start), ec); + if(ec) + { + rp.res.set_status(status::internal_server_error); + co_return co_await rp.send("Internal Server Error"); + } + } + + // Calculate how much to send + std::int64_t remaining = info.range_end - info.range_start + 1; + + // Stream file content + constexpr std::size_t buf_size = 16384; + char buffer[buf_size]; + + while(remaining > 0) + { + auto const to_read = static_cast( + (std::min)(remaining, static_cast(buf_size))); + + auto const n = f.read(buffer, to_read, ec); + if(ec || n == 0) + break; + + co_await rp.write(capy::const_buffer(buffer, n)); + remaining -= static_cast(n); + } + + co_return co_await rp.end(); +} + +} // http +} // boost diff --git a/src/server/statuses.cpp b/src/server/statuses.cpp new file mode 100644 index 00000000..cb8cd484 --- /dev/null +++ b/src/server/statuses.cpp @@ -0,0 +1,64 @@ +// +// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/http +// + +#include + +namespace boost { +namespace http { +namespace statuses { + +bool +is_empty( unsigned code ) noexcept +{ + switch( code ) + { + case 204: // No Content + case 205: // Reset Content + case 304: // Not Modified + return true; + default: + return false; + } +} + +bool +is_redirect( unsigned code ) noexcept +{ + switch( code ) + { + case 300: // Multiple Choices + case 301: // Moved Permanently + case 302: // Found + case 303: // See Other + case 305: // Use Proxy + case 307: // Temporary Redirect + case 308: // Permanent Redirect + return true; + default: + return false; + } +} + +bool +is_retry( unsigned code ) noexcept +{ + switch( code ) + { + case 502: // Bad Gateway + case 503: // Service Unavailable + case 504: // Gateway Timeout + return true; + default: + return false; + } +} + +} // statuses +} // http +} // boost diff --git a/test/unit/server/serve_static.cpp b/test/unit/server/serve_static.cpp new file mode 100644 index 00000000..159c1d81 --- /dev/null +++ b/test/unit/server/serve_static.cpp @@ -0,0 +1,11 @@ +// +// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/http +// + +// Test that header file is self-contained. +#include diff --git a/test/unit/server/statuses.cpp b/test/unit/server/statuses.cpp new file mode 100644 index 00000000..9287d3ad --- /dev/null +++ b/test/unit/server/statuses.cpp @@ -0,0 +1,79 @@ +// +// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/http +// + +// Test that header file is self-contained. +#include + +#include "test_suite.hpp" + +namespace boost { +namespace http { + +struct statuses_test +{ + void + test_is_empty() + { + // Empty body status codes + BOOST_TEST( statuses::is_empty( 204 ) ); + BOOST_TEST( statuses::is_empty( 205 ) ); + BOOST_TEST( statuses::is_empty( 304 ) ); + + // Non-empty status codes + BOOST_TEST( ! statuses::is_empty( 200 ) ); + BOOST_TEST( ! statuses::is_empty( 404 ) ); + BOOST_TEST( ! statuses::is_empty( 500 ) ); + } + + void + test_is_redirect() + { + // Redirect status codes + BOOST_TEST( statuses::is_redirect( 300 ) ); + BOOST_TEST( statuses::is_redirect( 301 ) ); + BOOST_TEST( statuses::is_redirect( 302 ) ); + BOOST_TEST( statuses::is_redirect( 303 ) ); + BOOST_TEST( statuses::is_redirect( 307 ) ); + BOOST_TEST( statuses::is_redirect( 308 ) ); + + // Not redirects + BOOST_TEST( ! statuses::is_redirect( 200 ) ); + BOOST_TEST( ! statuses::is_redirect( 304 ) ); // Not Modified is not a redirect + BOOST_TEST( ! statuses::is_redirect( 404 ) ); + } + + void + test_is_retry() + { + // Retryable status codes + BOOST_TEST( statuses::is_retry( 502 ) ); + BOOST_TEST( statuses::is_retry( 503 ) ); + BOOST_TEST( statuses::is_retry( 504 ) ); + + // Not retryable + BOOST_TEST( ! statuses::is_retry( 200 ) ); + BOOST_TEST( ! statuses::is_retry( 500 ) ); + BOOST_TEST( ! statuses::is_retry( 501 ) ); + } + + void + run() + { + test_is_empty(); + test_is_redirect(); + test_is_retry(); + } +}; + +TEST_SUITE( + statuses_test, + "boost.http.server.statuses"); + +} // http +} // boost