crosspoint-reader/src/network/CrossPointWebServer.cpp
cottongin f739869519
Add MD5 hash API for companion app sync and improve upload reliability
- Add /api/hash endpoint to compute and cache MD5 hashes on demand
- Extend /api/files response with md5 field for EPUBs (null if not cached)
- Compute and cache MD5 automatically after EPUB uploads
- Add flush() before close() in WebSocket and HTTP upload handlers
- New Md5Utils module using ESP32's mbedtls for chunked hash computation

The MD5 hashes enable the companion app to detect file changes without
downloading content. Hashes are cached in each book's .crosspoint cache
directory and invalidated when file size changes.
2026-01-26 16:13:27 -05:00

1762 lines
57 KiB
C++

#include "CrossPointWebServer.h"
#include <ArduinoJson.h>
#include <Epub.h>
#include <FsHelpers.h>
#include <SDCardManager.h>
#include <WiFi.h>
#include <esp_task_wdt.h>
#include <algorithm>
#include "BookListStore.h"
#include "BookManager.h"
#include "CrossPointSettings.h"
#include "html/FilesPageHtml.generated.h"
#include "html/HomePageHtml.generated.h"
#include "util/Md5Utils.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]);
// 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;
// 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();
Serial.printf("[%lu] [WEB] Cleared epub cache for: %s\n", millis(), filePath.c_str());
}
}
// Helper function to compute and cache MD5 hash after upload
void computeMd5AfterUpload(const String& filePath) {
// Only compute hash for EPUB files (companion app uses this for sync)
if (StringUtils::checkFileExtension(filePath, ".epub")) {
Serial.printf("[%lu] [WEB] Computing MD5 hash after upload for: %s\n", millis(), filePath.c_str());
const std::string md5 = Md5Utils::computeAndCacheMd5(filePath.c_str(), BookManager::CROSSPOINT_DIR);
if (!md5.empty()) {
Serial.printf("[%lu] [WEB] MD5 hash cached: %s\n", millis(), md5.c_str());
} else {
Serial.printf("[%lu] [WEB] Failed to compute MD5 hash\n", millis());
}
}
}
} // 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) {
Serial.printf("[%lu] [WEB] Web server already running\n", millis());
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) {
Serial.printf("[%lu] [WEB] Cannot start webserver - no valid network (mode=%d, status=%d)\n", millis(), wifiMode,
WiFi.status());
return;
}
// Store AP mode flag for later use (e.g., in handleStatus)
apMode = isInApMode;
Serial.printf("[%lu] [WEB] [MEM] Free heap before begin: %d bytes\n", millis(), ESP.getFreeHeap());
Serial.printf("[%lu] [WEB] Network mode: %s\n", millis(), apMode ? "AP" : "STA");
Serial.printf("[%lu] [WEB] Creating web server on port %d...\n", millis(), 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.
Serial.printf("[%lu] [WEB] [MEM] Free heap after WebServer allocation: %d bytes\n", millis(), ESP.getFreeHeap());
if (!server) {
Serial.printf("[%lu] [WEB] Failed to create WebServer!\n", millis());
return;
}
// Setup routes
Serial.printf("[%lu] [WEB] Setting up routes...\n", millis());
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("/api/hash", HTTP_GET, [this] { handleHash(); });
// Upload endpoint with special handling for multipart form data
server->on("/upload", HTTP_POST, [this] { handleUploadPost(); }, [this] { handleUpload(); });
// Create folder endpoint
server->on("/mkdir", HTTP_POST, [this] { handleCreateFolder(); });
// Delete file/folder endpoint
server->on("/delete", HTTP_POST, [this] { handleDelete(); });
// Archive/Unarchive endpoints
server->on("/archive", HTTP_POST, [this] { handleArchive(); });
server->on("/unarchive", HTTP_POST, [this] { handleUnarchive(); });
server->on("/api/archived", HTTP_GET, [this] { handleArchivedList(); });
// Download endpoint
server->on("/download", HTTP_GET, [this] { handleDownload(); });
// Rename endpoint
server->on("/rename", HTTP_POST, [this] { handleRename(); });
// Copy endpoint
server->on("/copy", HTTP_POST, [this] { handleCopy(); });
// Move endpoint
server->on("/move", HTTP_POST, [this] { handleMove(); });
// List management endpoints
server->on("/list", HTTP_GET, [this] { handleListGet(); });
server->on("/list", HTTP_POST, [this] { handleListPost(); });
server->onNotFound([this] { handleNotFound(); });
Serial.printf("[%lu] [WEB] [MEM] Free heap after route setup: %d bytes\n", millis(), ESP.getFreeHeap());
server->begin();
// Start WebSocket server for fast binary uploads
Serial.printf("[%lu] [WEB] Starting WebSocket server on port %d...\n", millis(), wsPort);
wsServer.reset(new WebSocketsServer(wsPort));
wsInstance = const_cast<CrossPointWebServer*>(this);
wsServer->begin();
wsServer->onEvent(wsEventCallback);
Serial.printf("[%lu] [WEB] WebSocket server started\n", millis());
running = true;
serverStartTime = millis();
Serial.printf("[%lu] [WEB] Web server started on port %d\n", millis(), port);
// Show the correct IP based on network mode
const String ipAddr = apMode ? WiFi.softAPIP().toString() : WiFi.localIP().toString();
Serial.printf("[%lu] [WEB] Access at http://%s/\n", millis(), ipAddr.c_str());
Serial.printf("[%lu] [WEB] WebSocket at ws://%s:%d/\n", millis(), ipAddr.c_str(), wsPort);
Serial.printf("[%lu] [WEB] [MEM] Free heap after server.begin(): %d bytes\n", millis(), ESP.getFreeHeap());
}
void CrossPointWebServer::stop() {
if (!running || !server) {
Serial.printf("[%lu] [WEB] stop() called but already stopped (running=%d, server=%p)\n", millis(), running,
server.get());
return;
}
Serial.printf("[%lu] [WEB] STOP INITIATED - setting running=false first\n", millis());
running = false; // Set this FIRST to prevent handleClient from using server
Serial.printf("[%lu] [WEB] [MEM] Free heap before stop: %d bytes\n", millis(), ESP.getFreeHeap());
// Close any in-progress WebSocket upload
if (wsUploadInProgress && wsUploadFile) {
wsUploadFile.close();
wsUploadInProgress = false;
}
// Stop WebSocket server
if (wsServer) {
Serial.printf("[%lu] [WEB] Stopping WebSocket server...\n", millis());
wsServer->close();
wsServer.reset();
wsInstance = nullptr;
Serial.printf("[%lu] [WEB] WebSocket server stopped\n", millis());
}
// Brief delay to allow any in-flight handleClient() calls to complete
delay(20);
server->stop();
Serial.printf("[%lu] [WEB] [MEM] Free heap after server->stop(): %d bytes\n", millis(), ESP.getFreeHeap());
// Brief delay before deletion
delay(10);
server.reset();
Serial.printf("[%lu] [WEB] Web server stopped and deleted\n", millis());
Serial.printf("[%lu] [WEB] [MEM] Free heap after delete server: %d bytes\n", millis(), 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
Serial.printf("[%lu] [WEB] [MEM] Free heap final: %d bytes\n", millis(), ESP.getFreeHeap());
}
void CrossPointWebServer::handleClient() const {
static unsigned long lastDebugPrint = 0;
// Check running flag FIRST before accessing server
if (!running) {
return;
}
// Double-check server pointer is valid
if (!server) {
Serial.printf("[%lu] [WEB] WARNING: handleClient called with null server!\n", millis());
return;
}
// Print debug every 10 seconds to confirm handleClient is being called
if (millis() - lastDebugPrint > 10000) {
Serial.printf("[%lu] [WEB] handleClient active, server running on port %d\n", millis(), port);
lastDebugPrint = millis();
}
server->handleClient();
// Handle WebSocket events
if (wsServer) {
wsServer->loop();
}
}
void CrossPointWebServer::handleRoot() const {
// Use chunked sending for consistency with handleFileList (avoids String allocation)
server->setContentLength(strlen(HomePageHtml));
server->send(200, "text/html", "");
server->sendContent_P(HomePageHtml);
Serial.printf("[%lu] [WEB] Served root page\n", millis());
}
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,
bool showHidden) const {
FsFile root = SdMan.open(path);
if (!root) {
Serial.printf("[%lu] [WEB] Failed to open directory: %s\n", millis(), path);
return;
}
if (!root.isDirectory()) {
Serial.printf("[%lu] [WEB] Not a directory: %s\n", millis(), path);
root.close();
return;
}
Serial.printf("[%lu] [WEB] Scanning files in: %s (showHidden=%d)\n", millis(), path, showHidden);
FsFile file = root.openNextFile();
char name[500];
while (file) {
file.getName(name, sizeof(name));
auto fileName = String(name);
// Skip hidden items (starting with ".") unless showHidden is true
// Always skip .crosspoint folder (internal cache) even when showing hidden
bool shouldHide = false;
if (fileName.startsWith(".")) {
if (!showHidden || fileName.equals(".crosspoint")) {
shouldHide = true;
}
}
// Check against explicitly hidden items list (always hidden)
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;
// md5 remains empty for directories
} else {
info.size = file.size();
info.isEpub = isEpubFile(info.name);
// For EPUBs, try to get cached MD5 hash
if (info.isEpub) {
// Build full file path
String fullPath = String(path);
if (!fullPath.endsWith("/")) {
fullPath += "/";
}
fullPath += fileName;
const std::string cachedMd5 =
Md5Utils::getCachedMd5(fullPath.c_str(), BookManager::CROSSPOINT_DIR, info.size);
if (!cachedMd5.empty()) {
info.md5 = String(cachedMd5.c_str());
}
// If not cached, md5 remains empty (companion app can request via /api/hash)
}
}
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 {
// Use chunked sending to avoid allocating 64KB+ contiguous RAM for String conversion.
// The original server->send(200, "text/html", FilesPageHtml) fails when heap is fragmented
// because it tries to create a String from the large PROGMEM array.
server->setContentLength(strlen(FilesPageHtml));
server->send(200, "text/html", "");
server->sendContent_P(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);
}
}
// Check if we should show hidden files
bool showHidden = false;
if (server->hasArg("showHidden")) {
showHidden = server->arg("showHidden") == "true";
}
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;
// Include md5 field for EPUBs (null if not cached, hash string if available)
if (info.isEpub) {
if (info.md5.isEmpty()) {
doc["md5"] = nullptr; // JSON null
} else {
doc["md5"] = info.md5;
}
}
const size_t written = serializeJson(doc, output, outputSize);
if (written >= outputSize) {
// JSON output truncated; skip this entry to avoid sending malformed JSON
Serial.printf("[%lu] [WEB] Skipping file entry with oversized JSON for name: %s\n", millis(),
info.name.c_str());
return;
}
if (seenFirst) {
server->sendContent(",");
} else {
seenFirst = true;
}
server->sendContent(output);
},
showHidden);
server->sendContent("]");
// End of streamed response, empty chunk to signal client
server->sendContent("");
Serial.printf("[%lu] [WEB] Served file listing page for path: %s\n", millis(), currentPath.c_str());
}
// Static variables for upload handling
static FsFile uploadFile;
static String uploadFileName;
static String uploadPath = "/";
static size_t uploadSize = 0;
static bool uploadSuccess = false;
static String uploadError = "";
// Upload write buffer - batches small writes into larger SD card operations
// 4KB is a good balance: large enough to reduce syscall overhead, small enough
// to keep individual write times short and avoid watchdog issues
constexpr size_t UPLOAD_BUFFER_SIZE = 4096; // 4KB buffer
static uint8_t uploadBuffer[UPLOAD_BUFFER_SIZE];
static size_t uploadBufferPos = 0;
// Diagnostic counters for upload performance analysis
static unsigned long uploadStartTime = 0;
static unsigned long totalWriteTime = 0;
static size_t writeCount = 0;
static bool flushUploadBuffer() {
if (uploadBufferPos > 0 && uploadFile) {
esp_task_wdt_reset(); // Reset watchdog before potentially slow SD write
const unsigned long writeStart = millis();
const size_t written = uploadFile.write(uploadBuffer, uploadBufferPos);
totalWriteTime += millis() - writeStart;
writeCount++;
esp_task_wdt_reset(); // Reset watchdog after SD write
if (written != uploadBufferPos) {
Serial.printf("[%lu] [WEB] [UPLOAD] Buffer flush failed: expected %d, wrote %d\n", millis(), uploadBufferPos,
written);
uploadBufferPos = 0;
return false;
}
uploadBufferPos = 0;
}
return true;
}
void CrossPointWebServer::handleUpload() 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) {
Serial.printf("[%lu] [WEB] [UPLOAD] ERROR: handleUpload called but server not running!\n", millis());
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();
uploadFileName = upload.filename;
uploadSize = 0;
uploadSuccess = false;
uploadError = "";
uploadStartTime = millis();
lastLoggedSize = 0;
uploadBufferPos = 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")) {
uploadPath = server->arg("path");
// Ensure path starts with /
if (!uploadPath.startsWith("/")) {
uploadPath = "/" + uploadPath;
}
// Remove trailing slash unless it's root
if (uploadPath.length() > 1 && uploadPath.endsWith("/")) {
uploadPath = uploadPath.substring(0, uploadPath.length() - 1);
}
} else {
uploadPath = "/";
}
Serial.printf("[%lu] [WEB] [UPLOAD] START: %s to path: %s\n", millis(), uploadFileName.c_str(), uploadPath.c_str());
Serial.printf("[%lu] [WEB] [UPLOAD] Free heap: %d bytes\n", millis(), ESP.getFreeHeap());
// Create file path
String filePath = uploadPath;
if (!filePath.endsWith("/")) filePath += "/";
filePath += uploadFileName;
// Check if file already exists - SD operations can be slow
esp_task_wdt_reset();
if (SdMan.exists(filePath.c_str())) {
Serial.printf("[%lu] [WEB] [UPLOAD] Overwriting existing file: %s\n", millis(), filePath.c_str());
esp_task_wdt_reset();
SdMan.remove(filePath.c_str());
}
// Open file for writing - this can be slow due to FAT cluster allocation
esp_task_wdt_reset();
if (!SdMan.openFileForWrite("WEB", filePath, uploadFile)) {
uploadError = "Failed to create file on SD card";
Serial.printf("[%lu] [WEB] [UPLOAD] FAILED to create file: %s\n", millis(), filePath.c_str());
return;
}
esp_task_wdt_reset();
Serial.printf("[%lu] [WEB] [UPLOAD] File created successfully: %s\n", millis(), filePath.c_str());
} else if (upload.status == UPLOAD_FILE_WRITE) {
if (uploadFile && uploadError.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 = UPLOAD_BUFFER_SIZE - uploadBufferPos;
const size_t toCopy = (remaining < space) ? remaining : space;
memcpy(uploadBuffer + uploadBufferPos, data, toCopy);
uploadBufferPos += toCopy;
data += toCopy;
remaining -= toCopy;
// Flush buffer when full
if (uploadBufferPos >= UPLOAD_BUFFER_SIZE) {
if (!flushUploadBuffer()) {
uploadError = "Failed to write to SD card - disk may be full";
uploadFile.close();
return;
}
}
}
uploadSize += upload.currentSize;
// Log progress every 100KB
if (uploadSize - lastLoggedSize >= 102400) {
const unsigned long elapsed = millis() - uploadStartTime;
const float kbps = (elapsed > 0) ? (uploadSize / 1024.0) / (elapsed / 1000.0) : 0;
Serial.printf("[%lu] [WEB] [UPLOAD] %d bytes (%.1f KB), %.1f KB/s, %d writes\n", millis(), uploadSize,
uploadSize / 1024.0, kbps, writeCount);
lastLoggedSize = uploadSize;
}
}
} else if (upload.status == UPLOAD_FILE_END) {
if (uploadFile) {
// Flush any remaining buffered data
if (!flushUploadBuffer()) {
uploadError = "Failed to write final data to SD card";
}
uploadFile.flush(); // Ensure FsFile internal buffer is written to SD card
uploadFile.close();
if (uploadError.isEmpty()) {
uploadSuccess = true;
const unsigned long elapsed = millis() - uploadStartTime;
const float avgKbps = (elapsed > 0) ? (uploadSize / 1024.0) / (elapsed / 1000.0) : 0;
const float writePercent = (elapsed > 0) ? (totalWriteTime * 100.0 / elapsed) : 0;
Serial.printf("[%lu] [WEB] [UPLOAD] Complete: %s (%d bytes in %lu ms, avg %.1f KB/s)\n", millis(),
uploadFileName.c_str(), uploadSize, elapsed, avgKbps);
Serial.printf("[%lu] [WEB] [UPLOAD] Diagnostics: %d writes, total write time: %lu ms (%.1f%%)\n", millis(),
writeCount, totalWriteTime, writePercent);
// Update traffic statistics
totalBytesUploaded += uploadSize;
totalFilesUploaded++;
// Clear epub cache to prevent stale metadata issues when overwriting files
String filePath = uploadPath;
if (!filePath.endsWith("/")) filePath += "/";
filePath += uploadFileName;
clearEpubCacheIfNeeded(filePath);
// Compute and cache MD5 hash for uploaded EPUB files
computeMd5AfterUpload(filePath);
}
}
} else if (upload.status == UPLOAD_FILE_ABORTED) {
uploadBufferPos = 0; // Discard buffered data
if (uploadFile) {
uploadFile.close();
// Try to delete the incomplete file
String filePath = uploadPath;
if (!filePath.endsWith("/")) filePath += "/";
filePath += uploadFileName;
SdMan.remove(filePath.c_str());
}
uploadError = "Upload aborted";
Serial.printf("[%lu] [WEB] Upload aborted\n", millis());
}
}
void CrossPointWebServer::handleUploadPost() const {
if (uploadSuccess) {
server->send(200, "text/plain", "File uploaded successfully: " + uploadFileName);
} else {
const String error = uploadError.isEmpty() ? "Unknown error during upload" : uploadError;
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;
Serial.printf("[%lu] [WEB] Creating folder: %s\n", millis(), folderPath.c_str());
// Check if already exists
if (SdMan.exists(folderPath.c_str())) {
server->send(400, "text/plain", "Folder already exists");
return;
}
// Create the folder
if (SdMan.mkdir(folderPath.c_str())) {
Serial.printf("[%lu] [WEB] Folder created successfully: %s\n", millis(), folderPath.c_str());
server->send(200, "text/plain", "Folder created: " + folderName);
} else {
Serial.printf("[%lu] [WEB] Failed to create folder: %s\n", millis(), folderPath.c_str());
server->send(500, "text/plain", "Failed to create folder");
}
}
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";
const bool isArchived = server->hasArg("archived") && server->arg("archived") == "true";
// 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) - but allow archived items
if (itemName.startsWith(".") && !isArchived) {
Serial.printf("[%lu] [WEB] Delete rejected - hidden/system item: %s\n", millis(), 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])) {
Serial.printf("[%lu] [WEB] Delete rejected - protected item: %s\n", millis(), itemPath.c_str());
server->send(403, "text/plain", "Cannot delete protected items");
return;
}
}
Serial.printf("[%lu] [WEB] Attempting to delete %s (archived=%d): %s\n", millis(), itemType.c_str(), isArchived,
itemPath.c_str());
bool success = false;
if (itemType == "folder") {
// Check if item exists
if (!SdMan.exists(itemPath.c_str())) {
Serial.printf("[%lu] [WEB] Delete failed - item not found: %s\n", millis(), itemPath.c_str());
server->send(404, "text/plain", "Item not found");
return;
}
// For folders, try to remove (will fail if not empty)
FsFile dir = SdMan.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();
Serial.printf("[%lu] [WEB] Delete failed - folder not empty: %s\n", millis(), itemPath.c_str());
server->send(400, "text/plain", "Folder is not empty. Delete contents first.");
return;
}
dir.close();
}
success = SdMan.rmdir(itemPath.c_str());
} else {
// For files, use BookManager to also clean up cache and recent books
if (isArchived) {
// For archived books, just pass the filename
success = BookManager::deleteBook(itemName.c_str(), true);
} else {
success = BookManager::deleteBook(itemPath.c_str(), false);
}
}
if (success) {
Serial.printf("[%lu] [WEB] Successfully deleted: %s\n", millis(), itemPath.c_str());
server->send(200, "text/plain", "Deleted successfully");
} else {
Serial.printf("[%lu] [WEB] Failed to delete: %s\n", millis(), itemPath.c_str());
server->send(500, "text/plain", "Failed to delete item");
}
}
void CrossPointWebServer::handleArchive() const {
if (!server->hasArg("path")) {
server->send(400, "text/plain", "Missing path");
return;
}
String bookPath = server->arg("path");
// Validate path
if (bookPath.isEmpty() || bookPath == "/") {
server->send(400, "text/plain", "Invalid path");
return;
}
// Ensure path starts with /
if (!bookPath.startsWith("/")) {
bookPath = "/" + bookPath;
}
Serial.printf("[%lu] [WEB] Archiving book: %s\n", millis(), bookPath.c_str());
if (BookManager::archiveBook(bookPath.c_str())) {
server->send(200, "text/plain", "Book archived successfully");
} else {
server->send(500, "text/plain", "Failed to archive book");
}
}
void CrossPointWebServer::handleUnarchive() const {
if (!server->hasArg("filename")) {
server->send(400, "text/plain", "Missing filename");
return;
}
const String filename = server->arg("filename");
if (filename.isEmpty()) {
server->send(400, "text/plain", "Invalid filename");
return;
}
Serial.printf("[%lu] [WEB] Unarchiving book: %s\n", millis(), filename.c_str());
// Get the original path before unarchiving (for response)
const std::string originalPath = BookManager::getArchivedBookOriginalPath(filename.c_str());
if (BookManager::unarchiveBook(filename.c_str())) {
// Return JSON with the original path
String response = "{\"success\":true,\"originalPath\":\"";
response += originalPath.c_str();
response += "\"}";
server->send(200, "application/json", response);
} else {
server->send(500, "text/plain", "Failed to unarchive book");
}
}
void CrossPointWebServer::handleArchivedList() const {
Serial.printf("[%lu] [WEB] Fetching archived books list\n", millis());
const auto archivedBooks = BookManager::listArchivedBooks();
// Build JSON response
String response = "[";
bool first = true;
for (const auto& filename : archivedBooks) {
if (!first) {
response += ",";
}
first = false;
const std::string originalPath = BookManager::getArchivedBookOriginalPath(filename);
response += "{\"filename\":\"";
response += filename.c_str();
response += "\",\"originalPath\":\"";
response += originalPath.c_str();
response += "\"}";
}
response += "]";
server->send(200, "application/json", response);
}
// 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:
Serial.printf("[%lu] [WS] Client %u disconnected\n", millis(), num);
// Clean up any in-progress upload
if (wsUploadInProgress && wsUploadFile) {
wsUploadFile.close();
// Delete incomplete file
String filePath = wsUploadPath;
if (!filePath.endsWith("/")) filePath += "/";
filePath += wsUploadFileName;
SdMan.remove(filePath.c_str());
Serial.printf("[%lu] [WS] Deleted incomplete upload: %s\n", millis(), filePath.c_str());
}
wsUploadInProgress = false;
break;
case WStype_CONNECTED: {
Serial.printf("[%lu] [WS] Client %u connected\n", millis(), num);
break;
}
case WStype_TEXT: {
// Parse control messages
String msg = String((char*)payload);
Serial.printf("[%lu] [WS] Text from client %u: %s\n", millis(), 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;
Serial.printf("[%lu] [WS] Starting upload: %s (%d bytes) to %s\n", millis(), wsUploadFileName.c_str(),
wsUploadSize, filePath.c_str());
// Check if file exists and remove it
esp_task_wdt_reset();
if (SdMan.exists(filePath.c_str())) {
SdMan.remove(filePath.c_str());
}
// Open file for writing
esp_task_wdt_reset();
if (!SdMan.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.flush(); // Ensure all buffered data is written to SD card
wsUploadFile.close();
wsUploadInProgress = false;
unsigned long elapsed = millis() - wsUploadStartTime;
float kbps = (elapsed > 0) ? (wsUploadSize / 1024.0) / (elapsed / 1000.0) : 0;
Serial.printf("[%lu] [WS] Upload complete: %s (%d bytes in %lu ms, %.1f KB/s)\n", millis(),
wsUploadFileName.c_str(), wsUploadSize, elapsed, kbps);
// Update traffic statistics
totalBytesUploaded += wsUploadSize;
totalFilesUploaded++;
// Clear epub cache to prevent stale metadata issues when overwriting files
String filePath = wsUploadPath;
if (!filePath.endsWith("/")) filePath += "/";
filePath += wsUploadFileName;
clearEpubCacheIfNeeded(filePath);
// Compute and cache MD5 hash for uploaded EPUB files
computeMd5AfterUpload(filePath);
wsServer->sendTXT(num, "DONE");
lastProgressSent = 0;
}
break;
}
default:
break;
}
}
void CrossPointWebServer::handleDownload() const {
// Validate path parameter exists
if (!server->hasArg("path")) {
server->send(400, "text/plain", "Missing path parameter");
return;
}
String filePath = server->arg("path");
// Validate path starts with /
if (!filePath.startsWith("/")) {
filePath = "/" + filePath;
}
// Security check: prevent directory traversal
if (filePath.indexOf("..") >= 0) {
Serial.printf("[%lu] [WEB] Download rejected - directory traversal attempt: %s\n", millis(), filePath.c_str());
server->send(403, "text/plain", "Invalid path");
return;
}
// Extract filename for security checks and Content-Disposition header
const String filename = filePath.substring(filePath.lastIndexOf('/') + 1);
// Security check: reject hidden/system files
if (filename.startsWith(".")) {
Serial.printf("[%lu] [WEB] Download rejected - hidden/system file: %s\n", millis(), filePath.c_str());
server->send(403, "text/plain", "Cannot download system files");
return;
}
// Check against explicitly protected items
for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) {
if (filename.equals(HIDDEN_ITEMS[i])) {
Serial.printf("[%lu] [WEB] Download rejected - protected item: %s\n", millis(), filePath.c_str());
server->send(403, "text/plain", "Cannot download protected items");
return;
}
}
// Check if file exists and open it
FsFile file;
if (!SdMan.openFileForRead("WEB", filePath, file)) {
Serial.printf("[%lu] [WEB] Download failed - file not found: %s\n", millis(), filePath.c_str());
server->send(404, "text/plain", "File not found");
return;
}
// Check that it's not a directory
if (file.isDirectory()) {
file.close();
Serial.printf("[%lu] [WEB] Download failed - path is a directory: %s\n", millis(), filePath.c_str());
server->send(400, "text/plain", "Cannot download a directory");
return;
}
const size_t fileSize = file.size();
Serial.printf("[%lu] [WEB] Starting download: %s (%d bytes)\n", millis(), filePath.c_str(), fileSize);
// Set headers for file download
server->sendHeader("Content-Disposition", "attachment; filename=\"" + filename + "\"");
server->sendHeader("Cache-Control", "no-cache");
server->setContentLength(fileSize);
server->send(200, "application/octet-stream", "");
// Stream file content in chunks
constexpr size_t DOWNLOAD_BUFFER_SIZE = 4096;
uint8_t buffer[DOWNLOAD_BUFFER_SIZE];
size_t totalSent = 0;
const unsigned long startTime = millis();
while (file.available()) {
esp_task_wdt_reset(); // Reset watchdog to prevent timeout on large files
const size_t bytesRead = file.read(buffer, DOWNLOAD_BUFFER_SIZE);
if (bytesRead == 0) {
break;
}
const size_t bytesWritten = server->client().write(buffer, bytesRead);
if (bytesWritten != bytesRead) {
Serial.printf("[%lu] [WEB] Download error - write failed at %d bytes\n", millis(), totalSent);
break;
}
totalSent += bytesWritten;
yield(); // Allow WiFi and other tasks to process
}
file.close();
const unsigned long elapsed = millis() - startTime;
const float kbps = (elapsed > 0) ? (totalSent / 1024.0) / (elapsed / 1000.0) : 0;
Serial.printf("[%lu] [WEB] Download complete: %s (%d bytes in %lu ms, %.1f KB/s)\n", millis(), filename.c_str(),
totalSent, elapsed, kbps);
// Update traffic statistics (only if download completed successfully)
if (totalSent == fileSize) {
totalBytesDownloaded += totalSent;
totalFilesDownloaded++;
}
}
void CrossPointWebServer::handleRename() const {
// Get path and new name from form data
if (!server->hasArg("path") || !server->hasArg("newName")) {
server->send(400, "text/plain", "Missing path or newName parameter");
return;
}
String itemPath = server->arg("path");
const String newName = server->arg("newName");
// Validate new name
if (newName.isEmpty()) {
server->send(400, "text/plain", "New name cannot be empty");
return;
}
// Reject names containing path separators
if (newName.indexOf('/') >= 0 || newName.indexOf('\\') >= 0) {
server->send(400, "text/plain", "Name cannot contain path separators");
return;
}
// Ensure path starts with /
if (!itemPath.startsWith("/")) {
itemPath = "/" + itemPath;
}
// Validate path
if (itemPath.isEmpty() || itemPath == "/") {
server->send(400, "text/plain", "Cannot rename root directory");
return;
}
// Security check: prevent renaming protected items
const String oldName = itemPath.substring(itemPath.lastIndexOf('/') + 1);
if (oldName.startsWith(".")) {
server->send(403, "text/plain", "Cannot rename system files");
return;
}
for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) {
if (oldName.equals(HIDDEN_ITEMS[i])) {
server->send(403, "text/plain", "Cannot rename protected items");
return;
}
}
// Check if source exists
if (!SdMan.exists(itemPath.c_str())) {
server->send(404, "text/plain", "Item not found");
return;
}
// Build new path (same directory, new name)
const int lastSlash = itemPath.lastIndexOf('/');
String newPath;
if (lastSlash == 0) {
newPath = "/" + newName;
} else {
newPath = itemPath.substring(0, lastSlash + 1) + newName;
}
// Check if destination already exists
if (SdMan.exists(newPath.c_str())) {
server->send(400, "text/plain", "An item with that name already exists");
return;
}
Serial.printf("[%lu] [WEB] Renaming: %s -> %s\n", millis(), itemPath.c_str(), newPath.c_str());
// Perform the rename
esp_task_wdt_reset();
if (SdMan.rename(itemPath.c_str(), newPath.c_str())) {
Serial.printf("[%lu] [WEB] Rename successful\n", millis());
// Clear epub cache for both old and new paths to prevent stale metadata
clearEpubCacheIfNeeded(itemPath); // Old path cache is now invalid
clearEpubCacheIfNeeded(newPath); // Ensure clean cache for new path
server->send(200, "text/plain", "Renamed successfully");
} else {
Serial.printf("[%lu] [WEB] Rename failed\n", millis());
server->send(500, "text/plain", "Failed to rename item");
}
}
bool CrossPointWebServer::copyFile(const String& srcPath, const String& destPath) const {
FsFile srcFile;
FsFile destFile;
// Open source file
if (!SdMan.openFileForRead("COPY", srcPath, srcFile)) {
Serial.printf("[%lu] [WEB] Copy failed - cannot open source: %s\n", millis(), srcPath.c_str());
return false;
}
// Check if destination exists and remove it
if (SdMan.exists(destPath.c_str())) {
SdMan.remove(destPath.c_str());
}
// Open destination file
if (!SdMan.openFileForWrite("COPY", destPath, destFile)) {
Serial.printf("[%lu] [WEB] Copy failed - cannot create dest: %s\n", millis(), destPath.c_str());
srcFile.close();
return false;
}
// Copy in chunks
constexpr size_t COPY_BUFFER_SIZE = 4096;
uint8_t buffer[COPY_BUFFER_SIZE];
size_t totalCopied = 0;
const size_t fileSize = srcFile.size();
while (srcFile.available()) {
esp_task_wdt_reset();
yield();
const size_t bytesRead = srcFile.read(buffer, COPY_BUFFER_SIZE);
if (bytesRead == 0) break;
const size_t bytesWritten = destFile.write(buffer, bytesRead);
if (bytesWritten != bytesRead) {
Serial.printf("[%lu] [WEB] Copy failed - write error at %d bytes\n", millis(), totalCopied);
srcFile.close();
destFile.close();
SdMan.remove(destPath.c_str());
return false;
}
totalCopied += bytesWritten;
}
srcFile.close();
destFile.close();
Serial.printf("[%lu] [WEB] Copy complete: %s -> %s (%d bytes)\n", millis(), srcPath.c_str(), destPath.c_str(),
totalCopied);
return true;
}
bool CrossPointWebServer::copyFolder(const String& srcPath, const String& destPath) const {
// Create destination directory
if (!SdMan.exists(destPath.c_str())) {
if (!SdMan.mkdir(destPath.c_str())) {
Serial.printf("[%lu] [WEB] Copy folder failed - cannot create dest dir: %s\n", millis(), destPath.c_str());
return false;
}
}
// Open source directory
FsFile srcDir = SdMan.open(srcPath.c_str());
if (!srcDir || !srcDir.isDirectory()) {
Serial.printf("[%lu] [WEB] Copy folder failed - cannot open source dir: %s\n", millis(), srcPath.c_str());
return false;
}
// Iterate through source directory
FsFile entry = srcDir.openNextFile();
char name[256];
bool success = true;
while (entry && success) {
esp_task_wdt_reset();
yield();
entry.getName(name, sizeof(name));
const String entryName = String(name);
// Skip hidden files
if (!entryName.startsWith(".")) {
String srcEntryPath = srcPath;
if (!srcEntryPath.endsWith("/")) srcEntryPath += "/";
srcEntryPath += entryName;
String destEntryPath = destPath;
if (!destEntryPath.endsWith("/")) destEntryPath += "/";
destEntryPath += entryName;
if (entry.isDirectory()) {
success = copyFolder(srcEntryPath, destEntryPath);
} else {
success = copyFile(srcEntryPath, destEntryPath);
}
}
entry.close();
entry = srcDir.openNextFile();
}
srcDir.close();
return success;
}
void CrossPointWebServer::handleCopy() const {
// Get source and destination paths
if (!server->hasArg("srcPath") || !server->hasArg("destPath")) {
server->send(400, "text/plain", "Missing srcPath or destPath parameter");
return;
}
String srcPath = server->arg("srcPath");
String destPath = server->arg("destPath");
// Ensure paths start with /
if (!srcPath.startsWith("/")) srcPath = "/" + srcPath;
if (!destPath.startsWith("/")) destPath = "/" + destPath;
// Validate paths
if (srcPath.isEmpty() || srcPath == "/") {
server->send(400, "text/plain", "Cannot copy root directory");
return;
}
// Security check: prevent copying protected items
const String srcName = srcPath.substring(srcPath.lastIndexOf('/') + 1);
if (srcName.startsWith(".")) {
server->send(403, "text/plain", "Cannot copy system files");
return;
}
for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) {
if (srcName.equals(HIDDEN_ITEMS[i])) {
server->send(403, "text/plain", "Cannot copy protected items");
return;
}
}
// Check if source exists
if (!SdMan.exists(srcPath.c_str())) {
server->send(404, "text/plain", "Source item not found");
return;
}
// Check if destination already exists
if (SdMan.exists(destPath.c_str())) {
server->send(400, "text/plain", "Destination already exists");
return;
}
// Prevent copying a folder into itself
if (destPath.startsWith(srcPath + "/")) {
server->send(400, "text/plain", "Cannot copy a folder into itself");
return;
}
Serial.printf("[%lu] [WEB] Copying: %s -> %s\n", millis(), srcPath.c_str(), destPath.c_str());
// Check if source is a file or directory
FsFile srcFile = SdMan.open(srcPath.c_str());
const bool isDirectory = srcFile.isDirectory();
srcFile.close();
bool success;
if (isDirectory) {
success = copyFolder(srcPath, destPath);
} else {
success = copyFile(srcPath, destPath);
}
if (success) {
Serial.printf("[%lu] [WEB] Copy successful\n", millis());
server->send(200, "text/plain", "Copied successfully");
} else {
Serial.printf("[%lu] [WEB] Copy failed\n", millis());
server->send(500, "text/plain", "Failed to copy item");
}
}
// Helper to recursively delete a folder
static bool deleteFolderRecursive(const String& path) {
FsFile dir = SdMan.open(path.c_str());
if (!dir || !dir.isDirectory()) {
return false;
}
FsFile entry = dir.openNextFile();
char name[256];
bool success = true;
while (entry && success) {
esp_task_wdt_reset();
yield();
entry.getName(name, sizeof(name));
const String entryName = String(name);
String entryPath = path;
if (!entryPath.endsWith("/")) entryPath += "/";
entryPath += entryName;
if (entry.isDirectory()) {
entry.close();
success = deleteFolderRecursive(entryPath);
} else {
entry.close();
success = SdMan.remove(entryPath.c_str());
}
if (success) {
entry = dir.openNextFile();
}
}
dir.close();
// Now remove the empty directory
if (success) {
success = SdMan.rmdir(path.c_str());
}
return success;
}
void CrossPointWebServer::handleMove() const {
// Get source and destination paths
if (!server->hasArg("srcPath") || !server->hasArg("destPath")) {
server->send(400, "text/plain", "Missing srcPath or destPath parameter");
return;
}
String srcPath = server->arg("srcPath");
String destPath = server->arg("destPath");
// Ensure paths start with /
if (!srcPath.startsWith("/")) srcPath = "/" + srcPath;
if (!destPath.startsWith("/")) destPath = "/" + destPath;
// Validate paths
if (srcPath.isEmpty() || srcPath == "/") {
server->send(400, "text/plain", "Cannot move root directory");
return;
}
// Security check: prevent moving protected items
const String srcName = srcPath.substring(srcPath.lastIndexOf('/') + 1);
if (srcName.startsWith(".")) {
server->send(403, "text/plain", "Cannot move system files");
return;
}
for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) {
if (srcName.equals(HIDDEN_ITEMS[i])) {
server->send(403, "text/plain", "Cannot move protected items");
return;
}
}
// Check if source exists
if (!SdMan.exists(srcPath.c_str())) {
server->send(404, "text/plain", "Source item not found");
return;
}
// Check if destination already exists
if (SdMan.exists(destPath.c_str())) {
server->send(400, "text/plain", "Destination already exists");
return;
}
// Prevent moving a folder into itself
if (destPath.startsWith(srcPath + "/")) {
server->send(400, "text/plain", "Cannot move a folder into itself");
return;
}
Serial.printf("[%lu] [WEB] Moving: %s -> %s\n", millis(), srcPath.c_str(), destPath.c_str());
// First, try atomic rename (fast, works on same filesystem)
esp_task_wdt_reset();
if (SdMan.rename(srcPath.c_str(), destPath.c_str())) {
Serial.printf("[%lu] [WEB] Move successful (via rename)\n", millis());
server->send(200, "text/plain", "Moved successfully");
return;
}
// Fallback: copy + delete (for cross-directory moves that rename doesn't support)
Serial.printf("[%lu] [WEB] Rename failed, trying copy+delete fallback\n", millis());
// Check if source is a file or directory
FsFile srcFile = SdMan.open(srcPath.c_str());
const bool isDirectory = srcFile.isDirectory();
srcFile.close();
bool copySuccess;
if (isDirectory) {
copySuccess = copyFolder(srcPath, destPath);
} else {
copySuccess = copyFile(srcPath, destPath);
}
if (!copySuccess) {
Serial.printf("[%lu] [WEB] Move failed - copy step failed\n", millis());
server->send(500, "text/plain", "Failed to move item");
return;
}
// Delete source
bool deleteSuccess;
if (isDirectory) {
deleteSuccess = deleteFolderRecursive(srcPath);
} else {
deleteSuccess = SdMan.remove(srcPath.c_str());
}
if (deleteSuccess) {
Serial.printf("[%lu] [WEB] Move successful (via copy+delete)\n", millis());
server->send(200, "text/plain", "Moved successfully");
} else {
// Copy succeeded but delete failed - warn but don't fail completely
Serial.printf("[%lu] [WEB] Move partial - copied but failed to delete source\n", millis());
server->send(200, "text/plain", "Moved (but source may still exist)");
}
}
void CrossPointWebServer::handleListGet() const {
Serial.printf("[%lu] [WEB] GET /list request\n", millis());
if (server->hasArg("name")) {
// Return specific list contents
const String name = server->arg("name");
BookList list;
if (!BookListStore::loadList(name.c_str(), list)) {
server->send(404, "application/json", "{\"error\":\"List not found\"}");
return;
}
// Build JSON response with full list details
JsonDocument doc;
doc["name"] = list.name;
doc["path"] = BookListStore::getListPath(list.name);
JsonArray booksArray = doc["books"].to<JsonArray>();
for (const auto& book : list.books) {
JsonObject bookObj = booksArray.add<JsonObject>();
bookObj["order"] = book.order;
bookObj["title"] = book.title;
bookObj["author"] = book.author;
bookObj["path"] = book.path;
}
String response;
serializeJson(doc, response);
server->send(200, "application/json", response);
Serial.printf("[%lu] [WEB] Returned list '%s' with %d books\n", millis(), name.c_str(), list.books.size());
} else {
// Return all lists
const auto lists = BookListStore::listAllLists();
JsonDocument doc;
JsonArray arr = doc.to<JsonArray>();
for (const auto& name : lists) {
JsonObject listObj = arr.add<JsonObject>();
listObj["name"] = name;
listObj["path"] = BookListStore::getListPath(name);
listObj["bookCount"] = BookListStore::getBookCount(name);
}
String response;
serializeJson(doc, response);
server->send(200, "application/json", response);
Serial.printf("[%lu] [WEB] Returned %d lists\n", millis(), lists.size());
}
}
void CrossPointWebServer::handleListPost() const {
Serial.printf("[%lu] [WEB] POST /list request\n", millis());
// Validate required parameters
if (!server->hasArg("action")) {
server->send(400, "application/json", "{\"error\":\"Missing action parameter\"}");
return;
}
if (!server->hasArg("name")) {
server->send(400, "application/json", "{\"error\":\"Missing name parameter\"}");
return;
}
const String action = server->arg("action");
const String name = server->arg("name");
if (name.isEmpty()) {
server->send(400, "application/json", "{\"error\":\"Name cannot be empty\"}");
return;
}
if (action == "upload") {
// Get the POST body
const String body = server->arg("plain");
if (body.isEmpty()) {
server->send(400, "application/json", "{\"error\":\"Missing request body\"}");
return;
}
Serial.printf("[%lu] [WEB] Uploading list '%s' (%d bytes)\n", millis(), name.c_str(), body.length());
// Parse the CSV body
BookList list;
list.name = name.c_str();
if (!BookListStore::parseFromText(body.c_str(), list)) {
server->send(400, "application/json", "{\"error\":\"Failed to parse list data\"}");
return;
}
// Save the list
if (!BookListStore::saveList(list)) {
server->send(500, "application/json", "{\"error\":\"Failed to save list\"}");
return;
}
// Return success with path
JsonDocument doc;
doc["success"] = true;
doc["path"] = BookListStore::getListPath(name.c_str());
String response;
serializeJson(doc, response);
server->send(200, "application/json", response);
Serial.printf("[%lu] [WEB] List '%s' uploaded successfully\n", millis(), name.c_str());
} else if (action == "delete") {
if (!BookListStore::listExists(name.c_str())) {
server->send(404, "application/json", "{\"error\":\"List not found\"}");
return;
}
if (!BookListStore::deleteList(name.c_str())) {
server->send(500, "application/json", "{\"error\":\"Failed to delete list\"}");
return;
}
// Clear pinned list if we just deleted it
if (strcmp(SETTINGS.pinnedListName, name.c_str()) == 0) {
SETTINGS.pinnedListName[0] = '\0';
SETTINGS.saveToFile();
}
server->send(200, "application/json", "{\"success\":true}");
Serial.printf("[%lu] [WEB] List '%s' deleted successfully\n", millis(), name.c_str());
} else {
server->send(400, "application/json", "{\"error\":\"Invalid action. Use 'upload' or 'delete'\"}");
}
}
void CrossPointWebServer::handleHash() const {
Serial.printf("[%lu] [WEB] GET /api/hash request\n", millis());
// Validate path parameter
if (!server->hasArg("path")) {
server->send(400, "application/json", "{\"error\":\"Missing path parameter\"}");
return;
}
String filePath = server->arg("path");
// Ensure path starts with /
if (!filePath.startsWith("/")) {
filePath = "/" + filePath;
}
// Security check: prevent directory traversal
if (filePath.indexOf("..") >= 0) {
Serial.printf("[%lu] [WEB] Hash rejected - directory traversal attempt: %s\n", millis(), filePath.c_str());
server->send(403, "application/json", "{\"error\":\"Invalid path\"}");
return;
}
// Extract filename for security checks
const String filename = filePath.substring(filePath.lastIndexOf('/') + 1);
// Security check: reject hidden/system files
if (filename.startsWith(".")) {
Serial.printf("[%lu] [WEB] Hash rejected - hidden/system file: %s\n", millis(), filePath.c_str());
server->send(403, "application/json", "{\"error\":\"Cannot hash system files\"}");
return;
}
// Check if file exists
if (!SdMan.exists(filePath.c_str())) {
Serial.printf("[%lu] [WEB] Hash failed - file not found: %s\n", millis(), filePath.c_str());
server->send(404, "application/json", "{\"error\":\"File not found\"}");
return;
}
// Get file size for cache validation and response
FsFile file;
if (!SdMan.openFileForRead("WEB", filePath, file)) {
server->send(500, "application/json", "{\"error\":\"Failed to open file\"}");
return;
}
if (file.isDirectory()) {
file.close();
server->send(400, "application/json", "{\"error\":\"Cannot hash a directory\"}");
return;
}
const size_t fileSize = file.size();
file.close();
Serial.printf("[%lu] [WEB] Computing hash for: %s (%zu bytes)\n", millis(), filePath.c_str(), fileSize);
// Try to get cached hash first
std::string md5 = Md5Utils::getCachedMd5(filePath.c_str(), BookManager::CROSSPOINT_DIR, fileSize);
// If not cached or invalid, compute and cache it
if (md5.empty()) {
md5 = Md5Utils::computeAndCacheMd5(filePath.c_str(), BookManager::CROSSPOINT_DIR);
if (md5.empty()) {
server->send(500, "application/json", "{\"error\":\"Failed to compute hash\"}");
return;
}
}
// Build JSON response
JsonDocument doc;
doc["md5"] = md5;
doc["size"] = fileSize;
String response;
serializeJson(doc, response);
server->send(200, "application/json", response);
Serial.printf("[%lu] [WEB] Hash computed: %s = %s\n", millis(), filePath.c_str(), md5.c_str());
}