diff --git a/README.md b/README.md index 8edcf2909f..1bdd0bbf96 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Crow is a C++ framework for creating HTTP or Websocket web services. It uses rou - Middleware support for extensions. - HTTP/1.1 and Websocket support. - Multi-part request and response support. + - Request body files for large uploads (write to disk while receiving). - Uses modern C++ (11/14) ### Still in development diff --git a/docs/guides/body-file.md b/docs/guides/body-file.md new file mode 100644 index 0000000000..3bfe059472 --- /dev/null +++ b/docs/guides/body-file.md @@ -0,0 +1,60 @@ +[:octicons-feed-tag-16: master](https://github.com/CrowCpp/Crow) + +By default Crow stores a request body in `req.body` (`std::string`). That is +the right default for JSON and form fields, but a large upload then occupies +the same amount of RAM as the file. + +A route can ask Crow to write the body to a unique file **while the bytes +arrive**. `req.body` stays empty; the handler uses `req.body_file_path`. + +This is the request-side counterpart of returning a file with +`response.set_static_file_info()`: the payload is never held as one string. + +## Route option + +```cpp +CROW_ROUTE(app, "/upload") + .methods(crow::HTTPMethod::Put, crow::HTTPMethod::Post) + .body_file()([](const crow::request& req) { + if (!req.has_body_file()) + return crow::response(500); + // req.body is empty; the bytes are in req.body_file_path + std::ifstream in(req.body_file_path, std::ios::binary); + // ... + return crow::response(200); + }); +``` + +Pass a directory to `.body_file()` to override the app default for +that route. Crow always generates a unique file name, so concurrent requests +do not share a path. + +## App options + +```cpp +app.body_file_directory("uploads"); // default: system temp directory +app.max_body_file_size(64ull * 1024 * 1024); // default: 0 = unlimited +``` + +When the written size would exceed `max_body_file_size()`, Crow discards the +partial file, still consumes the rest of the request so keep-alive stays in +sync, and responds `413 Payload Too Large` without running the handler. + +## Lifetime + +The file is deleted after the response is sent. Call `req.keep_body_file()` +if the application will use the file after the handler returns (for example +after renaming it into place). Copy `body_file_path` if another thread will +open it. + +A client that disconnects before the body is complete never reaches the +handler; the partial file is removed. + +## What this is not + +`.body_file()` stores the **raw** request body. `multipart/form-data` is still +parsed from `req.body` by `crow::multipart::message`. Saving individual +multipart parts to disk as they arrive is a separate feature. + +The handler still runs only after the full body has been received. The gain +is bounded memory, not an incremental handler API. diff --git a/docs/guides/routes.md b/docs/guides/routes.md index 4552905fac..15f0bd9d31 100644 --- a/docs/guides/routes.md +++ b/docs/guides/routes.md @@ -82,6 +82,7 @@ Handlers can also use information from the request by adding it as a parameter ` You can also access the URL parameters in the handler using `#!cpp req.url_params.get("param_name");`. If the parameter doesn't exist, `nullptr` is returned.

+To write a large request body to a file while it is received (instead of filling `req.body`), use `#!cpp .body_file()` on the route. See [Request body files](body-file.md). !!! note "Note      [:octicons-feed-tag-16: master](https://github.com/CrowCpp/Crow)" diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index f564ae5323..0d38aca4ad 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -93,6 +93,10 @@ add_executable(example_file_upload example_file_upload.cpp) add_warnings_optimizations(example_file_upload) target_link_libraries(example_file_upload PUBLIC Crow::Crow) +add_executable(example_body_file example_body_file.cpp) +add_warnings_optimizations(example_body_file) +target_link_libraries(example_body_file PUBLIC Crow::Crow) + add_executable(example_unix_socket example_unix_socket.cpp) add_warnings_optimizations(example_unix_socket) target_link_libraries(example_unix_socket PUBLIC Crow::Crow) diff --git a/examples/example_body_file.cpp b/examples/example_body_file.cpp new file mode 100644 index 0000000000..969208c528 --- /dev/null +++ b/examples/example_body_file.cpp @@ -0,0 +1,29 @@ +#include "crow.h" + +#include +#include +#include + +int main() +{ + crow::SimpleApp app; + app.body_file_directory("uploads").max_body_file_size(64ull * 1024 * 1024); + + // PUT or POST the raw body; Crow writes it to disk as the bytes arrive. + CROW_ROUTE(app, "/upload") + .methods(crow::HTTPMethod::Put, crow::HTTPMethod::Post) + .body_file()([](const crow::request& req) { + if (!req.has_body_file()) + return crow::response(500); + + std::ifstream in(req.body_file_path, std::ios::binary); + const std::string contents((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + + crow::json::wvalue reply; + reply["bytes"] = req.body_file_size; + reply["preview"] = contents.substr(0, 32); + return crow::response(reply); + }); + + app.port(18080).multithreaded().run(); +} diff --git a/include/crow/app.h b/include/crow/app.h index 7aa60522db..b4ba5f42f6 100644 --- a/include/crow/app.h +++ b/include/crow/app.h @@ -520,6 +520,51 @@ namespace crow return res_stream_threshold_; } + /// \brief Directory used for request body files created by routes that call `body_file()`. + /// + /// Empty (the default) uses the system temporary directory. Unique names are generated per request. + self_t& body_file_directory(std::string directory) + { + body_file_directory_ = std::move(directory); + return *this; + } + + const std::string& body_file_directory() const + { + return body_file_directory_; + } + + /// \brief Maximum number of bytes written to a request body file (0 = unlimited, the default). + /// + /// A body that exceeds the limit is discarded and the client receives HTTP 413. + /// Remaining body bytes are still consumed so a keep-alive connection stays in sync. + self_t& max_body_file_size(uint64_t bytes) + { + max_body_file_size_ = bytes; + return *this; + } + + uint64_t max_body_file_size() const + { + return max_body_file_size_; + } + + /// \brief Whether the matched route writes the request body to a file (used by the connection). + bool should_save_body_to_file(const routing_handle_result& found) + { + BaseRule* rule = router_.get_rule(found); + return rule && rule->stores_body_in_file(); + } + + /// \brief Create a unique path for a request body file (used by the connection). + std::string create_body_file_path(const routing_handle_result& found) + { + BaseRule* rule = router_.get_rule(found); + std::string directory = body_file_directory_; + if (rule && !rule->body_file_directory().empty()) + directory = rule->body_file_directory(); + return utility::create_temporary_file(directory); + } self_t& register_blueprint(Blueprint& blueprint) { @@ -920,6 +965,8 @@ namespace crow detail::socket::tcp_socket_options tcp_socket_options_{}; detail::socket::tcp_socket_options websocket_tcp_socket_options_{}; size_t res_stream_threshold_ = 1048576; + std::string body_file_directory_; + uint64_t max_body_file_size_{0}; Router router_; bool static_routes_added_{false}; diff --git a/include/crow/http_connection.h b/include/crow/http_connection.h index 2ebf72a956..5c4190f1c0 100644 --- a/include/crow/http_connection.h +++ b/include/crow/http_connection.h @@ -141,6 +141,17 @@ namespace crow parser_.done(); need_to_call_after_handlers_ = true; complete_request(); + return; + } + if (handler_->should_save_body_to_file(*routing_handle_result_)) + { + const std::string path = handler_->create_body_file_path(*routing_handle_result_); + if (!parser_.open_body_file(path, handler_->max_body_file_size())) + { + req_.body_error_code = status::INTERNAL_SERVER_ERROR; + parser_.discard_remaining_body(); + CROW_LOG_ERROR << "Failed to open request body file"; + } } } @@ -160,7 +171,12 @@ namespace crow add_keep_alive_ = req_.keep_alive; close_connection_ = req_.close_connection; - if (req_.check_version(1, 1)) // HTTP/1.1 + if (req_.body_error_code) + { + is_invalid_request = true; + res = response(req_.body_error_code); + } + else if (req_.check_version(1, 1)) // HTTP/1.1 { if (!req_.headers.count("host")) { diff --git a/include/crow/http_request.h b/include/crow/http_request.h index ab526f3eef..fe5405366f 100644 --- a/include/crow/http_request.h +++ b/include/crow/http_request.h @@ -51,6 +51,9 @@ namespace crow // NOTE: Already documented in "crow/app.h" query_string url_params; ///< The parameters associated with the request. (everything after the `?` in the URL) ci_map headers; std::string body; + std::string body_file_path; ///< Path of the file that received the body, if the route used `body_file()`. Empty when the body is in `body`. + uint64_t body_file_size{}; ///< Number of bytes written to `body_file_path` when the body was stored on disk. + int body_error_code{}; ///< Non-zero when the body could not be stored (`413` or `500`); the handler is not invoked. std::string remote_ip_address; ///< The IP address from which the request was sent. unsigned char http_ver_major, http_ver_minor; bool keep_alive, ///< Whether or not the server should send a `connection: Keep-Alive` header to the client. @@ -60,6 +63,7 @@ namespace crow // NOTE: Already documented in "crow/app.h" void* middleware_context{}; void* middleware_container{}; asio::io_context* io_context{}; + mutable bool persist_body_file{false}; ///< When true, a body file is not deleted after the request. Set via `keep_body_file()`. /// Construct an empty request. (sets the method to `GET`) request(): @@ -91,12 +95,29 @@ namespace crow // NOTE: Already documented in "crow/app.h" /// Get the body as parameters in QS format. /// - /// This is meant to be used with requests of type "application/x-www-form-urlencoded" + /// This is meant to be used with requests of type "application/x-www-form-urlencoded". + /// It reads `body`; it does not open `body_file_path`. const query_string get_body_params() const { return query_string(body, false); } + /// True when the request body was written to `body_file_path` instead of `body`. + bool has_body_file() const + { + return !body_file_path.empty(); + } + + /// Keep the body file after the response is sent. + + /// + /// Without this call the file is deleted once the connection is done with the request. + /// The handler must copy `body_file_path` if it will use the file after returning. + void keep_body_file() const + { + persist_body_file = true; + } + /// Send data to whoever made this request with a completion handler and return immediately. template void post(CompletionHandler handler) diff --git a/include/crow/parser.h b/include/crow/parser.h index 1417d6cb60..b517ec29eb 100644 --- a/include/crow/parser.h +++ b/include/crow/parser.h @@ -1,8 +1,13 @@ #pragma once +#include +#include +#include +#include +#include #include +#include #include -#include #include "crow/http_request.h" #include "crow/http_parser_merged.h" @@ -88,6 +93,26 @@ namespace crow static int on_body(http_parser* self_, const char* at, size_t length) { HTTPParser* self = static_cast(self_); + if (self->body_discard_) + return 0; + if (self->body_file_) + { + if (self->max_body_file_size_ != 0 && + (length > self->max_body_file_size_ || + self->body_bytes_ > self->max_body_file_size_ - length)) + { + self->fail_body_file(status::PAYLOAD_TOO_LARGE); + return 0; + } + self->body_file_->write(at, static_cast(length)); + if (!*self->body_file_) + { + self->fail_body_file(status::INTERNAL_SERVER_ERROR); + return 0; + } + self->body_bytes_ += length; + return 0; + } self->req.body.insert(self->req.body.end(), at, at + length); return 0; } @@ -95,6 +120,9 @@ namespace crow { HTTPParser* self = static_cast(self_); + self->close_body_file_stream(); + if (!self->req.body_file_path.empty()) + self->req.body_file_size = self->body_bytes_; self->message_complete = true; self->process_message(); return 0; @@ -106,6 +134,40 @@ namespace crow http_parser_init(this); } + ~HTTPParser() + { + cleanup_body_file(!req.persist_body_file); + } + + /// Open `path` and write subsequent body bytes there instead of `req.body`. + bool open_body_file(const std::string& path, uint64_t max_size) + { + if (path.empty()) + return false; + + cleanup_body_file(true); + body_file_ = std::make_unique(path, std::ios::binary | std::ios::out | std::ios::trunc); + if (!body_file_ || !body_file_->is_open()) + { + body_file_.reset(); + std::error_code ec; + std::filesystem::remove(path, ec); + return false; + } + + req.body_file_path = path; + max_body_file_size_ = max_size; + body_bytes_ = 0; + body_discard_ = false; + return true; + } + + /// Stop storing the remainder of the body (do not append it to `req.body`). + void discard_remaining_body() + { + body_discard_ = true; + } + // return false on error /// Parse a buffer into the different sections of an HTTP request. bool feed(const char* buffer, int length) @@ -139,12 +201,16 @@ namespace crow void clear() { + cleanup_body_file(!req.persist_body_file); req = crow::request(); header_field.clear(); header_value.clear(); header_building_state = 0; qs_point = 0; message_complete = false; + body_discard_ = false; + body_bytes_ = 0; + max_body_file_size_ = 0; state = CROW_NEW_MESSAGE(); } @@ -188,8 +254,40 @@ namespace crow request req; private: + void close_body_file_stream() + { + if (!body_file_) + return; + body_file_->flush(); + body_file_->close(); + body_file_.reset(); + } + + void cleanup_body_file(bool remove_file) + { + close_body_file_stream(); + if (remove_file && !req.body_file_path.empty()) + { + std::error_code ec; + std::filesystem::remove(req.body_file_path, ec); + req.body_file_path.clear(); + req.body_file_size = 0; + } + } + + void fail_body_file(int code) + { + body_discard_ = true; + cleanup_body_file(true); + req.body_error_code = code; + } + int header_building_state = 0; bool message_complete = false; + bool body_discard_{false}; + uint64_t body_bytes_{0}; + uint64_t max_body_file_size_{0}; + std::unique_ptr body_file_; std::string header_field; std::string header_value; diff --git a/include/crow/routing.h b/include/crow/routing.h index 42ac0a78f9..123704938e 100644 --- a/include/crow/routing.h +++ b/include/crow/routing.h @@ -154,12 +154,26 @@ namespace crow // NOTE: Already documented in "crow/app.h" const std::string& rule() { return rule_; } + /// True when this route writes the request body to a file while it is received. + bool stores_body_in_file() const + { + return body_file_; + } + + /// Directory for this route's body files; empty uses the app default or the system temp directory. + const std::string& body_file_directory() const + { + return body_file_directory_; + } + protected: uint64_t methods_{1ULL << static_cast(HTTPMethod::Get)}; std::string rule_; std::string name_; bool added_{false}; + bool body_file_{false}; + std::string body_file_directory_; std::unique_ptr rule_to_upgrade_; @@ -618,6 +632,20 @@ namespace crow // NOTE: Already documented in "crow/app.h" static_cast(this)->mw_indices_.template push(); return static_cast(*this); } + + /// Write the request body to a unique file while it is received. + + /// + /// `req.body` stays empty. The handler reads `req.body_file_path` instead. + /// An empty `directory` uses `app.body_file_directory()`, or the system + /// temporary directory. The file is deleted after the response unless the + /// handler calls `req.keep_body_file()`. + self_t& body_file(std::string directory = {}) + { + static_cast(this)->body_file_ = true; + static_cast(this)->body_file_directory_ = std::move(directory); + return static_cast(*this); + } }; /// A rule that can change its parameters during runtime. @@ -1842,6 +1870,19 @@ namespace crow // NOTE: Already documented in "crow/app.h" return blueprints_; } + /// Matched rule for a handle_initial() result, or nullptr when no application rule applied. + BaseRule* get_rule(const routing_handle_result& found) + { + if (found.catch_all || found.rule_index <= RULE_SPECIAL_REDIRECT_SLASH) + return nullptr; + if (found.method >= HTTPMethod::InternalMethodCount) + return nullptr; + auto& rules = per_methods_[static_cast(found.method)].rules; + if (found.rule_index >= rules.size()) + return nullptr; + return rules[found.rule_index]; + } + std::function& exception_handler() { return exception_handler_; diff --git a/include/crow/utility.h b/include/crow/utility.h index a6f550aae0..4f5659d9cf 100644 --- a/include/crow/utility.h +++ b/include/crow/utility.h @@ -13,11 +13,18 @@ #include #include #include +#include +#include #include "crow/settings.h" #include +#ifndef _WIN32 +#include +#include +#endif + // TODO(EDev): Adding C++20's [[likely]] and [[unlikely]] attributes might be useful #if defined(__GNUG__) || defined(__clang__) #define CROW_LIKELY(X) __builtin_expect(!!(X), 1) @@ -804,6 +811,45 @@ namespace crow return (std::filesystem::path(path) / fname).string(); } + /// Create an empty file with a unique name in `directory`. + + /// + /// An empty `directory` uses the system temporary directory. Returns an empty + /// string if the file cannot be created. + inline static std::string create_temporary_file(const std::string& directory, const std::string& prefix = "crow-body-") + { + std::error_code ec; + const std::filesystem::path dir = directory.empty() ? std::filesystem::temp_directory_path(ec) : std::filesystem::path(directory); + if (ec) + return {}; + std::filesystem::create_directories(dir, ec); + if (ec) + return {}; + + for (int attempt = 0; attempt < 128; ++attempt) + { + const auto path = dir / (prefix + random_alphanum(16)); +#ifndef _WIN32 + const int fd = ::open(path.string().c_str(), O_CREAT | O_EXCL | O_WRONLY, 0600); + if (fd != -1) + { + ::close(fd); + return path.string(); + } +#else + if (std::filesystem::exists(path, ec)) + continue; + std::ofstream file(path, std::ios::binary | std::ios::out | std::ios::trunc); + if (file) + { + file.close(); + return path.string(); + } +#endif + } + return {}; + } + /** * @brief Checks two string for equality. * Always returns false if strings differ in size. diff --git a/mkdocs.yml b/mkdocs.yml index e5013dbb99..eae6f97c38 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -70,6 +70,7 @@ nav: - SBOM Generation: guides/sbom.md - SSL: guides/ssl.md - Static Files: guides/static.md + - Request body files: guides/body-file.md - Blueprints: guides/blueprints.md - Compression: guides/compression.md - Websockets: guides/websockets.md diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 00db73a7be..beb86bfd67 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -22,6 +22,7 @@ enable_testing() # list the test sources set(TEST_SRCS unittest.cpp + body_file_tests.cpp query_string_tests.cpp unit_tests/test_http_response.cpp unit_tests/test_json.cpp diff --git a/tests/body_file_tests.cpp b/tests/body_file_tests.cpp new file mode 100644 index 0000000000..c92422e0f4 --- /dev/null +++ b/tests/body_file_tests.cpp @@ -0,0 +1,391 @@ +#define CROW_ENABLE_DEBUG +#define CROW_LOG_LEVEL 0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "catch2/catch_all.hpp" +#include "crow.h" + +using namespace crow; + +#ifdef CROW_USE_BOOST +namespace asio = boost::asio; +using asio_error_code = boost::system::error_code; +#else +using asio_error_code = asio::error_code; +#endif + +#define LOCALHOST_ADDRESS "127.0.0.1" + +namespace +{ + std::string read_all(const std::string& path) + { + std::ifstream in(path, std::ios::binary); + return std::string((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + } + + bool wait_until_removed(const std::string& path, std::chrono::milliseconds timeout) + { + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (std::filesystem::exists(path)) + { + if (std::chrono::steady_clock::now() >= deadline) + return false; + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + return true; + } + + bool directory_is_empty(const std::filesystem::path& path) + { + std::error_code ec; + return std::filesystem::directory_iterator(path, ec) == std::filesystem::directory_iterator(); + } + + bool response_complete(const std::string& data) + { + auto search_from = std::size_t{0}; + while (true) + { + const auto status_line = data.find("HTTP/1.1 ", search_from); + if (status_line == std::string::npos) + return false; + const auto code = std::atoi(data.c_str() + status_line + 9); + const auto header_end = data.find("\r\n\r\n", status_line); + if (header_end == std::string::npos) + return false; + if (code >= 100 && code < 200) + { + search_from = header_end + 4; + continue; + } + const auto length_pos = data.find("Content-Length:", status_line); + if (length_pos == std::string::npos || length_pos > header_end) + return true; + const auto length = static_cast(std::stoul(data.substr(length_pos + 15))); + return data.size() >= header_end + 4 + length; + } + } + + std::string http_body(const std::string& response) + { + auto search_from = std::size_t{0}; + while (true) + { + const auto status_line = response.find("HTTP/1.1 ", search_from); + if (status_line == std::string::npos) + return {}; + const auto code = std::atoi(response.c_str() + status_line + 9); + const auto header_end = response.find("\r\n\r\n", status_line); + if (header_end == std::string::npos) + return {}; + if (code >= 100 && code < 200) + { + search_from = header_end + 4; + continue; + } + return response.substr(header_end + 4); + } + } + + class TestClient + { + public: + TestClient(uint16_t port): + socket_(io_context_) + { + socket_.connect(asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), port)); + } + + void send(const std::string& data) + { + asio::write(socket_, asio::buffer(data)); + } + + void send(const char* data, std::size_t size) + { + asio::write(socket_, asio::buffer(data, size)); + } + + std::string receive() + { + std::string response; + std::array buffer{}; + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + while (!response_complete(response)) + { + REQUIRE(std::chrono::steady_clock::now() < deadline); + asio_error_code ec; + const auto n = socket_.read_some(asio::buffer(buffer), ec); + if (ec) + break; + response.append(buffer.data(), n); + } + REQUIRE(response_complete(response)); + return response; + } + + asio::ip::tcp::socket& socket() { return socket_; } + + private: + asio::io_context io_context_{}; + asio::ip::tcp::socket socket_; + }; + + struct TempDir + { + std::filesystem::path path; + + TempDir(): + path(std::filesystem::temp_directory_path() / ("crow-body-file-tests-" + utility::random_alphanum(12))) + { + std::filesystem::create_directories(path); + } + + ~TempDir() + { + std::error_code ec; + std::filesystem::remove_all(path, ec); + } + }; +} // namespace + +TEST_CASE("request_body_file", "[http][body_file]") +{ + TempDir dir; + SimpleApp app; + app.body_file_directory(dir.path.string()).max_body_file_size(1024 * 1024); + + CROW_ROUTE(app, "/memory") + .methods("POST"_method)([](const request& req) { + return std::string(req.has_body_file() ? "file" : "memory") + ':' + req.body; + }); + + CROW_ROUTE(app, "/upload") + .methods("POST"_method) + .body_file()([](const request& req) { + return std::string(req.has_body_file() ? "file:" : "memory:") + + std::to_string(req.body.size()) + ':' + + std::to_string(req.body_file_size) + ':' + + req.body_file_path + ':' + + read_all(req.body_file_path); + }); + + CROW_ROUTE(app, "/keep") + .methods("POST"_method) + .body_file()([](const request& req) { + req.keep_body_file(); + return req.body_file_path; + }); + + CROW_ROUTE(app, "/custom") + .methods("POST"_method) + .body_file((dir.path / "route-dir").string())([](const request& req) { + return req.body_file_path; + }); + + auto server = app.bindaddr(LOCALHOST_ADDRESS).port(45580).run_async(); + app.wait_for_server_start(); + + auto post = [](const std::string& target, const std::string& payload, + const std::string& extra_headers = {}) { + TestClient client(45580); + client.send( + "POST " + target + + " HTTP/1.1\r\n" + "Host: localhost\r\n" + + extra_headers + + "Content-Length: " + std::to_string(payload.size()) + + "\r\n" + "\r\n"); + if (!payload.empty()) + client.send(payload.data(), payload.size()); + return client.receive(); + }; + + const auto memory = post("/memory", "hello"); + CHECK(memory.find("HTTP/1.1 200") != std::string::npos); + CHECK(http_body(memory) == "memory:hello"); + + const std::string payload(64 * 1024, 'A'); + const auto uploaded = post("/upload", payload); + REQUIRE(uploaded.find("HTTP/1.1 200") != std::string::npos); + const auto uploaded_body = http_body(uploaded); + REQUIRE(uploaded_body.rfind("file:0:" + std::to_string(payload.size()) + ':', 0) == 0); + const auto path_start = uploaded_body.find(':', 7) + 1; + const auto path_end = uploaded_body.find(':', path_start); + REQUIRE(path_end != std::string::npos); + const auto path = uploaded_body.substr(path_start, path_end - path_start); + CHECK(path.find(dir.path.string()) != std::string::npos); + CHECK(uploaded_body.substr(path_end + 1) == payload); + CHECK(wait_until_removed(path, std::chrono::seconds(2))); + + const std::string binary(std::string("a") + '\0' + "b" + '\xff' + "c"); + const auto binary_response = post("/upload", binary); + REQUIRE(binary_response.find("HTTP/1.1 200") != std::string::npos); + const auto binary_body = http_body(binary_response); + CHECK(binary_body.substr(binary_body.rfind(':') + 1) == binary); + + const auto empty = post("/upload", ""); + REQUIRE(empty.find("HTTP/1.1 200") != std::string::npos); + CHECK(http_body(empty).rfind("file:0:0:", 0) == 0); + + { + TestClient client(45580); + client.send( + "POST /upload HTTP/1.1\r\n" + "Host: localhost\r\n" + "Transfer-Encoding: chunked\r\n" + "\r\n" + "5\r\nhello\r\n" + "6\r\n world\r\n" + "0\r\n\r\n"); + const auto response = client.receive(); + REQUIRE(response.find("HTTP/1.1 200") != std::string::npos); + CHECK(http_body(response).find("hello world") != std::string::npos); + } + + const auto continued = post("/upload", "ping", "Expect: 100-continue\r\n"); + REQUIRE(continued.find("100 Continue") != std::string::npos); + REQUIRE(continued.find("HTTP/1.1 200") != std::string::npos); + CHECK(http_body(continued).find("ping") != std::string::npos); + + { + const auto response = post("/keep", "abc"); + auto kept = http_body(response); + while (!kept.empty() && (kept.back() == '\n' || kept.back() == '\r')) + kept.pop_back(); + REQUIRE(std::filesystem::exists(kept)); + CHECK(read_all(kept) == "abc"); + std::filesystem::remove(kept); + } + + { + const auto response = post("/custom", "Z"); + auto custom_path = http_body(response); + while (!custom_path.empty() && (custom_path.back() == '\n' || custom_path.back() == '\r')) + custom_path.pop_back(); + CHECK(custom_path.find((dir.path / "route-dir").string()) != std::string::npos); + } + + { + TestClient client(45580); + client.send( + "POST /upload HTTP/1.1\r\n" + "Host: localhost\r\n" + "Content-Length: 4\r\n" + "\r\n" + "one!"); + const auto first = client.receive(); + CHECK(http_body(first).find("one!") != std::string::npos); + client.send( + "POST /upload HTTP/1.1\r\n" + "Host: localhost\r\n" + "Content-Length: 5\r\n" + "\r\n" + "two!!"); + const auto second = client.receive(); + CHECK(http_body(second).find("two!!") != std::string::npos); + } + + app.stop(); + server.wait(); +} + +TEST_CASE("request_body_file_too_large", "[http][body_file]") +{ + TempDir dir; + std::atomic handler_ran{false}; + SimpleApp app; + app.body_file_directory(dir.path.string()).max_body_file_size(8); + + CROW_ROUTE(app, "/upload") + .methods("POST"_method) + .body_file()([&handler_ran](const request&) { + handler_ran = true; + return "ran"; + }); + + auto server = app.bindaddr(LOCALHOST_ADDRESS).port(45581).run_async(); + app.wait_for_server_start(); + + TestClient client(45581); + client.send( + "POST /upload HTTP/1.1\r\n" + "Host: localhost\r\n" + "Content-Length: 9\r\n" + "\r\n" + "123456789"); + const auto response = client.receive(); + CHECK(response.find("HTTP/1.1 413") != std::string::npos); + CHECK_FALSE(handler_ran.load()); + CHECK(directory_is_empty(dir.path)); + + app.stop(); + server.wait(); +} + +TEST_CASE("request_body_file_disconnect_cleans_up", "[http][body_file]") +{ + TempDir dir; + std::atomic handler_ran{false}; + SimpleApp app; + app.body_file_directory(dir.path.string()); + + CROW_ROUTE(app, "/upload") + .methods("POST"_method) + .body_file()([&handler_ran](const request&) { + handler_ran = true; + return "ran"; + }); + + auto server = app.bindaddr(LOCALHOST_ADDRESS).port(45582).run_async(); + app.wait_for_server_start(); + + { + TestClient client(45582); + client.send( + "POST /upload HTTP/1.1\r\n" + "Host: localhost\r\n" + "Content-Length: 64\r\n" + "\r\n" + "partial"); + } + + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2); + bool empty = false; + while (std::chrono::steady_clock::now() < deadline) + { + empty = directory_is_empty(dir.path); + if (empty) + break; + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + CHECK(empty); + CHECK_FALSE(handler_ran.load()); + + app.stop(); + server.wait(); +} + +TEST_CASE("create_temporary_file", "[utility][body_file]") +{ + TempDir dir; + const auto first = utility::create_temporary_file(dir.path.string()); + const auto second = utility::create_temporary_file(dir.path.string()); + REQUIRE_FALSE(first.empty()); + REQUIRE_FALSE(second.empty()); + CHECK(first != second); + CHECK(std::filesystem::exists(first)); + CHECK(std::filesystem::file_size(first) == 0); +}