Multi-platform http server (#646)

- Add server support to `http-module`
- Ensure `DevelopmentService` works on all platforms (including posix)
- Ensure the built-in web server with dashboard works on all platforms (inluding posix). It still has some issues with certain featuers, but the basics work.
This commit is contained in:
Ken Van Hoeylandt
2026-09-06 14:16:23 +02:00
committed by GitHub
parent a0b2ee7ebc
commit 1b16184a72
24 changed files with 1890 additions and 901 deletions
-81
View File
@@ -1,81 +0,0 @@
#ifdef ESP_PLATFORM
#include <Tactility/network/HttpServer.h>
#include <Tactility/service/wifi/Wifi.h>
#include <tactility/log.h>
namespace tt::network {
constexpr auto* TAG = "HttpServer";
static constexpr size_t INTERNAL_URI_HANDLER_COUNT = 2;
bool HttpServer::startInternal() {
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.stack_size = stackSize;
config.server_port = port;
config.uri_match_fn = matchUri;
config.max_uri_handlers = handlers.size() + INTERNAL_URI_HANDLER_COUNT;
if (httpd_start(&server, &config) != ESP_OK) {
LOG_E(TAG, "Failed to start http server on port %u", (unsigned)port);
return false;
}
bool allRegistered = true;
for (std::vector<httpd_uri_t>::reference handler : handlers) {
if (httpd_register_uri_handler(server, &handler) != ESP_OK) {
LOG_E(TAG, "Failed to register URI handler: %s", handler.uri);
allRegistered = false;
}
}
if (!allRegistered) {
httpd_stop(server);
server = nullptr;
return false;
}
LOG_I(TAG, "Started on port %u", (unsigned)config.server_port);
return true;
}
void HttpServer::stopInternal() {
LOG_I(TAG, "Stopping server");
if (server != nullptr) {
if (httpd_stop(server) == ESP_OK) {
server = nullptr;
} else {
LOG_W(TAG, "Error while stopping");
}
}
}
bool HttpServer::start() {
auto lock = mutex.asScopedLock();
lock.lock();
if (isStarted()) {
LOG_W(TAG, "Already started");
return true;
}
return startInternal();
}
void HttpServer::stop() {
auto lock = mutex.asScopedLock();
lock.lock();
if (!isStarted()) {
LOG_W(TAG, "Not started");
return;
}
stopInternal();
}
}
#endif
+132
View File
@@ -0,0 +1,132 @@
#include <Tactility/network/HttpServerReq.h>
#include <tactility/log.h>
#include <algorithm>
namespace tt::network {
constexpr auto* TAG = "HttpServerReq";
bool getHeaderOrSendError(struct HttpServerRequest* request, const std::string& name, std::string& value) {
size_t length = http_server_request_get_header(request, name.c_str(), nullptr, 0);
if (length == 0) {
http_server_request_send_error(request, 400, "header missing");
return false;
}
value.resize(length);
http_server_request_get_header(request, name.c_str(), value.data(), length + 1);
return true;
}
bool getMultiPartBoundaryOrSendError(struct HttpServerRequest* request, std::string& boundary) {
std::string content_type;
if (!getHeaderOrSendError(request, "Content-Type", content_type)) {
return false;
}
auto boundary_index = content_type.find("boundary=");
if (boundary_index == std::string::npos) {
http_server_request_send_error(request, 400, "boundary not found in Content-Type");
return false;
}
boundary = content_type.substr(boundary_index + 9);
boundary = boundary.substr(0, boundary.find(';'));
// Trim any whitespace left by the ';' cut above, then unquote (RFC 2231 allows a quoted value).
while (!boundary.empty() && boundary.back() == ' ') {
boundary.pop_back();
}
if (boundary.size() >= 2 && boundary.front() == '"' && boundary.back() == '"') {
boundary = boundary.substr(1, boundary.size() - 2);
}
return true;
}
bool getQueryOrSendError(struct HttpServerRequest* request, std::string& query) {
size_t length = http_server_request_get_query(request, nullptr, 0);
if (length == 0) {
http_server_request_send_error(request, 400, "id not specified");
return false;
}
query.resize(length);
http_server_request_get_query(request, query.data(), length + 1);
return true;
}
// Reads exactly `length` bytes, or fails - unlike http_server_request_receive() itself, which may
// return fewer bytes than requested per call.
static bool receiveExact(struct HttpServerRequest* request, void* buffer, size_t length) {
size_t total_read = 0;
while (total_read < length) {
int read = http_server_request_receive(request, static_cast<char*>(buffer) + total_read, length - total_read);
if (read <= 0) {
return false;
}
total_read += static_cast<size_t>(read);
}
return true;
}
// Bounds a client's multipart preamble (boundary + part headers): without this, a client that
// keeps sending bytes without the terminator would make this buffer, and re-scan, unboundedly.
constexpr size_t MAX_PREAMBLE_LENGTH = 8192;
std::string receiveTextUntil(struct HttpServerRequest* request, const std::string& terminator) {
std::string result;
while (!result.ends_with(terminator)) {
if (result.length() >= MAX_PREAMBLE_LENGTH) {
return "";
}
char byte;
if (!receiveExact(request, &byte, 1)) {
return "";
}
result += byte;
}
return result;
}
bool readAndDiscardOrSendError(struct HttpServerRequest* request, const std::string& toRead) {
std::string buffer(toRead.length(), '\0');
if (!receiveExact(request, buffer.data(), toRead.length())) {
http_server_request_send_error(request, 400, "failed to read discardable data");
return false;
}
if (buffer != toRead) {
http_server_request_send_error(request, 400, "discardable data mismatch");
return false;
}
return true;
}
size_t receiveFile(struct HttpServerRequest* request, size_t length, const std::string& filePath) {
constexpr size_t BUFFER_SIZE = 512;
char buffer[BUFFER_SIZE];
size_t bytes_received = 0;
auto* file = fopen(filePath.c_str(), "wb");
if (file == nullptr) {
LOG_E(TAG, "Failed to open file for writing: %s", filePath.c_str());
return 0;
}
while (bytes_received < length) {
size_t expected_chunk_size = std::min<size_t>(BUFFER_SIZE, length - bytes_received);
int received = http_server_request_receive(request, buffer, expected_chunk_size);
if (received <= 0) {
LOG_E(TAG, "Receive failed, got 0 bytes but expected %zu more", length - bytes_received);
break;
}
if (fwrite(buffer, 1, static_cast<size_t>(received), file) != static_cast<size_t>(received)) {
LOG_E(TAG, "Failed to write all bytes");
break;
}
bytes_received += static_cast<size_t>(received);
}
fclose(file);
return bytes_received;
}
}
-192
View File
@@ -1,137 +1,10 @@
#include <Tactility/LogMessages.h>
#include <Tactility/StringUtils.h>
#include <Tactility/network/HttpdReq.h>
#include <tactility/log.h>
#include <memory>
#include <ranges>
#include <sstream>
#ifdef ESP_PLATFORM
namespace tt::network {
constexpr auto* TAG = "HttpdReq";
bool getHeaderOrSendError(httpd_req_t* request, const std::string& name, std::string& value) {
size_t header_size = httpd_req_get_hdr_value_len(request, name.c_str());
if (header_size == 0) {
httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "header missing");
return false;
}
auto header_buffer = std::make_unique<char[]>(header_size + 1);
if (header_buffer == nullptr) {
LOG_E(TAG, LOG_MESSAGE_ALLOC_FAILED);
httpd_resp_send_500(request);
return false;
}
if (httpd_req_get_hdr_value_str(request, name.c_str(), header_buffer.get(), header_size + 1) != ESP_OK) {
httpd_resp_send_500(request);
return false;
}
value = header_buffer.get();
return true;
}
bool getMultiPartBoundaryOrSendError(httpd_req_t* request, std::string& boundary) {
std::string content_type_header;
if (!getHeaderOrSendError(request, "Content-Type", content_type_header)) {
return false;
}
auto boundary_index = content_type_header.find("boundary=");
if (boundary_index == std::string::npos) {
httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "boundary not found in Content-Type");
return false;
}
boundary = content_type_header.substr(boundary_index + 9);
return true;
}
bool getQueryOrSendError(httpd_req_t* request, std::string& query) {
size_t buffer_length = httpd_req_get_url_query_len(request);
if (buffer_length == 0) {
httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "id not specified");
return false;
}
auto buffer = std::make_unique<char[]>(buffer_length + 1);
if (buffer.get() == nullptr || httpd_req_get_url_query_str(request, buffer.get(), buffer_length + 1) != ESP_OK) {
httpd_resp_send_500(request);
return false;
}
query = buffer.get();
return true;
}
std::unique_ptr<char[]> receiveByteArray(httpd_req_t* request, size_t length, size_t& bytesRead) {
assert(length > 0);
bytesRead = 0;
// We have to use malloc() because make_unique() throws an exception
// and we don't have exceptions enabled in the compiler settings
auto* buffer = static_cast<char*>(malloc(length));
if (buffer == nullptr) {
LOG_E(TAG, "Out of memory (failed to allocated %u bytes)", (unsigned)length);
return nullptr;
}
constexpr int MAX_TIMEOUT_RETRIES = 5;
int timeout_retries = 0;
while (bytesRead < length) {
size_t read_size = length - bytesRead;
int bytes_received = httpd_req_recv(request, buffer + bytesRead, read_size);
if (bytes_received == HTTPD_SOCK_ERR_TIMEOUT) {
// Timeout - retry with backoff
timeout_retries++;
if (timeout_retries >= MAX_TIMEOUT_RETRIES) {
LOG_W(TAG, "Recv timeout after %d retries, read %u/%u bytes", timeout_retries, (unsigned)bytesRead, (unsigned)length);
free(buffer);
return nullptr;
}
LOG_W(TAG, "Recv timeout, retry %d/%d", timeout_retries, MAX_TIMEOUT_RETRIES);
vTaskDelay(pdMS_TO_TICKS(100 * timeout_retries)); // Exponential backoff
continue;
}
if (bytes_received <= 0) {
LOG_W(TAG, "Received error %d after reading %u/%u bytes", bytes_received, (unsigned)bytesRead, (unsigned)length);
free(buffer);
return nullptr;
}
// Successful read - reset timeout counter
timeout_retries = 0;
bytesRead += bytes_received;
}
return std::unique_ptr<char[]>(buffer);
}
std::string receiveTextUntil(httpd_req_t* request, const std::string& terminator) {
size_t read_index = 0;
std::stringstream result;
while (!result.str().ends_with(terminator)) {
char buffer;
size_t bytes_read = httpd_req_recv(request, &buffer, 1);
if (bytes_read <= 0) {
return "";
} else {
read_index += bytes_read;
}
result << buffer;
}
return result.str();
}
std::map<std::string, std::string> parseContentDisposition(const std::vector<std::string>& input) {
std::map<std::string, std::string> result;
static std::string prefix = "Content-Disposition: ";
@@ -164,69 +37,4 @@ std::map<std::string, std::string> parseContentDisposition(const std::vector<std
return result;
}
bool readAndDiscardOrSendError(httpd_req_t* request, const std::string& toRead) {
size_t bytes_read;
auto buffer = receiveByteArray(request, toRead.length(), bytes_read);
if (buffer == nullptr || bytes_read != toRead.length()) {
httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "failed to read discardable data");
return false;
}
if (memcmp(buffer.get(), toRead.c_str(), bytes_read) != 0) {
httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "discardable data mismatch");
return false;
}
return true;
}
size_t receiveFile(httpd_req_t* request, size_t length, const std::string& filePath) {
constexpr auto BUFFER_SIZE = 512;
char buffer[BUFFER_SIZE];
size_t bytes_received = 0;
auto* file = fopen(filePath.c_str(), "wb");
if (file == nullptr) {
LOG_E(TAG, "Failed to open file for writing: %s", filePath.c_str());
return 0;
}
constexpr int MAX_TIMEOUT_RETRIES = 5;
int timeout_retries = 0;
while (bytes_received < length) {
auto expected_chunk_size = std::min<size_t>(BUFFER_SIZE, length - bytes_received);
int received = httpd_req_recv(request, buffer, expected_chunk_size);
if (received == HTTPD_SOCK_ERR_TIMEOUT) {
// Timeout - retry with backoff, same as receiveByteArray(). A large file takes many
// more chunks (and much longer overall) than the small reads elsewhere in this file,
// so it's far more likely to hit at least one transient stall somewhere along the way.
timeout_retries++;
if (timeout_retries >= MAX_TIMEOUT_RETRIES) {
LOG_E(TAG, "Recv timeout after %d retries, wrote %zu/%zu bytes", timeout_retries, bytes_received, length);
break;
}
LOG_W(TAG, "Recv timeout, retry %d/%d", timeout_retries, MAX_TIMEOUT_RETRIES);
vTaskDelay(pdMS_TO_TICKS(100 * timeout_retries)); // Exponential backoff
continue;
}
if (received <= 0) {
LOG_E(TAG, "Receive failed, got 0 bytes but expected %zu more", length - bytes_received);
break;
}
timeout_retries = 0;
size_t receive_chunk_size = (size_t)received;
if (fwrite(buffer, 1, receive_chunk_size, file) != receive_chunk_size) {
LOG_E(TAG, "Failed to write all bytes");
break;
}
bytes_received += receive_chunk_size;
}
fclose(file);
return bytes_received;
}
}
#endif // ESP_PLATFORM
+2 -1
View File
@@ -57,7 +57,8 @@ std::string urlEncode(const std::string& input) {
// Adapted from https://stackoverflow.com/a/29962178/3848666
std::string urlDecode(const std::string& input) {
std::string result;
size_t conversion_buffer, input_length = input.length();
unsigned int conversion_buffer;
size_t input_length = input.length();
for (size_t i = 0; i < input_length; i++) {
if (input[i] != '%') {