diff --git a/runtime-light/server/http/http-server-state.h b/runtime-light/server/http/http-server-state.h index 261a35dce4..05bf927c62 100644 --- a/runtime-light/server/http/http-server-state.h +++ b/runtime-light/server/http/http-server-state.h @@ -46,6 +46,7 @@ inline constexpr std::string_view CONTENT_LENGTH = "content-length"; inline constexpr std::string_view AUTHORIZATION = "authorization"; inline constexpr std::string_view ACCEPT_ENCODING = "accept-encoding"; inline constexpr std::string_view CONTENT_ENCODING = "content-encoding"; +inline constexpr std::string_view CONTENT_DISPOSITION = "content-disposition"; } // namespace headers @@ -69,6 +70,8 @@ struct HttpServerInstanceState final : private vk::not_copyable { // The headers_registered_callback function should only be invoked once std::optional> headers_registered_callback; + kphp::stl::unordered_set, kphp::memory::script_allocator> multipart_temporary_files; + private: kphp::stl::multimap, kphp::stl::string, kphp::memory::script_allocator> headers_; diff --git a/runtime-light/server/http/init-functions.cpp b/runtime-light/server/http/init-functions.cpp index eda7340671..d499cdb364 100644 --- a/runtime-light/server/http/init-functions.cpp +++ b/runtime-light/server/http/init-functions.cpp @@ -26,6 +26,7 @@ #include "runtime-light/core/globals/php-script-globals.h" #include "runtime-light/k2-platform/k2-api.h" #include "runtime-light/server/http/http-server-state.h" +#include "runtime-light/server/http/multipart/multipart.h" #include "runtime-light/state/instance-state.h" #include "runtime-light/stdlib/component/component-api.h" #include "runtime-light/stdlib/diagnostics/logs.h" @@ -325,12 +326,15 @@ void init_server(kphp::component::stream&& request_stream, kphp::stl::vector(invoke_http.body.data()), static_cast(invoke_http.body.size())}; + auto process_multipart_res{kphp::http::multipart::process_multipart_content_type(content_type, body_view, superglobals)}; + if (!process_multipart_res.has_value()) { + kphp::log::warning("{}", process_multipart_res.error()); + } } else { string body{reinterpret_cast(invoke_http.body.data()), static_cast(invoke_http.body.size())}; http_server_instance_st.opt_raw_post_data.emplace(std::move(body)); } - server.set_value(string{CONTENT_TYPE.data(), CONTENT_TYPE.size()}, string{content_type.data(), static_cast(content_type.size())}); break; } @@ -433,6 +437,9 @@ kphp::coro::task<> finalize_server() noexcept { [[fallthrough]]; } case kphp::http::response_state::completed: + for (const auto& temporary_file : http_server_instance_st.multipart_temporary_files) { + std::ignore = k2::unlink(temporary_file); + } co_return; } } diff --git a/runtime-light/server/http/multipart/details/parts-parsing.h b/runtime-light/server/http/multipart/details/parts-parsing.h new file mode 100644 index 0000000000..abcfa6f440 --- /dev/null +++ b/runtime-light/server/http/multipart/details/parts-parsing.h @@ -0,0 +1,176 @@ +// Compiler for PHP (aka KPHP) +// Copyright (c) 2026 LLC «V Kontakte» +// Distributed under the GPL v3 License, see LICENSE.notice.txt + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "common/algorithms/string-algorithms.h" +#include "runtime-light/server/http/http-server-state.h" + +namespace kphp::http::multipart::details { + +constexpr std::string_view HEADER_CONTENT_DISPOSITION_FORM_DATA = "form-data;"; + +inline std::string_view trim_crlf(std::string_view sv) noexcept { + if (sv.starts_with('\r')) { + sv.remove_prefix(1); + } + if (sv.starts_with('\n')) { + sv.remove_prefix(1); + } + + if (sv.ends_with('\n')) { + sv.remove_suffix(1); + } + if (sv.ends_with('\r')) { + sv.remove_suffix(1); + } + return sv; +} + +struct part_header { + std::string_view name; + std::string_view value; + + static std::optional parse(std::string_view header) noexcept { + auto [name_view, value_view]{vk::split_string_view(header, ':')}; + name_view = vk::trim(name_view); + value_view = vk::trim(value_view); + if (name_view.empty() || value_view.empty()) { + return std::nullopt; + } + return part_header{name_view, value_view}; + } + + bool name_is(std::string_view header_name) const noexcept { + const auto lower_name{name | std::views::transform([](auto c) noexcept { return std::tolower(c, std::locale::classic()); })}; + const auto lower_header_name{header_name | std::views::transform([](auto c) noexcept { return std::tolower(c, std::locale::classic()); })}; + return std::ranges::equal(lower_name, lower_header_name); + } + +private: + part_header(std::string_view name, std::string_view value) noexcept + : name(name), + value(value) {} +}; + +inline auto parse_headers(std::string_view sv) noexcept { + static constexpr std::string_view DELIM = "\r\n"; + return std::views::split(sv, DELIM) | std::views::transform([](auto raw_header) noexcept { return part_header::parse(std::string_view(raw_header)); }) | + std::views::take_while([](auto header_opt) noexcept { return header_opt.has_value(); }) | + std::views::transform([](auto header_opt) noexcept { return *header_opt; }); +} + +struct part_attribute { + std::string_view name; + std::string_view value; + + static std::optional parse(std::string_view attribute) noexcept { + auto [name_view, value_view]{vk::split_string_view(vk::trim(attribute), '=')}; + name_view = vk::trim(name_view); + value_view = vk::trim(value_view); + if (name_view.empty() || value_view.empty()) { + return std::nullopt; + } + + if (value_view.starts_with('"') && value_view.ends_with('"')) { + value_view.remove_suffix(1); + value_view.remove_prefix(1); + } + return part_attribute{name_view, value_view}; + } + +private: + part_attribute(std::string_view name, std::string_view value) noexcept + : name(name), + value(value) {} +}; + +inline auto parse_attrs(std::string_view header_value) noexcept { + static constexpr std::string_view DELIM = ";"; + return std::views::split(header_value, DELIM) | std::views::transform([](auto part) noexcept { return part_attribute::parse(std::string_view(part)); }) | + std::views::take_while([](auto attribute_opt) noexcept { return attribute_opt.has_value(); }) | + std::views::transform([](auto attribute_opt) noexcept { return *attribute_opt; }); +} + +struct part { + std::string_view name_attribute; + std::optional filename_attribute; + std::optional content_type; + std::string_view body; + + static std::optional parse(std::string_view part_view) noexcept { + static constexpr std::string_view PART_BODY_DELIM = "\r\n\r\n"; + + const size_t part_body_start{part_view.find(PART_BODY_DELIM)}; + if (part_body_start == std::string_view::npos) { + return std::nullopt; + } + + const std::string_view part_headers{part_view.substr(0, part_body_start)}; + const std::string_view part_body{part_view.substr(part_body_start + PART_BODY_DELIM.size())}; + + std::optional content_type{std::nullopt}; + std::optional filename_attribute{std::nullopt}; + std::optional name_attribute{std::nullopt}; + + for (const auto& header : parse_headers(part_headers)) { + if (header.name_is(kphp::http::headers::CONTENT_DISPOSITION)) { + if (!header.value.starts_with(HEADER_CONTENT_DISPOSITION_FORM_DATA)) { + return std::nullopt; + } + + // skip first Content-Disposition: form-data; + const size_t pos{header.value.find(';')}; + if (pos == std::string::npos) { + return std::nullopt; + } + + const std::string_view attributes{trim_crlf(header.value).substr(pos + 1)}; + for (const auto& attribute : parse_attrs(attributes)) { + if (attribute.name == "name") { + name_attribute = attribute.value; + } else if (attribute.name == "filename") { + filename_attribute = attribute.value; + } else { + // ignore unknown attribute + } + } + } else if (header.name_is(kphp::http::headers::CONTENT_TYPE)) { + content_type = trim_crlf(header.value); + } else { + // ignore unused header + } + } + if (!name_attribute.has_value() || name_attribute->empty()) { + return std::nullopt; + } + return part(*name_attribute, filename_attribute, content_type, part_body); + } + +private: + part(std::string_view name_attribute, std::optional filename_attribute, std::optional content_type, + std::string_view body) noexcept + : name_attribute(name_attribute), + filename_attribute(filename_attribute), + content_type(content_type), + body(body) {} +}; + +inline auto parse_multipart_parts(std::string_view body, std::string_view boundary) noexcept { + return std::views::split(body, std::views::join(std::array{std::string_view{"--"}, boundary})) | + std::views::filter([](auto raw_part) noexcept { return !std::string_view(raw_part).empty(); }) | + std::views::transform([](auto raw_part) noexcept -> std::optional { return part::parse(trim_crlf(std::string_view(raw_part))); }) | + std::views::take_while([](auto part_opt) noexcept { return part_opt.has_value(); }) | std::views::transform([](auto part_opt) { return *part_opt; }); +} + +} // namespace kphp::http::multipart::details diff --git a/runtime-light/server/http/multipart/details/parts-processing.cpp b/runtime-light/server/http/multipart/details/parts-processing.cpp new file mode 100644 index 0000000000..be7e0a3318 --- /dev/null +++ b/runtime-light/server/http/multipart/details/parts-processing.cpp @@ -0,0 +1,149 @@ +// Compiler for PHP (aka KPHP) +// Copyright (c) 2026 LLC «V Kontakte» +// Distributed under the GPL v3 License, see LICENSE.notice.txt + +#include "runtime-light/server/http/multipart/details/parts-processing.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "runtime-common/core/runtime-core.h" +#include "runtime-common/core/std/containers.h" +#include "runtime-common/stdlib/server/url-functions.h" +#include "runtime-light/k2-platform/k2-api.h" +#include "runtime-light/server/http/http-server-state.h" +#include "runtime-light/server/http/multipart/details/parts-parsing.h" +#include "runtime-light/state/component-state.h" +#include "runtime-light/stdlib/diagnostics/logs.h" +#include "runtime-light/stdlib/file/resource.h" +#include "runtime-light/stdlib/math/random-functions.h" + +namespace { + +constexpr std::string_view CONTENT_TYPE_APP_FORM_URLENCODED = "application/x-www-form-urlencoded"; + +constexpr std::string_view DEFAULT_CONTENT_TYPE = "text/plain"; + +constexpr int32_t UPLOAD_ERR_OK = 0; +constexpr int32_t UPLOAD_ERR_PARTIAL = 3; +constexpr int32_t UPLOAD_ERR_NO_FILE = 4; +constexpr int32_t UPLOAD_ERR_CANT_WRITE = 7; + +// Not implemented : +// constexpr int32_t UPLOAD_ERR_INI_SIZE = 1; // unused in kphp +// constexpr int32_t UPLOAD_ERR_FORM_SIZE = 2; // todo support header max-file-size +// constexpr int32_t UPLOAD_ERR_NO_TMP_DIR = 6; // todo support check tmp dir +// constexpr int32_t UPLOAD_ERR_EXTENSION = 8; // unused in kphp + +std::optional> generate_temporary_name() noexcept { + static constexpr std::string_view LETTERS{"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"}; + static constexpr auto random_letter{[]() noexcept { + int64_t pos{f$mt_rand(0, LETTERS.size() - 1)}; + return LETTERS[pos]; + }}; + static constexpr int64_t GENERATE_ATTEMPTS = 4; + static constexpr int64_t SYMBOLS_COUNT = 6; + + // todo rework with k2::tempnam or mkstemp + const auto& component_st{ComponentState::get()}; + auto tmp_dir_env{component_st.env.get_value(string{"TMPDIR"})}; + + std::string_view tmp_path{tmp_dir_env.is_string() ? std::string_view{tmp_dir_env.as_string().c_str(), tmp_dir_env.as_string().size()} : P_tmpdir}; + + for (int64_t attempt{}; attempt < GENERATE_ATTEMPTS; ++attempt) { + kphp::stl::string tmp_name{tmp_path.data(), tmp_path.size()}; + tmp_name.push_back('/'); + for (auto _ : std::views::iota(0, SYMBOLS_COUNT)) { + tmp_name.push_back(random_letter()); + } + auto is_exists_res{k2::access(tmp_name, F_OK)}; + if (!is_exists_res.has_value()) { + return tmp_name; + } + } + return std::nullopt; +} + +std::expected write_temporary_file(std::string_view tmp_name, std::span content) noexcept { + auto file_res{kphp::fs::file::open(tmp_name, "w")}; + if (!file_res.has_value()) { + return std::unexpected{UPLOAD_ERR_NO_FILE}; + } + + auto written_res{(*file_res).write(content)}; + if (!written_res.has_value()) { + return std::unexpected{UPLOAD_ERR_CANT_WRITE}; + } + + size_t file_size{*written_res}; + if (file_size < content.size()) { + return std::unexpected{UPLOAD_ERR_PARTIAL}; + } + return file_size; +} + +} // namespace + +namespace kphp::http::multipart::details { + +void process_post_multipart(const kphp::http::multipart::details::part& part, array& post) noexcept { + const string name{part.name_attribute.data(), static_cast(part.name_attribute.size())}; + const string body{part.body.data(), static_cast(part.body.size())}; + if (part.content_type.has_value() && !std::ranges::search(*part.content_type, CONTENT_TYPE_APP_FORM_URLENCODED).empty()) { + auto post_value{post.get_value(name)}; + f$parse_str(body, post_value); + post.set_value(name, std::move(post_value)); + } else { + post.set_value(name, body); + } +} + +void process_file_multipart(const kphp::http::multipart::details::part& part, array& files) noexcept { + kphp::log::assertion(part.filename_attribute.has_value()); + + auto tmp_name_opt{generate_temporary_name()}; + kphp::log::assertion(tmp_name_opt.has_value()); + auto tmp_name{*tmp_name_opt}; + auto write_res{write_temporary_file(tmp_name, {reinterpret_cast(part.body.data()), part.body.size()})}; + + if (write_res.has_value() || write_res.error() != UPLOAD_ERR_NO_FILE) { + HttpServerInstanceState::get().multipart_temporary_files.insert(*tmp_name_opt); + } + + array file{}; + if (!write_res.has_value()) { + file.set_value(string{"size"}, 0); + file.set_value(string{"tmp_name"}, string{}); + file.set_value(string{"error"}, write_res.error()); + } else { + const auto content_type{part.content_type.value_or(DEFAULT_CONTENT_TYPE)}; + file.set_value(string{"name"}, string{(*part.filename_attribute).data(), static_cast((*part.filename_attribute).size())}); + file.set_value(string{"type"}, string{content_type.data(), static_cast(content_type.size())}); + file.set_value(string{"size"}, static_cast(*write_res)); + file.set_value(string{"tmp_name"}, string{tmp_name.data(), static_cast(tmp_name.size())}); + file.set_value(string{"error"}, UPLOAD_ERR_OK); + } + + if (part.name_attribute.ends_with("[]")) { + string name{part.name_attribute.data(), static_cast(part.name_attribute.size() - 2)}; + mixed file_array{files.get_value(name)}; + + for (auto& attribute_it : file) { + string attribute{attribute_it.get_key().to_string()}; + mixed file_array_value{file_array.get_value(attribute)}; + file_array_value.push_back(attribute_it.get_value().to_string()); + file_array.set_value(attribute, file_array_value); + } + files.set_value(name, file_array); + } else { + string name{part.name_attribute.data(), static_cast(part.name_attribute.size())}; + files.set_value(name, file); + } +} +} // namespace kphp::http::multipart::details diff --git a/runtime-light/server/http/multipart/details/parts-processing.h b/runtime-light/server/http/multipart/details/parts-processing.h new file mode 100644 index 0000000000..fa2a0ceef1 --- /dev/null +++ b/runtime-light/server/http/multipart/details/parts-processing.h @@ -0,0 +1,16 @@ +// Compiler for PHP (aka KPHP) +// Copyright (c) 2026 LLC «V Kontakte» +// Distributed under the GPL v3 License, see LICENSE.notice.txt + +#pragma once + +#include "runtime-common/core/runtime-core.h" +#include "runtime-light/server/http/multipart/details/parts-parsing.h" + +namespace kphp::http::multipart::details { + +void process_post_multipart(const kphp::http::multipart::details::part& part, array& post) noexcept; + +void process_file_multipart(const kphp::http::multipart::details::part& part, array& files) noexcept; + +} // namespace kphp::http::multipart::details diff --git a/runtime-light/server/http/multipart/multipart.h b/runtime-light/server/http/multipart/multipart.h new file mode 100644 index 0000000000..bd9bb92234 --- /dev/null +++ b/runtime-light/server/http/multipart/multipart.h @@ -0,0 +1,59 @@ +// Compiler for PHP (aka KPHP) +// Copyright (c) 2026 LLC «V Kontakte» +// Distributed under the GPL v3 License, see LICENSE.notice.txt + +#pragma once + +#include +#include +#include +#include + +#include "runtime-light/core/globals/php-script-globals.h" +#include "runtime-light/server/http/multipart/details/parts-parsing.h" +#include "runtime-light/server/http/multipart/details/parts-processing.h" + +namespace kphp::http::multipart { + +namespace details { +constexpr std::string_view MULTIPART_BOUNDARY_EQ = "boundary="; + +inline std::optional extract_boundary(std::string_view content_type) noexcept { + const size_t boundary_start{content_type.find(details::MULTIPART_BOUNDARY_EQ)}; + if (boundary_start == std::string_view::npos) { + return std::nullopt; + } + + size_t boundary_end{content_type.find(';', boundary_start)}; + if (boundary_end == std::string_view::npos) { + boundary_end = content_type.size(); + } + + std::string_view boundary_view{ + content_type.substr(boundary_start + details::MULTIPART_BOUNDARY_EQ.size(), boundary_end - boundary_start - details::MULTIPART_BOUNDARY_EQ.size())}; + if (boundary_view.starts_with('"') && boundary_view.ends_with('"')) { + boundary_view.remove_suffix(1); + boundary_view.remove_prefix(1); + } + return boundary_view; +} + +} // namespace details + +inline std::expected process_multipart_content_type(std::string_view content_type, std::string_view body, + PhpScriptBuiltInSuperGlobals& superglobals) noexcept { + auto boundary_opt{details::extract_boundary(content_type)}; + if (!boundary_opt.has_value()) { + return std::unexpected{"cannot extract boundary in multipart content type"}; + } + for (const auto& part : details::parse_multipart_parts(body, *boundary_opt)) { + if (part.filename_attribute.has_value()) { + details::process_file_multipart(part, superglobals.v$_FILES.as_array()); + } else { + details::process_post_multipart(part, superglobals.v$_POST.as_array()); + } + } + return {}; +} + +} // namespace kphp::http::multipart diff --git a/runtime-light/server/server.cmake b/runtime-light/server/server.cmake index d61452945a..bf229f2128 100644 --- a/runtime-light/server/server.cmake +++ b/runtime-light/server/server.cmake @@ -3,6 +3,7 @@ prepend( server/ cli/cli-instance-state.cpp http/init-functions.cpp + http/multipart/details/parts-processing.cpp http/http-server-state.cpp job-worker/job-worker-server-state.cpp rpc/init-functions.cpp diff --git a/runtime-light/stdlib/rpc/rpc-api.cpp b/runtime-light/stdlib/rpc/rpc-api.cpp index 00a207ed7a..6ac41e3272 100644 --- a/runtime-light/stdlib/rpc/rpc-api.cpp +++ b/runtime-light/stdlib/rpc/rpc-api.cpp @@ -344,10 +344,11 @@ kphp::coro::task send_request(std::string_view actor, std co_return std::move(opt_response); }}; - static constexpr auto ignore_answer_awaiter_coroutine{[](kphp::component::stream stream, std::chrono::milliseconds timeout) -> kphp::coro::shared_task { - auto fetch_task{kphp::component::fetch_response(stream, [](std::span) noexcept {})}; - std::ignore = co_await kphp::coro::io_scheduler::get().schedule(std::move(fetch_task), timeout); - }}; + static constexpr auto ignore_answer_awaiter_coroutine{ + [](kphp::component::stream stream, std::chrono::milliseconds timeout) noexcept -> kphp::coro::shared_task { + auto fetch_task{kphp::component::fetch_response(stream, [](std::span) noexcept {})}; + std::ignore = co_await kphp::coro::io_scheduler::get().schedule(std::move(fetch_task), timeout); + }}; // normalize timeout using namespace std::chrono_literals; diff --git a/tests/python/tests/http_server/php/index.php b/tests/python/tests/http_server/php/index.php index d6ad319bfc..d0ea3e60da 100644 --- a/tests/python/tests/http_server/php/index.php +++ b/tests/python/tests/http_server/php/index.php @@ -237,6 +237,43 @@ public function work(string $output) { }); break; } +} else if ($_SERVER["PHP_SELF"] === "/test_multipart") { + switch($_GET["type"]) { + case "simple_names_attributes": + echo "name : " . $_POST["name"] . "\n"; + echo "role : " . $_POST["role"] . "\n"; + break; + case "simple_file_attribute": + echo "filename : " . $_FILES["file"]['name'] . "\n"; + $tmp_name = $_FILES["file"]['tmp_name']; + $file_first_line = file($tmp_name)[0]; + echo "content : " . $file_first_line; + break; + case "file_array_attribute": + $files = $_FILES["files"]; + $first_file = $files["tmp_name"][0]; + $file_first_line = file($first_file)[0]; + echo "content-1 : " . $file_first_line; + + $second_file = $files["tmp_name"][1]; + $file_first_line = file($second_file)[0]; + echo "content-2 : " . $file_first_line; + break; + case "name_urlencoded_attribute": + echo $_POST["form"]['name'] . "\n"; + echo $_POST["form"]['note'] . "\n"; + break; + case "non_terminating_boundary": + echo "name : " . $_POST["name"] . "\n"; + break; + case "superglobal_modify": + $_FILES = ["file" => ["tmp_name" => "not_exists.txt"]]; + break; + default: + echo "ERROR"; + return; + } + echo "OK"; } else if ($_SERVER["PHP_SELF"] === "/test_ignore_user_abort") { register_shutdown_function('shutdown_function'); /** @var I */ diff --git a/tests/python/tests/http_server/test_multipart.py b/tests/python/tests/http_server/test_multipart.py new file mode 100644 index 0000000000..9d09383961 --- /dev/null +++ b/tests/python/tests/http_server/test_multipart.py @@ -0,0 +1,218 @@ +import os +from urllib.parse import urlencode + +from python.lib.testcase import WebServerAutoTestCase + + +class TestMultipartContentType(WebServerAutoTestCase): + + def test_multipart_name_attributes(self): + boundary = "------------------------d74496d66958873e" + + data = (f"--{boundary}\r\n" + 'Content-Disposition: form-data; name="name"\r\n' + "\r\n" + "Ivan\r\n" + f"--{boundary}\r\n" + 'Content-Disposition: form-data; name="role"\r\n' + "\r\n" + "admin\r\n" + f"--{boundary}--\r\n" + ).encode("utf-8") + + headers = { + "Accept": "*/*", + "Content-Type": f"multipart/form-data; boundary={boundary}", + "Content-Length": str(len(data)), # keep if http_request doesn't auto-set it + } + + response = self.web_server.http_request( + uri="/test_multipart?type=simple_names_attributes", + method="POST", + headers=headers, + data=data, # body goes here + ) + + self.assertEqual(200, response.status_code) + self.assertTrue(response.content.find(b"name : Ivan") != -1) + self.assertTrue(response.content.find(b"role : admin") != -1) + + def test_multipart_non_terminating_boundary(self): + boundary = "------------------------d74496d66958873e" + + data = (f"--{boundary}\r\n" + 'Content-Disposition: form-data; name="name"\r\n' + "\r\n" + "Ivan\r\n" + f"--{boundary}\r\n" + ).encode("utf-8") + + headers = { + "Accept": "*/*", + "Content-Type": f"multipart/form-data; boundary={boundary}; charset=UTF-8", + "Content-Length": str(len(data)), # keep if http_request doesn't auto-set it + } + + response = self.web_server.http_request( + uri="/test_multipart?type=non_terminating_boundary", + method="POST", + headers=headers, + data=data, # body goes here + ) + + self.assertEqual(200, response.status_code) + self.assertTrue(response.content.find(b"name : Ivan") != -1) + + def test_multipart_filename_attribute(self): + + tmp_files = os.listdir("/tmp/") + boundary = "------------------------d74496d66958873e" + + file_bytes = b"Hello from test.txt\nSecond line\n" + + data = (f"--{boundary}\r\n" + 'Content-Disposition: form-data; name="file"; filename="test.txt"\r\n' + "Content-Type: text/plain\r\n" + "\r\n" + ).encode("utf-8") + file_bytes + ( + "\r\n" + f"--{boundary}--\r\n" + ).encode("utf-8") + + headers = { + "Accept": "*/*", + "Content-Type": f"multipart/form-data; boundary={boundary}", + "Content-Length": str(len(data)), + } + + response = self.web_server.http_request( + uri="/test_multipart?type=simple_file_attribute", + method="POST", + headers=headers, + data=data, + ) + + self.assertEqual(200, response.status_code) + self.assertTrue(response.content.find(b"filename : test.txt") != -1) + self.assertTrue(response.content.find(b"Hello from test.txt") != -1) + + tmp_files_after_script = os.listdir("/tmp/") + # check that script delete tmp files at the end + self.assertEqual(sorted(tmp_files), sorted(tmp_files_after_script)) + + def test_multipart_filename_array_attribute(self): + tmp_files = os.listdir("/tmp/") + + boundary = "------------------------d74496d66958873e" + + # Two "files" (their raw bytes) + file1_name = "a.txt" + file1_bytes = b"Hello from a.txt\n" + + file2_name = "b.txt" + file2_bytes = b"Hello from b.txt\n" + + # Array-style field name: files[] + data = (f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="files[]"; filename="{file1_name}"\r\n' + "Content-Type: text/plain\r\n" + "\r\n" + ).encode("utf-8") + file1_bytes + ( + "\r\n" + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="files[]"; filename="{file2_name}"\r\n' + "Content-Type: text/plain\r\n" + "\r\n" + ).encode("utf-8") + file2_bytes + ( + "\r\n" + f"--{boundary}--\r\n" + ).encode("utf-8") + + headers = { + "Accept": "*/*", + "Content-Type": f"multipart/form-data; boundary={boundary}", + "Content-Length": str(len(data)), + } + + response = self.web_server.http_request( + uri="/test_multipart?type=file_array_attribute", + method="POST", + headers=headers, + data=data, + ) + + self.assertEqual(200, response.status_code) + self.assertTrue(response.content.find(b"Hello from a.txt") != -1) + self.assertTrue(response.content.find(b"Hello from b.txt") != -1) + + tmp_files_after_script = os.listdir("/tmp/") + # check that script delete tmp files at the end + self.assertEqual(sorted(tmp_files), sorted(tmp_files_after_script)) + + def test_multipart_superglobal_modify(self): + + tmp_files = os.listdir("/tmp/") + boundary = "------------------------d74496d66958873e" + + file_bytes = b"Hello from test.txt\nSecond line\n" + + data = (f"--{boundary}\r\n" + 'Content-Disposition: form-data; name="file"; filename="test.txt"\r\n' + "Content-Type: text/plain\r\n" + "\r\n" + ).encode("utf-8") + file_bytes + ( + "\r\n" + f"--{boundary}--\r\n" + ).encode("utf-8") + + headers = { + "Accept": "*/*", + "Content-Type": f"multipart/form-data; boundary={boundary}", + "Content-Length": str(len(data)), + } + + response = self.web_server.http_request( + uri="/test_multipart?type=superglobal_modify", + method="POST", + headers=headers, + data=data, + ) + + self.assertEqual(200, response.status_code) + + tmp_files_after_script = os.listdir("/tmp/") + # check that script delete tmp files at the end + self.assertEqual(sorted(tmp_files), sorted(tmp_files_after_script)) + + def test_multipart_name_urlencoded_attribute(self): + boundary = "------------------------d74496d66958873e" + + # Part with explicit Content-Type: application/x-www-form-urlencoded + # (this is still multipart/form-data overall; only this part is urlencoded) + urlencoded_part = b"name=Ivan+Petrov&role=admin¬e=a%26b%3Dc%25" + + data = (f"--{boundary}\r\n" + 'Content-Disposition: form-data; name="form"\r\n' + "Content-Type: application/x-www-form-urlencoded; charset=UTF-8\r\n" + "\r\n" + ).encode("utf-8") + urlencoded_part + ( + "\r\n" + f"--{boundary}--\r\n" + ).encode("utf-8") + + headers = { + "Accept": "*/*", + "Content-Type": f"multipart/form-data; boundary={boundary}", + "Content-Length": str(len(data)), # omit if your http_request sets it + } + + response = self.web_server.http_request( + uri="/test_multipart?type=name_urlencoded_attribute", + method="POST", + headers=headers, + data=data, # raw body bytes + ) + + self.assertEqual(200, response.status_code) + self.assertTrue(response.content.find(b"Ivan Petrov") != -1) + self.assertTrue(response.content.find(b"a&b=c%") != -1)