#include "WebServer.hpp" #include "../SdCard.hpp" #include "esp_log.h" #include #include #include #include #include #include #include // Helper for min template 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); }