add download feature to web file transfer
This commit is contained in:
parent
ac1251282b
commit
59f493d293
@ -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);
|
||||
}
|
||||
|
||||
@ -63,4 +63,5 @@ class CrossPointWebServer {
|
||||
void handleArchive() const;
|
||||
void handleUnarchive() const;
|
||||
void handleArchivedList() const;
|
||||
void handleDownload() const;
|
||||
};
|
||||
|
||||
@ -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>`;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user