";
+
+ // Contents header with inline stats
+ html += "";
+
+ if (files.empty()) {
+ html += "
This folder is empty
";
+ } else {
+ html += "
";
+ html += "| Name | Type | Size | Actions |
";
+
+ // Sort files: folders first, then epub files, then other files, alphabetically within each group
+ std::sort(files.begin(), files.end(), [](const FileInfo& a, const FileInfo& b) {
+ // Folders come first
+ if (a.isDirectory != b.isDirectory) return a.isDirectory > b.isDirectory;
+ // Then sort by epub status (epubs first among files)
+ if (!a.isDirectory && !b.isDirectory) {
+ if (a.isEpub != b.isEpub) return a.isEpub > b.isEpub;
+ }
+ // Then alphabetically
+ return a.name < b.name;
+ });
+
+ for (const auto& file : files) {
+ String rowClass;
+ String icon;
+ String badge;
+ String typeStr;
+ String sizeStr;
+
+ if (file.isDirectory) {
+ rowClass = "folder-row";
+ icon = "📁";
+ badge = "FOLDER";
+ typeStr = "Folder";
+ sizeStr = "-";
+
+ // Build the path to this folder
+ String folderPath = currentPath;
+ if (!folderPath.endsWith("/")) folderPath += "/";
+ folderPath += file.name;
+
+ html += "";
+ html += "| " + icon + "";
+ html += "" + escapeHtml(file.name) + "" +
+ badge + " | ";
+ html += "" + typeStr + " | ";
+ html += "" + sizeStr + " | ";
+ // Escape quotes for JavaScript string
+ String escapedName = file.name;
+ escapedName.replace("'", "\\'");
+ String escapedPath = folderPath;
+ escapedPath.replace("'", "\\'");
+ html += " | ";
+ html += "
";
+ } else {
+ rowClass = file.isEpub ? "epub-file" : "";
+ icon = file.isEpub ? "📗" : "📄";
+ badge = file.isEpub ? "EPUB" : "";
+ String ext = file.name.substring(file.name.lastIndexOf('.') + 1);
+ ext.toUpperCase();
+ typeStr = ext;
+ sizeStr = formatFileSize(file.size);
+
+ // Build file path for delete
+ String filePath = currentPath;
+ if (!filePath.endsWith("/")) filePath += "/";
+ filePath += file.name;
+
+ html += "";
+ html += "| " + icon + "" + escapeHtml(file.name) + badge + " | ";
+ html += "" + typeStr + " | ";
+ html += "" + sizeStr + " | ";
+ // Escape quotes for JavaScript string
+ String escapedName = file.name;
+ escapedName.replace("'", "\\'");
+ String escapedPath = filePath;
+ escapedPath.replace("'", "\\'");
+ html += " | ";
+ html += "
";
+ }
+ }
+
+ html += "
";
+ }
+
+ html += "
";
+
+ html += FilesPageFooterHtml;
+
+ server->send(200, "text/html", html);
+ Serial.printf("[%lu] [WEB] Served file listing page for path: %s\n", millis(), currentPath.c_str());
+}
+
+// Static variables for upload handling
+static File uploadFile;
+static String uploadFileName;
+static String uploadPath = "/";
+static size_t uploadSize = 0;
+static bool uploadSuccess = false;
+static String uploadError = "";
+
+void CrossPointWebServer::handleUpload() {
+ static unsigned long lastWriteTime = 0;
+ static unsigned long uploadStartTime = 0;
+ static size_t lastLoggedSize = 0;
+
+ // 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;
+ }
+
+ HTTPUpload& upload = server->upload();
+
+ if (upload.status == UPLOAD_FILE_START) {
+ uploadFileName = upload.filename;
+ uploadSize = 0;
+ uploadSuccess = false;
+ uploadError = "";
+ uploadStartTime = millis();
+ lastWriteTime = millis();
+ lastLoggedSize = 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());
+
+ // Validate file extension
+ if (!isEpubFile(uploadFileName)) {
+ uploadError = "Only .epub files are allowed";
+ Serial.printf("[%lu] [WEB] [UPLOAD] REJECTED - not an epub file\n", millis());
+ return;
+ }
+
+ // Create file path
+ String filePath = uploadPath;
+ if (!filePath.endsWith("/")) filePath += "/";
+ filePath += uploadFileName;
+
+ // Check if file already exists
+ if (SD.exists(filePath.c_str())) {
+ Serial.printf("[%lu] [WEB] [UPLOAD] Overwriting existing file: %s\n", millis(), filePath.c_str());
+ SD.remove(filePath.c_str());
+ }
+
+ // Open file for writing
+ uploadFile = SD.open(filePath.c_str(), FILE_WRITE);
+ if (!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;
+ }
+
+ 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()) {
+ unsigned long writeStartTime = millis();
+ size_t written = uploadFile.write(upload.buf, upload.currentSize);
+ unsigned long writeEndTime = millis();
+ unsigned long writeDuration = writeEndTime - writeStartTime;
+
+ if (written != upload.currentSize) {
+ uploadError = "Failed to write to SD card - disk may be full";
+ uploadFile.close();
+ Serial.printf("[%lu] [WEB] [UPLOAD] WRITE ERROR - expected %d, wrote %d\n", millis(), upload.currentSize,
+ written);
+ } else {
+ uploadSize += written;
+
+ // Log progress every 50KB or if write took >100ms
+ if (uploadSize - lastLoggedSize >= 51200 || writeDuration > 100) {
+ unsigned long timeSinceStart = millis() - uploadStartTime;
+ unsigned long timeSinceLastWrite = millis() - lastWriteTime;
+ float kbps = (uploadSize / 1024.0) / (timeSinceStart / 1000.0);
+
+ Serial.printf(
+ "[%lu] [WEB] [UPLOAD] Progress: %d bytes (%.1f KB), %.1f KB/s, write took %lu ms, gap since last: %lu "
+ "ms\n",
+ millis(), uploadSize, uploadSize / 1024.0, kbps, writeDuration, timeSinceLastWrite);
+ lastLoggedSize = uploadSize;
+ }
+ lastWriteTime = millis();
+ }
+ }
+ } else if (upload.status == UPLOAD_FILE_END) {
+ if (uploadFile) {
+ uploadFile.close();
+
+ if (uploadError.isEmpty()) {
+ uploadSuccess = true;
+ Serial.printf("[%lu] [WEB] Upload complete: %s (%d bytes)\n", millis(), uploadFileName.c_str(), uploadSize);
+ }
+ }
+ } else if (upload.status == UPLOAD_FILE_ABORTED) {
+ if (uploadFile) {
+ uploadFile.close();
+ // Try to delete the incomplete file
+ String filePath = uploadPath;
+ if (!filePath.endsWith("/")) filePath += "/";
+ filePath += uploadFileName;
+ SD.remove(filePath.c_str());
+ }
+ uploadError = "Upload aborted";
+ Serial.printf("[%lu] [WEB] Upload aborted\n", millis());
+ }
+}
+
+void CrossPointWebServer::handleUploadPost() {
+ if (uploadSuccess) {
+ server->send(200, "text/plain", "File uploaded successfully: " + uploadFileName);
+ } else {
+ String error = uploadError.isEmpty() ? "Unknown error during upload" : uploadError;
+ server->send(400, "text/plain", error);
+ }
+}
+
+void CrossPointWebServer::handleCreateFolder() {
+ // Get folder name from form data
+ if (!server->hasArg("name")) {
+ server->send(400, "text/plain", "Missing folder name");
+ return;
+ }
+
+ 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 (SD.exists(folderPath.c_str())) {
+ server->send(400, "text/plain", "Folder already exists");
+ return;
+ }
+
+ // Create the folder
+ if (SD.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() {
+ // Get path from form data
+ if (!server->hasArg("path")) {
+ server->send(400, "text/plain", "Missing path");
+ return;
+ }
+
+ String itemPath = server->arg("path");
+ String itemType = server->hasArg("type") ? server->arg("type") : "file";
+
+ // 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
+ String itemName = itemPath.substring(itemPath.lastIndexOf('/') + 1);
+
+ // Check if item starts with a dot (hidden/system file)
+ if (itemName.startsWith(".")) {
+ 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;
+ }
+ }
+
+ // Check if item exists
+ if (!SD.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;
+ }
+
+ Serial.printf("[%lu] [WEB] Attempting to delete %s: %s\n", millis(), itemType.c_str(), itemPath.c_str());
+
+ bool success = false;
+
+ if (itemType == "folder") {
+ // For folders, try to remove (will fail if not empty)
+ File dir = SD.open(itemPath.c_str());
+ if (dir && dir.isDirectory()) {
+ // Check if folder is empty
+ File 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 = SD.rmdir(itemPath.c_str());
+ } else {
+ // For files, use remove
+ success = SD.remove(itemPath.c_str());
+ }
+
+ 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");
+ }
+}
diff --git a/src/activities/network/server/CrossPointWebServer.h b/src/activities/network/server/CrossPointWebServer.h
new file mode 100644
index 0000000..79c0b8e
--- /dev/null
+++ b/src/activities/network/server/CrossPointWebServer.h
@@ -0,0 +1,56 @@
+#pragma once
+
+#include