Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -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

Expand All @@ -69,6 +70,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
11 changes: 9 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 @@ -325,12 +326,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 @@ -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;
}
}
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 <array>
#include <cstddef>
#include <locale>
#include <optional>
#include <ranges>
#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 {

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.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) {}
};

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<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
149 changes: 149 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,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 <algorithm>
#include <cstddef>
#include <cstdint>
#include <expected>
#include <ranges>
#include <span>
#include <string_view>
#include <unistd.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

std::optional<kphp::stl::string<kphp::memory::script_allocator>> 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"})};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add // TODO comments in places we'd like to revisit in the future. Here we'd like to get rid of string creation


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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This whole loop should be reworked in the future. We'd like to have something like tmpnam in K2 API

Copy link
Contributor Author

@astrophysik astrophysik Mar 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, in old runtime mkstemp was used in this case

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());
}
auto is_exists_res{k2::access(tmp_name, F_OK)};
if (!is_exists_res.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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider following implementation:

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()) {
      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;
  }

Moreover, I don't see a reason to return std::expected<size_t, int32_t> instead of std::expected<void, size_t>

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks
Error code and size are used in mixed file{}; initialization

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<mixed>& post) noexcept {
const string name{part.name_attribute.data(), static_cast<string::size_type>(part.name_attribute.size())};
const 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()) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not *part.content_type == CONTENT_TYPE_APP_FORM_URLENCODED?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because content-type may looks like Content-type: application/x-www-form-urlencoded; charset=UTF-8

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_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<const std::byte*>(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<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 {
const auto content_type{part.content_type.value_or(DEFAULT_CONTENT_TYPE)};
file.set_value(string{"name"}, string{(*part.filename_attribute).data(), static_cast<string::size_type>((*part.filename_attribute).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);
}

if (part.name_attribute.ends_with("[]")) {
string name{part.name_attribute.data(), static_cast<string::size_type>(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<string::size_type>(part.name_attribute.size())};
files.set_value(name, file);
}
}
} // namespace kphp::http::multipart::details
16 changes: 16 additions & 0 deletions runtime-light/server/http/multipart/details/parts-processing.h
Original file line number Diff line number Diff line change
@@ -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<mixed>& post) noexcept;

void process_file_multipart(const kphp::http::multipart::details::part& part, array<mixed>& files) noexcept;

} // namespace kphp::http::multipart::details
Loading
Loading