diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index a135c9f0..4a27b7b1 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -44,6 +44,36 @@ void clearEpubCacheIfNeeded(const String& filePath) { Serial.printf("[%lu] [WEB] Cleared epub cache for: %s\n", millis(), filePath.c_str()); } } + +String normalizeWebPath(const String& inputPath) { + if (inputPath.isEmpty() || inputPath == "/") { + return "/"; + } + std::string normalized = FsHelpers::normalisePath(inputPath.c_str()); + String result = normalized.c_str(); + if (result.isEmpty()) { + return "/"; + } + if (!result.startsWith("/")) { + result = "/" + result; + } + if (result.length() > 1 && result.endsWith("/")) { + result = result.substring(0, result.length() - 1); + } + return result; +} + +bool isProtectedItemName(const String& name) { + if (name.startsWith(".")) { + return true; + } + for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) { + if (name.equals(HIDDEN_ITEMS[i])) { + return true; + } + } + return false; +} } // namespace // File listing page template - now using generated headers: @@ -109,6 +139,12 @@ void CrossPointWebServer::begin() { // Create folder endpoint server->on("/mkdir", HTTP_POST, [this] { handleCreateFolder(); }); + // Rename file endpoint + server->on("/rename", HTTP_POST, [this] { handleRename(); }); + + // Move file endpoint + server->on("/move", HTTP_POST, [this] { handleMove(); }); + // Delete file/folder endpoint server->on("/delete", HTTP_POST, [this] { handleDelete(); }); @@ -705,6 +741,181 @@ void CrossPointWebServer::handleCreateFolder() const { } } +void CrossPointWebServer::handleRename() const { + if (!server->hasArg("path") || !server->hasArg("name")) { + server->send(400, "text/plain", "Missing path or new name"); + return; + } + + String itemPath = normalizeWebPath(server->arg("path")); + String newName = server->arg("name"); + newName.trim(); + + if (itemPath.isEmpty() || itemPath == "/") { + server->send(400, "text/plain", "Invalid path"); + return; + } + if (newName.isEmpty()) { + server->send(400, "text/plain", "New name cannot be empty"); + return; + } + if (newName.indexOf('/') >= 0 || newName.indexOf('\\') >= 0) { + server->send(400, "text/plain", "Invalid file name"); + return; + } + if (isProtectedItemName(newName)) { + server->send(403, "text/plain", "Cannot rename to protected name"); + return; + } + + const String itemName = itemPath.substring(itemPath.lastIndexOf('/') + 1); + if (isProtectedItemName(itemName)) { + server->send(403, "text/plain", "Cannot rename protected item"); + return; + } + if (newName == itemName) { + server->send(200, "text/plain", "Name unchanged"); + return; + } + + if (!SdMan.exists(itemPath.c_str())) { + server->send(404, "text/plain", "Item not found"); + return; + } + + FsFile file = SdMan.open(itemPath.c_str()); + if (!file) { + server->send(500, "text/plain", "Failed to open file"); + return; + } + if (file.isDirectory()) { + file.close(); + server->send(400, "text/plain", "Only files can be renamed"); + return; + } + + String parentPath = itemPath.substring(0, itemPath.lastIndexOf('/')); + if (parentPath.isEmpty()) { + parentPath = "/"; + } + String newPath = parentPath; + if (!newPath.endsWith("/")) { + newPath += "/"; + } + newPath += newName; + + if (SdMan.exists(newPath.c_str())) { + file.close(); + server->send(409, "text/plain", "Target already exists"); + return; + } + + clearEpubCacheIfNeeded(itemPath); + const bool success = file.rename(newPath.c_str()); + file.close(); + + if (success) { + Serial.printf("[%lu] [WEB] Renamed file: %s -> %s\n", millis(), itemPath.c_str(), newPath.c_str()); + server->send(200, "text/plain", "Renamed successfully"); + } else { + Serial.printf("[%lu] [WEB] Failed to rename file: %s -> %s\n", millis(), itemPath.c_str(), newPath.c_str()); + server->send(500, "text/plain", "Failed to rename file"); + } +} + +void CrossPointWebServer::handleMove() const { + if (!server->hasArg("path") || !server->hasArg("dest")) { + server->send(400, "text/plain", "Missing path or destination"); + return; + } + + String itemPath = normalizeWebPath(server->arg("path")); + String destPath = normalizeWebPath(server->arg("dest")); + + if (itemPath.isEmpty() || itemPath == "/") { + server->send(400, "text/plain", "Invalid path"); + return; + } + if (destPath.isEmpty()) { + server->send(400, "text/plain", "Invalid destination"); + return; + } + + const String itemName = itemPath.substring(itemPath.lastIndexOf('/') + 1); + if (isProtectedItemName(itemName)) { + server->send(403, "text/plain", "Cannot move protected item"); + return; + } + if (destPath != "/") { + const String destName = destPath.substring(destPath.lastIndexOf('/') + 1); + if (isProtectedItemName(destName)) { + server->send(403, "text/plain", "Cannot move into protected folder"); + return; + } + } + + if (!SdMan.exists(itemPath.c_str())) { + server->send(404, "text/plain", "Item not found"); + return; + } + + FsFile file = SdMan.open(itemPath.c_str()); + if (!file) { + server->send(500, "text/plain", "Failed to open file"); + return; + } + if (file.isDirectory()) { + file.close(); + server->send(400, "text/plain", "Only files can be moved"); + return; + } + + if (!SdMan.exists(destPath.c_str())) { + file.close(); + server->send(404, "text/plain", "Destination not found"); + return; + } + FsFile destDir = SdMan.open(destPath.c_str()); + if (!destDir || !destDir.isDirectory()) { + if (destDir) { + destDir.close(); + } + file.close(); + server->send(400, "text/plain", "Destination is not a folder"); + return; + } + destDir.close(); + + String newPath = destPath; + if (!newPath.endsWith("/")) { + newPath += "/"; + } + newPath += itemName; + + if (newPath == itemPath) { + file.close(); + server->send(200, "text/plain", "Already in destination"); + return; + } + if (SdMan.exists(newPath.c_str())) { + file.close(); + server->send(409, "text/plain", "Target already exists"); + return; + } + + clearEpubCacheIfNeeded(itemPath); + const bool success = file.rename(newPath.c_str()); + file.close(); + + if (success) { + Serial.printf("[%lu] [WEB] Moved file: %s -> %s\n", millis(), itemPath.c_str(), newPath.c_str()); + server->send(200, "text/plain", "Moved successfully"); + } else { + Serial.printf("[%lu] [WEB] Failed to move file: %s -> %s\n", millis(), itemPath.c_str(), newPath.c_str()); + server->send(500, "text/plain", "Failed to move file"); + } +} + void CrossPointWebServer::handleDelete() const { // Get path from form data if (!server->hasArg("path")) { diff --git a/src/network/CrossPointWebServer.h b/src/network/CrossPointWebServer.h index 36030292..ea4f9272 100644 --- a/src/network/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -77,5 +77,7 @@ class CrossPointWebServer { void handleUpload() const; void handleUploadPost() const; void handleCreateFolder() const; + void handleRename() const; + void handleMove() const; void handleDelete() const; }; diff --git a/src/network/html/FilesPage.html b/src/network/html/FilesPage.html index 95993b8e..19dd0300 100644 --- a/src/network/html/FilesPage.html +++ b/src/network/html/FilesPage.html @@ -322,25 +322,47 @@ .folder-btn:hover { background-color: #d68910; } - /* Delete button styles */ - .delete-btn { + /* Action button styles */ + .delete-btn, + .rename-btn, + .move-btn { background: none; border: none; cursor: pointer; font-size: 1.1em; padding: 4px 8px; border-radius: 4px; - color: #95a5a6; transition: all 0.15s; } + .delete-btn { + color: #95a5a6; + } .delete-btn:hover { background-color: #fee; color: #e74c3c; } + .rename-btn { + color: #2980b9; + } + .rename-btn:hover { + background-color: #e8f4fd; + } + .move-btn { + color: #16a085; + } + .move-btn:hover { + background-color: #e6f7f4; + } .actions-col { - width: 60px; + width: 140px; text-align: center; } + .action-icon-group { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + } /* Failed uploads banner */ .failed-uploads-banner { background-color: #fff3cd; @@ -463,6 +485,32 @@ .delete-btn-cancel:hover { background-color: #7f8c8d; } + .rename-btn-confirm { + background-color: #3498db; + color: white; + padding: 10px 20px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1em; + width: 100%; + } + .rename-btn-confirm:hover { + background-color: #2e86c1; + } + .move-btn-confirm { + background-color: #16a085; + color: white; + padding: 10px 20px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1em; + width: 100%; + } + .move-btn-confirm:hover { + background-color: #138d75; + } .loader-container { display: flex; justify-content: center; @@ -558,12 +606,17 @@ font-size: 1.1em; } .actions-col { - width: 40px; + width: 120px; } - .delete-btn { + .delete-btn, + .rename-btn, + .move-btn { font-size: 1em; padding: 2px 4px; } + .action-icon-group { + gap: 4px; + } .no-files { padding: 20px; font-size: 0.9em; @@ -665,6 +718,37 @@ + + + + + +