From 3f93650901caf9e4094aeaee6a47d68efafeac97 Mon Sep 17 00:00:00 2001 From: Jo-Philipp Wich Date: Tue, 3 Sep 2019 19:34:33 +0200 Subject: [PATCH] luci-base: use cgi-io and rpcd-mod-file to handle file upload and browsing Remove the old server side support for file browsing and file uploading and switch to a client side widget instead which uses XMLHTTPRequests to upload files via cgi-io and RPC calls for file listing and status queries. Signed-off-by: Jo-Philipp Wich --- modules/luci-base/Makefile | 2 +- .../htdocs/luci-static/resources/cbi.js | 25 -- .../htdocs/luci-static/resources/form.js | 27 ++ .../htdocs/luci-static/resources/ui.js | 415 +++++++++++++++++- .../luci-base/luasrc/view/cbi/filebrowser.htm | 113 ----- modules/luci-base/luasrc/view/cbi/upload.htm | 30 +- .../root/usr/share/rpcd/acl.d/luci-base.json | 10 + .../luasrc/controller/admin/filebrowser.lua | 9 - 8 files changed, 462 insertions(+), 169 deletions(-) delete mode 100644 modules/luci-base/luasrc/view/cbi/filebrowser.htm delete mode 100644 modules/luci-mod-admin-full/luasrc/controller/admin/filebrowser.lua diff --git a/modules/luci-base/Makefile b/modules/luci-base/Makefile index 9bc8ec17a..92da37a22 100644 --- a/modules/luci-base/Makefile +++ b/modules/luci-base/Makefile @@ -12,7 +12,7 @@ LUCI_TYPE:=mod LUCI_BASENAME:=base LUCI_TITLE:=LuCI core libraries -LUCI_DEPENDS:=+lua +luci-lib-nixio +luci-lib-ip +rpcd +libubus-lua +luci-lib-jsonc +liblucihttp-lua +LUCI_DEPENDS:=+lua +luci-lib-nixio +luci-lib-ip +rpcd +libubus-lua +luci-lib-jsonc +liblucihttp-lua +rpcd-mod-file +cgi-io PKG_SOURCE:=v1.0.0.tar.gz diff --git a/modules/luci-base/htdocs/luci-static/resources/cbi.js b/modules/luci-base/htdocs/luci-static/resources/cbi.js index 5aa687b67..9144fbaf6 100644 --- a/modules/luci-base/htdocs/luci-static/resources/cbi.js +++ b/modules/luci-base/htdocs/luci-static/resources/cbi.js @@ -298,8 +298,6 @@ function cbi_init() { node.getAttribute('data-type')); } - document.querySelectorAll('[data-browser]').forEach(cbi_browser_init); - document.querySelectorAll('.cbi-tooltip:not(:empty)').forEach(function(s) { s.parentNode.classList.add('cbi-tooltip-container'); }); @@ -330,29 +328,6 @@ function cbi_init() { cbi_d_update(); } -function cbi_filebrowser(id, defpath) { - var field = L.dom.elem(id) ? id : document.getElementById(id); - var browser = window.open( - cbi_strings.path.browser + (field.value || defpath || '') + '?field=' + field.id, - "luci_filebrowser", "width=300,height=400,left=100,top=200,scrollbars=yes" - ); - - browser.focus(); -} - -function cbi_browser_init(field) -{ - field.parentNode.insertBefore( - E('img', { - 'src': L.resource('cbi/folder.gif'), - 'class': 'cbi-image-button', - 'click': function(ev) { - cbi_filebrowser(field, field.getAttribute('data-browser')); - ev.preventDefault(); - } - }), field.nextSibling); -} - function cbi_validate_form(form, errmsg) { /* if triggered by a section removal or addition, don't validate */ diff --git a/modules/luci-base/htdocs/luci-static/resources/form.js b/modules/luci-base/htdocs/luci-static/resources/form.js index be9b53a88..6832a1646 100644 --- a/modules/luci-base/htdocs/luci-static/resources/form.js +++ b/modules/luci-base/htdocs/luci-static/resources/form.js @@ -1673,6 +1673,32 @@ var CBIHiddenValue = CBIValue.extend({ } }); +var CBIFileUpload = CBIValue.extend({ + __name__: 'CBI.FileSelect', + + __init__: function(/* ... */) { + this.super('__init__', arguments); + + this.show_hidden = false; + this.enable_upload = true; + this.enable_remove = true; + this.root_directory = '/etc/luci-uploads'; + }, + + renderWidget: function(section_id, option_index, cfgvalue) { + var browserEl = new ui.FileUpload((cfgvalue != null) ? cfgvalue : this.default, { + id: this.cbid(section_id), + name: this.cbid(section_id), + show_hidden: this.show_hidden, + enable_upload: this.enable_upload, + enable_remove: this.enable_remove, + root_directory: this.root_directory + }); + + return browserEl.render(); + } +}); + var CBISectionValue = CBIValue.extend({ __name__: 'CBI.ContainerValue', __init__: function(map, section, option, cbiClass /*, ... */) { @@ -1726,5 +1752,6 @@ return L.Class.extend({ DummyValue: CBIDummyValue, Button: CBIButtonValue, HiddenValue: CBIHiddenValue, + FileUpload: CBIFileUpload, SectionValue: CBISectionValue }); diff --git a/modules/luci-base/htdocs/luci-static/resources/ui.js b/modules/luci-base/htdocs/luci-static/resources/ui.js index 3444549e0..c27dd7ebf 100644 --- a/modules/luci-base/htdocs/luci-static/resources/ui.js +++ b/modules/luci-base/htdocs/luci-static/resources/ui.js @@ -1,4 +1,5 @@ 'use strict'; +'require rpc'; 'require uci'; 'require validation'; @@ -1453,6 +1454,417 @@ var UIHiddenfield = UIElement.extend({ } }); +var UIFileUpload = UIElement.extend({ + __init__: function(value, options) { + this.value = value; + this.options = Object.assign({ + show_hidden: false, + enable_upload: true, + enable_remove: true, + root_directory: '/etc/luci-uploads' + }, options); + }, + + callFileStat: rpc.declare({ + 'object': 'file', + 'method': 'stat', + 'params': [ 'path' ], + 'expect': { '': {} } + }), + + callFileList: rpc.declare({ + 'object': 'file', + 'method': 'list', + 'params': [ 'path' ], + 'expect': { 'entries': [] } + }), + + callFileRemove: rpc.declare({ + 'object': 'file', + 'method': 'remove', + 'params': [ 'path' ] + }), + + bind: function(browserEl) { + this.node = browserEl; + + this.setUpdateEvents(browserEl, 'cbi-fileupload-select', 'cbi-fileupload-cancel'); + this.setChangeEvents(browserEl, 'cbi-fileupload-select', 'cbi-fileupload-cancel'); + + L.dom.bindClassInstance(browserEl, this); + + return browserEl; + }, + + render: function() { + return Promise.resolve(this.value != null ? this.callFileStat(this.value) : null).then(L.bind(function(stat) { + var label; + + if (L.isObject(stat) && stat.type != 'directory') + this.stat = stat; + + if (this.stat != null) + label = [ this.iconForType(this.stat.type), ' %s (%1000mB)'.format(this.truncatePath(this.stat.path), this.stat.size) ]; + else if (this.value != null) + label = [ this.iconForType('file'), ' %s (%s)'.format(this.truncatePath(this.value), _('File not accessible')) ]; + else + label = _('Select file…'); + + return this.bind(E('div', { 'id': this.options.id }, [ + E('button', { + 'class': 'btn', + 'click': L.ui.createHandlerFn(this, 'handleFileBrowser') + }, label), + E('div', { + 'class': 'cbi-filebrowser' + }), + E('input', { + 'type': 'hidden', + 'name': this.options.name, + 'value': this.value + }) + ])); + }, this)); + }, + + truncatePath: function(path) { + if (path.length > 50) + path = path.substring(0, 25) + '…' + path.substring(path.length - 25); + + return path; + }, + + iconForType: function(type) { + switch (type) { + case 'symlink': + return E('img', { + 'src': L.resource('cbi/link.gif'), + 'title': _('Symbolic link'), + 'class': 'middle' + }); + + case 'directory': + return E('img', { + 'src': L.resource('cbi/folder.gif'), + 'title': _('Directory'), + 'class': 'middle' + }); + + default: + return E('img', { + 'src': L.resource('cbi/file.gif'), + 'title': _('File'), + 'class': 'middle' + }); + } + }, + + canonicalizePath: function(path) { + return path.replace(/\/{2,}/, '/') + .replace(/\/\.(\/|$)/g, '/') + .replace(/[^\/]+\/\.\.(\/|$)/g, '/') + .replace(/\/$/, ''); + }, + + splitPath: function(path) { + var croot = this.canonicalizePath(this.options.root_directory || '/'), + cpath = this.canonicalizePath(path || '/'); + + if (cpath.length <= croot.length) + return [ croot ]; + + if (cpath.charAt(croot.length) != '/') + return [ croot ]; + + var parts = cpath.substring(croot.length + 1).split(/\//); + + parts.unshift(croot); + + return parts; + }, + + handleUpload: function(path, list, ev) { + var form = ev.target.parentNode, + fileinput = form.querySelector('input[type="file"]'), + nameinput = form.querySelector('input[type="text"]'), + filename = (nameinput.value != null ? nameinput.value : '').trim(); + + ev.preventDefault(); + + if (filename == '' || filename.match(/\//) || fileinput.files[0] == null) + return; + + var existing = list.filter(function(e) { return e.name == filename })[0]; + + if (existing != null && existing.type == 'directory') + return alert(_('A directory with the same name already exists.')); + else if (existing != null && !confirm(_('Overwrite existing file "%s" ?').format(filename))) + return; + + var data = new FormData(); + + data.append('sessionid', L.env.sessionid); + data.append('filename', path + '/' + filename); + data.append('filedata', fileinput.files[0]); + + return L.Request.post('/cgi-bin/cgi-upload', data, { + progress: L.bind(function(btn, ev) { + btn.firstChild.data = '%.2f%%'.format((ev.loaded / ev.total) * 100); + }, this, ev.target) + }).then(L.bind(function(path, ev, res) { + var reply = res.json(); + + if (L.isObject(reply) && reply.failure) + alert(_('Upload request failed: %s').format(reply.message)); + + return this.handleSelect(path, null, ev); + }, this, path, ev)); + }, + + handleDelete: function(path, fileStat, ev) { + var parent = path.replace(/\/[^\/]+$/, '') || '/', + name = path.replace(/^.+\//, ''), + msg; + + ev.preventDefault(); + + if (fileStat.type == 'directory') + msg = _('Do you really want to recursively delete the directory "%s" ?').format(name); + else + msg = _('Do you really want to delete "%s" ?').format(name); + + if (confirm(msg)) { + var button = this.node.firstElementChild, + hidden = this.node.lastElementChild; + + if (path == hidden.value) { + L.dom.content(button, _('Select file…')); + hidden.value = ''; + } + + return this.callFileRemove(path).then(L.bind(function(parent, ev, rc) { + if (rc == 0) + return this.handleSelect(parent, null, ev); + else if (rc == 6) + alert(_('Delete permission denied')); + else + alert(_('Delete request failed: %d %s').format(rc, rpc.getStatusText(rc))); + + }, this, parent, ev)); + } + }, + + renderUpload: function(path, list) { + if (!this.options.enable_upload) + return E([]); + + return E([ + E('a', { + 'href': '#', + 'class': 'btn cbi-button-positive', + 'click': function(ev) { + var uploadForm = ev.target.nextElementSibling, + fileInput = uploadForm.querySelector('input[type="file"]'); + + ev.target.style.display = 'none'; + uploadForm.style.display = ''; + fileInput.click(); + } + }, _('Upload file…')), + E('div', { 'class': 'upload', 'style': 'display:none' }, [ + E('input', { + 'type': 'file', + 'style': 'display:none', + 'change': function(ev) { + var nameinput = ev.target.parentNode.querySelector('input[type="text"]'), + uploadbtn = ev.target.parentNode.querySelector('button.cbi-button-save'); + + nameinput.value = ev.target.value.replace(/^.+[\/\\]/, ''); + uploadbtn.disabled = false; + } + }), + E('button', { + 'class': 'btn', + 'click': function(ev) { + ev.preventDefault(); + ev.target.previousElementSibling.click(); + } + }, _('Browse…')), + E('div', {}, E('input', { 'type': 'text', 'placeholder': _('Filename') })), + E('button', { + 'class': 'btn cbi-button-save', + 'click': L.ui.createHandlerFn(this, 'handleUpload', path, list), + 'disabled': true + }, _('Upload file')) + ]) + ]); + }, + + renderListing: function(container, path, list) { + var breadcrumb = E('p'), + rows = E('ul'); + + list.sort(function(a, b) { + var isDirA = (a.type == 'directory'), + isDirB = (b.type == 'directory'); + + if (isDirA != isDirB) + return isDirA < isDirB; + + return a.name > b.name; + }); + + for (var i = 0; i < list.length; i++) { + if (!this.options.show_hidden && list[i].name.charAt(0) == '.') + continue; + + var entrypath = this.canonicalizePath(path + '/' + list[i].name), + selected = (entrypath == this.node.lastElementChild.value), + mtime = new Date(list[i].mtime * 1000); + + rows.appendChild(E('li', [ + E('div', { 'class': 'name' }, [ + this.iconForType(list[i].type), + ' ', + E('a', { + 'href': '#', + 'style': selected ? 'font-weight:bold' : null, + 'click': L.ui.createHandlerFn(this, 'handleSelect', + entrypath, list[i].type != 'directory' ? list[i] : null) + }, '%h'.format(list[i].name)) + ]), + E('div', { 'class': 'mtime hide-xs' }, [ + ' %04d-%02d-%02d %02d:%02d:%02d '.format( + mtime.getFullYear(), + mtime.getMonth() + 1, + mtime.getDate(), + mtime.getHours(), + mtime.getMinutes(), + mtime.getSeconds()) + ]), + E('div', [ + selected ? E('button', { + 'class': 'btn', + 'click': L.ui.createHandlerFn(this, 'handleReset') + }, _('Deselect')) : '', + this.options.enable_remove ? E('button', { + 'class': 'btn cbi-button-negative', + 'click': L.ui.createHandlerFn(this, 'handleDelete', entrypath, list[i]) + }, _('Delete')) : '' + ]) + ])); + } + + if (!rows.firstElementChild) + rows.appendChild(E('em', _('No entries in this directory'))); + + var dirs = this.splitPath(path), + cur = ''; + + for (var i = 0; i < dirs.length; i++) { + cur = cur ? cur + '/' + dirs[i] : dirs[i]; + L.dom.append(breadcrumb, [ + i ? ' » ' : '', + E('a', { + 'href': '#', + 'click': L.ui.createHandlerFn(this, 'handleSelect', cur || '/', null) + }, dirs[i] != '' ? '%h'.format(dirs[i]) : E('em', '(root)')), + ]); + } + + L.dom.content(container, [ + breadcrumb, + rows, + E('div', { 'class': 'right' }, [ + this.renderUpload(path, list), + E('a', { + 'href': '#', + 'class': 'btn', + 'click': L.ui.createHandlerFn(this, 'handleCancel') + }, _('Cancel')) + ]), + ]); + }, + + handleCancel: function(ev) { + var button = this.node.firstElementChild, + browser = button.nextElementSibling; + + browser.classList.remove('open'); + button.style.display = ''; + + this.node.dispatchEvent(new CustomEvent('cbi-fileupload-cancel', {})); + }, + + handleReset: function(ev) { + var button = this.node.firstElementChild, + hidden = this.node.lastElementChild; + + hidden.value = ''; + L.dom.content(button, _('Select file…')); + + this.handleCancel(ev); + }, + + handleSelect: function(path, fileStat, ev) { + var browser = L.dom.parent(ev.target, '.cbi-filebrowser'), + ul = browser.querySelector('ul'); + + if (fileStat == null) { + L.dom.content(ul, E('em', { 'class': 'spinning' }, _('Loading directory contents…'))); + this.callFileList(path).then(L.bind(this.renderListing, this, browser, path)); + } + else { + var button = this.node.firstElementChild, + hidden = this.node.lastElementChild; + + path = this.canonicalizePath(path); + + L.dom.content(button, [ + this.iconForType(fileStat.type), + ' %s (%1000mB)'.format(this.truncatePath(path), fileStat.size) + ]); + + browser.classList.remove('open'); + button.style.display = ''; + hidden.value = path; + + this.stat = Object.assign({ path: path }, fileStat); + this.node.dispatchEvent(new CustomEvent('cbi-fileupload-select', { detail: this.stat })); + } + }, + + handleFileBrowser: function(ev) { + var button = ev.target, + browser = button.nextElementSibling, + path = this.stat ? this.stat.path.replace(/\/[^\/]+$/, '') : this.options.root_directory; + + if (this.options.root_directory.indexOf(path) != 0) + path = this.options.root_directory; + + ev.preventDefault(); + + return this.callFileList(path).then(L.bind(function(button, browser, path, list) { + document.querySelectorAll('.cbi-filebrowser.open').forEach(function(browserEl) { + L.dom.findClassInstance(browserEl).handleCancel(ev); + }); + + button.style.display = 'none'; + browser.classList.add('open'); + + return this.renderListing(browser, path, list); + }, this, button, browser, path)); + }, + + getValue: function() { + return this.node.lastElementChild.value; + }, + + setValue: function(value) { + this.node.lastElementChild.value = value; + } +}); + return L.Class.extend({ __init__: function() { @@ -2173,5 +2585,6 @@ return L.Class.extend({ Dropdown: UIDropdown, DynamicList: UIDynamicList, Combobox: UICombobox, - Hiddenfield: UIHiddenfield + Hiddenfield: UIHiddenfield, + FileUpload: UIFileUpload }); diff --git a/modules/luci-base/luasrc/view/cbi/filebrowser.htm b/modules/luci-base/luasrc/view/cbi/filebrowser.htm deleted file mode 100644 index 806b1b5f4..000000000 --- a/modules/luci-base/luasrc/view/cbi/filebrowser.htm +++ /dev/null @@ -1,113 +0,0 @@ - - - - - Filebrowser - LuCI - - - - - - <% - require("nixio.fs") - require("nixio.util") - require("luci.http") - require("luci.dispatcher") - - local field = luci.http.formvalue('field') - local request = luci.dispatcher.context.args - local path = { '' } - - for i = 1, #request do - if request[i] ~= '..' and #request[i] > 0 then - path[#path+1] = request[i] - end - end - - local filestat = nixio.fs.stat(table.concat(path, '/')) - local baseurl = { 'admin', 'filebrowser' } - - if filestat and filestat.type == "reg" then - path[#path] = '' - elseif not (filestat and filestat.type == "dir") then - path = { '', '' } - else - path[#path+1] = '' - end - - filepath = table.concat(path, '/') - - local entries = {} - local _, e - for _, e in luci.util.vspairs(nixio.util.consume((nixio.fs.dir(filepath)))) do - local p = filepath .. e - local s = nixio.fs.stat(p) - if s then - entries[#entries+1] = { - name = e, - path = p, - type = s.type - } - end - end - -%> -
- Location: - <% for i, dir in ipairs(path) do %> - <% if i == 1 then %> - (root) - <% elseif next(path, i) then %> - <% baseurl[#baseurl+1] = luci.http.urlencode(dir) %> - / <%=pcdata(dir)%> - <% else %> - <% baseurl[#baseurl+1] = luci.http.urlencode(dir) %> - / <%=pcdata(dir)%> - <% end %> - <% end %> -
- -
- -
-
    - <% for _, e in ipairs(entries) do if e.type == 'dir' then -%> -
  • - <%:Directory%> - <%=pcdata(e.name)%>/ -
  • - <% end end -%> - - <% for _, e in ipairs(entries) do if e.type ~= 'dir' then -%> -
  • - <%:File%> - <%=pcdata(e.name)%> -
  • - <% end end -%> -
-
- - diff --git a/modules/luci-base/luasrc/view/cbi/upload.htm b/modules/luci-base/luasrc/view/cbi/upload.htm index 3c3d82b65..e61049538 100644 --- a/modules/luci-base/luasrc/view/cbi/upload.htm +++ b/modules/luci-base/luasrc/view/cbi/upload.htm @@ -1,24 +1,14 @@ -<% - local t = require("luci.tools.webadmin") - local v = self:cfgvalue(section) - local s = v and nixio.fs.stat(v) --%> <%+cbi/valueheader%> - <% if s then %> - <%:Uploaded File%> (<%=t.byte_format(s.size)%>) - <% if self.unsafeupload then %> - /> - " alt="<%:Replace entry%>" title="<%:Replace entry%>" src="<%=resource%>/cbi/reload.gif" /> - <% end %> - <% end %> - <% if not self.unsafeupload then %> - /> - <% end %> +> - <% if (not s) or (s and not self.unsafeupload) then %> - /> - <% end %> - /> <%+cbi/valuefooter%> diff --git a/modules/luci-base/root/usr/share/rpcd/acl.d/luci-base.json b/modules/luci-base/root/usr/share/rpcd/acl.d/luci-base.json index 2804cc700..57e0ae384 100644 --- a/modules/luci-base/root/usr/share/rpcd/acl.d/luci-base.json +++ b/modules/luci-base/root/usr/share/rpcd/acl.d/luci-base.json @@ -20,7 +20,12 @@ "luci-access": { "description": "Grant access to basic LuCI procedures", "read": { + "file": { + "/": [ "list" ], + "/*": [ "list" ] + }, "ubus": { + "file": [ "list", "stat" ], "iwinfo": [ "assoclist" ], "luci": [ "getBoardJSON", "getDUIDHints", "getHostHints", "getIfaddrs", "getInitList", "getLocaltime", "getTimezones", "getDHCPLeases", "getLEDs", "getNetworkDevices", "getUSBDevices", "getHostname", "getTTYDevices", "getWirelessDevices" ], "network.device": [ "status" ], @@ -32,7 +37,12 @@ "uci": [ "*" ] }, "write": { + "cgi-io": [ "upload", "/etc/luci-uploads/*" ], + "file": { + "/etc/luci-uploads/*": [ "write" ] + }, "ubus": { + "file": [ "remove" ], "iwinfo": [ "scan" ], "luci": [ "setInitAction", "setLocaltime" ], "uci": [ "add", "apply", "confirm", "delete", "order", "set" ] diff --git a/modules/luci-mod-admin-full/luasrc/controller/admin/filebrowser.lua b/modules/luci-mod-admin-full/luasrc/controller/admin/filebrowser.lua deleted file mode 100644 index 257261513..000000000 --- a/modules/luci-mod-admin-full/luasrc/controller/admin/filebrowser.lua +++ /dev/null @@ -1,9 +0,0 @@ --- Copyright 2008 Steven Barth --- Copyright 2008 Jo-Philipp Wich --- Licensed to the public under the Apache License 2.0. - -module("luci.controller.admin.filebrowser", package.seeall) - -function index() - entry( {"admin", "filebrowser"}, template("cbi/filebrowser") ).leaf = true -end -- 2.25.1