add download feature to web file transfer

This commit is contained in:
cottongin 2026-01-23 07:57:44 -05:00
parent ac1251282b
commit 59f493d293
No known key found for this signature in database
GPG Key ID: 0ECC91FE4655C262
3 changed files with 112 additions and 4 deletions

View File

@ -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);
}

View File

@ -63,4 +63,5 @@ class CrossPointWebServer {
void handleArchive() const;
void handleUnarchive() const;
void handleArchivedList() const;
void handleDownload() const;
};

View File

@ -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 += `<td>${file.name.split('.').pop().toUpperCase()}</td>`;
fileTableContent += `<td>${formatFileSize(file.size)}</td>`;
fileTableContent += '<td class="actions-col">';
fileTableContent += `<a href="/download?path=${encodeURIComponent(filePath)}" class="download-btn" title="Download file">⬇️</a>`;
if (isBookFormat(file.name)) {
fileTableContent += `<button class="archive-btn" onclick="openArchiveModal('${file.name.replaceAll("'", "\\'")}', '${filePath.replaceAll("'", "\\'")}')" title="Archive book">📦</button>`;
}