Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
8460a39
[k2] add support multipart/form-data to HTTP server
nekishdev Oct 9, 2025
c6800d1
[k2] refactor multipart/form-data via ranges
nekishdev Mar 1, 2026
95f8761
wip
astrophysik Mar 4, 2026
3cf7a2f
wip
astrophysik Mar 5, 2026
858b5da
wip
astrophysik Mar 5, 2026
3498cfe
wip
astrophysik Mar 6, 2026
a75db65
wip
astrophysik Mar 6, 2026
db552d2
wip
astrophysik Mar 6, 2026
1feac9b
add tests
astrophysik Mar 6, 2026
dc53e7a
small fix
astrophysik Mar 6, 2026
70155f6
small fix
astrophysik Mar 6, 2026
45028bd
small fix
astrophysik Mar 6, 2026
49c97cc
small fix
astrophysik Mar 6, 2026
1eeb6d5
small fix
astrophysik Mar 6, 2026
6ff99ad
small fix
astrophysik Mar 6, 2026
7402059
small fix
astrophysik Mar 6, 2026
d55d677
apply review
astrophysik Mar 11, 2026
2a5a562
add test assert
astrophysik Mar 11, 2026
8fb5338
fix format
astrophysik Mar 11, 2026
a3e9d1c
fix format
astrophysik Mar 11, 2026
8b2afd3
apply review
astrophysik Mar 11, 2026
87b958b
fix format
astrophysik Mar 12, 2026
7b4eed0
remove extra const
astrophysik Mar 12, 2026
e6fd803
add include
astrophysik Mar 12, 2026
9dc1aec
apply review
astrophysik Mar 13, 2026
383ff2d
fix format
astrophysik Mar 13, 2026
31734c5
small fixes
astrophysik Mar 25, 2026
197ac4a
fix format
astrophysik Mar 25, 2026
6233a90
small fix
astrophysik Mar 25, 2026
e9bca64
add inline for headers constants
astrophysik Mar 26, 2026
5465ad7
small fixes
astrophysik Mar 27, 2026
409672d
add multipart_temporary_files clear
astrophysik Mar 27, 2026
246305f
remove saving extra temp files
astrophysik Mar 27, 2026
c1e793d
small fixes
astrophysik Mar 27, 2026
23a2b9a
some code improvements
astrophysik Mar 27, 2026
6355069
apply format
astrophysik Mar 27, 2026
5787687
remove some const
astrophysik Mar 27, 2026
6285b2c
add more tests
astrophysik Mar 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions runtime-light/server/http/http-server-state.h
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,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

Expand All @@ -70,6 +71,8 @@ struct HttpServerInstanceState final : private vk::not_copyable {
// The headers_registered_callback function should only be invoked once
std::optional<kphp::coro::task<>> headers_registered_callback;

kphp::stl::unordered_set<kphp::stl::string<kphp::memory::script_allocator>, kphp::memory::script_allocator> multipart_temporary_files;

private:
kphp::stl::multimap<kphp::stl::string<kphp::memory::script_allocator>, kphp::stl::string<kphp::memory::script_allocator>, kphp::memory::script_allocator>
headers_;
Expand Down
12 changes: 10 additions & 2 deletions runtime-light/server/http/init-functions.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -324,12 +325,15 @@ void init_server(kphp::component::stream&& request_stream, kphp::stl::vector<std
f$parse_str(body, superglobals.v$_POST);
http_server_instance_st.opt_raw_post_data.emplace(std::move(body));
} else if (!std::ranges::search(content_type, CONTENT_TYPE_MULTIPART_FORM_DATA).empty()) {
kphp::log::error("unsupported content-type: {}", CONTENT_TYPE_MULTIPART_FORM_DATA);
std::string_view body_view{reinterpret_cast<const char*>(invoke_http.body.data()), static_cast<string::size_type>(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<const char*>(invoke_http.body.data()), static_cast<string::size_type>(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<string::size_type>(content_type.size())});
break;
}
Expand Down Expand Up @@ -429,6 +433,10 @@ 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);
}
http_server_instance_st.multipart_temporary_files.clear();
co_return;
}
}
Expand Down
176 changes: 176 additions & 0 deletions runtime-light/server/http/multipart/details/parts-parsing.h
Original file line number Diff line number Diff line change
@@ -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 <algorithm>
#include <cstddef>
#include <locale>
#include <optional>
#include <ranges>
#include <string>
#include <string_view>
#include <utility>

#include "common/algorithms/string-algorithms.h"
#include "runtime-light/server/http/http-server-state.h"

namespace kphp::http::multipart::details {

inline 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<part_header> 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<part_attribute> 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.size() > 1) {
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<std::string_view> filename_attribute;
std::optional<std::string_view> content_type;
std::string_view body;

static std::optional<part> 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<std::string_view> content_type{std::nullopt};
std::optional<std::string_view> filename_attribute{std::nullopt};
std::optional<std::string_view> 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<std::string_view> filename_attribute, std::optional<std::string_view> content_type,
std::string_view body) noexcept
: name_attribute(name_attribute),
filename_attribute(filename_attribute),
content_type(content_type),
body(body) {}
};

template<typename Delim>
auto parse_multipart_parts(std::string_view body, Delim&& delim) noexcept {
return std::views::split(body, std::forward<Delim>(delim)) | std::views::filter([](auto raw_part) noexcept { return !std::string_view(raw_part).empty(); }) |
std::views::transform([](auto raw_part) noexcept -> std::optional<part> { 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
159 changes: 159 additions & 0 deletions runtime-light/server/http/multipart/details/parts-processing.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// 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 <algorithm>
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <expected>
#include <optional>
#include <ranges>
#include <span>
#include <string_view>
#include <unistd.h>
#include <utility>

#include "runtime-common/core/allocator/script-allocator.h"
#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

inline constexpr std::string_view LETTERS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
inline constexpr int64_t GENERATE_ATTEMPTS = 4;
inline constexpr int64_t SYMBOLS_COUNT = 6;

char random_letter() noexcept {
int64_t pos{f$mt_rand(0, LETTERS.size() - 1)};
return LETTERS[pos];
}

std::optional<kphp::stl::string<kphp::memory::script_allocator>> generate_temporary_name() noexcept {
// todo rework with k2::tempnam or mkstemp
auto tmp_dir_env{ComponentState::get().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<kphp::memory::script_allocator> 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());
}
if (!k2::access(tmp_name, F_OK).has_value()) {
return tmp_name;
}
}
return std::nullopt;
}

std::expected<size_t, int32_t> write_temporary_file(std::string_view tmp_name, std::span<const std::byte> 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()) {
std::ignore = k2::unlink(tmp_name);
return std::unexpected{UPLOAD_ERR_CANT_WRITE};
}

size_t file_size{*written_res};
if (file_size < content.size()) {
std::ignore = k2::unlink(tmp_name);
return std::unexpected{UPLOAD_ERR_PARTIAL};
}
return file_size;
}

array<mixed> build_file_array(std::string_view filename, std::string_view tmp_name, std::string_view content_type,
std::expected<size_t, int32_t> write_res) noexcept {
array<mixed> 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 {
file.set_value(string{"name"}, string{filename.data(), static_cast<string::size_type>(filename.size())});
file.set_value(string{"type"}, string{content_type.data(), static_cast<string::size_type>(content_type.size())});
file.set_value(string{"size"}, static_cast<int64_t>(*write_res));
file.set_value(string{"tmp_name"}, string{tmp_name.data(), static_cast<string::size_type>(tmp_name.size())});
file.set_value(string{"error"}, UPLOAD_ERR_OK);
}
return file;
}

void add_file_to_array(array<mixed>& files, std::string_view name_attribute, array<mixed> file) noexcept {
if (name_attribute.ends_with("[]")) {
string name{name_attribute.data(), static_cast<string::size_type>(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{name_attribute.data(), static_cast<string::size_type>(name_attribute.size())};
files.set_value(name, file);
}
}

} // namespace

namespace kphp::http::multipart::details {

void process_post_multipart(const kphp::http::multipart::details::part& part, array<mixed>& post) noexcept {
string name{part.name_attribute.data(), static_cast<string::size_type>(part.name_attribute.size())};
string body{part.body.data(), static_cast<string::size_type>(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<mixed>& files) noexcept {
kphp::log::assertion(part.filename_attribute.has_value());
auto tmp_name{*(generate_temporary_name().or_else([]() { // NOLINT
kphp::log::error("cannot generate unique name for multipart temporary file"); // no return
return std::optional<kphp::stl::string<kphp::memory::script_allocator>>{};
}))};
auto body_bytes_span{std::as_bytes(std::span<const char>(part.body.data(), part.body.size()))};
auto write_res{write_temporary_file(tmp_name, body_bytes_span)};
if (write_res.has_value()) {
HttpServerInstanceState::get().multipart_temporary_files.insert(tmp_name);
}
auto content_type{part.content_type.value_or(DEFAULT_CONTENT_TYPE)};
auto file{build_file_array(*part.filename_attribute, tmp_name, content_type, write_res)};
add_file_to_array(files, part.name_attribute, std::move(file));
}
} // namespace kphp::http::multipart::details
Loading
Loading