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("/unarchive", HTTP_POST, [this] { handleUnarchive(); });
|
||||||
server->on("/api/archived", HTTP_GET, [this] { handleArchivedList(); });
|
server->on("/api/archived", HTTP_GET, [this] { handleArchivedList(); });
|
||||||
|
|
||||||
|
// Download endpoint
|
||||||
|
server->on("/download", HTTP_GET, [this] { handleDownload(); });
|
||||||
|
|
||||||
server->onNotFound([this] { handleNotFound(); });
|
server->onNotFound([this] { handleNotFound(); });
|
||||||
Serial.printf("[%lu] [WEB] [MEM] Free heap after route setup: %d bytes\n", millis(), ESP.getFreeHeap());
|
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;
|
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 handleArchive() const;
|
||||||
void handleUnarchive() const;
|
void handleUnarchive() const;
|
||||||
void handleArchivedList() const;
|
void handleArchivedList() const;
|
||||||
|
void handleDownload() const;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -323,7 +323,7 @@
|
|||||||
background-color: #d68910;
|
background-color: #d68910;
|
||||||
}
|
}
|
||||||
/* Action button styles */
|
/* Action button styles */
|
||||||
.delete-btn, .archive-btn {
|
.delete-btn, .archive-btn, .download-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@ -332,6 +332,8 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: #95a5a6;
|
color: #95a5a6;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
}
|
}
|
||||||
.delete-btn:hover {
|
.delete-btn:hover {
|
||||||
background-color: #fee;
|
background-color: #fee;
|
||||||
@ -341,8 +343,12 @@
|
|||||||
background-color: #e8f4fd;
|
background-color: #e8f4fd;
|
||||||
color: #3498db;
|
color: #3498db;
|
||||||
}
|
}
|
||||||
|
.download-btn:hover {
|
||||||
|
background-color: #e8f6e9;
|
||||||
|
color: #27ae60;
|
||||||
|
}
|
||||||
.actions-col {
|
.actions-col {
|
||||||
width: 90px;
|
width: 120px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
/* Archived files button */
|
/* Archived files button */
|
||||||
@ -623,9 +629,9 @@
|
|||||||
font-size: 1.1em;
|
font-size: 1.1em;
|
||||||
}
|
}
|
||||||
.actions-col {
|
.actions-col {
|
||||||
width: 40px;
|
width: 70px;
|
||||||
}
|
}
|
||||||
.delete-btn {
|
.delete-btn, .archive-btn, .download-btn {
|
||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
padding: 2px 4px;
|
padding: 2px 4px;
|
||||||
}
|
}
|
||||||
@ -892,6 +898,7 @@
|
|||||||
fileTableContent += `<td>${file.name.split('.').pop().toUpperCase()}</td>`;
|
fileTableContent += `<td>${file.name.split('.').pop().toUpperCase()}</td>`;
|
||||||
fileTableContent += `<td>${formatFileSize(file.size)}</td>`;
|
fileTableContent += `<td>${formatFileSize(file.size)}</td>`;
|
||||||
fileTableContent += '<td class="actions-col">';
|
fileTableContent += '<td class="actions-col">';
|
||||||
|
fileTableContent += `<a href="/download?path=${encodeURIComponent(filePath)}" class="download-btn" title="Download file">⬇️</a>`;
|
||||||
if (isBookFormat(file.name)) {
|
if (isBookFormat(file.name)) {
|
||||||
fileTableContent += `<button class="archive-btn" onclick="openArchiveModal('${file.name.replaceAll("'", "\\'")}', '${filePath.replaceAll("'", "\\'")}')" title="Archive book">📦</button>`;
|
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