mirror of
https://github.com/ByteWelder/Tactility.git
synced 2026-04-18 09:25:06 +00:00
1867 lines
66 KiB
C++
1867 lines
66 KiB
C++
#ifdef ESP_PLATFORM
|
|
|
|
#include <tactility/check.h>
|
|
#include <Tactility/service/webserver/WebServerService.h>
|
|
#include <Tactility/service/webserver/AssetVersion.h>
|
|
#include <Tactility/service/ServiceManifest.h>
|
|
#include <Tactility/settings/WebServerSettings.h>
|
|
#include <Tactility/MountPoints.h>
|
|
#include <Tactility/file/File.h>
|
|
#include <Tactility/Logger.h>
|
|
#include <Tactility/lvgl/Statusbar.h>
|
|
#include <Tactility/Mutex.h>
|
|
|
|
#include <Tactility/TactilityConfig.h>
|
|
#include <tactility/hal/Device.h>
|
|
#include <Tactility/app/AppRegistration.h>
|
|
#include <Tactility/app/AppManifest.h>
|
|
#include <Tactility/app/App.h>
|
|
#include <Tactility/hal/sdcard/SdCardDevice.h>
|
|
#include <Tactility/service/wifi/Wifi.h>
|
|
#include <esp_wifi_default.h>
|
|
#include <Tactility/network/HttpdReq.h>
|
|
#include <Tactility/network/Url.h>
|
|
#include <Tactility/Paths.h>
|
|
#include <Tactility/lvgl/LvglSync.h>
|
|
#include <Tactility/lvgl/Lvgl.h>
|
|
#include <Tactility/StringUtils.h>
|
|
|
|
#include <ranges>
|
|
#include <tactility/filesystem/file_system.h>
|
|
|
|
#if TT_FEATURE_SCREENSHOT_ENABLED
|
|
#include <lv_screenshot.h>
|
|
#endif
|
|
|
|
#include <tactility/lvgl_icon_statusbar.h>
|
|
|
|
#include <atomic>
|
|
#include <cctype>
|
|
#include <cerrno>
|
|
#include <cstring>
|
|
#include <esp_chip_info.h>
|
|
#include <esp_flash.h>
|
|
#include <esp_heap_caps.h>
|
|
#include <esp_netif.h>
|
|
#include <esp_system.h>
|
|
#include <esp_vfs_fat.h>
|
|
#include <esp_wifi.h>
|
|
#include <freertos/FreeRTOS.h>
|
|
#include <freertos/task.h>
|
|
#include <iomanip>
|
|
#include <lwip/ip4_addr.h>
|
|
#include <mbedtls/base64.h>
|
|
#include <sstream>
|
|
|
|
namespace tt::service::webserver {
|
|
|
|
static const auto LOGGER = tt::Logger("WebServerService");
|
|
|
|
// Helper to convert chip model enum to human-readable string
|
|
static const char* getChipModelName(esp_chip_model_t model) {
|
|
switch (model) {
|
|
case CHIP_ESP32: return "ESP32";
|
|
case CHIP_ESP32S2: return "ESP32-S2";
|
|
case CHIP_ESP32S3: return "ESP32-S3";
|
|
case CHIP_ESP32C3: return "ESP32-C3";
|
|
case CHIP_ESP32C2: return "ESP32-C2";
|
|
case CHIP_ESP32C6: return "ESP32-C6";
|
|
case CHIP_ESP32H2: return "ESP32-H2";
|
|
#ifdef CHIP_ESP32P4
|
|
case CHIP_ESP32P4: return "ESP32-P4";
|
|
#endif
|
|
#ifdef CHIP_ESP32C5
|
|
case CHIP_ESP32C5: return "ESP32-C5";
|
|
#endif
|
|
#ifdef CHIP_ESP32C61
|
|
case CHIP_ESP32C61: return "ESP32-C61";
|
|
#endif
|
|
default: return "Unknown";
|
|
}
|
|
}
|
|
|
|
// Cached settings to avoid SD card reads on every HTTP request
|
|
static Mutex g_settingsMutex;
|
|
static settings::webserver::WebServerSettings g_cachedSettings;
|
|
static bool g_settingsCached = false;
|
|
|
|
// Global instance pointer for controlling the service (atomic to prevent TOCTOU races)
|
|
static std::atomic<WebServerService*> g_webServerInstance{nullptr};
|
|
|
|
constexpr int MAX_UPLOAD_SIZE = 10 * 1024 * 1024; // 10 MB limit
|
|
|
|
static void publish_event(WebServerService* webserver, WebServerEvent event) {
|
|
webserver->getPubsub()->publish(event);
|
|
}
|
|
|
|
std::shared_ptr<PubSub<WebServerEvent>> getPubsub() {
|
|
WebServerService* webserver = g_webServerInstance.load();
|
|
if (webserver == nullptr) {
|
|
check(false, "Service not running");
|
|
}
|
|
|
|
return webserver->getPubsub();
|
|
}
|
|
|
|
static bool secureCompare(const std::string& a, const std::string& b) {
|
|
size_t maxLen = std::max(a.size(), b.size());
|
|
volatile unsigned char result = 0;
|
|
result |= (a.size() != b.size());
|
|
for (size_t i = 0; i < maxLen; ++i) {
|
|
unsigned char ca = (i < a.size()) ? static_cast<unsigned char>(a[i]) : 0;
|
|
unsigned char cb = (i < b.size()) ? static_cast<unsigned char>(b[i]) : 0;
|
|
result |= ca ^ cb;
|
|
}
|
|
return result == 0;
|
|
}
|
|
|
|
// Helper to send 401 Unauthorized response with WWW-Authenticate header
|
|
static esp_err_t sendUnauthorized(httpd_req_t* request, const char* message) {
|
|
httpd_resp_set_hdr(request, "WWW-Authenticate", "Basic realm=\"Tactility\"");
|
|
httpd_resp_send_err(request, HTTPD_401_UNAUTHORIZED, message);
|
|
return ESP_OK; // Response was sent successfully
|
|
}
|
|
|
|
// Helper to validate HTTP Basic Auth on sensitive endpoints
|
|
// Returns ESP_OK with authPassed=true if auth succeeded or is disabled
|
|
// Returns ESP_OK with authPassed=false if auth failed (401 response already sent)
|
|
static esp_err_t validateRequestAuth(httpd_req_t* request, bool& authPassed) {
|
|
authPassed = false;
|
|
|
|
// Copy settings under lock to avoid race with settings update callback
|
|
settings::webserver::WebServerSettings settings;
|
|
{
|
|
auto lock = g_settingsMutex.asScopedLock();
|
|
lock.lock();
|
|
settings = g_cachedSettings;
|
|
}
|
|
|
|
if (!settings.webServerAuthEnabled) {
|
|
authPassed = true;
|
|
return ESP_OK; // Auth disabled, allow request
|
|
}
|
|
|
|
// Get Authorization header
|
|
size_t auth_len = httpd_req_get_hdr_value_len(request, "Authorization");
|
|
if (auth_len == 0) {
|
|
return sendUnauthorized(request, "Authorization required");
|
|
}
|
|
|
|
std::string auth_header(auth_len + 1, '\0');
|
|
if (httpd_req_get_hdr_value_str(request, "Authorization", auth_header.data(), auth_len + 1) != ESP_OK) {
|
|
LOGGER.warn("Failed to read Authorization header");
|
|
return sendUnauthorized(request, "Authorization required");
|
|
}
|
|
auth_header.resize(auth_len); // Remove null terminator from string length
|
|
|
|
// Check for "Basic " prefix
|
|
if (auth_header.rfind("Basic ", 0) != 0) {
|
|
LOGGER.warn("Authorization header is not Basic auth");
|
|
return sendUnauthorized(request, "Basic authorization required");
|
|
}
|
|
|
|
// Extract base64 encoded credentials
|
|
std::string base64_creds = auth_header.substr(6);
|
|
|
|
// Decode base64 using mbedtls (available in ESP-IDF)
|
|
size_t decoded_len = 0;
|
|
// First pass to get length
|
|
mbedtls_base64_decode(nullptr, 0, &decoded_len,
|
|
reinterpret_cast<const unsigned char*>(base64_creds.c_str()),
|
|
base64_creds.length());
|
|
|
|
std::string decoded(decoded_len, '\0');
|
|
size_t actual_len = 0;
|
|
int ret = mbedtls_base64_decode(reinterpret_cast<unsigned char*>(decoded.data()),
|
|
decoded_len, &actual_len,
|
|
reinterpret_cast<const unsigned char*>(base64_creds.c_str()),
|
|
base64_creds.length());
|
|
if (ret != 0) {
|
|
LOGGER.warn("Failed to decode base64 credentials");
|
|
return sendUnauthorized(request, "Invalid credentials format");
|
|
}
|
|
decoded.resize(actual_len);
|
|
|
|
// Parse username:password
|
|
size_t colon_pos = decoded.find(':');
|
|
if (colon_pos == std::string::npos) {
|
|
LOGGER.warn("Invalid credentials format (no colon separator)");
|
|
return sendUnauthorized(request, "Invalid credentials format");
|
|
}
|
|
|
|
std::string username = decoded.substr(0, colon_pos);
|
|
std::string password = decoded.substr(colon_pos + 1);
|
|
|
|
// Validate against cached settings
|
|
bool usernameMatch = secureCompare(username, settings.webServerUsername);
|
|
bool passwordMatch = secureCompare(password, settings.webServerPassword);
|
|
if (!usernameMatch || !passwordMatch) {
|
|
LOGGER.warn("Invalid credentials for user '{}'", username);
|
|
return sendUnauthorized(request, "Invalid credentials");
|
|
}
|
|
|
|
authPassed = true;
|
|
return ESP_OK; // Auth successful
|
|
}
|
|
|
|
bool WebServerService::onStart(ServiceContext& service) {
|
|
LOGGER.info("Starting WebServer service...");
|
|
|
|
// Register global instance
|
|
g_webServerInstance.store(this);
|
|
|
|
// Create statusbar icon (hidden initially, shown when server actually starts)
|
|
statusbarIconId = lvgl::statusbar_icon_add();
|
|
lvgl::statusbar_icon_set_visibility(statusbarIconId, false);
|
|
|
|
// Run asset synchronization on startup
|
|
if (!syncAssets()) {
|
|
LOGGER.warn("Asset sync failed, but continuing with available assets");
|
|
}
|
|
|
|
// Load and cache settings once at boot
|
|
bool serverEnabled;
|
|
{
|
|
auto lock = g_settingsMutex.asScopedLock();
|
|
lock.lock();
|
|
g_cachedSettings = settings::webserver::loadOrGetDefault();
|
|
g_settingsCached = true;
|
|
serverEnabled = g_cachedSettings.webServerEnabled;
|
|
}
|
|
// Subscribe to settings change events to refresh cache
|
|
settingsEventSubscription = pubsub->subscribe([](WebServerEvent event) {
|
|
if (event == WebServerEvent::WebServerSettingsChanged) {
|
|
auto lock = g_settingsMutex.asScopedLock();
|
|
lock.lock();
|
|
g_cachedSettings = settings::webserver::loadOrGetDefault();
|
|
g_settingsCached = true;
|
|
}
|
|
});
|
|
|
|
// Start HTTP server only if enabled in settings (default: OFF to save memory)
|
|
if (serverEnabled) {
|
|
LOGGER.info("WebServer enabled in settings, starting HTTP server...");
|
|
setEnabled(true);
|
|
} else {
|
|
LOGGER.info("WebServer disabled in settings, NOT starting HTTP server (saves ~10KB RAM)");
|
|
setEnabled(false);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
void WebServerService::onStop(ServiceContext& service) {
|
|
g_webServerInstance.store(nullptr);
|
|
|
|
pubsub->unsubscribe(settingsEventSubscription);
|
|
settingsEventSubscription = 0;
|
|
|
|
setEnabled(false);
|
|
|
|
// Remove statusbar icon
|
|
if (statusbarIconId >= 0) {
|
|
lvgl::statusbar_icon_remove(statusbarIconId);
|
|
statusbarIconId = -1;
|
|
}
|
|
}
|
|
|
|
// region Enable/Disable
|
|
|
|
void WebServerService::setEnabled(bool enabled) {
|
|
auto lock = mutex.asScopedLock();
|
|
lock.lock();
|
|
|
|
if (enabled) {
|
|
if (!httpServer || !httpServer->isStarted()) {
|
|
startServer();
|
|
}
|
|
} else {
|
|
if (httpServer && httpServer->isStarted()) {
|
|
stopServer();
|
|
}
|
|
}
|
|
}
|
|
|
|
bool WebServerService::isEnabled() const {
|
|
auto lock = mutex.asScopedLock();
|
|
lock.lock();
|
|
return httpServer && httpServer->isStarted();
|
|
}
|
|
|
|
// region AP Mode WiFi Management
|
|
|
|
bool WebServerService::startApMode() {
|
|
// Copy settings locally
|
|
settings::webserver::WebServerSettings settings;
|
|
{
|
|
auto lock = g_settingsMutex.asScopedLock();
|
|
lock.lock();
|
|
settings = g_cachedSettings;
|
|
}
|
|
|
|
if (settings.wifiMode != settings::webserver::WiFiMode::AccessPoint) {
|
|
LOGGER.info("Not in AP mode, skipping AP WiFi initialization");
|
|
return true; // Not an error, just not needed
|
|
}
|
|
|
|
LOGGER.info("Starting WiFi in Access Point mode...");
|
|
|
|
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
|
|
if (esp_wifi_init(&cfg) != ESP_OK) {
|
|
LOGGER.error("esp_wifi_init() failed");
|
|
return false;
|
|
}
|
|
apWifiInitialized = true;
|
|
|
|
// Create the AP network interface
|
|
apNetif = esp_netif_create_default_wifi_ap();
|
|
if (apNetif == nullptr) {
|
|
LOGGER.error("esp_netif_create_default_wifi_ap() failed");
|
|
esp_wifi_deinit();
|
|
apWifiInitialized = false;
|
|
return false;
|
|
}
|
|
|
|
if (esp_wifi_set_mode(WIFI_MODE_AP) != ESP_OK) {
|
|
LOGGER.error("esp_wifi_set_mode(AP) failed");
|
|
stopApMode();
|
|
return false;
|
|
}
|
|
|
|
// Configure static IP for AP: 192.168.4.1/24
|
|
esp_netif_ip_info_t ip_info;
|
|
memset(&ip_info, 0, sizeof(esp_netif_ip_info_t));
|
|
ip_info.ip.addr = ipaddr_addr("192.168.4.1");
|
|
ip_info.gw.addr = ipaddr_addr("192.168.4.1");
|
|
ip_info.netmask.addr = ipaddr_addr("255.255.255.0");
|
|
|
|
if (esp_netif_dhcps_stop(apNetif) != ESP_OK) {
|
|
LOGGER.error("esp_netif_dhcps_stop() failed");
|
|
stopApMode();
|
|
return false;
|
|
}
|
|
|
|
if (esp_netif_set_ip_info(apNetif, &ip_info) != ESP_OK) {
|
|
LOGGER.error("esp_netif_set_ip_info() failed");
|
|
stopApMode();
|
|
return false;
|
|
}
|
|
|
|
if (esp_netif_dhcps_start(apNetif) != ESP_OK) {
|
|
LOGGER.error("esp_netif_dhcps_start() failed");
|
|
stopApMode();
|
|
return false;
|
|
}
|
|
|
|
// Configure WiFi AP settings
|
|
wifi_config_t wifi_config;
|
|
memset(&wifi_config, 0, sizeof(wifi_config_t));
|
|
|
|
// Set SSID
|
|
strncpy(reinterpret_cast<char*>(wifi_config.ap.ssid), settings.apSsid.c_str(), sizeof(wifi_config.ap.ssid) - 1);
|
|
wifi_config.ap.ssid[sizeof(wifi_config.ap.ssid) - 1] = '\0';
|
|
wifi_config.ap.ssid_len = static_cast<uint8_t>(settings.apSsid.length());
|
|
|
|
// Set password and auth mode
|
|
if (settings.apOpenNetwork) {
|
|
// User explicitly chose an open network
|
|
wifi_config.ap.authmode = WIFI_AUTH_OPEN;
|
|
LOGGER.info("AP configured with OPEN authentication (user choice)");
|
|
} else if (settings.apPassword.length() >= 8 && settings.apPassword.length() <= 63) {
|
|
wifi_config.ap.authmode = WIFI_AUTH_WPA2_PSK;
|
|
strncpy(reinterpret_cast<char*>(wifi_config.ap.password), settings.apPassword.c_str(), sizeof(wifi_config.ap.password) - 1);
|
|
wifi_config.ap.password[sizeof(wifi_config.ap.password) - 1] = '\0';
|
|
LOGGER.info("AP configured with WPA2-PSK authentication");
|
|
} else {
|
|
if (!settings.apPassword.empty()) {
|
|
LOGGER.warn("AP password invalid (must be 8-63 chars, got {}) - using OPEN mode", settings.apPassword.length());
|
|
}
|
|
wifi_config.ap.authmode = WIFI_AUTH_OPEN;
|
|
LOGGER.warn("AP configured with OPEN authentication (no password)");
|
|
}
|
|
|
|
wifi_config.ap.max_connection = 4;
|
|
wifi_config.ap.channel = settings.apChannel;
|
|
|
|
if (esp_wifi_set_config(WIFI_IF_AP, &wifi_config) != ESP_OK) {
|
|
LOGGER.error("esp_wifi_set_config(AP) failed");
|
|
stopApMode();
|
|
return false;
|
|
}
|
|
|
|
if (esp_wifi_start() != ESP_OK) {
|
|
LOGGER.error("esp_wifi_start() failed");
|
|
stopApMode();
|
|
return false;
|
|
}
|
|
|
|
LOGGER.info("WiFi AP started - SSID: '{}', Channel: {}, IP: 192.168.4.1", settings.apSsid, settings.apChannel);
|
|
return true;
|
|
}
|
|
|
|
void WebServerService::stopApMode() {
|
|
if (apWifiInitialized) {
|
|
esp_err_t err;
|
|
if (apNetif != nullptr) {
|
|
esp_wifi_clear_default_wifi_driver_and_handlers(apNetif);
|
|
}
|
|
err = esp_wifi_stop();
|
|
if (err != ESP_OK && err != ESP_ERR_WIFI_NOT_STARTED) {
|
|
LOGGER.warn("esp_wifi_stop() in cleanup: {}", esp_err_to_name(err));
|
|
}
|
|
LOGGER.info("WiFi AP stopped");
|
|
|
|
err = esp_wifi_set_mode(WIFI_MODE_STA);
|
|
if (err != ESP_OK) {
|
|
LOGGER.warn("esp_wifi_set_mode() in cleanup: {}", esp_err_to_name(err));
|
|
}
|
|
LOGGER.info("Wifi mode set back to STA");
|
|
|
|
apWifiInitialized = false;
|
|
}
|
|
|
|
if (apNetif != nullptr) {
|
|
esp_netif_destroy(apNetif);
|
|
apNetif = nullptr;
|
|
}
|
|
}
|
|
|
|
// endregion
|
|
|
|
bool WebServerService::startServer() {
|
|
// Copy settings locally to minimize lock duration
|
|
settings::webserver::WebServerSettings settings;
|
|
{
|
|
auto lock = g_settingsMutex.asScopedLock();
|
|
lock.lock();
|
|
settings = g_cachedSettings;
|
|
}
|
|
|
|
// Start AP mode WiFi if configured
|
|
if (settings.wifiMode == settings::webserver::WiFiMode::AccessPoint) {
|
|
if (!startApMode()) {
|
|
LOGGER.error("Failed to start AP mode WiFi - HTTP server will not start");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// NOTE: If you see 'no slots left for registering handler', increase CONFIG_HTTPD_MAX_URI_HANDLERS in sdkconfig (default is 8, 16+ recommended for many endpoints)
|
|
void* ctx = this; // Avoid IDE warnings about 'this' in designated initializers
|
|
std::vector<httpd_uri_t> handlers = {
|
|
{
|
|
.uri = "/",
|
|
.method = HTTP_GET,
|
|
.handler = handleRoot,
|
|
.user_ctx = ctx
|
|
},
|
|
// Note: /upload removed in favor of POST /fs/upload handled by /fs/* dispatcher
|
|
{
|
|
.uri = "/filebrowser",
|
|
.method = HTTP_GET,
|
|
.handler = handleFileBrowser,
|
|
.user_ctx = ctx
|
|
},
|
|
// Consolidated /fs/* handlers (dispatch internally) to save uri handler slots
|
|
{
|
|
.uri = "/fs/*",
|
|
.method = HTTP_GET,
|
|
.handler = handleFsGenericGet,
|
|
.user_ctx = ctx
|
|
},
|
|
{
|
|
.uri = "/fs/*",
|
|
.method = HTTP_POST,
|
|
.handler = handleFsGenericPost,
|
|
.user_ctx = ctx
|
|
},
|
|
// Consolidated admin POST endpoints to save handler slots
|
|
{
|
|
.uri = "/admin/*",
|
|
.method = HTTP_POST,
|
|
.handler = handleAdminPost,
|
|
.user_ctx = ctx
|
|
},
|
|
// API endpoints for system info, apps, wifi, etc
|
|
{
|
|
.uri = "/api/*",
|
|
.method = HTTP_GET,
|
|
.handler = handleApiGet,
|
|
.user_ctx = ctx
|
|
},
|
|
{
|
|
.uri = "/api/*",
|
|
.method = HTTP_POST,
|
|
.handler = handleApiPost,
|
|
.user_ctx = ctx
|
|
},
|
|
{
|
|
.uri = "/api/*",
|
|
.method = HTTP_PUT,
|
|
.handler = handleApiPut,
|
|
.user_ctx = ctx
|
|
},
|
|
{
|
|
.uri = "/*", // Catch-all for dynamic assets
|
|
.method = HTTP_GET,
|
|
.handler = handleAssets,
|
|
.user_ctx = ctx
|
|
}
|
|
};
|
|
|
|
httpServer = std::make_unique<network::HttpServer>(
|
|
settings.webServerPort,
|
|
"0.0.0.0",
|
|
handlers,
|
|
8192 // Stack size
|
|
);
|
|
|
|
httpServer->start();
|
|
if (!httpServer->isStarted()) {
|
|
LOGGER.error("Failed to start HTTP server on port {}", settings.webServerPort);
|
|
httpServer.reset();
|
|
return false;
|
|
}
|
|
|
|
LOGGER.info("HTTP server started successfully on port {}", settings.webServerPort);
|
|
publish_event(this, WebServerEvent::WebServerStarted);
|
|
|
|
// Show statusbar icon
|
|
if (statusbarIconId >= 0) {
|
|
lvgl::statusbar_icon_set_image(statusbarIconId, LVGL_ICON_STATUSBAR_CLOUD);
|
|
lvgl::statusbar_icon_set_visibility(statusbarIconId, true);
|
|
LOGGER.info("WebServer statusbar icon shown ({} mode)",
|
|
settings.wifiMode == settings::webserver::WiFiMode::AccessPoint ? "AP" : "Station");
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
void WebServerService::stopServer() {
|
|
if (!httpServer) {
|
|
return;
|
|
}
|
|
|
|
httpServer->stop();
|
|
httpServer.reset();
|
|
|
|
// Stop AP mode WiFi if we started it
|
|
if (apWifiInitialized || apNetif != nullptr) {
|
|
stopApMode();
|
|
}
|
|
|
|
LOGGER.info("HTTP server stopped");
|
|
publish_event(this, WebServerEvent::WebServerStopped);
|
|
|
|
if (statusbarIconId >= 0) {
|
|
lvgl::statusbar_icon_set_visibility(statusbarIconId, false);
|
|
}
|
|
}
|
|
|
|
// region Endpoints
|
|
|
|
|
|
|
|
esp_err_t WebServerService::handleRoot(httpd_req_t* request) {
|
|
LOGGER.info("GET / -> redirecting to /dashboard.html");
|
|
httpd_resp_set_status(request, "302 Found");
|
|
httpd_resp_set_hdr(request, "Location", "/dashboard.html");
|
|
return httpd_resp_send(request, nullptr, 0);
|
|
}
|
|
|
|
// region File Browser helpers & handlers
|
|
|
|
// Helper to determine content type from file extension
|
|
static const char* getContentType(const std::string& path) {
|
|
// Check from the end to avoid matching extensions in directory names
|
|
auto endsWith = [&path](const char* ext) {
|
|
size_t extLen = strlen(ext);
|
|
return path.length() >= extLen &&
|
|
path.compare(path.length() - extLen, extLen, ext) == 0;
|
|
};
|
|
|
|
// HTML/Text
|
|
if (endsWith(".html") || endsWith(".htm")) return "text/html";
|
|
if (endsWith(".css")) return "text/css";
|
|
if (endsWith(".js")) return "application/javascript";
|
|
if (endsWith(".json")) return "application/json";
|
|
if (endsWith(".xml")) return "application/xml";
|
|
if (endsWith(".txt")) return "text/plain";
|
|
|
|
// Images
|
|
if (endsWith(".png")) return "image/png";
|
|
if (endsWith(".jpg") || endsWith(".jpeg")) return "image/jpeg";
|
|
if (endsWith(".gif")) return "image/gif";
|
|
if (endsWith(".svg")) return "image/svg+xml";
|
|
if (endsWith(".ico")) return "image/x-icon";
|
|
if (endsWith(".webp")) return "image/webp";
|
|
|
|
// Fonts
|
|
if (endsWith(".woff")) return "font/woff";
|
|
if (endsWith(".woff2")) return "font/woff2";
|
|
if (endsWith(".ttf")) return "font/ttf";
|
|
if (endsWith(".otf")) return "font/otf";
|
|
if (endsWith(".eot")) return "application/vnd.ms-fontobject";
|
|
|
|
// Audio/Video
|
|
if (endsWith(".mp3")) return "audio/mpeg";
|
|
if (endsWith(".wav")) return "audio/wav";
|
|
if (endsWith(".ogg")) return "audio/ogg";
|
|
if (endsWith(".mp4")) return "video/mp4";
|
|
if (endsWith(".webm")) return "video/webm";
|
|
|
|
// Archives/Documents
|
|
if (endsWith(".pdf")) return "application/pdf";
|
|
if (endsWith(".zip")) return "application/zip";
|
|
if (endsWith(".gz")) return "application/gzip";
|
|
|
|
// Default
|
|
return "application/octet-stream";
|
|
}
|
|
|
|
static bool isAllowedBasePath(const std::string& path, bool allowRoot = false) {
|
|
if (path.empty()) return false;
|
|
// Check for ".." as a complete path component
|
|
if (path == ".." || path.starts_with("../") ||
|
|
path.find("/../") != std::string::npos || path.ends_with("/..")) {
|
|
return false;
|
|
}
|
|
if (allowRoot && path == "/") return true;
|
|
return path == "/data" || path.starts_with("/data/") || path == "/sdcard" || path.starts_with("/sdcard/");
|
|
}
|
|
|
|
// Normalize client-supplied path: URL-decode, trim quotes/control chars, ensure leading slash, collapse duplicate slashes
|
|
static std::string normalizePath(const std::string& raw) {
|
|
// Helper: hex to int
|
|
auto hexVal = [](char c)->int {
|
|
if (c >= '0' && c <= '9') return c - '0';
|
|
if (c >= 'a' && c <= 'f') return 10 + (c - 'a');
|
|
if (c >= 'A' && c <= 'F') return 10 + (c - 'A');
|
|
return -1;
|
|
};
|
|
|
|
std::string s = raw;
|
|
// Remove surrounding single or double quotes
|
|
if (s.size() >= 2 && ((s.front() == '\'' && s.back() == '\'') || (s.front() == '"' && s.back() == '"'))) {
|
|
s = s.substr(1, s.size() - 2);
|
|
}
|
|
|
|
// URL-decode: %xx and '+' -> ' '
|
|
std::string decoded;
|
|
decoded.reserve(s.size());
|
|
for (size_t i = 0; i < s.size(); ++i) {
|
|
char c = s[i];
|
|
if (c == '%') {
|
|
if (i + 2 < s.size()) {
|
|
int hi = hexVal(s[i+1]);
|
|
int lo = hexVal(s[i+2]);
|
|
if (hi >= 0 && lo >= 0) {
|
|
decoded.push_back(static_cast<char>((hi << 4) | lo));
|
|
i += 2;
|
|
continue;
|
|
}
|
|
}
|
|
// malformed %, keep it
|
|
decoded.push_back(c);
|
|
} else if (c == '+') {
|
|
decoded.push_back(' ');
|
|
} else {
|
|
// strip control characters
|
|
if (static_cast<unsigned char>(c) > 31) decoded.push_back(c);
|
|
}
|
|
}
|
|
|
|
// Trim whitespace from ends
|
|
size_t start = 0;
|
|
while (start < decoded.size() && isspace((unsigned char)decoded[start])) ++start;
|
|
size_t end = decoded.size();
|
|
while (end > start && isspace((unsigned char)decoded[end-1])) --end;
|
|
std::string trimmed = decoded.substr(start, end - start);
|
|
|
|
// Ensure leading slash
|
|
if (!trimmed.empty() && trimmed.front() != '/') trimmed = '/' + trimmed;
|
|
if (trimmed.empty()) trimmed = "/";
|
|
|
|
// Collapse duplicate slashes
|
|
std::string out;
|
|
out.reserve(trimmed.size());
|
|
bool lastSlash = false;
|
|
for (char c : trimmed) {
|
|
if (c == '/') {
|
|
if (!lastSlash) { out.push_back(c); lastSlash = true; }
|
|
} else { out.push_back(c); lastSlash = false; }
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
static std::string escapeJson(const std::string& s) {
|
|
std::ostringstream o;
|
|
for (char c : s) {
|
|
switch (c) {
|
|
case '"': o << "\\\""; break;
|
|
case '\\': o << "\\\\"; break;
|
|
case '\n': o << "\\n"; break;
|
|
case '\r': o << "\\r"; break;
|
|
case '\t': o << "\\t"; break;
|
|
default:
|
|
if (static_cast<unsigned char>(c) < 0x20) {
|
|
o << "\\u" << std::hex << std::setw(4) << std::setfill('0') << (int)c;
|
|
} else {
|
|
o << c;
|
|
}
|
|
}
|
|
}
|
|
return o.str();
|
|
}
|
|
|
|
static bool getQueryParam(httpd_req_t* req, const char* key, std::string& out) {
|
|
size_t len = httpd_req_get_url_query_len(req) + 1;
|
|
if (len <= 1) return false;
|
|
std::unique_ptr<char[]> buf(new char[len]);
|
|
if (httpd_req_get_url_query_str(req, buf.get(), len) != ESP_OK) return false;
|
|
// Allocate buffer large enough for the entire query string (worst case)
|
|
std::unique_ptr<char[]> value(new char[len]);
|
|
if (httpd_query_key_value(buf.get(), key, value.get(), len) == ESP_OK) {
|
|
out = value.get();
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
static bool uriMatches(const char* uri, const char* route) {
|
|
const size_t n = strlen(route);
|
|
return strncmp(uri, route, n) == 0 && (uri[n] == '\0' || uri[n] == '?' || uri[n] == '/');
|
|
}
|
|
|
|
esp_err_t WebServerService::handleFileBrowser(httpd_req_t* request) {
|
|
LOGGER.info("GET /filebrowser -> redirecting to /dashboard.html#files");
|
|
httpd_resp_set_status(request, "302 Found");
|
|
httpd_resp_set_hdr(request, "Location", "/dashboard.html#files");
|
|
return httpd_resp_send(request, nullptr, 0);
|
|
}
|
|
|
|
esp_err_t WebServerService::handleFsList(httpd_req_t* request) {
|
|
std::string path;
|
|
// Log raw query string for diagnostics
|
|
size_t qlen = httpd_req_get_url_query_len(request) + 1;
|
|
if (qlen > 1) {
|
|
std::unique_ptr<char[]> qbuf(new char[qlen]);
|
|
if (httpd_req_get_url_query_str(request, qbuf.get(), qlen) == ESP_OK) {
|
|
LOGGER.info("GET /fs/list raw query: {}", qbuf.get());
|
|
}
|
|
}
|
|
|
|
if (!getQueryParam(request, "path", path) || path.empty()) path = "/";
|
|
std::string norm = normalizePath(path);
|
|
LOGGER.info("GET /fs/list decoded path: '{}' normalized: '{}'", path, norm);
|
|
|
|
// Allow root path for listing mount points
|
|
if (!isAllowedBasePath(norm, true)) {
|
|
LOGGER.warn("GET /fs/list - invalid path requested: '{}' normalized: '{}'", path, norm);
|
|
httpd_resp_set_type(request, "application/json");
|
|
httpd_resp_sendstr(request, "{\"error\":\"invalid path\"}");
|
|
return ESP_OK;
|
|
}
|
|
|
|
std::ostringstream json;
|
|
json << "{\"path\":\"" << norm << "\",\"entries\":[";
|
|
struct FsIterContext {
|
|
std::ostringstream& json;
|
|
uint16_t count = 0;
|
|
};
|
|
FsIterContext fs_iter_context { json };
|
|
// Special handling for root: show available mount points
|
|
if (norm == "/") {
|
|
file_system_for_each(&fs_iter_context, [] (auto* fs, void* context) {
|
|
auto* fs_iter_context = static_cast<FsIterContext*>(context);
|
|
char path[128];
|
|
if (file_system_is_mounted(fs) && file_system_get_path(fs, path, sizeof(path)) == ERROR_NONE && strcmp(path, "/system") != 0) {
|
|
fs_iter_context->count++;
|
|
if (fs_iter_context->count != 1) fs_iter_context->json << ","; // add separator between json array entries
|
|
fs_iter_context->json << "{\"name\":\"" << path << "\",\"type\":\"dir\",\"size\":0}";
|
|
}
|
|
return true;
|
|
});
|
|
|
|
json << "]}";
|
|
} else {
|
|
std::vector<dirent> entries;
|
|
int res = file::scandir(norm, entries, file::direntFilterDotEntries, nullptr);
|
|
if (res < 0) {
|
|
httpd_resp_set_type(request, "application/json");
|
|
httpd_resp_sendstr(request, "{\"error\":\"scan failed\"}");
|
|
return ESP_OK;
|
|
}
|
|
bool first = true;
|
|
for (auto& e : entries) {
|
|
if (!first) json << ','; else first = false;
|
|
std::string name = e.d_name;
|
|
bool is_dir = (e.d_type == file::TT_DT_DIR || e.d_type == file::TT_DT_CHR);
|
|
std::string full = norm + "/" + name;
|
|
long size = 0;
|
|
if (!is_dir) {
|
|
struct stat st;
|
|
if (stat(full.c_str(), &st) == 0) {
|
|
size = st.st_size;
|
|
}
|
|
}
|
|
json << "{\"name\":\"" << escapeJson(name) << "\",\"type\":\"" << (is_dir?"dir":"file") << "\",\"size\":" << size << "}";
|
|
}
|
|
json << "]}";
|
|
}
|
|
|
|
httpd_resp_set_type(request, "application/json");
|
|
httpd_resp_sendstr(request, json.str().c_str());
|
|
return ESP_OK;
|
|
}
|
|
|
|
esp_err_t WebServerService::handleFsDownload(httpd_req_t* request) {
|
|
std::string path;
|
|
if (!getQueryParam(request, "path", path) || path.empty()) {
|
|
httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "path required");
|
|
return ESP_FAIL;
|
|
}
|
|
std::string norm = normalizePath(path);
|
|
if (!isAllowedBasePath(norm) || !file::isFile(norm)) {
|
|
LOGGER.warn("GET /fs/download - not found or invalid path: '{}' normalized: '{}'", path, norm);
|
|
httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "not found");
|
|
return ESP_FAIL;
|
|
}
|
|
httpd_resp_set_type(request, getContentType(norm));
|
|
// Suggest download - build header into a local string so it remains valid
|
|
std::string fname = file::getLastPathSegment(norm);
|
|
std::string disposition = std::string("attachment; filename=\"") + fname + "\"";
|
|
// RFC5987 fallback (filename*): percent-encode UTF-8 bytes for wider browser compatibility
|
|
auto pctEncode = [](const std::string& s)->std::string{
|
|
std::ostringstream oss;
|
|
for (unsigned char c : s) {
|
|
if (std::isalnum(c) || c=='-' || c=='.' || c=='_' || c=='~') {
|
|
oss << c;
|
|
} else {
|
|
oss << '%';
|
|
std::ostringstream hex;
|
|
hex << std::uppercase << std::hex << std::setw(2) << std::setfill('0') << (int)c;
|
|
oss << hex.str();
|
|
}
|
|
}
|
|
return oss.str();
|
|
};
|
|
std::string pct = pctEncode(fname);
|
|
if (!pct.empty()) {
|
|
disposition += std::string("; filename*=UTF-8''") + pct;
|
|
}
|
|
// Set single Content-Disposition header (avoid adding duplicate headers)
|
|
httpd_resp_set_hdr(request, "Content-Disposition", disposition.c_str());
|
|
FILE* fp = fopen(norm.c_str(), "rb");
|
|
if (!fp) { httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "open failed"); return ESP_FAIL; }
|
|
char buf[512]; size_t n;
|
|
while ((n = fread(buf,1,sizeof(buf),fp))>0) {
|
|
if (httpd_resp_send_chunk(request, buf, n) != ESP_OK) { fclose(fp); return ESP_FAIL; }
|
|
}
|
|
fclose(fp);
|
|
httpd_resp_send_chunk(request, nullptr, 0);
|
|
return ESP_OK;
|
|
}
|
|
|
|
esp_err_t WebServerService::handleFsUpload(httpd_req_t* request) {
|
|
std::string path;
|
|
|
|
// Log raw query and decoded path for diagnostics
|
|
size_t qlen = httpd_req_get_url_query_len(request) + 1;
|
|
if (qlen > 1) {
|
|
std::unique_ptr<char[]> qbuf(new char[qlen]);
|
|
if (httpd_req_get_url_query_str(request, qbuf.get(), qlen) == ESP_OK) {
|
|
LOGGER.info("POST /fs/upload raw query: {}", qbuf.get());
|
|
}
|
|
}
|
|
|
|
if (!getQueryParam(request, "path", path) || path.empty()) {
|
|
httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "path required");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
// Log decoded path and headers
|
|
char content_type[64] = {0};
|
|
httpd_req_get_hdr_value_str(request, "Content-Type", content_type, sizeof(content_type));
|
|
std::string norm = normalizePath(path);
|
|
LOGGER.info("POST /fs/upload decoded path: '{}' normalized: '{}' Content-Length: {} Content-Type: {}", path, norm, (int)request->content_len, content_type[0] ? content_type : "(null)");
|
|
|
|
if (!isAllowedBasePath(norm)) {
|
|
LOGGER.warn("POST /fs/upload - invalid path requested: '{}' normalized: '{}'", path, norm);
|
|
httpd_resp_send_err(request, HTTPD_403_FORBIDDEN, "invalid path");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
if (request->content_len > MAX_UPLOAD_SIZE) {
|
|
httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "file too large");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
// Ensure parent directory exists (after size check to avoid creating dirs for rejected uploads)
|
|
if (!file::findOrCreateParentDirectory(norm, 0755)) {
|
|
httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "failed to create parent directory");
|
|
return ESP_FAIL;
|
|
}
|
|
FILE* fp = fopen(norm.c_str(), "wb");
|
|
if (!fp) { httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "open failed"); return ESP_FAIL; }
|
|
char buf[512]; int remaining = request->content_len; int received=0;
|
|
constexpr int MAX_TIMEOUT_RETRIES = 5;
|
|
int timeout_retries = 0;
|
|
while (remaining > 0) {
|
|
int to_read = remaining > (int)sizeof(buf) ? (int)sizeof(buf) : remaining;
|
|
int ret = httpd_req_recv(request, buf, to_read);
|
|
if (ret == HTTPD_SOCK_ERR_TIMEOUT) {
|
|
// Timeout - retry with backoff
|
|
timeout_retries++;
|
|
if (timeout_retries >= MAX_TIMEOUT_RETRIES) {
|
|
LOGGER.error("Upload recv timeout after {} retries", timeout_retries);
|
|
fclose(fp);
|
|
remove(norm.c_str()); // Clean up partial file
|
|
httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "recv timeout");
|
|
return ESP_FAIL;
|
|
}
|
|
LOGGER.warn("Upload recv timeout, retry {}/{}", timeout_retries, MAX_TIMEOUT_RETRIES);
|
|
vTaskDelay(pdMS_TO_TICKS(100 * timeout_retries)); // Linear backoff
|
|
continue;
|
|
}
|
|
if (ret <= 0) {
|
|
LOGGER.error("Upload recv failed with error {}", ret);
|
|
fclose(fp);
|
|
remove(norm.c_str()); // Clean up partial file
|
|
httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "recv failed");
|
|
return ESP_FAIL;
|
|
}
|
|
// Successful read - reset timeout counter
|
|
timeout_retries = 0;
|
|
size_t written = fwrite(buf, 1, ret, fp);
|
|
if (written != (size_t)ret) {
|
|
fclose(fp);
|
|
remove(norm.c_str());
|
|
httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "write failed");
|
|
return ESP_FAIL;
|
|
}
|
|
remaining -= ret;
|
|
received += ret;
|
|
}
|
|
fclose(fp);
|
|
httpd_resp_set_type(request, "text/plain");
|
|
std::string msg = std::string("Uploaded ") + std::to_string(received) + " bytes";
|
|
httpd_resp_sendstr(request, msg.c_str());
|
|
return ESP_OK;
|
|
}
|
|
|
|
// Generic GET dispatcher for /fs/* URIs
|
|
esp_err_t WebServerService::handleFsGenericGet(httpd_req_t* request) {
|
|
// Auth check for all /fs/* endpoints (file system access is sensitive)
|
|
bool authPassed = false;
|
|
esp_err_t authResult = validateRequestAuth(request, authPassed);
|
|
if (!authPassed) {
|
|
return authResult;
|
|
}
|
|
|
|
const char* uri = request->uri;
|
|
if (uriMatches(uri, "/fs/list")) return handleFsList(request);
|
|
if (uriMatches(uri, "/fs/download")) return handleFsDownload(request);
|
|
if (uriMatches(uri, "/fs/tree")) return handleFsTree(request);
|
|
LOGGER.warn("GET {} - not found in fs generic dispatcher", uri);
|
|
httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "not found");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
// Generic POST dispatcher for /fs/* URIs
|
|
esp_err_t WebServerService::handleFsGenericPost(httpd_req_t* request) {
|
|
// Auth check for all /fs/* endpoints (file system access is sensitive)
|
|
bool authPassed = false;
|
|
esp_err_t authResult = validateRequestAuth(request, authPassed);
|
|
if (!authPassed) {
|
|
return authResult;
|
|
}
|
|
|
|
const char* uri = request->uri;
|
|
if (uriMatches(uri, "/fs/mkdir")) return handleFsMkdir(request);
|
|
if (uriMatches(uri, "/fs/delete")) return handleFsDelete(request);
|
|
if (uriMatches(uri, "/fs/rename")) return handleFsRename(request);
|
|
if (uriMatches(uri, "/fs/upload")) return handleFsUpload(request);
|
|
LOGGER.warn("POST {} - not found in fs generic dispatcher", uri);
|
|
httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "not found");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
// Admin dispatcher for consolidated small POST endpoints (e.g. sync, reboot)
|
|
esp_err_t WebServerService::handleAdminPost(httpd_req_t* request) {
|
|
// Auth check for all /admin/* endpoints (admin actions are sensitive)
|
|
bool authPassed = false;
|
|
esp_err_t authResult = validateRequestAuth(request, authPassed);
|
|
if (!authPassed) {
|
|
return authResult;
|
|
}
|
|
|
|
const char* uri = request->uri;
|
|
if (strncmp(uri, "/admin/sync", 11) == 0) return handleSync(request);
|
|
if (strncmp(uri, "/admin/reboot", 13) == 0) return handleReboot(request);
|
|
LOGGER.info("POST {} - not found in admin dispatcher", uri);
|
|
httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "not found");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
// API GET dispatcher - returns JSON system information
|
|
// Note: /api/sysinfo is intentionally public for monitoring use cases
|
|
esp_err_t WebServerService::handleApiGet(httpd_req_t* request) {
|
|
const char* uri = request->uri;
|
|
|
|
// Public endpoint: sysinfo (basic device info for monitoring)
|
|
if (strncmp(uri, "/api/sysinfo", 12) == 0) {
|
|
return handleApiSysinfo(request);
|
|
}
|
|
|
|
// Protected endpoints require authentication
|
|
bool authPassed = false;
|
|
esp_err_t authResult = validateRequestAuth(request, authPassed);
|
|
if (!authPassed) {
|
|
return authResult;
|
|
}
|
|
|
|
// Auth-protected endpoints
|
|
if (strncmp(uri, "/api/apps", 9) == 0) {
|
|
return handleApiApps(request);
|
|
}
|
|
if (strncmp(uri, "/api/wifi", 9) == 0) {
|
|
return handleApiWifi(request);
|
|
}
|
|
if (strncmp(uri, "/api/screenshot", 15) == 0) {
|
|
return handleApiScreenshot(request);
|
|
}
|
|
|
|
LOGGER.warn("GET {} - not found in api dispatcher", uri);
|
|
httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "not found");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
// API POST dispatcher - all POST endpoints require authentication
|
|
esp_err_t WebServerService::handleApiPost(httpd_req_t* request) {
|
|
bool authPassed = false;
|
|
esp_err_t authResult = validateRequestAuth(request, authPassed);
|
|
if (!authPassed) {
|
|
return authResult;
|
|
}
|
|
|
|
const char* uri = request->uri;
|
|
if (strncmp(uri, "/api/apps/run", 13) == 0) {
|
|
return handleApiAppsRun(request);
|
|
}
|
|
if (strncmp(uri, "/api/apps/uninstall", 19) == 0) {
|
|
return handleApiAppsUninstall(request);
|
|
}
|
|
|
|
LOGGER.warn("POST {} - not found in api dispatcher", uri);
|
|
httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "not found");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
// API PUT dispatcher - all PUT endpoints require authentication
|
|
esp_err_t WebServerService::handleApiPut(httpd_req_t* request) {
|
|
bool authPassed = false;
|
|
esp_err_t authResult = validateRequestAuth(request, authPassed);
|
|
if (!authPassed) {
|
|
return authResult;
|
|
}
|
|
|
|
const char* uri = request->uri;
|
|
if (strncmp(uri, "/api/apps/install", 17) == 0) {
|
|
return handleApiAppsInstall(request);
|
|
}
|
|
|
|
LOGGER.warn("PUT {} - not found in api dispatcher", uri);
|
|
httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "not found");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
esp_err_t WebServerService::handleApiSysinfo(httpd_req_t* request) {
|
|
LOGGER.info("GET /api/sysinfo");
|
|
|
|
std::ostringstream json;
|
|
json << "{";
|
|
|
|
// Firmware info
|
|
json << "\"firmware\":{";
|
|
json << "\"version\":\"" << TT_VERSION << "\",";
|
|
json << "\"idf_version\":\"" << ESP_IDF_VERSION_MAJOR << "." << ESP_IDF_VERSION_MINOR << "." << ESP_IDF_VERSION_PATCH << "\"";
|
|
json << "},";
|
|
|
|
// Chip info
|
|
esp_chip_info_t chip_info;
|
|
esp_chip_info(&chip_info);
|
|
json << "\"chip\":{";
|
|
json << "\"model\":\"" << getChipModelName(chip_info.model) << "\",";
|
|
json << "\"cores\":" << (int)chip_info.cores << ",";
|
|
json << "\"revision\":" << (int)chip_info.revision << ",";
|
|
|
|
// Decode features into an array of strings
|
|
json << "\"features\":[";
|
|
bool first_feature = true;
|
|
if (chip_info.features & CHIP_FEATURE_EMB_FLASH) {
|
|
json << "\"Embedded Flash\"";
|
|
first_feature = false;
|
|
}
|
|
if (chip_info.features & CHIP_FEATURE_WIFI_BGN) {
|
|
if (!first_feature) json << ",";
|
|
json << "\"WiFi 2.4GHz\"";
|
|
first_feature = false;
|
|
}
|
|
if (chip_info.features & CHIP_FEATURE_BLE) {
|
|
if (!first_feature) json << ",";
|
|
json << "\"BLE\"";
|
|
first_feature = false;
|
|
}
|
|
if (chip_info.features & CHIP_FEATURE_BT) {
|
|
if (!first_feature) json << ",";
|
|
json << "\"Bluetooth Classic\"";
|
|
first_feature = false;
|
|
}
|
|
if (chip_info.features & CHIP_FEATURE_IEEE802154) {
|
|
if (!first_feature) json << ",";
|
|
json << "\"IEEE 802.15.4\"";
|
|
first_feature = false;
|
|
}
|
|
if (chip_info.features & CHIP_FEATURE_EMB_PSRAM) {
|
|
if (!first_feature) json << ",";
|
|
json << "\"Embedded PSRAM\"";
|
|
}
|
|
json << "],";
|
|
|
|
// Internal flash size
|
|
uint32_t flash_size = 0;
|
|
esp_flash_get_size(nullptr, &flash_size);
|
|
json << "\"flash_size\":" << flash_size;
|
|
json << "},";
|
|
|
|
// Memory - Internal heap
|
|
size_t heap_free = heap_caps_get_free_size(MALLOC_CAP_INTERNAL);
|
|
size_t heap_total = heap_caps_get_total_size(MALLOC_CAP_INTERNAL);
|
|
size_t heap_min_free = heap_caps_get_minimum_free_size(MALLOC_CAP_INTERNAL);
|
|
size_t heap_largest = heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL);
|
|
|
|
json << "\"heap\":{";
|
|
json << "\"free\":" << heap_free << ",";
|
|
json << "\"total\":" << heap_total << ",";
|
|
json << "\"min_free\":" << heap_min_free << ",";
|
|
json << "\"largest_block\":" << heap_largest;
|
|
json << "},";
|
|
|
|
// Memory - PSRAM (external)
|
|
size_t psram_free = heap_caps_get_free_size(MALLOC_CAP_SPIRAM);
|
|
size_t psram_total = heap_caps_get_total_size(MALLOC_CAP_SPIRAM);
|
|
size_t psram_min_free = heap_caps_get_minimum_free_size(MALLOC_CAP_SPIRAM);
|
|
size_t psram_largest = heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM);
|
|
|
|
json << "\"psram\":{";
|
|
json << "\"free\":" << psram_free << ",";
|
|
json << "\"total\":" << psram_total << ",";
|
|
json << "\"min_free\":" << psram_min_free << ",";
|
|
json << "\"largest_block\":" << psram_largest;
|
|
json << "},";
|
|
|
|
// Storage info
|
|
json << "\"storage\":{";
|
|
uint64_t storage_total = 0, storage_free = 0;
|
|
|
|
struct FsIterContext {
|
|
std::ostringstream& json;
|
|
uint16_t count = 0;
|
|
};
|
|
FsIterContext fs_iter_context { json };
|
|
file_system_for_each(&fs_iter_context, [] (auto* fs, void* context) {
|
|
char mount_path[128] = "";
|
|
if (file_system_get_path(fs, mount_path, sizeof(mount_path)) != ERROR_NONE) return true;
|
|
if (strcmp(mount_path, "/system") == 0) return true; // Hide system partition
|
|
|
|
bool mounted = file_system_is_mounted(fs);
|
|
auto* fs_iter_context = static_cast<FsIterContext*>(context);
|
|
auto& json_context = fs_iter_context->json;
|
|
std::string mount_path_cpp = mount_path;
|
|
|
|
fs_iter_context->count++;
|
|
if (fs_iter_context->count != 1) json_context << ","; // add separator between json array entries
|
|
json_context << "\"" << mount_path_cpp.substr(1) << "\":{";
|
|
|
|
uint64_t storage_total = 0, storage_free = 0;
|
|
if (esp_vfs_fat_info(mount_path, &storage_total, &storage_free) == ESP_OK) {
|
|
json_context << "\"free\":" << storage_free << ",";
|
|
json_context << "\"total\":" << storage_total << ",";
|
|
} else {
|
|
json_context << "\"free\":0,";
|
|
json_context << "\"total\":0,";
|
|
}
|
|
|
|
json_context << "\"mounted\":" << (mounted ? "true" : "false") << "";
|
|
json_context << "}";
|
|
return true;
|
|
});
|
|
|
|
json << "},"; // end storage
|
|
|
|
// Uptime (in seconds)
|
|
TickType_t ticks = xTaskGetTickCount();
|
|
float uptime_sec = static_cast<float>(ticks) / configTICK_RATE_HZ;
|
|
json << "\"uptime\":" << static_cast<int>(uptime_sec) << ",";
|
|
|
|
// Task count
|
|
UBaseType_t task_count = uxTaskGetNumberOfTasks();
|
|
json << "\"task_count\":" << task_count << ",";
|
|
|
|
// Feature flags
|
|
json << "\"features_enabled\":{";
|
|
#if TT_FEATURE_SCREENSHOT_ENABLED
|
|
json << "\"screenshot\":true";
|
|
#else
|
|
json << "\"screenshot\":false";
|
|
#endif
|
|
json << "}";
|
|
|
|
json << "}";
|
|
|
|
httpd_resp_set_type(request, "application/json");
|
|
httpd_resp_sendstr(request, json.str().c_str());
|
|
return ESP_OK;
|
|
}
|
|
|
|
// GET /api/apps - List installed apps
|
|
esp_err_t WebServerService::handleApiApps(httpd_req_t* request) {
|
|
LOGGER.info("GET /api/apps");
|
|
|
|
auto manifests = app::getAppManifests();
|
|
|
|
std::ostringstream json;
|
|
json << "{\"apps\":[";
|
|
|
|
bool first = true;
|
|
for (const auto& manifest : manifests) {
|
|
if (!first) json << ",";
|
|
first = false;
|
|
|
|
json << "{";
|
|
json << "\"id\":\"" << escapeJson(manifest->appId) << "\",";
|
|
json << "\"name\":\"" << escapeJson(manifest->appName) << "\",";
|
|
json << "\"version\":\"" << escapeJson(manifest->appVersionName) << "\",";
|
|
|
|
const char* category = "user";
|
|
if (manifest->appCategory == app::Category::System) category = "system";
|
|
else if (manifest->appCategory == app::Category::Settings) category = "settings";
|
|
json << "\"category\":\"" << category << "\",";
|
|
|
|
json << "\"isExternal\":" << (manifest->appLocation.isExternal() ? "true" : "false") << ",";
|
|
json << "\"hidden\":" << ((manifest->appFlags & app::AppManifest::Flags::Hidden) ? "true" : "false");
|
|
|
|
if (!manifest->appIcon.empty()) {
|
|
json << ",\"icon\":\"" << escapeJson(manifest->appIcon) << "\"";
|
|
}
|
|
|
|
json << "}";
|
|
}
|
|
|
|
json << "]}";
|
|
|
|
httpd_resp_set_type(request, "application/json");
|
|
httpd_resp_sendstr(request, json.str().c_str());
|
|
return ESP_OK;
|
|
}
|
|
|
|
// POST /api/apps/run?id=xxx - Run an app
|
|
esp_err_t WebServerService::handleApiAppsRun(httpd_req_t* request) {
|
|
LOGGER.info("POST /api/apps/run");
|
|
|
|
std::string appId;
|
|
if (!getQueryParam(request, "id", appId) || appId.empty()) {
|
|
httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "id parameter required");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
auto manifest = app::findAppManifestById(appId);
|
|
if (!manifest) {
|
|
httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "app not found");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
// Stop if already running
|
|
if (app::isRunning(appId)) {
|
|
app::stopAll(appId);
|
|
}
|
|
|
|
app::start(appId);
|
|
|
|
LOGGER.info("[200] /api/apps/run {}", appId);
|
|
httpd_resp_sendstr(request, "ok");
|
|
return ESP_OK;
|
|
}
|
|
|
|
// POST /api/apps/uninstall?id=xxx - Uninstall an app
|
|
esp_err_t WebServerService::handleApiAppsUninstall(httpd_req_t* request) {
|
|
LOGGER.info("POST /api/apps/uninstall");
|
|
|
|
std::string appId;
|
|
if (!getQueryParam(request, "id", appId) || appId.empty()) {
|
|
httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "id parameter required");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
auto manifest = app::findAppManifestById(appId);
|
|
if (!manifest) {
|
|
LOGGER.info("[200] /api/apps/uninstall {} (app wasn't installed)", appId);
|
|
httpd_resp_sendstr(request, "ok");
|
|
return ESP_OK;
|
|
}
|
|
|
|
// Only allow uninstalling external apps
|
|
if (manifest->appLocation.isInternal()) {
|
|
httpd_resp_send_err(request, HTTPD_403_FORBIDDEN, "cannot uninstall system apps");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
if (app::uninstall(appId)) {
|
|
LOGGER.info("[200] /api/apps/uninstall {}", appId);
|
|
httpd_resp_sendstr(request, "ok");
|
|
return ESP_OK;
|
|
} else {
|
|
LOGGER.warn("[500] /api/apps/uninstall {}", appId);
|
|
httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "uninstall failed");
|
|
return ESP_FAIL;
|
|
}
|
|
}
|
|
|
|
// PUT /api/apps/install - Install an app from multipart form upload
|
|
esp_err_t WebServerService::handleApiAppsInstall(httpd_req_t* request) {
|
|
LOGGER.info("PUT /api/apps/install");
|
|
|
|
std::string boundary;
|
|
if (!network::getMultiPartBoundaryOrSendError(request, boundary)) {
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
size_t content_left = request->content_len;
|
|
constexpr size_t MAX_APP_UPLOAD_SIZE = 20 * 1024 * 1024;
|
|
|
|
// Read headers until empty line (skip boundary line first)
|
|
auto content_headers_data = network::receiveTextUntil(request, "\r\n\r\n");
|
|
content_left -= content_headers_data.length();
|
|
|
|
// Split headers into lines and filter empty ones
|
|
auto content_headers = string::split(content_headers_data, "\r\n")
|
|
| std::views::filter([](const std::string& line) {
|
|
return line.length() > 0;
|
|
})
|
|
| std::ranges::to<std::vector>();
|
|
|
|
auto content_disposition_map = network::parseContentDisposition(content_headers);
|
|
if (content_disposition_map.empty()) {
|
|
LOGGER.warn("parseContentDisposition returned empty map for: {}", content_headers_data);
|
|
httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "invalid content disposition");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
auto filename_entry = content_disposition_map.find("filename");
|
|
if (filename_entry == content_disposition_map.end()) {
|
|
LOGGER.warn("filename not found in content disposition map");
|
|
httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "filename parameter missing");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
// Calculate file size
|
|
auto boundary_and_newlines_after_file = std::format("\r\n--{}--\r\n", boundary);
|
|
if (content_left <= boundary_and_newlines_after_file.length()) {
|
|
httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "invalid multipart payload");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
auto file_size = content_left - boundary_and_newlines_after_file.length();
|
|
if (file_size == 0 || file_size > MAX_APP_UPLOAD_SIZE) {
|
|
httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "file too large");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
// Create tmp directory
|
|
const std::string tmp_path = getTempPath();
|
|
if (!file::findOrCreateDirectory(tmp_path, 0777)) {
|
|
httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "failed to create temp directory");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
std::string safe_name = file::getLastPathSegment(filename_entry->second);
|
|
if (safe_name.empty() || safe_name.find("..") != std::string::npos ||
|
|
safe_name.find('/') != std::string::npos || safe_name.find('\\') != std::string::npos) {
|
|
httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "invalid filename");
|
|
return ESP_FAIL;
|
|
}
|
|
auto file_path = std::format("{}/{}", tmp_path, safe_name);
|
|
|
|
if (network::receiveFile(request, file_size, file_path) != file_size) {
|
|
file::deleteFile(file_path);
|
|
httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "failed to save file");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
content_left -= file_size;
|
|
|
|
// Read and discard trailing boundary
|
|
if (!network::readAndDiscardOrSendError(request, boundary_and_newlines_after_file)) {
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
// Install the app
|
|
if (!app::install(file_path)) {
|
|
file::deleteFile(file_path);
|
|
httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "installation failed");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
// Cleanup temp file
|
|
if (!file::deleteFile(file_path)) {
|
|
LOGGER.warn("Failed to delete temp file {}", file_path);
|
|
}
|
|
|
|
LOGGER.info("[200] /api/apps/install -> {}", file_path);
|
|
httpd_resp_sendstr(request, "ok");
|
|
return ESP_OK;
|
|
}
|
|
|
|
// Helper to convert radio state to string
|
|
static const char* radioStateToJsonString(wifi::RadioState state) {
|
|
switch (state) {
|
|
case wifi::RadioState::On: return "on";
|
|
case wifi::RadioState::OnPending: return "turning_on";
|
|
case wifi::RadioState::Off: return "off";
|
|
case wifi::RadioState::OffPending: return "turning_off";
|
|
case wifi::RadioState::ConnectionPending: return "connecting";
|
|
case wifi::RadioState::ConnectionActive: return "connected";
|
|
default: return "unknown";
|
|
}
|
|
}
|
|
|
|
// GET /api/wifi - WiFi status
|
|
esp_err_t WebServerService::handleApiWifi(httpd_req_t* request) {
|
|
LOGGER.info("GET /api/wifi");
|
|
|
|
auto state = wifi::getRadioState();
|
|
auto ip = wifi::getIp();
|
|
auto ssid = wifi::getConnectionTarget();
|
|
auto rssi = wifi::getRssi();
|
|
bool secure = wifi::isConnectionSecure();
|
|
|
|
std::ostringstream json;
|
|
json << "{";
|
|
json << "\"state\":\"" << radioStateToJsonString(state) << "\",";
|
|
json << "\"ip\":\"" << escapeJson(ip) << "\",";
|
|
json << "\"ssid\":\"" << escapeJson(ssid) << "\",";
|
|
json << "\"rssi\":" << rssi << ",";
|
|
json << "\"secure\":" << (secure ? "true" : "false");
|
|
json << "}";
|
|
|
|
httpd_resp_set_type(request, "application/json");
|
|
httpd_resp_sendstr(request, json.str().c_str());
|
|
return ESP_OK;
|
|
}
|
|
|
|
// GET /api/screenshot - Capture and return screenshot as PNG
|
|
// Screenshots are saved to SD card root (if available) or /data with incrementing numbers
|
|
esp_err_t WebServerService::handleApiScreenshot(httpd_req_t* request) {
|
|
LOGGER.info("GET /api/screenshot");
|
|
|
|
#if TT_FEATURE_SCREENSHOT_ENABLED
|
|
// Determine save location: prefer SD card root if mounted, otherwise /data
|
|
std::string save_path;
|
|
if (!findFirstMountedSdCardPath(save_path)) {
|
|
save_path = file::MOUNT_POINT_DATA;
|
|
}
|
|
|
|
// Find next available filename with incrementing number
|
|
std::string screenshot_path;
|
|
bool found_slot = false;
|
|
for (int i = 1; i <= 9999; ++i) {
|
|
screenshot_path = std::format("{}/webscreenshot{}.png", save_path, i);
|
|
if (!file::isFile(screenshot_path)) {
|
|
found_slot = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!found_slot) {
|
|
httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "no available screenshot slots");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
LOGGER.info("Screenshot will be saved to: {}", screenshot_path);
|
|
|
|
// LVGL's lodepng uses lv_fs which requires the "A:" prefix
|
|
std::string lvgl_screenshot_path = lvgl::PATH_PREFIX + screenshot_path;
|
|
|
|
// Capture screenshot using LVGL
|
|
if (lvgl::lock(pdMS_TO_TICKS(100))) {
|
|
bool success = lv_screenshot_create(lv_scr_act(), LV_100ASK_SCREENSHOT_SV_PNG, lvgl_screenshot_path.c_str());
|
|
lvgl::unlock();
|
|
|
|
if (!success) {
|
|
LOGGER.error("lv_screenshot_create failed for path: {}", lvgl_screenshot_path);
|
|
httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "screenshot capture failed");
|
|
return ESP_FAIL;
|
|
}
|
|
LOGGER.info("Screenshot captured successfully");
|
|
} else {
|
|
LOGGER.error("Could not acquire LVGL lock within 100ms");
|
|
httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "could not acquire LVGL lock");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
// Send the file (use regular path for fopen, not LVGL path)
|
|
httpd_resp_set_type(request, "image/png");
|
|
|
|
FILE* fp = fopen(screenshot_path.c_str(), "rb");
|
|
if (!fp) {
|
|
httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "failed to open screenshot");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
char buf[512];
|
|
size_t n;
|
|
while ((n = fread(buf, 1, sizeof(buf), fp)) > 0) {
|
|
if (httpd_resp_send_chunk(request, buf, n) != ESP_OK) {
|
|
fclose(fp);
|
|
return ESP_FAIL;
|
|
}
|
|
}
|
|
fclose(fp);
|
|
httpd_resp_send_chunk(request, nullptr, 0);
|
|
|
|
// File is kept on storage (not deleted) for user access
|
|
LOGGER.info("[200] /api/screenshot -> {}", screenshot_path);
|
|
return ESP_OK;
|
|
#else
|
|
httpd_resp_send_err(request, HTTPD_501_METHOD_NOT_IMPLEMENTED, "screenshot feature not enabled");
|
|
return ESP_FAIL;
|
|
#endif
|
|
}
|
|
|
|
esp_err_t WebServerService::handleFsTree(httpd_req_t* request) {
|
|
|
|
LOGGER.info("GET /fs/tree");
|
|
|
|
std::ostringstream json;
|
|
json << "{";
|
|
// Gather mount points
|
|
auto mounts = file::getFileSystemDirents();
|
|
json << "\"mounts\": [";
|
|
bool firstMount = true;
|
|
for (auto& m : mounts) {
|
|
if (!firstMount) json << ','; else firstMount = false;
|
|
std::string name = m.d_name;
|
|
std::string path = (name == std::string("data") || name == std::string("/data")) ? std::string("/data") : std::string("/") + name;
|
|
// normalize possible duplicate slash
|
|
if (!path.starts_with("/")) path = std::string("/") + path;
|
|
json << "{\"name\":\"" << escapeJson(name) << "\",\"path\":\"" << escapeJson(path) << "\",\"entries\": [";
|
|
|
|
std::vector<dirent> entries;
|
|
int res = file::scandir(path, entries, file::direntFilterDotEntries, nullptr);
|
|
if (res > 0) {
|
|
bool first = true;
|
|
for (auto& e : entries) {
|
|
if (!first) json << ','; else first = false;
|
|
std::string en = e.d_name;
|
|
bool is_dir = (e.d_type == file::TT_DT_DIR || e.d_type == file::TT_DT_CHR);
|
|
json << "{\"name\":\"" << escapeJson(en) << "\",\"type\":\"" << (is_dir?"dir":"file") << "\"}";
|
|
}
|
|
}
|
|
|
|
json << "]}";
|
|
}
|
|
json << "]}";
|
|
|
|
httpd_resp_set_type(request, "application/json");
|
|
httpd_resp_sendstr(request, json.str().c_str());
|
|
return ESP_OK;
|
|
}
|
|
|
|
// Create a directory at the specified path (POST /fs/mkdir?path=/data/newdir)
|
|
esp_err_t WebServerService::handleFsMkdir(httpd_req_t* request) {
|
|
std::string path;
|
|
if (!getQueryParam(request, "path", path) || path.empty()) {
|
|
httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "path required");
|
|
return ESP_FAIL;
|
|
}
|
|
std::string norm = normalizePath(path);
|
|
LOGGER.info("POST /fs/mkdir requested: '{}' normalized: '{}'", path, norm);
|
|
if (!isAllowedBasePath(norm)) {
|
|
httpd_resp_send_err(request, HTTPD_403_FORBIDDEN, "invalid path");
|
|
return ESP_FAIL;
|
|
}
|
|
bool ok = file::findOrCreateDirectory(norm, 0755);
|
|
if (!ok) { httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "mkdir failed"); return ESP_FAIL; }
|
|
httpd_resp_sendstr(request, "ok");
|
|
return ESP_OK;
|
|
}
|
|
|
|
static bool isRootMountPoint(const std::string& path) {
|
|
return path == "/data" || path == "/sdcard";
|
|
}
|
|
|
|
// Delete a file or directory (POST /fs/delete?path=/data/foo)
|
|
esp_err_t WebServerService::handleFsDelete(httpd_req_t* request) {
|
|
std::string path;
|
|
if (!getQueryParam(request, "path", path) || path.empty()) {
|
|
httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "path required");
|
|
return ESP_FAIL;
|
|
}
|
|
std::string norm = normalizePath(path);
|
|
LOGGER.info("POST /fs/delete requested: '{}' normalized: '{}'", path, norm);
|
|
if (!isAllowedBasePath(norm)) {
|
|
httpd_resp_send_err(request, HTTPD_403_FORBIDDEN, "invalid path");
|
|
return ESP_FAIL;
|
|
}
|
|
if (isRootMountPoint(norm)) {
|
|
httpd_resp_send_err(request, HTTPD_403_FORBIDDEN, "cannot delete mount point");
|
|
return ESP_FAIL;
|
|
}
|
|
bool ok = true;
|
|
if (file::isDirectory(norm)) ok = file::deleteRecursively(norm);
|
|
else if (file::isFile(norm)) ok = file::deleteFile(norm);
|
|
else ok = false;
|
|
if (!ok) { httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "delete failed"); return ESP_FAIL; }
|
|
httpd_resp_sendstr(request, "ok");
|
|
return ESP_OK;
|
|
}
|
|
|
|
// Rename a file or folder (POST /fs/rename?path=/data/oldname&newName=newname)
|
|
esp_err_t WebServerService::handleFsRename(httpd_req_t* request) {
|
|
std::string path;
|
|
std::string newName;
|
|
if (!getQueryParam(request, "path", path) || path.empty()) {
|
|
httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "path required");
|
|
return ESP_FAIL;
|
|
}
|
|
if (!getQueryParam(request, "newName", newName) || newName.empty()) {
|
|
httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "newName required");
|
|
return ESP_FAIL;
|
|
}
|
|
std::string norm = normalizePath(path);
|
|
LOGGER.info("POST /fs/rename requested: '{}' normalized: '{}' -> newName: '{}'", path.c_str(), norm.c_str(), newName.c_str());
|
|
if (!isAllowedBasePath(norm)) {
|
|
httpd_resp_send_err(request, HTTPD_403_FORBIDDEN, "invalid path");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
// Basic validation of newName: must not contain path separators or '..'
|
|
// Trim whitespace from newName
|
|
auto trim = [](std::string& s){ size_t st=0; while (st<s.size() && isspace((unsigned char)s[st])) ++st; size_t ed=s.size(); while (ed>st && isspace((unsigned char)s[ed-1])) --ed; s = s.substr(st, ed-st); };
|
|
trim(newName);
|
|
if (newName.empty() || newName.find('/') != std::string::npos || newName.find('\\') != std::string::npos || newName.find("..") != std::string::npos) {
|
|
httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "invalid newName");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
// compute parent directory
|
|
std::string parent = "/";
|
|
size_t pos = norm.find_last_of('/');
|
|
if (pos != std::string::npos) {
|
|
parent = (pos == 0) ? std::string("/") : norm.substr(0, pos);
|
|
}
|
|
|
|
if (!isAllowedBasePath(parent)) {
|
|
httpd_resp_send_err(request, HTTPD_403_FORBIDDEN, "invalid target parent");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
std::string target = file::getChildPath(parent, newName);
|
|
|
|
// Prevent overwrite: fail if target exists
|
|
if (file::isFile(target) || file::isDirectory(target)) {
|
|
httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "target exists");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
// perform rename
|
|
int r = rename(norm.c_str(), target.c_str());
|
|
if (r != 0) {
|
|
int e = errno;
|
|
LOGGER.warn("rename failed errno={} ({}) -> {} -> {}", e, strerror(e), norm, target);
|
|
// Return errno string to client to aid debugging
|
|
std::string msg = std::string("rename failed: ") + strerror(e);
|
|
httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, msg.c_str());
|
|
return ESP_FAIL;
|
|
}
|
|
httpd_resp_sendstr(request, "ok");
|
|
return ESP_OK;
|
|
}
|
|
|
|
// endregion
|
|
|
|
esp_err_t WebServerService::handleSync(httpd_req_t* request) {
|
|
|
|
LOGGER.info("POST /sync");
|
|
|
|
bool success = syncAssets();
|
|
|
|
if (success) {
|
|
httpd_resp_sendstr(request, "Assets synchronized successfully");
|
|
} else {
|
|
httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "Asset sync failed");
|
|
}
|
|
|
|
return success ? ESP_OK : ESP_FAIL;
|
|
}
|
|
|
|
esp_err_t WebServerService::handleReboot(httpd_req_t* request) {
|
|
|
|
LOGGER.info("POST /reboot");
|
|
|
|
httpd_resp_sendstr(request, "Rebooting...");
|
|
|
|
// Reboot after a short delay to allow response to be sent
|
|
vTaskDelay(pdMS_TO_TICKS(2000));
|
|
esp_restart();
|
|
|
|
return ESP_OK; // Unreachable, but satisfies function signature
|
|
}
|
|
|
|
esp_err_t WebServerService::handleAssets(httpd_req_t* request) {
|
|
// Auth check for UI access control
|
|
bool authPassed = false;
|
|
esp_err_t authResult = validateRequestAuth(request, authPassed);
|
|
if (!authPassed) {
|
|
return authResult;
|
|
}
|
|
|
|
const char* uri = request->uri;
|
|
LOGGER.info("GET {}", uri);
|
|
|
|
// Special case: serve favicon from system assets
|
|
if (strcmp(uri, "/favicon.ico") == 0) {
|
|
const char* faviconPath = "/data/system/spinner.png";
|
|
if (file::isFile(faviconPath)) {
|
|
httpd_resp_set_type(request, "image/png");
|
|
httpd_resp_set_hdr(request, "Cache-Control", "public, max-age=86400");
|
|
|
|
auto lock = file::getLock(faviconPath);
|
|
lock->lock(portMAX_DELAY);
|
|
|
|
FILE* fp = fopen(faviconPath, "rb");
|
|
if (fp) {
|
|
char buffer[512];
|
|
size_t bytesRead;
|
|
while ((bytesRead = fread(buffer, 1, sizeof(buffer), fp)) > 0) {
|
|
if (httpd_resp_send_chunk(request, buffer, bytesRead) != ESP_OK) {
|
|
fclose(fp);
|
|
lock->unlock();
|
|
return ESP_FAIL;
|
|
}
|
|
}
|
|
fclose(fp);
|
|
lock->unlock();
|
|
httpd_resp_send_chunk(request, nullptr, 0);
|
|
LOGGER.info("[200] {} (favicon)", uri);
|
|
return ESP_OK;
|
|
}
|
|
lock->unlock();
|
|
}
|
|
// If favicon not found, return 404 silently (browsers handle this gracefully)
|
|
httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "Not found");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
// Special case: if requesting dashboard.html but it doesn't exist, serve default.html
|
|
std::string requestedPath = uri;
|
|
if (auto qpos = requestedPath.find('?'); qpos != std::string::npos) {
|
|
requestedPath = requestedPath.substr(0, qpos);
|
|
}
|
|
requestedPath = normalizePath(requestedPath);
|
|
if (requestedPath == "/.." || requestedPath.ends_with("/..") || requestedPath.find("/../") != std::string::npos) {
|
|
httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "invalid path");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
std::string dataPath = std::string("/data/webserver") + requestedPath;
|
|
|
|
if (requestedPath == "/dashboard.html" && !file::isFile(dataPath.c_str())) {
|
|
// Dashboard doesn't exist, try default.html
|
|
dataPath = "/data/webserver/default.html";
|
|
LOGGER.info("dashboard.html not found, serving default.html");
|
|
}
|
|
|
|
// Try to serve from Data partition first
|
|
if (file::isFile(dataPath.c_str())) {
|
|
httpd_resp_set_type(request, getContentType(dataPath));
|
|
|
|
// Read and send file using standard C FILE* operations
|
|
auto lock = file::getLock(dataPath);
|
|
lock->lock(portMAX_DELAY);
|
|
|
|
FILE* fp = fopen(dataPath.c_str(), "rb");
|
|
if (fp) {
|
|
char buffer[512];
|
|
size_t bytesRead;
|
|
while ((bytesRead = fread(buffer, 1, sizeof(buffer), fp)) > 0) {
|
|
if (httpd_resp_send_chunk(request, buffer, bytesRead) != ESP_OK) {
|
|
fclose(fp);
|
|
lock->unlock();
|
|
return ESP_FAIL;
|
|
}
|
|
}
|
|
fclose(fp);
|
|
lock->unlock();
|
|
|
|
httpd_resp_send_chunk(request, nullptr, 0); // End of chunks
|
|
LOGGER.info("[200] {} (from Data)", uri);
|
|
return ESP_OK;
|
|
}
|
|
lock->unlock();
|
|
}
|
|
|
|
// Fallback to SD card
|
|
std::string sdPath = std::string("/sdcard/tactility/webserver") + requestedPath;
|
|
if (file::isFile(sdPath.c_str())) {
|
|
httpd_resp_set_type(request, getContentType(sdPath));
|
|
|
|
auto lock = file::getLock(sdPath);
|
|
lock->lock(portMAX_DELAY);
|
|
|
|
FILE* fp = fopen(sdPath.c_str(), "rb");
|
|
if (fp) {
|
|
char buffer[512];
|
|
size_t bytesRead;
|
|
while ((bytesRead = fread(buffer, 1, sizeof(buffer), fp)) > 0) {
|
|
if (httpd_resp_send_chunk(request, buffer, bytesRead) != ESP_OK) {
|
|
fclose(fp);
|
|
lock->unlock();
|
|
return ESP_FAIL;
|
|
}
|
|
}
|
|
fclose(fp);
|
|
lock->unlock();
|
|
|
|
httpd_resp_send_chunk(request, nullptr, 0); // End of chunks
|
|
LOGGER.info("[200] {} (from SD)", uri);
|
|
return ESP_OK;
|
|
}
|
|
lock->unlock();
|
|
}
|
|
|
|
// File not found
|
|
LOGGER.warn("[404] {}", uri);
|
|
httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "File not found");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
extern const ServiceManifest manifest = {
|
|
.id = "WebServer",
|
|
.createService = create<WebServerService>
|
|
};
|
|
|
|
void setWebServerEnabled(bool enabled) {
|
|
WebServerService* instance = g_webServerInstance.load();
|
|
if (instance != nullptr) {
|
|
instance->setEnabled(enabled);
|
|
// Don't log here - startServer()/stopServer() already log the actual result
|
|
} else {
|
|
LOGGER.warn("WebServer service not available, cannot {}", enabled ? "start" : "stop");
|
|
}
|
|
}
|
|
|
|
} // namespace
|
|
|
|
#endif // ESP_PLATFORM
|