Files
crosspoint-reader-mod/src/network/CrossPointWebServer.cpp
Xuan-Son Nguyen 5816ab2a47
Some checks failed
CI (build) / clang-format (push) Has been cancelled
CI (build) / cppcheck (push) Has been cancelled
CI (build) / build (push) Has been cancelled
CI (build) / Test Status (push) Has been cancelled
feat: use pre-compressed HTML pages (#861)
## Summary

Pre-compress the HTML file to save flash space. I'm using `gzip` because
it's supported everywhere (indeed, we are using the same optimization on
[llama.cpp server](https://github.com/ggml-org/llama.cpp), our HTML page
is huge 😅 ).

This free up ~40KB flash space.

Some users suggested using `brotli` which is known to further reduce 20%
in size, but it doesn't supported by firefox (only supports if served
via HTTPS), and some reverse proxy like nginx doesn't support it out of
the box (unrelated in this context, but just mention for completeness)

```
PR:
RAM:   [===       ]  31.0% (used 101700 bytes from 327680 bytes)
Flash: [==========]  95.5% (used 6259244 bytes from 6553600 bytes)

master:
RAM:   [===       ]  31.0% (used 101700 bytes from 327680 bytes)
Flash: [==========]  96.2% (used 6302416 bytes from 6553600 bytes)
```

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? **PARTIALLY**, only the
python part
2026-02-14 18:49:39 +03:00

1309 lines
40 KiB
C++

#include "CrossPointWebServer.h"
#include <ArduinoJson.h>
#include <Epub.h>
#include <FsHelpers.h>
#include <HalStorage.h>
#include <Logging.h>
#include <WiFi.h>
#include <esp_task_wdt.h>
#include <algorithm>
#include "CrossPointSettings.h"
#include "SettingsList.h"
#include "html/FilesPageHtml.generated.h"
#include "html/HomePageHtml.generated.h"
#include "html/SettingsPageHtml.generated.h"
#include "util/StringUtils.h"
namespace {
// Folders/files to hide from the web interface file browser
// Note: Items starting with "." are automatically hidden
const char* HIDDEN_ITEMS[] = {"System Volume Information", "XTCache"};
constexpr size_t HIDDEN_ITEMS_COUNT = sizeof(HIDDEN_ITEMS) / sizeof(HIDDEN_ITEMS[0]);
constexpr uint16_t UDP_PORTS[] = {54982, 48123, 39001, 44044, 59678};
constexpr uint16_t LOCAL_UDP_PORT = 8134;
// Static pointer for WebSocket callback (WebSocketsServer requires C-style callback)
CrossPointWebServer* wsInstance = nullptr;
// WebSocket upload state
FsFile wsUploadFile;
String wsUploadFileName;
String wsUploadPath;
size_t wsUploadSize = 0;
size_t wsUploadReceived = 0;
unsigned long wsUploadStartTime = 0;
bool wsUploadInProgress = false;
String wsLastCompleteName;
size_t wsLastCompleteSize = 0;
unsigned long wsLastCompleteAt = 0;
// Helper function to clear epub cache after upload
void clearEpubCacheIfNeeded(const String& filePath) {
// Only clear cache for .epub files
if (StringUtils::checkFileExtension(filePath, ".epub")) {
Epub(filePath.c_str(), "/.crosspoint").clearCache();
LOG_DBG("WEB", "Cleared epub cache for: %s", filePath.c_str());
}
}
String normalizeWebPath(const String& inputPath) {
if (inputPath.isEmpty() || inputPath == "/") {
return "/";
}
std::string normalized = FsHelpers::normalisePath(inputPath.c_str());
String result = normalized.c_str();
if (result.isEmpty()) {
return "/";
}
if (!result.startsWith("/")) {
result = "/" + result;
}
if (result.length() > 1 && result.endsWith("/")) {
result = result.substring(0, result.length() - 1);
}
return result;
}
bool isProtectedItemName(const String& name) {
if (name.startsWith(".")) {
return true;
}
for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) {
if (name.equals(HIDDEN_ITEMS[i])) {
return true;
}
}
return false;
}
} // namespace
// File listing page template - now using generated headers:
// - HomePageHtml (from html/HomePage.html)
// - FilesPageHeaderHtml (from html/FilesPageHeader.html)
// - FilesPageFooterHtml (from html/FilesPageFooter.html)
CrossPointWebServer::CrossPointWebServer() {}
CrossPointWebServer::~CrossPointWebServer() { stop(); }
void CrossPointWebServer::begin() {
if (running) {
LOG_DBG("WEB", "Web server already running");
return;
}
// Check if we have a valid network connection (either STA connected or AP mode)
const wifi_mode_t wifiMode = WiFi.getMode();
const bool isStaConnected = (wifiMode & WIFI_MODE_STA) && (WiFi.status() == WL_CONNECTED);
const bool isInApMode = (wifiMode & WIFI_MODE_AP) && (WiFi.softAPgetStationNum() >= 0); // AP is running
if (!isStaConnected && !isInApMode) {
LOG_DBG("WEB", "Cannot start webserver - no valid network (mode=%d, status=%d)", wifiMode, WiFi.status());
return;
}
// Store AP mode flag for later use (e.g., in handleStatus)
apMode = isInApMode;
LOG_DBG("WEB", "[MEM] Free heap before begin: %d bytes", ESP.getFreeHeap());
LOG_DBG("WEB", "Network mode: %s", apMode ? "AP" : "STA");
LOG_DBG("WEB", "Creating web server on port %d...", port);
server.reset(new WebServer(port));
// Disable WiFi sleep to improve responsiveness and prevent 'unreachable' errors.
// This is critical for reliable web server operation on ESP32.
WiFi.setSleep(false);
// Note: WebServer class doesn't have setNoDelay() in the standard ESP32 library.
// We rely on disabling WiFi sleep for responsiveness.
LOG_DBG("WEB", "[MEM] Free heap after WebServer allocation: %d bytes", ESP.getFreeHeap());
if (!server) {
LOG_ERR("WEB", "Failed to create WebServer!");
return;
}
// Setup routes
LOG_DBG("WEB", "Setting up routes...");
server->on("/", HTTP_GET, [this] { handleRoot(); });
server->on("/files", HTTP_GET, [this] { handleFileList(); });
server->on("/api/status", HTTP_GET, [this] { handleStatus(); });
server->on("/api/files", HTTP_GET, [this] { handleFileListData(); });
server->on("/download", HTTP_GET, [this] { handleDownload(); });
// Upload endpoint with special handling for multipart form data
server->on("/upload", HTTP_POST, [this] { handleUploadPost(upload); }, [this] { handleUpload(upload); });
// Create folder endpoint
server->on("/mkdir", HTTP_POST, [this] { handleCreateFolder(); });
// Rename file endpoint
server->on("/rename", HTTP_POST, [this] { handleRename(); });
// Move file endpoint
server->on("/move", HTTP_POST, [this] { handleMove(); });
// Delete file/folder endpoint
server->on("/delete", HTTP_POST, [this] { handleDelete(); });
// Settings endpoints
server->on("/settings", HTTP_GET, [this] { handleSettingsPage(); });
server->on("/api/settings", HTTP_GET, [this] { handleGetSettings(); });
server->on("/api/settings", HTTP_POST, [this] { handlePostSettings(); });
server->onNotFound([this] { handleNotFound(); });
LOG_DBG("WEB", "[MEM] Free heap after route setup: %d bytes", ESP.getFreeHeap());
server->begin();
// Start WebSocket server for fast binary uploads
LOG_DBG("WEB", "Starting WebSocket server on port %d...", wsPort);
wsServer.reset(new WebSocketsServer(wsPort));
wsInstance = const_cast<CrossPointWebServer*>(this);
wsServer->begin();
wsServer->onEvent(wsEventCallback);
LOG_DBG("WEB", "WebSocket server started");
udpActive = udp.begin(LOCAL_UDP_PORT);
LOG_DBG("WEB", "Discovery UDP %s on port %d", udpActive ? "enabled" : "failed", LOCAL_UDP_PORT);
running = true;
LOG_DBG("WEB", "Web server started on port %d", port);
// Show the correct IP based on network mode
const String ipAddr = apMode ? WiFi.softAPIP().toString() : WiFi.localIP().toString();
LOG_DBG("WEB", "Access at http://%s/", ipAddr.c_str());
LOG_DBG("WEB", "WebSocket at ws://%s:%d/", ipAddr.c_str(), wsPort);
LOG_DBG("WEB", "[MEM] Free heap after server.begin(): %d bytes", ESP.getFreeHeap());
}
void CrossPointWebServer::stop() {
if (!running || !server) {
LOG_DBG("WEB", "stop() called but already stopped (running=%d, server=%p)", running, server.get());
return;
}
LOG_DBG("WEB", "STOP INITIATED - setting running=false first");
running = false; // Set this FIRST to prevent handleClient from using server
LOG_DBG("WEB", "[MEM] Free heap before stop: %d bytes", ESP.getFreeHeap());
// Close any in-progress WebSocket upload
if (wsUploadInProgress && wsUploadFile) {
wsUploadFile.close();
wsUploadInProgress = false;
}
// Stop WebSocket server
if (wsServer) {
LOG_DBG("WEB", "Stopping WebSocket server...");
wsServer->close();
wsServer.reset();
wsInstance = nullptr;
LOG_DBG("WEB", "WebSocket server stopped");
}
if (udpActive) {
udp.stop();
udpActive = false;
}
// Brief delay to allow any in-flight handleClient() calls to complete
delay(20);
server->stop();
LOG_DBG("WEB", "[MEM] Free heap after server->stop(): %d bytes", ESP.getFreeHeap());
// Brief delay before deletion
delay(10);
server.reset();
LOG_DBG("WEB", "Web server stopped and deleted");
LOG_DBG("WEB", "[MEM] Free heap after delete server: %d bytes", ESP.getFreeHeap());
// Note: Static upload variables (uploadFileName, uploadPath, uploadError) are declared
// later in the file and will be cleared when they go out of scope or on next upload
LOG_DBG("WEB", "[MEM] Free heap final: %d bytes", ESP.getFreeHeap());
}
void CrossPointWebServer::handleClient() {
static unsigned long lastDebugPrint = 0;
// Check running flag FIRST before accessing server
if (!running) {
return;
}
// Double-check server pointer is valid
if (!server) {
LOG_DBG("WEB", "WARNING: handleClient called with null server!");
return;
}
// Print debug every 10 seconds to confirm handleClient is being called
if (millis() - lastDebugPrint > 10000) {
LOG_DBG("WEB", "handleClient active, server running on port %d", port);
lastDebugPrint = millis();
}
server->handleClient();
// Handle WebSocket events
if (wsServer) {
wsServer->loop();
}
// Respond to discovery broadcasts
if (udpActive) {
int packetSize = udp.parsePacket();
if (packetSize > 0) {
char buffer[16];
int len = udp.read(buffer, sizeof(buffer) - 1);
if (len > 0) {
buffer[len] = '\0';
if (strcmp(buffer, "hello") == 0) {
String hostname = WiFi.getHostname();
if (hostname.isEmpty()) {
hostname = "crosspoint";
}
String message = "crosspoint (on " + hostname + ");" + String(wsPort);
udp.beginPacket(udp.remoteIP(), udp.remotePort());
udp.write(reinterpret_cast<const uint8_t*>(message.c_str()), message.length());
udp.endPacket();
}
}
}
}
}
CrossPointWebServer::WsUploadStatus CrossPointWebServer::getWsUploadStatus() const {
WsUploadStatus status;
status.inProgress = wsUploadInProgress;
status.received = wsUploadReceived;
status.total = wsUploadSize;
status.filename = wsUploadFileName.c_str();
status.lastCompleteName = wsLastCompleteName.c_str();
status.lastCompleteSize = wsLastCompleteSize;
status.lastCompleteAt = wsLastCompleteAt;
return status;
}
static void sendHtmlContent(WebServer* server, const char* data, size_t len) {
server->sendHeader("Content-Encoding", "gzip");
server->send_P(200, "text/html", data, len);
}
void CrossPointWebServer::handleRoot() const {
sendHtmlContent(server.get(), HomePageHtml, sizeof(HomePageHtml));
LOG_DBG("WEB", "Served root page");
}
void CrossPointWebServer::handleNotFound() const {
String message = "404 Not Found\n\n";
message += "URI: " + server->uri() + "\n";
server->send(404, "text/plain", message);
}
void CrossPointWebServer::handleStatus() const {
// Get correct IP based on AP vs STA mode
const String ipAddr = apMode ? WiFi.softAPIP().toString() : WiFi.localIP().toString();
JsonDocument doc;
doc["version"] = CROSSPOINT_VERSION;
doc["ip"] = ipAddr;
doc["mode"] = apMode ? "AP" : "STA";
doc["rssi"] = apMode ? 0 : WiFi.RSSI();
doc["freeHeap"] = ESP.getFreeHeap();
doc["uptime"] = millis() / 1000;
String json;
serializeJson(doc, json);
server->send(200, "application/json", json);
}
void CrossPointWebServer::scanFiles(const char* path, const std::function<void(FileInfo)>& callback) const {
FsFile root = Storage.open(path);
if (!root) {
LOG_DBG("WEB", "Failed to open directory: %s", path);
return;
}
if (!root.isDirectory()) {
LOG_DBG("WEB", "Not a directory: %s", path);
root.close();
return;
}
LOG_DBG("WEB", "Scanning files in: %s", path);
FsFile file = root.openNextFile();
char name[500];
while (file) {
file.getName(name, sizeof(name));
auto fileName = String(name);
// Skip hidden items (starting with ".")
bool shouldHide = fileName.startsWith(".");
// Check against explicitly hidden items list
if (!shouldHide) {
for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) {
if (fileName.equals(HIDDEN_ITEMS[i])) {
shouldHide = true;
break;
}
}
}
if (!shouldHide) {
FileInfo info;
info.name = fileName;
info.isDirectory = file.isDirectory();
if (info.isDirectory) {
info.size = 0;
info.isEpub = false;
} else {
info.size = file.size();
info.isEpub = isEpubFile(info.name);
}
callback(info);
}
file.close();
yield(); // Yield to allow WiFi and other tasks to process during long scans
esp_task_wdt_reset(); // Reset watchdog to prevent timeout on large directories
file = root.openNextFile();
}
root.close();
}
bool CrossPointWebServer::isEpubFile(const String& filename) const {
String lower = filename;
lower.toLowerCase();
return lower.endsWith(".epub");
}
void CrossPointWebServer::handleFileList() const {
sendHtmlContent(server.get(), FilesPageHtml, sizeof(FilesPageHtml));
}
void CrossPointWebServer::handleFileListData() const {
// Get current path from query string (default to root)
String currentPath = "/";
if (server->hasArg("path")) {
currentPath = server->arg("path");
// Ensure path starts with /
if (!currentPath.startsWith("/")) {
currentPath = "/" + currentPath;
}
// Remove trailing slash unless it's root
if (currentPath.length() > 1 && currentPath.endsWith("/")) {
currentPath = currentPath.substring(0, currentPath.length() - 1);
}
}
server->setContentLength(CONTENT_LENGTH_UNKNOWN);
server->send(200, "application/json", "");
server->sendContent("[");
char output[512];
constexpr size_t outputSize = sizeof(output);
bool seenFirst = false;
JsonDocument doc;
scanFiles(currentPath.c_str(), [this, &output, &doc, seenFirst](const FileInfo& info) mutable {
doc.clear();
doc["name"] = info.name;
doc["size"] = info.size;
doc["isDirectory"] = info.isDirectory;
doc["isEpub"] = info.isEpub;
const size_t written = serializeJson(doc, output, outputSize);
if (written >= outputSize) {
// JSON output truncated; skip this entry to avoid sending malformed JSON
LOG_DBG("WEB", "Skipping file entry with oversized JSON for name: %s", info.name.c_str());
return;
}
if (seenFirst) {
server->sendContent(",");
} else {
seenFirst = true;
}
server->sendContent(output);
});
server->sendContent("]");
// End of streamed response, empty chunk to signal client
server->sendContent("");
LOG_DBG("WEB", "Served file listing page for path: %s", currentPath.c_str());
}
void CrossPointWebServer::handleDownload() const {
if (!server->hasArg("path")) {
server->send(400, "text/plain", "Missing path");
return;
}
String itemPath = server->arg("path");
if (itemPath.isEmpty() || itemPath == "/") {
server->send(400, "text/plain", "Invalid path");
return;
}
if (!itemPath.startsWith("/")) {
itemPath = "/" + itemPath;
}
const String itemName = itemPath.substring(itemPath.lastIndexOf('/') + 1);
if (itemName.startsWith(".")) {
server->send(403, "text/plain", "Cannot access system files");
return;
}
for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) {
if (itemName.equals(HIDDEN_ITEMS[i])) {
server->send(403, "text/plain", "Cannot access protected items");
return;
}
}
if (!Storage.exists(itemPath.c_str())) {
server->send(404, "text/plain", "Item not found");
return;
}
FsFile file = Storage.open(itemPath.c_str());
if (!file) {
server->send(500, "text/plain", "Failed to open file");
return;
}
if (file.isDirectory()) {
file.close();
server->send(400, "text/plain", "Path is a directory");
return;
}
String contentType = "application/octet-stream";
if (isEpubFile(itemPath)) {
contentType = "application/epub+zip";
}
char nameBuf[128] = {0};
String filename = "download";
if (file.getName(nameBuf, sizeof(nameBuf))) {
filename = nameBuf;
}
server->setContentLength(file.size());
server->sendHeader("Content-Disposition", "attachment; filename=\"" + filename + "\"");
server->send(200, contentType.c_str(), "");
WiFiClient client = server->client();
client.write(file);
file.close();
}
// Diagnostic counters for upload performance analysis
static unsigned long uploadStartTime = 0;
static unsigned long totalWriteTime = 0;
static size_t writeCount = 0;
static bool flushUploadBuffer(CrossPointWebServer::UploadState& state) {
if (state.bufferPos > 0 && state.file) {
esp_task_wdt_reset(); // Reset watchdog before potentially slow SD write
const unsigned long writeStart = millis();
const size_t written = state.file.write(state.buffer.data(), state.bufferPos);
totalWriteTime += millis() - writeStart;
writeCount++;
esp_task_wdt_reset(); // Reset watchdog after SD write
if (written != state.bufferPos) {
LOG_DBG("WEB", "[UPLOAD] Buffer flush failed: expected %d, wrote %d", state.bufferPos, written);
state.bufferPos = 0;
return false;
}
state.bufferPos = 0;
}
return true;
}
void CrossPointWebServer::handleUpload(UploadState& state) const {
static size_t lastLoggedSize = 0;
// Reset watchdog at start of every upload callback - HTTP parsing can be slow
esp_task_wdt_reset();
// Safety check: ensure server is still valid
if (!running || !server) {
LOG_DBG("WEB", "[UPLOAD] ERROR: handleUpload called but server not running!");
return;
}
const HTTPUpload& upload = server->upload();
if (upload.status == UPLOAD_FILE_START) {
// Reset watchdog - this is the critical 1% crash point
esp_task_wdt_reset();
state.fileName = upload.filename;
state.size = 0;
state.success = false;
state.error = "";
uploadStartTime = millis();
lastLoggedSize = 0;
state.bufferPos = 0;
totalWriteTime = 0;
writeCount = 0;
// Get upload path from query parameter (defaults to root if not specified)
// Note: We use query parameter instead of form data because multipart form
// fields aren't available until after file upload completes
if (server->hasArg("path")) {
state.path = server->arg("path");
// Ensure path starts with /
if (!state.path.startsWith("/")) {
state.path = "/" + state.path;
}
// Remove trailing slash unless it's root
if (state.path.length() > 1 && state.path.endsWith("/")) {
state.path = state.path.substring(0, state.path.length() - 1);
}
} else {
state.path = "/";
}
LOG_DBG("WEB", "[UPLOAD] START: %s to path: %s", state.fileName.c_str(), state.path.c_str());
LOG_DBG("WEB", "[UPLOAD] Free heap: %d bytes", ESP.getFreeHeap());
// Create file path
String filePath = state.path;
if (!filePath.endsWith("/")) filePath += "/";
filePath += state.fileName;
// Check if file already exists - SD operations can be slow
esp_task_wdt_reset();
if (Storage.exists(filePath.c_str())) {
LOG_DBG("WEB", "[UPLOAD] Overwriting existing file: %s", filePath.c_str());
esp_task_wdt_reset();
Storage.remove(filePath.c_str());
}
// Open file for writing - this can be slow due to FAT cluster allocation
esp_task_wdt_reset();
if (!Storage.openFileForWrite("WEB", filePath, state.file)) {
state.error = "Failed to create file on SD card";
LOG_DBG("WEB", "[UPLOAD] FAILED to create file: %s", filePath.c_str());
return;
}
esp_task_wdt_reset();
LOG_DBG("WEB", "[UPLOAD] File created successfully: %s", filePath.c_str());
} else if (upload.status == UPLOAD_FILE_WRITE) {
if (state.file && state.error.isEmpty()) {
// Buffer incoming data and flush when buffer is full
// This reduces SD card write operations and improves throughput
const uint8_t* data = upload.buf;
size_t remaining = upload.currentSize;
while (remaining > 0) {
const size_t space = UploadState::UPLOAD_BUFFER_SIZE - state.bufferPos;
const size_t toCopy = (remaining < space) ? remaining : space;
memcpy(state.buffer.data() + state.bufferPos, data, toCopy);
state.bufferPos += toCopy;
data += toCopy;
remaining -= toCopy;
// Flush buffer when full
if (state.bufferPos >= UploadState::UPLOAD_BUFFER_SIZE) {
if (!flushUploadBuffer(state)) {
state.error = "Failed to write to SD card - disk may be full";
state.file.close();
return;
}
}
}
state.size += upload.currentSize;
// Log progress every 100KB
if (state.size - lastLoggedSize >= 102400) {
const unsigned long elapsed = millis() - uploadStartTime;
const float kbps = (elapsed > 0) ? (state.size / 1024.0) / (elapsed / 1000.0) : 0;
LOG_DBG("WEB", "[UPLOAD] %d bytes (%.1f KB), %.1f KB/s, %d writes", state.size, state.size / 1024.0, kbps,
writeCount);
lastLoggedSize = state.size;
}
}
} else if (upload.status == UPLOAD_FILE_END) {
if (state.file) {
// Flush any remaining buffered data
if (!flushUploadBuffer(state)) {
state.error = "Failed to write final data to SD card";
}
state.file.close();
if (state.error.isEmpty()) {
state.success = true;
const unsigned long elapsed = millis() - uploadStartTime;
const float avgKbps = (elapsed > 0) ? (state.size / 1024.0) / (elapsed / 1000.0) : 0;
const float writePercent = (elapsed > 0) ? (totalWriteTime * 100.0 / elapsed) : 0;
LOG_DBG("WEB", "[UPLOAD] Complete: %s (%d bytes in %lu ms, avg %.1f KB/s)", state.fileName.c_str(), state.size,
elapsed, avgKbps);
LOG_DBG("WEB", "[UPLOAD] Diagnostics: %d writes, total write time: %lu ms (%.1f%%)", writeCount, totalWriteTime,
writePercent);
// Clear epub cache to prevent stale metadata issues when overwriting files
String filePath = state.path;
if (!filePath.endsWith("/")) filePath += "/";
filePath += state.fileName;
clearEpubCacheIfNeeded(filePath);
}
}
} else if (upload.status == UPLOAD_FILE_ABORTED) {
state.bufferPos = 0; // Discard buffered data
if (state.file) {
state.file.close();
// Try to delete the incomplete file
String filePath = state.path;
if (!filePath.endsWith("/")) filePath += "/";
filePath += state.fileName;
Storage.remove(filePath.c_str());
}
state.error = "Upload aborted";
LOG_DBG("WEB", "Upload aborted");
}
}
void CrossPointWebServer::handleUploadPost(UploadState& state) const {
if (state.success) {
server->send(200, "text/plain", "File uploaded successfully: " + state.fileName);
} else {
const String error = state.error.isEmpty() ? "Unknown error during upload" : state.error;
server->send(400, "text/plain", error);
}
}
void CrossPointWebServer::handleCreateFolder() const {
// Get folder name from form data
if (!server->hasArg("name")) {
server->send(400, "text/plain", "Missing folder name");
return;
}
const String folderName = server->arg("name");
// Validate folder name
if (folderName.isEmpty()) {
server->send(400, "text/plain", "Folder name cannot be empty");
return;
}
// Get parent path
String parentPath = "/";
if (server->hasArg("path")) {
parentPath = server->arg("path");
if (!parentPath.startsWith("/")) {
parentPath = "/" + parentPath;
}
if (parentPath.length() > 1 && parentPath.endsWith("/")) {
parentPath = parentPath.substring(0, parentPath.length() - 1);
}
}
// Build full folder path
String folderPath = parentPath;
if (!folderPath.endsWith("/")) folderPath += "/";
folderPath += folderName;
LOG_DBG("WEB", "Creating folder: %s", folderPath.c_str());
// Check if already exists
if (Storage.exists(folderPath.c_str())) {
server->send(400, "text/plain", "Folder already exists");
return;
}
// Create the folder
if (Storage.mkdir(folderPath.c_str())) {
LOG_DBG("WEB", "Folder created successfully: %s", folderPath.c_str());
server->send(200, "text/plain", "Folder created: " + folderName);
} else {
LOG_DBG("WEB", "Failed to create folder: %s", folderPath.c_str());
server->send(500, "text/plain", "Failed to create folder");
}
}
void CrossPointWebServer::handleRename() const {
if (!server->hasArg("path") || !server->hasArg("name")) {
server->send(400, "text/plain", "Missing path or new name");
return;
}
String itemPath = normalizeWebPath(server->arg("path"));
String newName = server->arg("name");
newName.trim();
if (itemPath.isEmpty() || itemPath == "/") {
server->send(400, "text/plain", "Invalid path");
return;
}
if (newName.isEmpty()) {
server->send(400, "text/plain", "New name cannot be empty");
return;
}
if (newName.indexOf('/') >= 0 || newName.indexOf('\\') >= 0) {
server->send(400, "text/plain", "Invalid file name");
return;
}
if (isProtectedItemName(newName)) {
server->send(403, "text/plain", "Cannot rename to protected name");
return;
}
const String itemName = itemPath.substring(itemPath.lastIndexOf('/') + 1);
if (isProtectedItemName(itemName)) {
server->send(403, "text/plain", "Cannot rename protected item");
return;
}
if (newName == itemName) {
server->send(200, "text/plain", "Name unchanged");
return;
}
if (!Storage.exists(itemPath.c_str())) {
server->send(404, "text/plain", "Item not found");
return;
}
FsFile file = Storage.open(itemPath.c_str());
if (!file) {
server->send(500, "text/plain", "Failed to open file");
return;
}
if (file.isDirectory()) {
file.close();
server->send(400, "text/plain", "Only files can be renamed");
return;
}
String parentPath = itemPath.substring(0, itemPath.lastIndexOf('/'));
if (parentPath.isEmpty()) {
parentPath = "/";
}
String newPath = parentPath;
if (!newPath.endsWith("/")) {
newPath += "/";
}
newPath += newName;
if (Storage.exists(newPath.c_str())) {
file.close();
server->send(409, "text/plain", "Target already exists");
return;
}
clearEpubCacheIfNeeded(itemPath);
const bool success = file.rename(newPath.c_str());
file.close();
if (success) {
LOG_DBG("WEB", "Renamed file: %s -> %s", itemPath.c_str(), newPath.c_str());
server->send(200, "text/plain", "Renamed successfully");
} else {
LOG_ERR("WEB", "Failed to rename file: %s -> %s", itemPath.c_str(), newPath.c_str());
server->send(500, "text/plain", "Failed to rename file");
}
}
void CrossPointWebServer::handleMove() const {
if (!server->hasArg("path") || !server->hasArg("dest")) {
server->send(400, "text/plain", "Missing path or destination");
return;
}
String itemPath = normalizeWebPath(server->arg("path"));
String destPath = normalizeWebPath(server->arg("dest"));
if (itemPath.isEmpty() || itemPath == "/") {
server->send(400, "text/plain", "Invalid path");
return;
}
if (destPath.isEmpty()) {
server->send(400, "text/plain", "Invalid destination");
return;
}
const String itemName = itemPath.substring(itemPath.lastIndexOf('/') + 1);
if (isProtectedItemName(itemName)) {
server->send(403, "text/plain", "Cannot move protected item");
return;
}
if (destPath != "/") {
const String destName = destPath.substring(destPath.lastIndexOf('/') + 1);
if (isProtectedItemName(destName)) {
server->send(403, "text/plain", "Cannot move into protected folder");
return;
}
}
if (!Storage.exists(itemPath.c_str())) {
server->send(404, "text/plain", "Item not found");
return;
}
FsFile file = Storage.open(itemPath.c_str());
if (!file) {
server->send(500, "text/plain", "Failed to open file");
return;
}
if (file.isDirectory()) {
file.close();
server->send(400, "text/plain", "Only files can be moved");
return;
}
if (!Storage.exists(destPath.c_str())) {
file.close();
server->send(404, "text/plain", "Destination not found");
return;
}
FsFile destDir = Storage.open(destPath.c_str());
if (!destDir || !destDir.isDirectory()) {
if (destDir) {
destDir.close();
}
file.close();
server->send(400, "text/plain", "Destination is not a folder");
return;
}
destDir.close();
String newPath = destPath;
if (!newPath.endsWith("/")) {
newPath += "/";
}
newPath += itemName;
if (newPath == itemPath) {
file.close();
server->send(200, "text/plain", "Already in destination");
return;
}
if (Storage.exists(newPath.c_str())) {
file.close();
server->send(409, "text/plain", "Target already exists");
return;
}
clearEpubCacheIfNeeded(itemPath);
const bool success = file.rename(newPath.c_str());
file.close();
if (success) {
LOG_DBG("WEB", "Moved file: %s -> %s", itemPath.c_str(), newPath.c_str());
server->send(200, "text/plain", "Moved successfully");
} else {
LOG_ERR("WEB", "Failed to move file: %s -> %s", itemPath.c_str(), newPath.c_str());
server->send(500, "text/plain", "Failed to move file");
}
}
void CrossPointWebServer::handleDelete() const {
// Get path from form data
if (!server->hasArg("path")) {
server->send(400, "text/plain", "Missing path");
return;
}
String itemPath = server->arg("path");
const String itemType = server->hasArg("type") ? server->arg("type") : "file";
// Validate path
if (itemPath.isEmpty() || itemPath == "/") {
server->send(400, "text/plain", "Cannot delete root directory");
return;
}
// Ensure path starts with /
if (!itemPath.startsWith("/")) {
itemPath = "/" + itemPath;
}
// Security check: prevent deletion of protected items
const String itemName = itemPath.substring(itemPath.lastIndexOf('/') + 1);
// Check if item starts with a dot (hidden/system file)
if (itemName.startsWith(".")) {
LOG_DBG("WEB", "Delete rejected - hidden/system item: %s", itemPath.c_str());
server->send(403, "text/plain", "Cannot delete system files");
return;
}
// Check against explicitly protected items
for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) {
if (itemName.equals(HIDDEN_ITEMS[i])) {
LOG_DBG("WEB", "Delete rejected - protected item: %s", itemPath.c_str());
server->send(403, "text/plain", "Cannot delete protected items");
return;
}
}
// Check if item exists
if (!Storage.exists(itemPath.c_str())) {
LOG_DBG("WEB", "Delete failed - item not found: %s", itemPath.c_str());
server->send(404, "text/plain", "Item not found");
return;
}
LOG_DBG("WEB", "Attempting to delete %s: %s", itemType.c_str(), itemPath.c_str());
bool success = false;
if (itemType == "folder") {
// For folders, try to remove (will fail if not empty)
FsFile dir = Storage.open(itemPath.c_str());
if (dir && dir.isDirectory()) {
// Check if folder is empty
FsFile entry = dir.openNextFile();
if (entry) {
// Folder is not empty
entry.close();
dir.close();
LOG_DBG("WEB", "Delete failed - folder not empty: %s", itemPath.c_str());
server->send(400, "text/plain", "Folder is not empty. Delete contents first.");
return;
}
dir.close();
}
success = Storage.rmdir(itemPath.c_str());
} else {
// For files, use remove
success = Storage.remove(itemPath.c_str());
}
if (success) {
LOG_DBG("WEB", "Successfully deleted: %s", itemPath.c_str());
server->send(200, "text/plain", "Deleted successfully");
} else {
LOG_ERR("WEB", "Failed to delete: %s", itemPath.c_str());
server->send(500, "text/plain", "Failed to delete item");
}
}
void CrossPointWebServer::handleSettingsPage() const {
sendHtmlContent(server.get(), SettingsPageHtml, sizeof(SettingsPageHtml));
LOG_DBG("WEB", "Served settings page");
}
void CrossPointWebServer::handleGetSettings() const {
auto settings = getSettingsList();
server->setContentLength(CONTENT_LENGTH_UNKNOWN);
server->send(200, "application/json", "");
server->sendContent("[");
char output[512];
constexpr size_t outputSize = sizeof(output);
bool seenFirst = false;
JsonDocument doc;
for (const auto& s : settings) {
if (!s.key) continue; // Skip ACTION-only entries
doc.clear();
doc["key"] = s.key;
doc["name"] = s.name;
doc["category"] = s.category;
switch (s.type) {
case SettingType::TOGGLE: {
doc["type"] = "toggle";
if (s.valuePtr) {
doc["value"] = static_cast<int>(SETTINGS.*(s.valuePtr));
}
break;
}
case SettingType::ENUM: {
doc["type"] = "enum";
if (s.valuePtr) {
doc["value"] = static_cast<int>(SETTINGS.*(s.valuePtr));
} else if (s.valueGetter) {
doc["value"] = static_cast<int>(s.valueGetter());
}
JsonArray options = doc["options"].to<JsonArray>();
for (const auto& opt : s.enumValues) {
options.add(opt);
}
break;
}
case SettingType::VALUE: {
doc["type"] = "value";
if (s.valuePtr) {
doc["value"] = static_cast<int>(SETTINGS.*(s.valuePtr));
}
doc["min"] = s.valueRange.min;
doc["max"] = s.valueRange.max;
doc["step"] = s.valueRange.step;
break;
}
case SettingType::STRING: {
doc["type"] = "string";
if (s.stringGetter) {
doc["value"] = s.stringGetter();
} else if (s.stringPtr) {
doc["value"] = s.stringPtr;
}
break;
}
default:
continue;
}
const size_t written = serializeJson(doc, output, outputSize);
if (written >= outputSize) {
LOG_DBG("WEB", "Skipping oversized setting JSON for: %s", s.key);
continue;
}
if (seenFirst) {
server->sendContent(",");
} else {
seenFirst = true;
}
server->sendContent(output);
}
server->sendContent("]");
server->sendContent("");
LOG_DBG("WEB", "Served settings API");
}
void CrossPointWebServer::handlePostSettings() {
if (!server->hasArg("plain")) {
server->send(400, "text/plain", "Missing JSON body");
return;
}
const String body = server->arg("plain");
JsonDocument doc;
const DeserializationError err = deserializeJson(doc, body);
if (err) {
server->send(400, "text/plain", String("Invalid JSON: ") + err.c_str());
return;
}
auto settings = getSettingsList();
int applied = 0;
for (auto& s : settings) {
if (!s.key) continue;
if (!doc[s.key].is<JsonVariant>()) continue;
switch (s.type) {
case SettingType::TOGGLE: {
const int val = doc[s.key].as<int>() ? 1 : 0;
if (s.valuePtr) {
SETTINGS.*(s.valuePtr) = val;
}
applied++;
break;
}
case SettingType::ENUM: {
const int val = doc[s.key].as<int>();
if (val >= 0 && val < static_cast<int>(s.enumValues.size())) {
if (s.valuePtr) {
SETTINGS.*(s.valuePtr) = static_cast<uint8_t>(val);
} else if (s.valueSetter) {
s.valueSetter(static_cast<uint8_t>(val));
}
applied++;
}
break;
}
case SettingType::VALUE: {
const int val = doc[s.key].as<int>();
if (val >= s.valueRange.min && val <= s.valueRange.max) {
if (s.valuePtr) {
SETTINGS.*(s.valuePtr) = static_cast<uint8_t>(val);
}
applied++;
}
break;
}
case SettingType::STRING: {
const std::string val = doc[s.key].as<std::string>();
if (s.stringSetter) {
s.stringSetter(val);
} else if (s.stringPtr && s.stringMaxLen > 0) {
strncpy(s.stringPtr, val.c_str(), s.stringMaxLen - 1);
s.stringPtr[s.stringMaxLen - 1] = '\0';
}
applied++;
break;
}
default:
break;
}
}
SETTINGS.saveToFile();
LOG_DBG("WEB", "Applied %d setting(s)", applied);
server->send(200, "text/plain", String("Applied ") + String(applied) + " setting(s)");
}
// WebSocket callback trampoline
void CrossPointWebServer::wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length) {
if (wsInstance) {
wsInstance->onWebSocketEvent(num, type, payload, length);
}
}
// WebSocket event handler for fast binary uploads
// Protocol:
// 1. Client sends TEXT message: "START:<filename>:<size>:<path>"
// 2. Client sends BINARY messages with file data chunks
// 3. Server sends TEXT "PROGRESS:<received>:<total>" after each chunk
// 4. Server sends TEXT "DONE" or "ERROR:<message>" when complete
void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length) {
switch (type) {
case WStype_DISCONNECTED:
LOG_DBG("WS", "Client %u disconnected", num);
// Clean up any in-progress upload
if (wsUploadInProgress && wsUploadFile) {
wsUploadFile.close();
// Delete incomplete file
String filePath = wsUploadPath;
if (!filePath.endsWith("/")) filePath += "/";
filePath += wsUploadFileName;
Storage.remove(filePath.c_str());
LOG_DBG("WS", "Deleted incomplete upload: %s", filePath.c_str());
}
wsUploadInProgress = false;
break;
case WStype_CONNECTED: {
LOG_DBG("WS", "Client %u connected", num);
break;
}
case WStype_TEXT: {
// Parse control messages
String msg = String((char*)payload);
LOG_DBG("WS", "Text from client %u: %s", num, msg.c_str());
if (msg.startsWith("START:")) {
// Parse: START:<filename>:<size>:<path>
int firstColon = msg.indexOf(':', 6);
int secondColon = msg.indexOf(':', firstColon + 1);
if (firstColon > 0 && secondColon > 0) {
wsUploadFileName = msg.substring(6, firstColon);
wsUploadSize = msg.substring(firstColon + 1, secondColon).toInt();
wsUploadPath = msg.substring(secondColon + 1);
wsUploadReceived = 0;
wsUploadStartTime = millis();
// Ensure path is valid
if (!wsUploadPath.startsWith("/")) wsUploadPath = "/" + wsUploadPath;
if (wsUploadPath.length() > 1 && wsUploadPath.endsWith("/")) {
wsUploadPath = wsUploadPath.substring(0, wsUploadPath.length() - 1);
}
// Build file path
String filePath = wsUploadPath;
if (!filePath.endsWith("/")) filePath += "/";
filePath += wsUploadFileName;
LOG_DBG("WS", "Starting upload: %s (%d bytes) to %s", wsUploadFileName.c_str(), wsUploadSize,
filePath.c_str());
// Check if file exists and remove it
esp_task_wdt_reset();
if (Storage.exists(filePath.c_str())) {
Storage.remove(filePath.c_str());
}
// Open file for writing
esp_task_wdt_reset();
if (!Storage.openFileForWrite("WS", filePath, wsUploadFile)) {
wsServer->sendTXT(num, "ERROR:Failed to create file");
wsUploadInProgress = false;
return;
}
esp_task_wdt_reset();
wsUploadInProgress = true;
wsServer->sendTXT(num, "READY");
} else {
wsServer->sendTXT(num, "ERROR:Invalid START format");
}
}
break;
}
case WStype_BIN: {
if (!wsUploadInProgress || !wsUploadFile) {
wsServer->sendTXT(num, "ERROR:No upload in progress");
return;
}
// Write binary data directly to file
esp_task_wdt_reset();
size_t written = wsUploadFile.write(payload, length);
esp_task_wdt_reset();
if (written != length) {
wsUploadFile.close();
wsUploadInProgress = false;
wsServer->sendTXT(num, "ERROR:Write failed - disk full?");
return;
}
wsUploadReceived += written;
// Send progress update (every 64KB or at end)
static size_t lastProgressSent = 0;
if (wsUploadReceived - lastProgressSent >= 65536 || wsUploadReceived >= wsUploadSize) {
String progress = "PROGRESS:" + String(wsUploadReceived) + ":" + String(wsUploadSize);
wsServer->sendTXT(num, progress);
lastProgressSent = wsUploadReceived;
}
// Check if upload complete
if (wsUploadReceived >= wsUploadSize) {
wsUploadFile.close();
wsUploadInProgress = false;
wsLastCompleteName = wsUploadFileName;
wsLastCompleteSize = wsUploadSize;
wsLastCompleteAt = millis();
unsigned long elapsed = millis() - wsUploadStartTime;
float kbps = (elapsed > 0) ? (wsUploadSize / 1024.0) / (elapsed / 1000.0) : 0;
LOG_DBG("WS", "Upload complete: %s (%d bytes in %lu ms, %.1f KB/s)", wsUploadFileName.c_str(), wsUploadSize,
elapsed, kbps);
// Clear epub cache to prevent stale metadata issues when overwriting files
String filePath = wsUploadPath;
if (!filePath.endsWith("/")) filePath += "/";
filePath += wsUploadFileName;
clearEpubCacheIfNeeded(filePath);
wsServer->sendTXT(num, "DONE");
lastProgressSent = 0;
}
break;
}
default:
break;
}
}