diff --git a/README.md b/README.md index 027fc3d..3d8f98b 100644 --- a/README.md +++ b/README.md @@ -23,3 +23,5 @@ n/a ![1 Videos List](images/pic1.png) ![2 Video Playing](images/pic2.png) ![3 Images List](images/pic3.png) +![4 Context menu](images/pic4.png) +![5 Settings Pane With Upload And Create Functionality](images/pic5.png) \ No newline at end of file diff --git a/images/pic1.png b/images/pic1.png index 75a2b6d..d549827 100644 Binary files a/images/pic1.png and b/images/pic1.png differ diff --git a/images/pic2.png b/images/pic2.png index 5d37929..8aec7e6 100644 Binary files a/images/pic2.png and b/images/pic2.png differ diff --git a/images/pic3.png b/images/pic3.png index 2f6511a..15b9e56 100644 Binary files a/images/pic3.png and b/images/pic3.png differ diff --git a/images/pic4.png b/images/pic4.png new file mode 100644 index 0000000..8c95137 Binary files /dev/null and b/images/pic4.png differ diff --git a/images/pic5.png b/images/pic5.png new file mode 100644 index 0000000..65eb51d Binary files /dev/null and b/images/pic5.png differ diff --git a/src/core/routes/Routes.py b/src/core/routes/Routes.py index f0e917b..306996b 100644 --- a/src/core/routes/Routes.py +++ b/src/core/routes/Routes.py @@ -1,8 +1,9 @@ # Python imports -import os, json, secrets +import os, json, secrets, re, shutil # Lib imports from flask import request, session, render_template, send_from_directory, redirect +from flask_uploads import UploadSet, configure_uploads, ALL from flask_login import current_user @@ -14,6 +15,8 @@ from core.utils.shellfm import WindowController # Get file manager controller msgHandler = MessageHandler() window_controllers = {} +# valid_fname_pat = re.compile(r"/^[a-zA-Z0-9-_\[\]\(\)| ]+$/") +valid_fname_pat = re.compile(r"[a-z0-9A-Z-_\[\]\(\)\| ]{4,20}") def get_window_controller(): @@ -88,13 +91,14 @@ def listFiles(_hash = None): msg = "Can't manage the request type..." return msgHandler.createMessageJSON("danger", msg) -@app.route('/api/file-manager-action/<_type>/<_hash>') +@app.route('/api/file-manager-action/<_type>/<_hash>', methods=['GET', 'POST']) def fileManagerAction(_type, _hash = None): view = get_window_controller().get_window(1).get_view(0) - if _type == "reset-path" and _hash == None: + if _type == "reset-path" and _hash == "None": view.set_to_home() - return redirect("/") + msg = "Returning to home directory..." + return msgHandler.createMessageJSON("success", msg) folder = view.get_current_directory() file = view.get_path_part_from_hash(_hash) @@ -119,6 +123,30 @@ def fileManagerAction(_type, _hash = None): return msgHandler.createMessageJSON("success", msg) + # NOTE: Positionally protecting actions further down that are privlidged + # Be aware of ordering! + msg = "Log in with an Admin privlidged user to do this action!" + if not oidc.user_loggedin: + return msgHandler.createMessageJSON("danger", msg) + elif oidc.user_loggedin: + isAdmin = oidc.user_getfield("isAdmin") + if isAdmin != "yes" : + return msgHandler.createMessageJSON("danger", msg) + + + if _type == "delete": + try: + msg = f"[Success] Deleted the file/folder -->: {file} !" + if os.path.isfile(fpath): + os.unlink(fpath) + else: + shutil.rmtree(fpath) + return msgHandler.createMessageJSON("success", msg) + except Exception as e: + msg = "[Error] Unable to delete the file/folder...." + return msgHandler.createMessageJSON("danger", msg) + + @app.route('/api/list-favorites', methods=['GET', 'POST']) def listFavorites(): if request.method == 'POST': @@ -161,13 +189,91 @@ def manageFavorites(_action): fave = Favorites(link = sub_path) db.session.add(fave) msg = "Added to Favorites successfully..." - else: + elif ACTION == "delete": fave = db.session.query(Favorites).filter_by(link = sub_path).first() db.session.delete(fave) msg = "Deleted from Favorites successfully..." + else: + msg = "Couldn't handle action for favorites item..." + return msgHandler.createMessageJSON("danger", msg) db.session.commit() return msgHandler.createMessageJSON("success", msg) else: msg = "Can't manage the request type..." return msgHandler.createMessageJSON("danger", msg) + + +@app.route('/api/create/<_type>', methods=['GET', 'POST']) +def create_item(_type = None): + if request.method == 'POST': + msg = "Log in with an Admin privlidged user to upload files!" + if not oidc.user_loggedin: + return msgHandler.createMessageJSON("danger", msg) + elif oidc.user_loggedin: + isAdmin = oidc.user_getfield("isAdmin") + if isAdmin != "yes" : + return msgHandler.createMessageJSON("danger", msg) + + TYPE = _type.strip() + FNAME = str(request.values['fname']).strip() + + if not re.fullmatch(valid_fname_pat, FNAME): + msg = "A new item name can only contain alphanumeric, -, _, |, [], (), or spaces and must be minimum of 4 and max of 20 characters..." + return msgHandler.createMessageJSON("danger", msg) + + view = get_window_controller().get_window(1).get_view(0) + folder = view.get_current_directory() + new_item = folder + '/' + FNAME + + try: + if TYPE == "dir": + os.mkdir(new_item) + elif TYPE == "file": + open(new_item + ".txt", 'a').close() + else: + msg = "Couldn't handle action type for api create..." + return msgHandler.createMessageJSON("danger", msg) + except Exception as e: + print(repr(e)) + msg = "Couldn't create file/folder. An unexpected error occured..." + return msgHandler.createMessageJSON("danger", msg) + + + msg = "[Success] created the file/dir..." + return msgHandler.createMessageJSON("success", msg) + else: + msg = "Can't manage the request type..." + return msgHandler.createMessageJSON("danger", msg) + + +@app.route('/upload', methods=['GET', 'POST']) +def upload(): + if request.method == 'POST' and len(request.files) > 0: + msg = "Log in with an Admin privlidged user to upload files!" + if not oidc.user_loggedin: + return msgHandler.createMessageJSON("danger", msg) + elif oidc.user_loggedin: + isAdmin = oidc.user_getfield("isAdmin") + if isAdmin != "yes" : + return msgHandler.createMessageJSON("danger", msg) + + view = get_window_controller().get_window(1).get_view(0) + folder = view.get_current_directory() + UPLOADS_PTH = folder + '/' + files = UploadSet('files', ALL, default_dest=lambda x: UPLOADS_PTH) + configure_uploads(app, files) + + for file in request.files: + try: + files.save(request.files[file]) + except Exception as e: + print(repr(e)) + msg = "[Error] Failed to upload some or all of the file(s)..." + return msgHandler.createMessageJSON("danger", msg) + + msg = "[Success] Uploaded file(s)..." + return msgHandler.createMessageJSON("success", msg) + else: + msg = "Can't manage the request type..." + return msgHandler.createMessageJSON("danger", msg) diff --git a/src/core/static/css/context-menu.css b/src/core/static/css/context-menu.css new file mode 100644 index 0000000..781dc3a --- /dev/null +++ b/src/core/static/css/context-menu.css @@ -0,0 +1,50 @@ +.menu { + width: 165px; + z-index: 999; + box-shadow: 0 4px 5px 3px rgba(0, 0, 0, 0.2); + background-color: rgba(0, 0, 0, 0.64); + position: fixed; + display: none; + transition: 0.2s display ease-in; +} +.menu .menu-options { + list-style: none; + padding: 10px 0; + z-index: 1; +} +.menu .menu-options .menu-option { + font-weight: 500; + z-index: 1; + padding: 10px 40px 10px 20px; + cursor: pointer; +} + +.menu .menu-options .menu-option:hover { + background: rgba(255, 255, 255, 0.64); + color: rgba(0, 0, 0, 0.5); +} + +button { + background: grey; + border: none; +} +button .next { + color: green; +} +button[disabled="false"]:hover .next { + color: red; + animation: move 0.5s; + animation-iteration-count: 2; +} + +@keyframes move { + from { + transform: translate(0%); + } + 50% { + transform: translate(-40%); + } + to { + transform: transform(0%); + } +} diff --git a/src/core/static/js/ajax.js b/src/core/static/js/ajax.js index d636992..4f9de7b 100644 --- a/src/core/static/js/ajax.js +++ b/src/core/static/js/ajax.js @@ -1,3 +1,13 @@ +const goHomeAjax = async (hash) => { + const data = "empty=NULL"; + doAjax("api/file-manager-action/reset-path/None", data, "reset-path"); +} + +const deleteItemAjax = async (hash) => { + const data = "empty=NULL"; + doAjax("api/file-manager-action/delete/" + hash, data, "delete-file"); +} + const listFilesAjax = async (hash) => { const data = "empty=NULL"; doAjax("api/list-files/" + hash, data, "list-files"); @@ -19,6 +29,8 @@ const manageFavoritesAjax = async (action) => { } + + const doAjax = (actionPath, data, action) => { let xhttp = new XMLHttpRequest(); @@ -44,6 +56,60 @@ const doAjax = (actionPath, data, action) => { xhttp.send(data); } +const doAjaxUpload = (actionPath, data, fname, action) => { + let bs64 = btoa(unescape(encodeURIComponent(fname))).split("==")[0]; + const query = '[id="' + bs64 + '"]'; + let progressbar = document.querySelector(query); + let xhttp = new XMLHttpRequest(); + + xhttp.onreadystatechange = function() { + if (this.readyState === 4 && this.status === 200) { + if (this.responseText != null) { // this.responseXML if getting XML data + postAjaxController(JSON.parse(this.responseText), action); + } else { + msg = "[Fail] Status Code: " + response.status + + "\n[Message] --> " + response.statusText; + handleMessage('alert-warning', msg); + } + } + }; + + // For upload tracking with GET... + xhttp.onprogress = function (e) { + if (e.lengthComputable) { + percent = (e.loaded / e.total) * 100; + text = parseFloat(percent).toFixed(2) + '% Complete (' + fname + ')'; + if (e.loaded !== e.total ) { + updateProgressBar(progressbar, text, percent, "info"); + } else { + updateProgressBar(progressbar, text, percent, "success"); + } + } + } + + // For upload tracking with POST... + xhttp.upload.addEventListener("progress", function(e){ + if (e.lengthComputable) { + percent = parseFloat( Math.floor( + ( + (e.loaded / e.total) * 100 ).toFixed(2) + ).toFixed(2) + ); + text = percent + '% Complete (' + fname + ')'; + if (percent <= 95) { + updateProgressBar(progressbar, text, percent, "info"); + } else { + updateProgressBar(progressbar, text, percent, "success"); + } + } + }, false); + + xhttp.open("POST", actionPath); + // Force return to be JSON NOTE: Use application/xml to force XML + xhttp.overrideMimeType('application/json'); + xhttp.send(data); +} + const fetchData = async (url) => { let response = await fetch(url); return await response.json(); diff --git a/src/core/static/js/context-menu.js b/src/core/static/js/context-menu.js new file mode 100644 index 0000000..583a8bf --- /dev/null +++ b/src/core/static/js/context-menu.js @@ -0,0 +1,41 @@ +const menu = document.querySelector(".menu"); +let menuVisible = false; +let active_card = null; + +const toggleMenu = command => { + menu.style.display = command === "show" ? "block" : "none"; + menu.style.zIndex = "9999"; + menuVisible = !menuVisible; +}; + +const setPosition = ({ top, left }) => { + menu.style.left = `${left}px`; + menu.style.top = `${top}px`; + toggleMenu("show"); +}; + +window.addEventListener("click", e => { + if(menuVisible) toggleMenu("hide"); +}); + +window.addEventListener("contextmenu", e => { + e.preventDefault(); + + let target = e.target; + let elm = target; + while (elm.nodeName != "BODY") { + if (!elm.classList.contains("card")) { + elm = elm.parentElement; + } else { + active_card = elm; + break + } + } + + const origin = { + left: e.pageX, + top: e.pageY + }; + setPosition(origin); + return false; +}); diff --git a/src/core/static/js/events-privileged.js b/src/core/static/js/events-privileged.js new file mode 100644 index 0000000..05c1f13 --- /dev/null +++ b/src/core/static/js/events-privileged.js @@ -0,0 +1,30 @@ +const createItem = (type) => { + if (type == null || type == '') { + displayMessage("Create type isn't set...", "danger", 3, "settings-alert-zone-new-items"); + return ; + } + + let newItem = document.getElementById("newItem"); + let fname = newItem.value; + + const regex = /^[a-z0-9A-Z-_\[\]\(\)\| ]{4,20}$/; + if (fname.search(regex) == -1) { + displayMessage("A new item name can only contain alphanumeric, -, _, |, [], (), or spaces and must be minimum of 4 and max of 20 characters...", "danger", 3, "settings-alert-zone-new-items"); + return ; + } + + newItem.value = ""; + createItemAjax(type, fname); +} + + +$( "#toUpload" ).bind( "change", function(eve) { + const files = eve.target.files; + setUploadListTitles(files); + +}); + +$( "#uploadFiles" ).bind( "click", function(eve) { + const files = document.getElementById('toUpload').files; + uploadFiles(files); +}); diff --git a/src/core/static/js/post-ajax.js b/src/core/static/js/post-ajax.js index 23d9e85..ab051eb 100644 --- a/src/core/static/js/post-ajax.js +++ b/src/core/static/js/post-ajax.js @@ -1,7 +1,35 @@ const postAjaxController = (data, action) => { if (data.message) { - message = data.message - displayMessage(message.text, message.type); + type = data.message.type + message = data.message.text + + if (action === "reset-path") { + reloadDirectory(); + return ; + } + + if (action === "delete-file") { + reloadDirectory(); + displayMessage(message, type); + return ; + } + + if (action === "upload-text" || action === "upload-file") { + let field = null; + if (action === "upload-text") field = "settings-alert-zone-text"; + if (action === "upload-file") field = "settings-alert-zone-files"; + displayMessage(message, type, 3, field); + reloadDirectory(); + return ; + } + + if (action === "create-item") { + displayMessage(message, type, 3, "settings-alert-zone-new-items"); + reloadDirectory(); + return ; + } + + displayMessage(message, type); return ; } diff --git a/src/core/static/js/privileged-logic.js b/src/core/static/js/privileged-logic.js new file mode 100644 index 0000000..0bde86b --- /dev/null +++ b/src/core/static/js/privileged-logic.js @@ -0,0 +1,89 @@ + +// Uploader Logic +const setUploadListTitles = (files = null) => { + if (files == null) { + return ; + } + + let list = document.getElementById('uploadListTitles'); + clearChildNodes(list); + for (var i = 0; i < files.length; i++) { + let liTag = document.createElement('LI'); + let name = document.createTextNode(files[i].name); + + liTag.className = "list-group-item disabled progress-bar"; + let bs64 = btoa(unescape(encodeURIComponent(files[i].name))).split("==")[0]; + liTag.setAttribute("id", bs64); + liTag.append(name); + list.append(liTag); + } +} + + +const uploadFiles = (files = null) => { + const size = files.length; + + if (files == null || size < 1) { + displayMessage("Nothing to upload...", "warning", "page-alert-zone-2"); + return ; + } + + // Multi-upload... + if (size > 1) { + for (var i = 0; i < size; i++) { + file = files[i]; + name = file.name; + data = createFormDataFiles([file]); + doAjaxUpload('upload', data, name, "upload-file"); + } + } else { // Single upload... + data = createFormDataFiles(files); + name = files[0].name; + doAjaxUpload('upload', data, name, "upload-file"); + } +} + +const createFormDataFiles = (files) => { + let form = new FormData(); + + for (var i = 0; i < files.length; i++) { + form.append(files[i].name, files[i]); + } + return form; +} + +// Progressbar handler +const updateProgressBar = (progressbar = null, text = "Nothing uploading...", + percent = 0, type = "error") => { + if (progressbar == null) { + return ; + } + + + if (type == "info") { + progressbar.setAttribute("aria-valuenow", percent); + progressbar.style.width = percent + "%"; + // progressbar.innerText = text; + progressbar.classList.remove('bg-success'); + progressbar.classList.add('progress-bar-animated'); + progressbar.classList.add('bg-info'); + return ; + } + + if (type == "success") { + progressbar.setAttribute("aria-valuenow", 100); + progressbar.style.width = "100%"; + // progressbar.innerText = text; + progressbar.classList.remove('progress-bar-animated'); + progressbar.classList.remove('bg-info'); + progressbar.classList.add('bg-success'); + return ; + } + + progressbar.style.width = "100%"; + progressbar.innerText = "An Error Occured"; + progressbar.classList.remove('progress-bar-animated'); + progressbar.classList.remove('bg-info'); + progressbar.classList.remove('bg-success'); + progressbar.classList.add('bg-danger'); +} diff --git a/src/core/static/js/ui-logic.js b/src/core/static/js/ui-logic.js index 042d9e2..bfb90a9 100644 --- a/src/core/static/js/ui-logic.js +++ b/src/core/static/js/ui-logic.js @@ -1,3 +1,37 @@ +// Context Menu items + +const goHome = () => { + goHomeAjax(); +} + +const clearUlList = () => { + const titles = document.getElementById('uploadListTitles'); + const files = document.getElementById('toUpload'); + + files.value = null; + clearChildNodes(titles); +} + +const downloadItem = (eve) => { + let elm = active_card.querySelector('a'); + elm.click(); +} + +const deleteItem = (eve) => { + let elm = active_card.querySelector('[hash]'); // With attribute named "hash" + let elm2 = active_card.querySelector('[title]'); // With attribute named "title" + const hash = elm.getAttribute("hash"); + const title = elm2.getAttribute("title"); + + let res = confirm("Delete: " + title + " ?"); + if (res == true) { + deleteItemAjax(hash); + } +} + + + +// Header menu items const reloadDirectory = () => { const target = document.getElementById('refresh-btn'); const hash = target.getAttribute("hash"); @@ -5,7 +39,7 @@ const reloadDirectory = () => { } const goUpADirectory = () => { - const target = document.getElementById('back-btn') + const target = document.getElementById('back-btn'); const hash = target.getAttribute("hash"); listFilesAjax(hash); } diff --git a/src/core/templates/layout.html b/src/core/templates/layout.html index ceb5c24..d67c414 100644 --- a/src/core/templates/layout.html +++ b/src/core/templates/layout.html @@ -34,6 +34,7 @@ + {% block header_css_additional %} {% endblock header_css_additional %} @@ -47,11 +48,25 @@ {% endblock %} - --> + + + + {% block body_header %} {% include "body-header.html" %} @@ -115,6 +130,8 @@ {% block body_scripts_additional %} {% endblock body_scripts_additional%} + + {% endblock %} diff --git a/src/core/templates/modals/options-modal.html b/src/core/templates/modals/options-modal.html index f4e3686..3e7e523 100644 --- a/src/core/templates/modals/options-modal.html +++ b/src/core/templates/modals/options-modal.html @@ -11,27 +11,33 @@