diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index cbe63a5..e41889a 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -112,6 +112,9 @@ void CrossPointWebServer::begin() { server->on("/unarchive", HTTP_POST, [this] { handleUnarchive(); }); server->on("/api/archived", HTTP_GET, [this] { handleArchivedList(); }); + // Download endpoint + server->on("/download", HTTP_GET, [this] { handleDownload(); }); + server->onNotFound([this] { handleNotFound(); }); Serial.printf("[%lu] [WEB] [MEM] Free heap after route setup: %d bytes\n", millis(), ESP.getFreeHeap()); @@ -917,3 +920,100 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* 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); +} diff --git a/src/network/CrossPointWebServer.h b/src/network/CrossPointWebServer.h index 1d1c35e..470eeee 100644 --- a/src/network/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -63,4 +63,5 @@ class CrossPointWebServer { void handleArchive() const; void handleUnarchive() const; void handleArchivedList() const; + void handleDownload() const; }; diff --git a/src/network/html/FilesPage.html b/src/network/html/FilesPage.html index bcde366..2761bba 100644 --- a/src/network/html/FilesPage.html +++ b/src/network/html/FilesPage.html @@ -323,7 +323,7 @@ background-color: #d68910; } /* Action button styles */ - .delete-btn, .archive-btn { + .delete-btn, .archive-btn, .download-btn { background: none; border: none; cursor: pointer; @@ -332,6 +332,8 @@ border-radius: 4px; color: #95a5a6; transition: all 0.15s; + text-decoration: none; + display: inline-block; } .delete-btn:hover { background-color: #fee; @@ -341,8 +343,12 @@ background-color: #e8f4fd; color: #3498db; } + .download-btn:hover { + background-color: #e8f6e9; + color: #27ae60; + } .actions-col { - width: 90px; + width: 120px; text-align: center; } /* Archived files button */ @@ -623,9 +629,9 @@ font-size: 1.1em; } .actions-col { - width: 40px; + width: 70px; } - .delete-btn { + .delete-btn, .archive-btn, .download-btn { font-size: 1em; padding: 2px 4px; } @@ -892,6 +898,7 @@ fileTableContent += `