From 85b0be83bfbcea1baf5ddd8e9185fb7238c3c195 Mon Sep 17 00:00:00 2001 From: Scott Shawcroft Date: Tue, 16 Aug 2022 13:51:40 -0700 Subject: [PATCH] Add file and directory renaming Internally this is done with a MOVE HTTP verb. It is modeled after WebDAV MOVE but not exact to keep the Destination header shorter and have more consistent response codes. Fixes #6647 --- docs/workflows.md | 41 ++++++++- .../shared/web_workflow/static/directory.html | 4 +- .../shared/web_workflow/static/directory.js | 61 +++++++++++--- .../shared/web_workflow/static/style.css | 4 + .../shared/web_workflow/static/welcome.js | 2 +- supervisor/shared/web_workflow/web_workflow.c | 83 +++++++++++++++---- 6 files changed, 160 insertions(+), 35 deletions(-) diff --git a/docs/workflows.md b/docs/workflows.md index 345379b9f8..7938c441f2 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -202,6 +202,24 @@ Example: curl -v -u :passw0rd -X PUT -L --location-trusted http://circuitpython.local/fs/lib/hello/world/ ``` +##### Move +Moves the directory at the given path to ``X-Destination``. Also known as rename. + +The custom `X-Destination` header stores the destination path of the directory. + +* `201 Created` - Directory renamed +* `401 Unauthorized` - Incorrect password +* `403 Forbidden` - No `CIRCUITPY_WEB_API_PASSWORD` set +* `404 Not Found` - Source directory not found or destination path is missing +* `409 Conflict` - USB is active and preventing file system modification +* `412 Precondition Failed` - The destination path is already in use + +Example: + +```sh +curl -v -u :passw0rd -X MOVE -H "X-Destination: /fs/lib/hello2/" -L --location-trusted http://circuitpython.local/fs/lib/hello/ +``` + ##### DELETE Deletes the directory and all of its contents. @@ -214,7 +232,7 @@ Deletes the directory and all of its contents. Example: ```sh -curl -v -u :passw0rd -X DELETE -L --location-trusted http://circuitpython.local/fs/lib/hello/world/ +curl -v -u :passw0rd -X DELETE -L --location-trusted http://circuitpython.local/fs/lib/hello2/world/ ``` @@ -270,6 +288,25 @@ curl -v -u :passw0rd -L --location-trusted http://circuitpython.local/fs/lib/hel ``` +##### Move +Moves the file at the given path to the ``X-Destination``. Also known as rename. + +The custom `X-Destination` header stores the destination path of the file. + +* `201 Created` - File renamed +* `401 Unauthorized` - Incorrect password +* `403 Forbidden` - No `CIRCUITPY_WEB_API_PASSWORD` set +* `404 Not Found` - Source file not found or destination path is missing +* `409 Conflict` - USB is active and preventing file system modification +* `412 Precondition Failed` - The destination path is already in use + +Example: + +```sh +curl -v -u :passw0rd -X MOVE -H "X-Destination: /fs/lib/hello/world2.txt" -L --location-trusted http://circuitpython.local/fs/lib/hello/world.txt +``` + + ##### DELETE Deletes the file. @@ -283,7 +320,7 @@ Deletes the file. Example: ```sh -curl -v -u :passw0rd -X DELETE -L --location-trusted http://circuitpython.local/fs/lib/hello/world.txt +curl -v -u :passw0rd -X DELETE -L --location-trusted http://circuitpython.local/fs/lib/hello/world2.txt ``` ### `/cp/` diff --git a/supervisor/shared/web_workflow/static/directory.html b/supervisor/shared/web_workflow/static/directory.html index 5551410d73..447932b49b 100644 --- a/supervisor/shared/web_workflow/static/directory.html +++ b/supervisor/shared/web_workflow/static/directory.html @@ -10,9 +10,9 @@

 

- + - +
TypeSizePathModified
TypeSizePathModified

diff --git a/supervisor/shared/web_workflow/static/directory.js b/supervisor/shared/web_workflow/static/directory.js index 981d95f68c..ebbd077af8 100644 --- a/supervisor/shared/web_workflow/static/directory.js +++ b/supervisor/shared/web_workflow/static/directory.js @@ -5,15 +5,19 @@ var url_base = window.location; var current_path; var editable = undefined; +function compareValues(a, b) { + if (a.directory == b.directory && a.name.toLowerCase() === b.name.toLowerCase()) { + return 0; + } else if (a.directory != b.directory) { + return a.directory < b.directory ? 1 : -1; + } else { + return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1; + } +} + async function refresh_list() { - function compareValues(a, b) { - if (a.directory == b.directory && a.name.toLowerCase() === b.name.toLowerCase()) { - return 0; - } else { - return a.directory.toString().substring(3,4)+a.name.toLowerCase() < b.directory.toString().substring(3,4)+b.name.toLowerCase() ? -1 : 1; - } - } + current_path = window.location.hash.substr(1); if (current_path == "") { @@ -52,7 +56,7 @@ async function refresh_list() { } } - if (window.location.path != "/fs/") { + if (current_path != "/") { var clone = template.content.cloneNode(true); var td = clone.querySelectorAll("td"); td[0].textContent = "📁"; @@ -62,6 +66,8 @@ async function refresh_list() { path.textContent = ".."; // Remove the delete button td[4].replaceChildren(); + td[5].replaceChildren(); + td[6].replaceChildren(); new_children.push(clone); } @@ -82,6 +88,7 @@ async function refresh_list() { file_path = api_url; } + var text_file = false; if (f.directory) { icon = "📁"; } else if(f.name.endsWith(".txt") || @@ -89,24 +96,37 @@ async function refresh_list() { f.name.endsWith(".js") || f.name.endsWith(".json")) { icon = "📄"; + text_file = true; } else if (f.name.endsWith(".html")) { icon = "🌐"; + text_file = true; } td[0].textContent = icon; td[1].textContent = f.file_size; - var path = clone.querySelector("a"); + var path = clone.querySelector("a.path"); path.href = file_path; path.textContent = f.name; - td[3].textContent = (new Date(f.modified_ns / 1000000)).toLocaleString(); + let modtime = clone.querySelector("td.modtime"); + modtime.textContent = (new Date(f.modified_ns / 1000000)).toLocaleString(); var delete_button = clone.querySelector("button.delete"); delete_button.value = api_url; delete_button.disabled = !editable; delete_button.onclick = del; - if (editable && !f.directory) { + + var rename_button = clone.querySelector("button.rename"); + rename_button.value = api_url; + rename_button.disabled = !editable; + rename_button.onclick = rename; + + let edit_link = clone.querySelector(".edit_link"); + if (text_file && editable && !f.directory) { edit_url = new URL(edit_url, url_base); - let edit_link = clone.querySelector(".edit_link"); edit_link.href = edit_url + } else if (f.directory) { + edit_link.style = "display: none;"; + } else { + edit_link.querySelector("button").disabled = true; } new_children.push(clone); @@ -188,6 +208,23 @@ async function del(e) { } } +async function rename(e) { + let fn = new URL(e.target.value); + var new_fn = prompt("Rename to ", fn.pathname.substr(3)); + let new_uri = new URL("/fs" + new_fn, fn); + const response = await fetch(e.target.value, + { + method: "MOVE", + headers: { + 'X-Destination': new_uri.pathname, + }, + } + ) + if (response.ok) { + refresh_list(); + } +} + find_devices(); let mkdir_button = document.getElementById("mkdir"); diff --git a/supervisor/shared/web_workflow/static/style.css b/supervisor/shared/web_workflow/static/style.css index 17564af4fd..ab9dde7fb8 100644 --- a/supervisor/shared/web_workflow/static/style.css +++ b/supervisor/shared/web_workflow/static/style.css @@ -17,3 +17,7 @@ body { margin: 0; font-size: 0.7em; } + +:disabled { + filter: saturate(0%); +} diff --git a/supervisor/shared/web_workflow/static/welcome.js b/supervisor/shared/web_workflow/static/welcome.js index d53950d37c..e32d6924b5 100644 --- a/supervisor/shared/web_workflow/static/welcome.js +++ b/supervisor/shared/web_workflow/static/welcome.js @@ -1,7 +1,7 @@ var url_base = window.location; var current_path; -var mdns_works = window.location.hostname.endsWith(".local"); +var mdns_works = url_base.hostname.endsWith(".local"); async function find_devices() { var version_response = await fetch("/cp/version.json"); diff --git a/supervisor/shared/web_workflow/web_workflow.c b/supervisor/shared/web_workflow/web_workflow.c index 1144d41bdc..677a413d85 100644 --- a/supervisor/shared/web_workflow/web_workflow.c +++ b/supervisor/shared/web_workflow/web_workflow.c @@ -78,8 +78,9 @@ typedef struct { enum request_state state; char method[8]; char path[256]; + char destination[256]; char header_key[64]; - char header_value[64]; + char header_value[256]; // We store the origin so we can reply back with it. char origin[64]; size_t content_length; @@ -553,6 +554,15 @@ static void _reply_conflict(socketpool_socket_obj_t *socket, _request *request) _send_str(socket, "\r\nUSB storage active."); } + +static void _reply_precondition_failed(socketpool_socket_obj_t *socket, _request *request) { + _send_strs(socket, + "HTTP/1.1 412 Precondition Failed\r\n", + "Content-Length: 0\r\n", NULL); + _cors_header(socket, request); + _send_str(socket, "\r\n"); +} + static void _reply_payload_too_large(socketpool_socket_obj_t *socket, _request *request) { _send_strs(socket, "HTTP/1.1 413 Payload Too Large\r\n", @@ -986,6 +996,28 @@ static uint8_t _hex2nibble(char h) { return h - 'a' + 0xa; } +// Decode percent encoding in place. Only do this once on a string! +static void _decode_percents(char *str) { + size_t o = 0; + size_t i = 0; + size_t startlen = strlen(str); + while (i < startlen) { + if (str[i] == '%') { + str[o] = _hex2nibble(str[i + 1]) << 4 | _hex2nibble(str[i + 2]); + i += 3; + } else { + if (i != o) { + str[o] = str[i]; + } + i += 1; + } + o += 1; + } + if (o < i) { + str[o] = '\0'; + } +} + static bool _reply(socketpool_socket_obj_t *socket, _request *request) { if (request->redirect) { _reply_redirect(socket, request, request->path); @@ -1006,23 +1038,8 @@ static bool _reply(socketpool_socket_obj_t *socket, _request *request) { // Decode any percent encoded bytes so that we're left with UTF-8. // We only do this on /fs/ paths and after redirect so that any // path echoing we do stays encoded. - size_t o = 0; - size_t i = 0; - while (i < strlen(request->path)) { - if (request->path[i] == '%') { - request->path[o] = _hex2nibble(request->path[i + 1]) << 4 | _hex2nibble(request->path[i + 2]); - i += 3; - } else { - if (i != o) { - request->path[o] = request->path[i]; - } - i += 1; - } - o += 1; - } - if (o < i) { - request->path[o] = '\0'; - } + _decode_percents(request->path); + char *path = request->path + 3; size_t pathlen = strlen(path); FATFS *fs = filesystem_circuitpy(); @@ -1066,6 +1083,34 @@ static bool _reply(socketpool_socket_obj_t *socket, _request *request) { _reply_no_content(socket, request); return true; } + } else if (strcasecmp(request->method, "MOVE") == 0) { + if (_usb_active()) { + _reply_conflict(socket, request); + return false; + } + + _decode_percents(request->destination); + char *destination = request->destination + 3; + size_t destinationlen = strlen(destination); + if (destination[destinationlen - 1] == '/' && destinationlen > 1) { + destination[destinationlen - 1] = '\0'; + } + + FRESULT result = f_rename(fs, path, destination); + #if CIRCUITPY_USB_MSC + usb_msc_unlock(); + #endif + if (result == FR_EXIST) { // File exists and won't be overwritten. + _reply_precondition_failed(socket, request); + } else if (result == FR_NO_PATH || result == FR_NO_FILE) { // Missing higher directories or target file. + _reply_missing(socket, request); + } else if (result != FR_OK) { + ESP_LOGE(TAG, "move error %d %s", result, path); + _reply_server_error(socket, request); + } else { + _reply_created(socket, request); + return true; + } } else if (directory) { if (strcasecmp(request->method, "GET") == 0) { FF_DIR dir; @@ -1318,6 +1363,8 @@ static void _process_request(socketpool_socket_obj_t *socket, _request *request) } else if (strcasecmp(request->header_key, "Sec-WebSocket-Key") == 0 && strlen(request->header_value) == 24) { strcpy(request->websocket_key, request->header_value); + } else if (strcasecmp(request->header_key, "X-Destination") == 0) { + strcpy(request->destination, request->header_value); } ESP_LOGI(TAG, "Header %s %s", request->header_key, request->header_value); } else if (request->offset > sizeof(request->header_value) - 1) {