knxdisplay/main/webserver/FileManagerHandlers.cpp
2026-01-25 15:20:12 +01:00

497 lines
16 KiB
C++

#include "WebServer.hpp"
#include "../SdCard.hpp"
#include "esp_log.h"
#include <cstring>
#include <string.h>
#include <cstdio>
#include <sys/stat.h>
#include <dirent.h>
#include <unistd.h>
#include <errno.h>
// Helper for min
template<typename T>
static inline T minVal(T a, T b) { return (a < b) ? a : b; }
static const char* TAG = "WebServer";
// Embedded file manager HTML (from Flash)
extern const uint8_t filemanager_html_start[] asm("_binary_filemanager_html_start");
extern const uint8_t filemanager_html_end[] asm("_binary_filemanager_html_end");
// Maximum upload size (16 MB) - streaming, so RAM usage is low
static constexpr size_t MAX_UPLOAD_SIZE = 16 * 1024 * 1024;
// Buffer size for streaming uploads
static constexpr size_t UPLOAD_BUFFER_SIZE = 4096;
// GET /files - Serve embedded file manager HTML
esp_err_t WebServer::fileManagerHandler(httpd_req_t* req) {
httpd_resp_set_type(req, "text/html; charset=utf-8");
size_t len = filemanager_html_end - filemanager_html_start;
httpd_resp_send(req, (const char*)filemanager_html_start, len);
return ESP_OK;
}
// GET /api/files/list?path=/ - List directory contents
esp_err_t WebServer::filesListHandler(httpd_req_t* req) {
char path[128] = "/";
getQueryParam(req, "path", path, sizeof(path));
char fullPath[256];
snprintf(fullPath, sizeof(fullPath), "%.7s%.127s", SdCard::MOUNT_POINT, path);
if (!SdCard::instance().isMounted()) {
return sendJsonError(req, "SD card not mounted");
}
DIR* dir = opendir(fullPath);
if (!dir) {
return sendJsonError(req, "Cannot open directory");
}
cJSON* json = cJSON_CreateObject();
cJSON_AddBoolToObject(json, "success", true);
cJSON_AddStringToObject(json, "path", path);
cJSON* files = cJSON_AddArrayToObject(json, "files");
struct dirent* entry;
while ((entry = readdir(dir)) != nullptr) {
if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) continue;
char entryPath[512];
size_t fullPathLen = strlen(fullPath);
const char* sep = (fullPathLen > 0 && fullPath[fullPathLen-1] == '/') ? "" : "/";
snprintf(entryPath, sizeof(entryPath), "%.255s%s%.255s", fullPath, sep, entry->d_name);
struct stat st;
bool isDir = false;
size_t fileSize = 0;
if (stat(entryPath, &st) == 0) {
isDir = S_ISDIR(st.st_mode);
fileSize = st.st_size;
} else {
isDir = (entry->d_type == DT_DIR);
}
cJSON* fileObj = cJSON_CreateObject();
cJSON_AddStringToObject(fileObj, "name", entry->d_name);
cJSON_AddBoolToObject(fileObj, "isDir", isDir);
cJSON_AddNumberToObject(fileObj, "size", (double)fileSize);
cJSON_AddItemToArray(files, fileObj);
}
closedir(dir);
return sendJsonObject(req, json);
}
// POST /api/files/upload?path=/ - Upload file (multipart/form-data) with streaming
esp_err_t WebServer::filesUploadHandler(httpd_req_t* req) {
char path[128] = "/";
getQueryParam(req, "path", path, sizeof(path));
if (!SdCard::instance().isMounted()) {
return sendJsonError(req, "SD card not mounted");
}
if (req->content_len > MAX_UPLOAD_SIZE) {
return sendJsonError(req, "File too large (max 16MB)");
}
char contentType[128] = "";
if (httpd_req_get_hdr_value_str(req, "Content-Type", contentType, sizeof(contentType)) != ESP_OK) {
return sendJsonError(req, "Missing Content-Type");
}
char* boundaryStart = strstr(contentType, "boundary=");
if (!boundaryStart) {
return sendJsonError(req, "Invalid multipart boundary");
}
boundaryStart += 9;
if (*boundaryStart == '"') boundaryStart++;
char boundaryValue[72];
strncpy(boundaryValue, boundaryStart, sizeof(boundaryValue) - 1);
boundaryValue[sizeof(boundaryValue) - 1] = '\0';
char* endPtr = boundaryValue;
while (*endPtr && *endPtr != '"' && *endPtr != ';' && *endPtr != ' ' && *endPtr != '\r' && *endPtr != '\n') {
endPtr++;
}
*endPtr = '\0';
char boundary[80];
snprintf(boundary, sizeof(boundary), "\r\n--%s", boundaryValue);
size_t boundaryLen = strlen(boundary);
// Allocate streaming buffer
char* buffer = new char[UPLOAD_BUFFER_SIZE];
if (!buffer) {
return sendJsonError(req, "Out of memory");
}
size_t contentLen = req->content_len;
size_t totalReceived = 0;
size_t bufferFilled = 0;
// Phase 1: Read header to find filename and data start
// Headers are typically < 512 bytes, read up to 2KB to be safe
static constexpr size_t HEADER_MAX = 2048;
char header[HEADER_MAX];
size_t headerLen = 0;
char* dataStartInHeader = nullptr;
while (headerLen < HEADER_MAX && totalReceived < contentLen) {
int ret = httpd_req_recv(req, header + headerLen,
minVal(HEADER_MAX - headerLen, contentLen - totalReceived));
if (ret <= 0) {
delete[] buffer;
return sendJsonError(req, "Receive failed");
}
headerLen += ret;
totalReceived += ret;
// Look for end of headers (\r\n\r\n)
dataStartInHeader = (char*)memmem(header, headerLen, "\r\n\r\n", 4);
if (dataStartInHeader) {
dataStartInHeader += 4; // Skip past \r\n\r\n
break;
}
}
if (!dataStartInHeader) {
delete[] buffer;
return sendJsonError(req, "Invalid multipart header");
}
// Extract filename from header
char* filenameStart = strstr(header, "filename=\"");
if (!filenameStart) {
delete[] buffer;
return sendJsonError(req, "No filename found");
}
filenameStart += 10;
char* filenameEnd = strchr(filenameStart, '"');
if (!filenameEnd || filenameEnd > dataStartInHeader) {
delete[] buffer;
return sendJsonError(req, "Invalid filename");
}
size_t filenameLen = filenameEnd - filenameStart;
char filename[128];
if (filenameLen >= sizeof(filename)) filenameLen = sizeof(filename) - 1;
memcpy(filename, filenameStart, filenameLen);
filename[filenameLen] = '\0';
// Prepare target path
char resolvedDir[384];
char targetDirPath[256];
snprintf(targetDirPath, sizeof(targetDirPath), "%.7s%.127s", SdCard::MOUNT_POINT, path);
const char* actualDir = targetDirPath;
if (resolveCaseInsensitivePath(targetDirPath, resolvedDir, sizeof(resolvedDir))) {
actualDir = resolvedDir;
}
char fullPath[384];
snprintf(fullPath, sizeof(fullPath), "%.250s/%.120s", actualDir, filename);
ESP_LOGI(TAG, "Streaming upload: %s (total content: %zu bytes)", fullPath, contentLen);
// Create directory if needed
struct stat dirStat;
if (stat(actualDir, &dirStat) != 0) {
if (mkdir(actualDir, 0755) != 0 && errno != EEXIST) {
delete[] buffer;
return sendJsonError(req, "Cannot create directory");
}
}
// Open file for writing
FILE* f = fopen(fullPath, "wb");
if (!f) {
ESP_LOGE(TAG, "Cannot create file: %s (errno=%d)", fullPath, errno);
delete[] buffer;
return sendJsonError(req, "Cannot create file");
}
// Calculate how much file data is already in our header buffer
size_t headerDataLen = headerLen - (dataStartInHeader - header);
size_t fileSize = 0;
// Copy initial file data from header buffer to streaming buffer
memcpy(buffer, dataStartInHeader, headerDataLen);
bufferFilled = headerDataLen;
// Phase 2: Stream remaining data, looking for end boundary
bool boundaryFound = false;
while (totalReceived < contentLen || bufferFilled > 0) {
// Try to receive more data if buffer has space
if (totalReceived < contentLen && bufferFilled < UPLOAD_BUFFER_SIZE) {
size_t toRead = minVal(UPLOAD_BUFFER_SIZE - bufferFilled, contentLen - totalReceived);
int ret = httpd_req_recv(req, buffer + bufferFilled, toRead);
if (ret < 0) {
fclose(f);
unlink(fullPath);
delete[] buffer;
return sendJsonError(req, "Receive failed");
}
if (ret > 0) {
bufferFilled += ret;
totalReceived += ret;
}
}
// Check for boundary in buffer
char* boundaryPos = nullptr;
if (bufferFilled >= boundaryLen) {
boundaryPos = (char*)memmem(buffer, bufferFilled, boundary, boundaryLen);
}
if (boundaryPos) {
// Found end boundary - write data before it
size_t dataToWrite = boundaryPos - buffer;
if (dataToWrite > 0) {
size_t written = fwrite(buffer, 1, dataToWrite, f);
if (written != dataToWrite) {
fclose(f);
unlink(fullPath);
delete[] buffer;
return sendJsonError(req, "Write failed");
}
fileSize += written;
}
boundaryFound = true;
break;
}
// No boundary found - write safe portion (keep boundaryLen-1 bytes for boundary detection)
if (totalReceived >= contentLen) {
// All data received, write everything (should not happen normally)
size_t written = fwrite(buffer, 1, bufferFilled, f);
fileSize += written;
bufferFilled = 0;
break;
}
size_t safeToWrite = (bufferFilled > boundaryLen) ? (bufferFilled - boundaryLen + 1) : 0;
if (safeToWrite > 0) {
size_t written = fwrite(buffer, 1, safeToWrite, f);
if (written != safeToWrite) {
fclose(f);
unlink(fullPath);
delete[] buffer;
return sendJsonError(req, "Write failed");
}
fileSize += written;
// Shift remaining data to front of buffer
memmove(buffer, buffer + safeToWrite, bufferFilled - safeToWrite);
bufferFilled -= safeToWrite;
}
}
fclose(f);
delete[] buffer;
if (!boundaryFound) {
ESP_LOGW(TAG, "Upload finished without finding boundary (file may be truncated)");
}
ESP_LOGI(TAG, "Upload complete: %s (%zu bytes)", fullPath, fileSize);
cJSON* json = cJSON_CreateObject();
cJSON_AddBoolToObject(json, "success", true);
cJSON_AddStringToObject(json, "filename", filename);
cJSON_AddNumberToObject(json, "size", (double)fileSize);
return sendJsonObject(req, json);
}
// GET /api/files/download?file=/path/to/file - Download file
esp_err_t WebServer::filesDownloadHandler(httpd_req_t* req) {
char file[256] = "";
if (!getQueryParam(req, "file", file, sizeof(file)) || strlen(file) == 0) {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing file parameter");
return ESP_FAIL;
}
char fullPath[384];
snprintf(fullPath, sizeof(fullPath), "%.7s%.255s", SdCard::MOUNT_POINT, file);
const char* basename = strrchr(file, '/');
basename = basename ? basename + 1 : file;
char disposition[256];
snprintf(disposition, sizeof(disposition), "attachment; filename=\"%.200s\"", basename);
httpd_resp_set_hdr(req, "Content-Disposition", disposition);
return sendFile(req, fullPath);
}
// DELETE /api/files/delete?file=/path/to/file - Delete file or empty directory
esp_err_t WebServer::filesDeleteHandler(httpd_req_t* req) {
char file[256] = "";
if (!getQueryParam(req, "file", file, sizeof(file)) || strlen(file) == 0) {
return sendJsonError(req, "Missing file parameter");
}
if (!SdCard::instance().isMounted()) {
return sendJsonError(req, "SD card not mounted");
}
char fullPath[384];
snprintf(fullPath, sizeof(fullPath), "%.7s%.255s", SdCard::MOUNT_POINT, file);
ESP_LOGI(TAG, "Deleting: %s", fullPath);
struct stat st;
if (stat(fullPath, &st) != 0) {
return sendJsonError(req, "File not found");
}
int result;
if (S_ISDIR(st.st_mode)) {
result = rmdir(fullPath);
} else {
result = unlink(fullPath);
}
if (result == 0) {
return sendJsonSuccess(req);
}
return sendJsonError(req, "Delete failed (directory not empty?)");
}
// POST /api/files/mkdir?path=/path/to/new/dir - Create directory
esp_err_t WebServer::filesMkdirHandler(httpd_req_t* req) {
char path[256] = "";
if (!getQueryParam(req, "path", path, sizeof(path)) || strlen(path) == 0) {
return sendJsonError(req, "Missing path parameter");
}
if (!SdCard::instance().isMounted()) {
return sendJsonError(req, "SD card not mounted");
}
char fullPath[384];
snprintf(fullPath, sizeof(fullPath), "%.7s%.255s", SdCard::MOUNT_POINT, path);
ESP_LOGI(TAG, "Creating directory: %s", fullPath);
if (mkdir(fullPath, 0755) == 0) {
return sendJsonSuccess(req);
}
return sendJsonError(req, "Cannot create directory");
}
// GET /api/files/read?file=/path/to/file - Read text file content for editor
esp_err_t WebServer::filesReadHandler(httpd_req_t* req) {
char file[256] = "";
if (!getQueryParam(req, "file", file, sizeof(file)) || strlen(file) == 0) {
return sendJsonError(req, "Missing file parameter");
}
if (!SdCard::instance().isMounted()) {
return sendJsonError(req, "SD card not mounted");
}
char targetPath[384];
snprintf(targetPath, sizeof(targetPath), "%.7s%.255s", SdCard::MOUNT_POINT, file);
char resolvedPath[384];
const char* actualPath = targetPath;
if (resolveCaseInsensitivePath(targetPath, resolvedPath, sizeof(resolvedPath))) {
actualPath = resolvedPath;
}
struct stat st;
if (stat(actualPath, &st) != 0) {
return sendJsonError(req, "File not found");
}
if (st.st_size > 65536) {
return sendJsonError(req, "File too large for editor (max 64KB)");
}
FILE* f = fopen(actualPath, "r");
if (!f) {
return sendJsonError(req, "Cannot open file");
}
char* content = new char[st.st_size + 1];
if (!content) {
fclose(f);
return sendJsonError(req, "Out of memory");
}
size_t bytesRead = fread(content, 1, st.st_size, f);
fclose(f);
content[bytesRead] = '\0';
httpd_resp_set_type(req, "text/plain; charset=utf-8");
httpd_resp_send(req, content, bytesRead);
delete[] content;
return ESP_OK;
}
// POST /api/files/write?file=/path/to/file - Write text file content from editor
esp_err_t WebServer::filesWriteHandler(httpd_req_t* req) {
char file[256] = "";
if (!getQueryParam(req, "file", file, sizeof(file)) || strlen(file) == 0) {
return sendJsonError(req, "Missing file parameter");
}
if (!SdCard::instance().isMounted()) {
return sendJsonError(req, "SD card not mounted");
}
if (req->content_len > 65536) {
return sendJsonError(req, "Content too large (max 64KB)");
}
char targetPath[384];
snprintf(targetPath, sizeof(targetPath), "%.7s%.255s", SdCard::MOUNT_POINT, file);
char resolvedPath[384];
const char* actualPath = targetPath;
if (resolveCaseInsensitivePath(targetPath, resolvedPath, sizeof(resolvedPath))) {
actualPath = resolvedPath;
}
char* content = new char[req->content_len + 1];
if (!content) {
return sendJsonError(req, "Out of memory");
}
size_t received = 0;
while (received < req->content_len) {
int ret = httpd_req_recv(req, content + received, req->content_len - received);
if (ret <= 0) {
delete[] content;
return sendJsonError(req, "Receive failed");
}
received += ret;
}
content[received] = '\0';
ESP_LOGI(TAG, "Writing file: %s (%zu bytes)", actualPath, received);
FILE* f = fopen(actualPath, "w");
if (!f) {
delete[] content;
return sendJsonError(req, "Cannot create file");
}
size_t written = fwrite(content, 1, received, f);
fclose(f);
delete[] content;
if (written != received) {
return sendJsonError(req, "Write incomplete");
}
return sendJsonSuccess(req);
}