This commit is contained in:
J. Nick Koston 2025-06-29 14:35:38 -05:00
parent f61a40efb8
commit 6596f864be
No known key found for this signature in database
6 changed files with 327 additions and 356 deletions

View File

@ -1,5 +1,7 @@
from esphome.components.esp32 import add_idf_sdkconfig_option
from esphome.components.esp32 import add_idf_component, add_idf_sdkconfig_option
import esphome.config_validation as cv
from esphome.const import CONF_OTA
from esphome.core import CORE
CODEOWNERS = ["@dentra"]
@ -12,3 +14,9 @@ CONFIG_SCHEMA = cv.All(
async def to_code(config):
# Increase the maximum supported size of headers section in HTTP request packet to be processed by the server
add_idf_sdkconfig_option("CONFIG_HTTPD_MAX_REQ_HDR_LEN", 1024)
# Check if web_server component has OTA enabled
web_server_config = CORE.config.get("web_server", {})
if web_server_config.get(CONF_OTA, True): # OTA is enabled by default
# Add multipart parser component for OTA support
add_idf_component(name="zorxx/multipart-parser", ref="1.0.1")

View File

@ -1,253 +0,0 @@
#ifdef USE_ESP_IDF
#ifdef USE_WEBSERVER_OTA
#include "multipart_parser.h"
#include "multipart_parser_utils.h"
#include "esphome/core/log.h"
namespace esphome {
namespace web_server_idf {
static const char *const TAG = "multipart_parser";
// Constants for multipart parsing
static constexpr size_t CRLF_LENGTH = 2;
static constexpr size_t MIN_BOUNDARY_BUFFER = 4; // Extra bytes to keep for split boundary detection
static constexpr const char *CRLF_STR = "\r\n";
bool MultipartParser::parse(const uint8_t *data, size_t len) {
// Append new data to buffer
if (data && len > 0) {
buffer_.insert(buffer_.end(), data, data + len);
}
// Limit iterations to prevent infinite loops
static constexpr size_t MAX_ITERATIONS = 10;
size_t iterations = 0;
bool made_progress = true;
while (made_progress && state_ != DONE && state_ != ERROR && !buffer_.empty() && iterations < MAX_ITERATIONS) {
made_progress = false;
iterations++;
switch (state_) {
case BOUNDARY_SEARCH:
if (find_boundary()) {
state_ = HEADERS;
made_progress = true;
}
break;
case HEADERS:
if (parse_headers()) {
state_ = CONTENT;
made_progress = true;
}
break;
case CONTENT:
if (extract_content()) {
// Content is ready, return to caller
return true;
}
// If we're waiting for more data in CONTENT state, exit the loop
return false;
default:
ESP_LOGE(TAG, "Invalid parser state: %d", state_);
state_ = ERROR;
break;
}
}
if (iterations >= MAX_ITERATIONS) {
ESP_LOGW(TAG, "Parser reached maximum iterations, possible malformed data");
}
return part_ready_;
}
bool MultipartParser::get_current_part(Part &part) const {
if (!part_ready_ || content_length_ == 0) {
return false;
}
part.name = current_name_;
part.filename = current_filename_;
part.content_type = current_content_type_;
part.data = buffer_.data();
part.length = content_length_;
return true;
}
void MultipartParser::consume_part() {
if (!part_ready_) {
return;
}
// Remove consumed data from buffer
if (content_length_ < buffer_.size()) {
buffer_.erase(buffer_.begin(), buffer_.begin() + content_length_);
} else {
buffer_.clear();
}
// Reset for next part
part_ready_ = false;
content_length_ = 0;
current_name_.clear();
current_filename_.clear();
current_content_type_.clear();
// Look for next boundary
state_ = BOUNDARY_SEARCH;
}
void MultipartParser::reset() {
buffer_.clear();
state_ = BOUNDARY_SEARCH;
part_ready_ = false;
content_length_ = 0;
current_name_.clear();
current_filename_.clear();
current_content_type_.clear();
}
bool MultipartParser::find_boundary() {
// Look for boundary in buffer
size_t boundary_pos = find_pattern(reinterpret_cast<const uint8_t *>(boundary_.c_str()), boundary_.length());
if (boundary_pos == std::string::npos) {
// Keep some data for next iteration to handle split boundaries
if (buffer_.size() > boundary_.length() + MIN_BOUNDARY_BUFFER) {
buffer_.erase(buffer_.begin(), buffer_.end() - boundary_.length() - MIN_BOUNDARY_BUFFER);
}
return false;
}
// Remove everything up to and including the boundary
buffer_.erase(buffer_.begin(), buffer_.begin() + boundary_pos + boundary_.length());
// Skip CRLF after boundary
if (buffer_.size() >= CRLF_LENGTH && buffer_[0] == '\r' && buffer_[1] == '\n') {
buffer_.erase(buffer_.begin(), buffer_.begin() + CRLF_LENGTH);
}
// Check if this is the end boundary
if (buffer_.size() >= CRLF_LENGTH && buffer_[0] == '-' && buffer_[1] == '-') {
state_ = DONE;
return false;
}
return true;
}
bool MultipartParser::parse_headers() {
// Limit header lines to prevent DOS attacks
static constexpr size_t MAX_HEADER_LINES = 50;
size_t header_count = 0;
while (header_count < MAX_HEADER_LINES) {
std::string line = read_line();
if (line.empty()) {
// Check if we have enough data for a line
auto crlf_pos = find_pattern(reinterpret_cast<const uint8_t *>(CRLF_STR), CRLF_LENGTH);
if (crlf_pos == std::string::npos) {
return false; // Need more data
}
// Empty line means headers are done
buffer_.erase(buffer_.begin(), buffer_.begin() + CRLF_LENGTH);
return true;
}
process_header_line(line);
header_count++;
}
ESP_LOGW(TAG, "Too many headers in multipart data");
state_ = ERROR;
return false;
}
void MultipartParser::process_header_line(const std::string &line) {
if (str_startswith_case_insensitive(line, "content-disposition:")) {
// Extract name and filename parameters
current_name_ = extract_header_param(line, "name");
current_filename_ = extract_header_param(line, "filename");
} else if (str_startswith_case_insensitive(line, "content-type:")) {
current_content_type_ = extract_header_value(line);
}
// RFC 7578: Ignore any other Content-* headers
}
bool MultipartParser::extract_content() {
// Look for next boundary
std::string search_boundary = CRLF_STR + boundary_;
size_t boundary_pos =
find_pattern(reinterpret_cast<const uint8_t *>(search_boundary.c_str()), search_boundary.length());
if (boundary_pos != std::string::npos) {
// Found complete part
content_length_ = boundary_pos;
part_ready_ = true;
return true;
}
// No boundary found yet, but we might have partial content
// Keep enough bytes to ensure we don't split a boundary
size_t safe_length = buffer_.size();
if (safe_length > search_boundary.length() + MIN_BOUNDARY_BUFFER) {
safe_length -= search_boundary.length() + MIN_BOUNDARY_BUFFER;
if (safe_length > 0) {
content_length_ = safe_length;
// We have partial content but not complete yet
return false;
}
}
return false;
}
std::string MultipartParser::read_line() {
// Limit line length to prevent excessive memory usage
static constexpr size_t MAX_LINE_LENGTH = 4096;
auto crlf_pos = find_pattern(reinterpret_cast<const uint8_t *>(CRLF_STR), CRLF_LENGTH);
if (crlf_pos == std::string::npos) {
// If we have too much data without CRLF, it's likely malformed
if (buffer_.size() > MAX_LINE_LENGTH) {
ESP_LOGW(TAG, "Header line too long, truncating");
state_ = ERROR;
}
return "";
}
if (crlf_pos > MAX_LINE_LENGTH) {
ESP_LOGW(TAG, "Header line exceeds maximum length");
state_ = ERROR;
return "";
}
std::string line(buffer_.begin(), buffer_.begin() + crlf_pos);
buffer_.erase(buffer_.begin(), buffer_.begin() + crlf_pos + CRLF_LENGTH);
return line;
}
size_t MultipartParser::find_pattern(const uint8_t *pattern, size_t pattern_len, size_t start) const {
if (buffer_.size() < pattern_len + start) {
return std::string::npos;
}
for (size_t i = start; i <= buffer_.size() - pattern_len; ++i) {
if (memcmp(buffer_.data() + i, pattern, pattern_len) == 0) {
return i;
}
}
return std::string::npos;
}
} // namespace web_server_idf
} // namespace esphome
#endif // USE_WEBSERVER_OTA
#endif // USE_ESP_IDF

View File

@ -1,75 +0,0 @@
#pragma once
#ifdef USE_ESP_IDF
#ifdef USE_WEBSERVER_OTA
#include <string>
#include <vector>
#include <cstring>
namespace esphome {
namespace web_server_idf {
// Multipart form data parser for ESP-IDF
// Implements RFC 7578 compliant multipart/form-data parsing
class MultipartParser {
public:
static constexpr const char *MULTIPART_BOUNDARY_PREFIX = "--";
enum State : uint8_t { BOUNDARY_SEARCH, HEADERS, CONTENT, DONE, ERROR };
struct Part {
std::string name;
std::string filename;
std::string content_type;
const uint8_t *data;
size_t length;
};
explicit MultipartParser(const std::string &boundary)
: boundary_(MULTIPART_BOUNDARY_PREFIX + boundary),
state_(BOUNDARY_SEARCH),
content_length_(0),
part_ready_(false) {}
// Process incoming data chunk
// Returns true if a complete part is available
bool parse(const uint8_t *data, size_t len);
// Get the current part if available
bool get_current_part(Part &part) const;
// Consume the current part and move to next
void consume_part();
State get_state() const { return state_; }
bool is_done() const { return state_ == DONE; }
bool has_error() const { return state_ == ERROR; }
// Reset parser for reuse
void reset();
private:
bool find_boundary();
bool parse_headers();
void process_header_line(const std::string &line);
bool extract_content();
std::string read_line();
size_t find_pattern(const uint8_t *pattern, size_t pattern_len, size_t start = 0) const;
std::string boundary_;
State state_;
std::vector<uint8_t> buffer_;
// Current part info
std::string current_name_;
std::string current_filename_;
std::string current_content_type_;
size_t content_length_{0};
bool part_ready_{false};
};
} // namespace web_server_idf
} // namespace esphome
#endif // USE_WEBSERVER_OTA
#endif // USE_ESP_IDF

View File

@ -0,0 +1,193 @@
#ifdef USE_ESP_IDF
#ifdef USE_WEBSERVER_OTA
#include "multipart_reader.h"
#include "esphome/core/log.h"
#include <cstring>
#include <cctype>
namespace esphome {
namespace web_server_idf {
static const char *const TAG = "multipart_reader";
MultipartReader::MultipartReader(const std::string &boundary) {
// Initialize settings with callbacks
memset(&settings_, 0, sizeof(settings_));
settings_.on_header_field = on_header_field;
settings_.on_header_value = on_header_value;
settings_.on_part_data_begin = on_part_data_begin;
settings_.on_part_data = on_part_data;
settings_.on_part_data_end = on_part_data_end;
settings_.on_headers_complete = on_headers_complete;
// Create parser with boundary
parser_ = multipart_parser_init(boundary.c_str(), &settings_);
if (parser_) {
multipart_parser_set_data(parser_, this);
}
}
MultipartReader::~MultipartReader() {
if (parser_) {
multipart_parser_free(parser_);
}
}
size_t MultipartReader::parse(const char *data, size_t len) {
if (!parser_) {
return 0;
}
return multipart_parser_execute(parser_, data, len);
}
int MultipartReader::on_header_field(multipart_parser *parser, const char *at, size_t length) {
MultipartReader *reader = static_cast<MultipartReader *>(multipart_parser_get_data(parser));
// If we were processing a value, save it
if (!reader->current_header_value_.empty()) {
// Process the previous header
std::string field_lower = reader->current_header_field_;
std::transform(field_lower.begin(), field_lower.end(), field_lower.begin(), ::tolower);
if (field_lower == "content-disposition") {
// Parse name and filename from Content-Disposition
size_t name_pos = reader->current_header_value_.find("name=");
if (name_pos != std::string::npos) {
name_pos += 5;
size_t end_pos;
if (reader->current_header_value_[name_pos] == '"') {
name_pos++;
end_pos = reader->current_header_value_.find('"', name_pos);
} else {
end_pos = reader->current_header_value_.find_first_of("; \r\n", name_pos);
}
if (end_pos != std::string::npos) {
reader->current_part_.name = reader->current_header_value_.substr(name_pos, end_pos - name_pos);
}
}
size_t filename_pos = reader->current_header_value_.find("filename=");
if (filename_pos != std::string::npos) {
filename_pos += 9;
size_t end_pos;
if (reader->current_header_value_[filename_pos] == '"') {
filename_pos++;
end_pos = reader->current_header_value_.find('"', filename_pos);
} else {
end_pos = reader->current_header_value_.find_first_of("; \r\n", filename_pos);
}
if (end_pos != std::string::npos) {
reader->current_part_.filename = reader->current_header_value_.substr(filename_pos, end_pos - filename_pos);
}
}
} else if (field_lower == "content-type") {
reader->current_part_.content_type = reader->current_header_value_;
}
reader->current_header_value_.clear();
}
// Start new header field
reader->current_header_field_.assign(at, length);
reader->in_headers_ = true;
return 0;
}
int MultipartReader::on_header_value(multipart_parser *parser, const char *at, size_t length) {
MultipartReader *reader = static_cast<MultipartReader *>(multipart_parser_get_data(parser));
reader->current_header_value_.append(at, length);
return 0;
}
int MultipartReader::on_headers_complete(multipart_parser *parser) {
MultipartReader *reader = static_cast<MultipartReader *>(multipart_parser_get_data(parser));
// Process last header if any
if (!reader->current_header_value_.empty()) {
std::string field_lower = reader->current_header_field_;
std::transform(field_lower.begin(), field_lower.end(), field_lower.begin(), ::tolower);
if (field_lower == "content-disposition") {
// Parse name and filename from Content-Disposition
size_t name_pos = reader->current_header_value_.find("name=");
if (name_pos != std::string::npos) {
name_pos += 5;
size_t end_pos;
if (reader->current_header_value_[name_pos] == '"') {
name_pos++;
end_pos = reader->current_header_value_.find('"', name_pos);
} else {
end_pos = reader->current_header_value_.find_first_of("; \r\n", name_pos);
}
if (end_pos != std::string::npos) {
reader->current_part_.name = reader->current_header_value_.substr(name_pos, end_pos - name_pos);
}
}
size_t filename_pos = reader->current_header_value_.find("filename=");
if (filename_pos != std::string::npos) {
filename_pos += 9;
size_t end_pos;
if (reader->current_header_value_[filename_pos] == '"') {
filename_pos++;
end_pos = reader->current_header_value_.find('"', filename_pos);
} else {
end_pos = reader->current_header_value_.find_first_of("; \r\n", filename_pos);
}
if (end_pos != std::string::npos) {
reader->current_part_.filename = reader->current_header_value_.substr(filename_pos, end_pos - filename_pos);
}
}
} else if (field_lower == "content-type") {
reader->current_part_.content_type = reader->current_header_value_;
}
}
reader->in_headers_ = false;
reader->current_header_field_.clear();
reader->current_header_value_.clear();
ESP_LOGD(TAG, "Part headers complete: name='%s', filename='%s', content_type='%s'",
reader->current_part_.name.c_str(), reader->current_part_.filename.c_str(),
reader->current_part_.content_type.c_str());
return 0;
}
int MultipartReader::on_part_data_begin(multipart_parser *parser) {
MultipartReader *reader = static_cast<MultipartReader *>(multipart_parser_get_data(parser));
ESP_LOGD(TAG, "Part data begin");
return 0;
}
int MultipartReader::on_part_data(multipart_parser *parser, const char *at, size_t length) {
MultipartReader *reader = static_cast<MultipartReader *>(multipart_parser_get_data(parser));
// Only process file uploads
if (reader->has_file() && reader->data_callback_) {
reader->data_callback_(reinterpret_cast<const uint8_t *>(at), length);
}
return 0;
}
int MultipartReader::on_part_data_end(multipart_parser *parser) {
MultipartReader *reader = static_cast<MultipartReader *>(multipart_parser_get_data(parser));
ESP_LOGD(TAG, "Part data end");
if (reader->part_complete_callback_) {
reader->part_complete_callback_();
}
// Clear part info for next part
reader->current_part_ = Part{};
return 0;
}
} // namespace web_server_idf
} // namespace esphome
#endif // USE_WEBSERVER_OTA
#endif // USE_ESP_IDF

View File

@ -0,0 +1,65 @@
#pragma once
#ifdef USE_ESP_IDF
#ifdef USE_WEBSERVER_OTA
#include <esp_http_server.h>
#include <multipart_parser.h>
#include <string>
#include <functional>
namespace esphome {
namespace web_server_idf {
// Wrapper around zorxx/multipart-parser for ESP-IDF OTA uploads
class MultipartReader {
public:
struct Part {
std::string name;
std::string filename;
std::string content_type;
};
using DataCallback = std::function<void(const uint8_t *data, size_t len)>;
using PartCompleteCallback = std::function<void()>;
explicit MultipartReader(const std::string &boundary);
~MultipartReader();
// Set callbacks for handling data
void set_data_callback(DataCallback callback) { data_callback_ = callback; }
void set_part_complete_callback(PartCompleteCallback callback) { part_complete_callback_ = callback; }
// Parse incoming data
size_t parse(const char *data, size_t len);
// Get current part info
const Part &get_current_part() const { return current_part_; }
// Check if we found a file upload
bool has_file() const { return !current_part_.filename.empty(); }
private:
static int on_header_field(multipart_parser *parser, const char *at, size_t length);
static int on_header_value(multipart_parser *parser, const char *at, size_t length);
static int on_part_data_begin(multipart_parser *parser);
static int on_part_data(multipart_parser *parser, const char *at, size_t length);
static int on_part_data_end(multipart_parser *parser);
static int on_headers_complete(multipart_parser *parser);
multipart_parser *parser_{nullptr};
multipart_parser_settings settings_{};
Part current_part_;
std::string current_header_field_;
std::string current_header_value_;
DataCallback data_callback_;
PartCompleteCallback part_complete_callback_;
bool in_headers_{false};
};
} // namespace web_server_idf
} // namespace esphome
#endif // USE_WEBSERVER_OTA
#endif // USE_ESP_IDF

View File

@ -9,8 +9,7 @@
#include "utils.h"
#ifdef USE_WEBSERVER_OTA
#include "multipart_parser.h"
#include "multipart_parser_utils.h"
#include "multipart_reader.h"
#endif
#include "web_server_idf.h"
@ -79,16 +78,30 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) {
#ifdef USE_WEBSERVER_OTA
// Check if this is a multipart form data request (for OTA updates)
const char *boundary_start = nullptr;
size_t boundary_len = 0;
bool is_multipart = false;
std::string boundary;
if (content_type.has_value()) {
const char *ct = content_type.value().c_str();
is_multipart = parse_multipart_boundary(ct, &boundary_start, &boundary_len);
const std::string &ct = content_type.value();
size_t boundary_pos = ct.find("boundary=");
if (boundary_pos != std::string::npos) {
boundary_pos += 9; // Skip "boundary="
size_t boundary_end = ct.find_first_of(" ;\r\n", boundary_pos);
if (boundary_end == std::string::npos) {
boundary_end = ct.length();
}
if (ct[boundary_pos] == '"' && boundary_end > boundary_pos + 1 && ct[boundary_end - 1] == '"') {
// Quoted boundary
boundary = ct.substr(boundary_pos + 1, boundary_end - boundary_pos - 2);
} else {
// Unquoted boundary
boundary = ct.substr(boundary_pos, boundary_end - boundary_pos);
}
is_multipart = ct.find("multipart/form-data") != std::string::npos && !boundary.empty();
}
if (!is_multipart && !is_form_urlencoded(ct)) {
ESP_LOGW(TAG, "Unsupported content type for POST: %s", ct);
if (!is_multipart && ct.find("application/x-www-form-urlencoded") == std::string::npos) {
ESP_LOGW(TAG, "Unsupported content type for POST: %s", ct.c_str());
// fallback to get handler to support backward compatibility
return AsyncWebServer::request_handler(r);
}
@ -109,7 +122,7 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) {
#ifdef USE_WEBSERVER_OTA
// Handle multipart form data
if (is_multipart && boundary_start && boundary_len > 0) {
if (is_multipart && !boundary.empty()) {
// Create request object
AsyncWebServerRequest req(r);
auto *server = static_cast<AsyncWebServer *>(r->user_ctx);
@ -128,18 +141,36 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) {
return ESP_OK;
}
// Handle multipart upload - create boundary string only when needed
std::string boundary(boundary_start, boundary_len);
MultipartParser parser(boundary);
// Handle multipart upload using the multipart-parser library
MultipartReader reader(boundary);
static constexpr size_t CHUNK_SIZE = 1024;
uint8_t *chunk_buf = new uint8_t[CHUNK_SIZE];
char *chunk_buf = new char[CHUNK_SIZE];
size_t total_len = r->content_len;
size_t remaining = total_len;
bool first_part = true;
std::string current_filename;
bool upload_started = false;
// Set up callbacks for the multipart reader
reader.set_data_callback([&](const uint8_t *data, size_t len) {
if (!current_filename.empty()) {
found_handler->handleUpload(&req, current_filename, upload_started ? 1 : 0, const_cast<uint8_t *>(data), len,
false);
upload_started = true;
}
});
reader.set_part_complete_callback([&]() {
if (!current_filename.empty() && upload_started) {
// Signal end of this part
found_handler->handleUpload(&req, current_filename, 2, nullptr, 0, false);
current_filename.clear();
upload_started = false;
}
});
while (remaining > 0) {
size_t to_read = std::min(remaining, CHUNK_SIZE);
int recv_len = httpd_req_recv(r, reinterpret_cast<char *>(chunk_buf), to_read);
int recv_len = httpd_req_recv(r, chunk_buf, to_read);
if (recv_len <= 0) {
delete[] chunk_buf;
@ -152,23 +183,25 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) {
}
// Parse multipart data
if (parser.parse(chunk_buf, recv_len)) {
MultipartParser::Part part;
if (parser.get_current_part(part) && !part.filename.empty()) {
// This is a file upload
found_handler->handleUpload(&req, part.filename, first_part ? 0 : 1, const_cast<uint8_t *>(part.data),
part.length, false);
first_part = false;
parser.consume_part();
}
size_t parsed = reader.parse(chunk_buf, recv_len);
if (parsed != recv_len) {
ESP_LOGW(TAG, "Multipart parser error at byte %zu", total_len - remaining + parsed);
delete[] chunk_buf;
httpd_resp_send_err(r, HTTPD_400_BAD_REQUEST, nullptr);
return ESP_FAIL;
}
// Check if we found a new file part
if (reader.has_file() && current_filename.empty()) {
current_filename = reader.get_current_part().filename;
}
remaining -= recv_len;
}
// Final call to handler
if (!first_part) {
found_handler->handleUpload(&req, "", 2, nullptr, 0, true);
// Final cleanup - send final signal if upload was in progress
if (!current_filename.empty() && upload_started) {
found_handler->handleUpload(&req, current_filename, 2, nullptr, 0, true);
}
delete[] chunk_buf;