- 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.
1762 lines
57 KiB
C++
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());
|
|
}
|