From 49077e7023eb2006009327635e11ad0b119b76af Mon Sep 17 00:00:00 2001 From: Marcel Baumgartner Date: Sat, 30 Mar 2024 00:18:40 +0100 Subject: [PATCH] Reimplemented the file manager with a cleaner ui, a base path protection from the core and modular and expandable --- Moonlight/Assets/Core/css/utils.css | 12 + .../FileManager/css/blazorContextMenu.css | 198 ++++++++ .../FileManager/js/blazorContextMenu.js | 313 +++++++++++++ .../Assets/FileManager/js/filemanager.js | 199 ++++++++ Moonlight/Core/Helpers/HostFileActions.cs | 14 +- Moonlight/Core/Services/PluginService.cs | 2 +- .../FileManager/FileManagerFeature.cs | 27 ++ .../Http/Controllers/UploadController.cs | 17 +- .../DeleteFileManagerAction.cs | 24 + .../DownloadFileManagerAction.cs | 40 ++ .../RenameFileManagerAction.cs | 30 ++ .../Interfaces/IFileManagerAction.cs | 14 + .../Abstractions/FileAccess/BaseFileAccess.cs | 20 +- .../Services/FileManagerInteropService.cs | 41 ++ .../Services/SharedFileAccessService.cs | 14 +- .../UI/Components/FileEditor.razor | 2 +- .../UI/Components/FileManager.razor | 6 +- .../UI/Components/FileUploader.razor | 18 +- .../FileManager/UI/Components/FileView.razor | 16 +- .../UI/NewFileManager/Editor.razor | 60 +++ .../UI/NewFileManager/FileEditor.razor | 112 +++++ .../UI/NewFileManager/FileManager.razor | 440 ++++++++++++++++++ .../UI/NewFileManager/FileView.razor | 387 +++++++++++++++ .../Servers/Services/ServerService.cs | 2 +- .../Features/Servers/UI/UserViews/Files.razor | 2 +- .../Features/Servers/UI/UserViews/Reset.razor | 2 +- .../Features/Servers/UI/Views/Test.razor | 12 + Moonlight/Moonlight.csproj | 3 +- 28 files changed, 1986 insertions(+), 41 deletions(-) create mode 100644 Moonlight/Assets/FileManager/css/blazorContextMenu.css create mode 100644 Moonlight/Assets/FileManager/js/blazorContextMenu.js create mode 100644 Moonlight/Assets/FileManager/js/filemanager.js create mode 100644 Moonlight/Features/FileManager/Implementations/DeleteFileManagerAction.cs create mode 100644 Moonlight/Features/FileManager/Implementations/DownloadFileManagerAction.cs create mode 100644 Moonlight/Features/FileManager/Implementations/RenameFileManagerAction.cs create mode 100644 Moonlight/Features/FileManager/Interfaces/IFileManagerAction.cs create mode 100644 Moonlight/Features/FileManager/Services/FileManagerInteropService.cs create mode 100644 Moonlight/Features/FileManager/UI/NewFileManager/Editor.razor create mode 100644 Moonlight/Features/FileManager/UI/NewFileManager/FileEditor.razor create mode 100644 Moonlight/Features/FileManager/UI/NewFileManager/FileManager.razor create mode 100644 Moonlight/Features/FileManager/UI/NewFileManager/FileView.razor create mode 100644 Moonlight/Features/Servers/UI/Views/Test.razor diff --git a/Moonlight/Assets/Core/css/utils.css b/Moonlight/Assets/Core/css/utils.css index a61d8c2..c20546b 100644 --- a/Moonlight/Assets/Core/css/utils.css +++ b/Moonlight/Assets/Core/css/utils.css @@ -14,4 +14,16 @@ tr:hover .table-row-hover-content { .hide-scrollbar { -ms-overflow-style: none; /* IE and Edge */ scrollbar-width: none; /* Firefox */ +} + +.blur-unless-hover { + filter: blur(5px); +} + +.blur-unless-hover:hover { + filter: none; +} + +.blur { + filter: blur(5px); } \ No newline at end of file diff --git a/Moonlight/Assets/FileManager/css/blazorContextMenu.css b/Moonlight/Assets/FileManager/css/blazorContextMenu.css new file mode 100644 index 0000000..e11b49f --- /dev/null +++ b/Moonlight/Assets/FileManager/css/blazorContextMenu.css @@ -0,0 +1,198 @@ +.blazor-context-menu--default { + position: fixed; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + padding: 5px 0; +} + + +.blazor-context-menu__list { + list-style-type: none; + padding-left: 5px; + padding-right: 5px; + margin: 0px; +} + +.blazor-context-menu__seperator { + min-width: 120px; + color: #333; + position: relative; +} + +.blazor-context-menu__seperator__hr { + display: block; + margin-top: 0.5em; + margin-bottom: 0.5em; + margin-left: auto; + margin-right: auto; + border-style: inset; + border-width: 1px; +} + +.blazor-context-menu__item--default { + min-width: 120px; + padding: 6px; + text-align: left; + white-space: nowrap; + position: relative; + cursor: pointer; +} + + .blazor-context-menu__item--default:hover { + background-color: rgba(0,0,0,.05); + } + + +.blazor-context-menu__item--with-submenu:after { + content: ""; + position: absolute; + right: -8px; + top: 50%; + -webkit-transform: translateY(-50%); + transform: translateY(-50%); + border: 6px solid transparent; + border-left-color: #615c5c; +} + +.blazor-context-menu__item--default-disabled { + min-width: 120px; + padding: 6px; + text-align: left; + white-space: nowrap; + position: relative; + cursor: not-allowed; + opacity: 0.6; +} + +.blazor-context-menu--hidden { + display: none; +} + +/*============== ANIMATIONS ==============*/ +/*-------------- FadeIn ------------------*/ +.blazor-context-menu__animations--fadeIn { + animation-name: fadeIn; + animation-direction: reverse; + animation-duration: 0.2s; +} + +.blazor-context-menu__animations--fadeIn-shown { + animation-name: fadeIn; + animation-duration: 0.2s; +} + +@keyframes fadeIn { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} + + +/*-------------- Grow ------------------*/ + +.blazor-context-menu__animations--grow { + animation-name: grow; + animation-direction: reverse; + animation-duration: 0.2s; +} + +.blazor-context-menu__animations--grow-shown { + animation-name: grow; + animation-duration: 0.2s; +} + +@keyframes grow { + 0% { + transform: scale(0); + transform-origin: top left; + opacity: 0; + } + + 100% { + transform: scale(1); + transform-origin: top left; + opacity: 1; + } +} + +/*-------------- Slide ------------------*/ + +.blazor-context-menu.blazor-context-menu__animations--slide { + animation-name: slide; + animation-direction: reverse; + animation-duration: 0.2s; +} + +.blazor-context-menu.blazor-context-menu__animations--slide-shown { + animation-name: slide; + animation-duration: 0.2s; +} + +@keyframes slide { + 0% { + transform: translateX(-5px); + opacity: 0; + } + + 100% { + transform: translateX(0px); + opacity: 1; + } +} + +.blazor-context-submenu.blazor-context-menu__animations--slide { + animation-name: slide-submenu; + animation-direction: reverse; + animation-duration: 0.2s; +} + +.blazor-context-submenu.blazor-context-menu__animations--slide-shown { + animation-name: slide-submenu; + animation-duration: 0.2s; +} + +@keyframes slide-submenu { + 0% { + transform: translateX(-25px); + z-index: -1; + opacity: 0; + } + 90% { + z-index: -1; + } + 100% { + transform: translateX(0px); + z-index: unset; + opacity: 1; + } +} + +/*-------------- Zoom ------------------*/ + +.blazor-context-menu__animations--zoom { + animation-name: zoom; + animation-direction: reverse; + animation-duration: 0.2s; +} + +.blazor-context-menu__animations--zoom-shown { + animation-name: zoom; + animation-duration: 0.2s; +} + +@keyframes zoom { + 0% { + transform: scale(0.5); + opacity: 0; + } + + 100% { + transform: scale(1); + opacity: 1; + } +} \ No newline at end of file diff --git a/Moonlight/Assets/FileManager/js/blazorContextMenu.js b/Moonlight/Assets/FileManager/js/blazorContextMenu.js new file mode 100644 index 0000000..d77b82f --- /dev/null +++ b/Moonlight/Assets/FileManager/js/blazorContextMenu.js @@ -0,0 +1,313 @@ +"use strict"; + +var blazorContextMenu = function (blazorContextMenu) { + + var closest = null; + if (window.Element && !Element.prototype.closest) { + closest = function (el, s) { + var matches = (el.document || el.ownerDocument).querySelectorAll(s), i; + do { + i = matches.length; + while (--i >= 0 && matches.item(i) !== el) { }; + } while ((i < 0) && (el = el.parentElement)); + return el; + }; + } + else { + closest = function (el, s) { + return el.closest(s); + }; + } + + + var openMenus = []; + + //Helper functions + //======================================== + function guid() { + function s4() { + return Math.floor((1 + Math.random()) * 0x10000) + .toString(16) + .substring(1); + } + return s4() + s4() + '-' + s4() + '-' + s4() + '-' + s4() + '-' + s4() + s4() + s4(); + } + + function findFirstChildByClass(element, className) { + var foundElement = null; + function recurse(element, className, found) { + for (var i = 0; i < element.children.length && !found; i++) { + var el = element.children[i]; + if (el.classList.contains(className)) { + found = true; + foundElement = element.children[i]; + break; + } + if (found) + break; + recurse(element.children[i], className, found); + } + } + recurse(element, className, false); + return foundElement; + } + + function findAllChildsByClass(element, className) { + var foundElements = new Array(); + function recurse(element, className) { + for (var i = 0; i < element.children.length; i++) { + var el = element.children[i]; + if (el.classList.contains(className)) { + foundElements.push(element.children[i]); + } + recurse(element.children[i], className); + } + } + recurse(element, className); + return foundElements; + } + + function removeItemFromArray(array, item) { + for (var i = 0; i < array.length; i++) { + if (array[i] === item) { + array.splice(i, 1); + } + } + } + + var sleepUntil = function (f, timeoutMs) { + return new Promise(function (resolve, reject){ + var timeWas = new Date(); + var wait = setInterval(function () { + if (f()) { + clearInterval(wait); + resolve(); + } else if (new Date() - timeWas > timeoutMs) { + clearInterval(wait); + reject(); + } + }, 20); + }); + } + + + //=========================================== + + var menuHandlerReference = null; + //var openingMenu = false; + + blazorContextMenu.SetMenuHandlerReference = function (dotnetRef) { + if (!menuHandlerReference) { + menuHandlerReference = dotnetRef; + } + } + + var addToOpenMenus = function (menu, menuId, target) { + var instanceId = guid(); + openMenus.push({ + id: menuId, + target: target, + instanceId: instanceId + }); + menu.dataset["instanceId"] = instanceId; + }; + + blazorContextMenu.ManualShow = function (menuId, x, y) { + //openingMenu = true; + var menu = document.getElementById(menuId); + if (!menu) throw new Error("No context menu with id '" + menuId + "' was found"); + addToOpenMenus(menu, menuId, null); + showMenuCommon(menu, menuId, x, y, null, null); + } + + blazorContextMenu.OnContextMenu = function (e, menuId, stopPropagation) { + //openingMenu = true; + var menu = document.getElementById(menuId); + if (!menu) throw new Error("No context menu with id '" + menuId + "' was found"); + addToOpenMenus(menu, menuId, e.target); + var triggerDotnetRef = JSON.parse(e.currentTarget.dataset["dotnetref"]); + showMenuCommon(menu, menuId, e.x, e.y, e.target, triggerDotnetRef); + e.preventDefault(); + if (stopPropagation) { + e.stopPropagation(); + } + return false; + }; + + var showMenuCommon = function (menu, menuId, x, y, target, triggerDotnetRef) { + return blazorContextMenu.Show(menuId, x, y, target, triggerDotnetRef).then(function () { + return sleepUntil(function () { return menu.clientWidth > 0 }, 1000); //Wait until the menu has spawned so clientWidth and offsetLeft report correctly + }).then(function () { + //check for overflow + var leftOverflownPixels = menu.offsetLeft + menu.clientWidth - window.innerWidth; + if (leftOverflownPixels > 0) { + menu.style.left = (menu.offsetLeft - menu.clientWidth) + "px"; + } + + var topOverflownPixels = menu.offsetTop + menu.clientHeight - window.innerHeight; + if (topOverflownPixels > 0) { + menu.style.top = (menu.offsetTop - menu.clientHeight) + "px"; + } + + //openingMenu = false; + }); + } + + blazorContextMenu.Init = function () { + document.addEventListener("mouseup", function (e) { + handleAutoHideEvent(e, "mouseup"); + }); + + document.addEventListener("mousedown", function (e) { + handleAutoHideEvent(e, "mousedown"); + }); + + function handleAutoHideEvent(e, autoHideEvent) { + if (openMenus.length > 0) { + for (var i = 0; i < openMenus.length; i++) { + var currentMenu = openMenus[i]; + var menuElement = document.getElementById(currentMenu.id); + if (menuElement && menuElement.dataset["autohide"] == "true" && menuElement.dataset["autohideevent"] == autoHideEvent) { + var clickedInsideMenu = menuElement.contains(e.target); + if (!clickedInsideMenu) { + blazorContextMenu.Hide(currentMenu.id); + } + } + + } + } + } + + window.addEventListener('resize', function () { + if (openMenus.length > 0) { + for (var i = 0; i < openMenus.length; i++) { + var currentMenu = openMenus[i]; + var menuElement = document.getElementById(currentMenu.id); + if (menuElement && menuElement.dataset["autohide"] == "true") { + blazorContextMenu.Hide(currentMenu.id); + } + } + } + }, true); + }; + + + blazorContextMenu.Show = function (menuId, x, y, target, triggerDotnetRef) { + var targetId = null; + if (target) { + if (!target.id) { + //add an id to the target dynamically so that it can be referenced later + //TODO: Rewrite this once this Blazor limitation is lifted + target.id = guid(); + } + targetId = target.id; + } + + return menuHandlerReference.invokeMethodAsync('ShowMenu', menuId, x.toString(), y.toString(), targetId, triggerDotnetRef); + } + + blazorContextMenu.Hide = function (menuId) { + var menuElement = document.getElementById(menuId); + var instanceId = menuElement.dataset["instanceId"]; + return menuHandlerReference.invokeMethodAsync('HideMenu', menuId).then(function (hideSuccessful) { + if (menuElement.classList.contains("blazor-context-menu") && hideSuccessful) { + //this is a root menu. Remove from openMenus list + var openMenu = openMenus.find(function (item) { + return item.instanceId == instanceId; + }); + if (openMenu) { + removeItemFromArray(openMenus, openMenu); + } + } + }); + } + + blazorContextMenu.IsMenuShown = function (menuId) { + var menuElement = document.getElementById(menuId); + var instanceId = menuElement.dataset["instanceId"]; + var menu = openMenus.find(function (item) { + return item.instanceId == instanceId; + }); + return typeof(menu) != 'undefined' && menu != null; + } + + var subMenuTimeout = null; + blazorContextMenu.OnMenuItemMouseOver = function (e, xOffset, currentItemElement) { + if (closest(e.target, ".blazor-context-menu__wrapper") != closest(currentItemElement, ".blazor-context-menu__wrapper")) { + //skip child menu mouseovers + return; + } + if (currentItemElement.getAttribute("itemEnabled") != "true") return; + + var subMenu = findFirstChildByClass(currentItemElement, "blazor-context-submenu"); + if (!subMenu) return; //item does not contain a submenu + + subMenuTimeout = setTimeout(function () { + subMenuTimeout = null; + + var currentMenu = closest(currentItemElement, ".blazor-context-menu__wrapper"); + var currentMenuList = currentMenu.children[0]; + var rootMenu = closest(currentItemElement, ".blazor-context-menu"); + var targetRect = currentItemElement.getBoundingClientRect(); + var x = targetRect.left + currentMenu.clientWidth - xOffset; + var y = targetRect.top; + var instanceId = rootMenu.dataset["instanceId"]; + + var openMenu = openMenus.find(function (item) { + return item.instanceId == instanceId; + }); + blazorContextMenu.Show(subMenu.id, x, y, openMenu.target).then(function () { + var leftOverflownPixels = subMenu.offsetLeft + subMenu.clientWidth - window.innerWidth; + if (leftOverflownPixels > 0) { + subMenu.style.left = (subMenu.offsetLeft - subMenu.clientWidth - currentMenu.clientWidth - xOffset) + "px" + } + + var topOverflownPixels = subMenu.offsetTop + subMenu.clientHeight - window.innerHeight; + if (topOverflownPixels > 0) { + subMenu.style.top = (subMenu.offsetTop - topOverflownPixels) + "px"; + } + + var closeSubMenus = function () { + var childSubMenus = findAllChildsByClass(currentItemElement, "blazor-context-submenu"); + var i = childSubMenus.length; + while (i--) { + var subMenu = childSubMenus[i]; + blazorContextMenu.Hide(subMenu.id); + } + + i = currentMenuList.childNodes.length; + while (i--) { + var child = currentMenuList.children[i]; + if (child == currentItemElement) continue; + child.removeEventListener("mouseover", closeSubMenus); + } + }; + + var i = currentMenuList.childNodes.length; + while (i--) { + var child = currentMenuList.childNodes[i]; + if (child == currentItemElement) continue; + + child.addEventListener("mouseover", closeSubMenus); + } + }); + }, 200); + } + + blazorContextMenu.OnMenuItemMouseOut = function (e) { + if (subMenuTimeout) { + clearTimeout(subMenuTimeout); + } + } + + + blazorContextMenu.RegisterTriggerReference = function (triggerElement, triggerDotNetRef) { + if (triggerElement) { + triggerElement.dataset["dotnetref"] = JSON.stringify(triggerDotNetRef.serializeAsArg()); + } + } + + return blazorContextMenu; +}({}); + +blazorContextMenu.Init(); \ No newline at end of file diff --git a/Moonlight/Assets/FileManager/js/filemanager.js b/Moonlight/Assets/FileManager/js/filemanager.js new file mode 100644 index 0000000..164e63b --- /dev/null +++ b/Moonlight/Assets/FileManager/js/filemanager.js @@ -0,0 +1,199 @@ +window.filemanager = { + + urlCache: new Map(), + + dropzone: { + init: function (id, urlId, progressReporter) { + function preventDefaults(e) { + e.preventDefault(); + e.stopPropagation(); + } + + async function handleDrop(e) { + e.preventDefault(); + + if (e.dataTransfer.items && e.dataTransfer.items.length > 0) { + + moonlight.toasts.create("dropZoneUploadProgress", "Preparing to upload"); + + await performUpload(e.dataTransfer.items); + + moonlight.toasts.remove("dropZoneUploadProgress"); + moonlight.toasts.success("", "Successfully uploaded files", 5000); + + progressReporter.invokeMethodAsync("UpdateStatus"); + } + + //TODO: HANDLE UNSUPPORTED which would call else + } + + async function performUpload(items) { + const fileEntries = []; + + // Collect file entries from DataTransferItemList + for (let i = 0; i < items.length; i++) { + if (items[i].kind === 'file') { + const entry = items[i].webkitGetAsEntry(); + if (entry.isFile) { + fileEntries.push(entry); + } else if (entry.isDirectory) { + await readDirectory(entry, fileEntries); + } + } + } + + // Upload files one by one + for (const fileEntry of fileEntries) { + moonlight.toasts.modify("dropZoneUploadProgress", `Uploading '${fileEntry.name}'`); + + await uploadFile(fileEntry); + } + } + + async function readDirectory(directoryEntry, fileEntries = []) { + const directoryReader = directoryEntry.createReader(); + + return new Promise(async (resolve, reject) => { + const readBatch = async () => { + directoryReader.readEntries(async function (entries) { + for (const entry of entries) { + if (entry.isFile) { + fileEntries.push(entry); + } else if (entry.isDirectory) { + await readDirectory(entry, fileEntries); + } + } + + // If there are more entries to read, call readBatch again + if (entries.length === 100) { + await readBatch(); + } else { + resolve(); + } + }, reject); + }; + + // Start reading the first batch + await readBatch(); + }); + } + + async function uploadFile(file) { + // Upload the file to the server + let formData = new FormData(); + formData.append('file', await getFile(file)); + formData.append("path", file.fullPath); + + var url = filemanager.urlCache.get(urlId); + + // Create a new fetch request + let request = new Request(url, { + method: 'POST', + body: formData + }); + + request.onprogress = function (event) { + if (event.lengthComputable) { + let percentComplete = (event.loaded / event.total) * 100; + console.log(`Upload progress: ${percentComplete.toFixed(2)}%`); + console.log(`Bytes transferred: ${event.loaded} of ${event.total}`); + } + }; + + try { + // Use the fetch API to send the request + var response = await fetch(request); + + if (!response.ok) { + var errorText = await response.text(); + + moonlight.toasts.danger(`Failed to upload '${file.name}'`, errorText, 5000); + } + } catch (error) { + moonlight.toasts.danger(`Failed to upload '${file.name}'`, error.toString(), 5000); + } + } + + async function getFile(fileEntry) { + try { + return new Promise((resolve, reject) => fileEntry.file(resolve, reject)); + } catch (err) { + console.log(err); + } + } + + const dropArea = document.getElementById(id); + + ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { + dropArea.addEventListener(eventName, preventDefaults, false); + }); + + // Handle dropped files and folders + dropArea.addEventListener('drop', handleDrop, false); + } + }, + fileselect: { + init: function (id, urlId, progressReporter) { + const inputElement = document.getElementById(id); + + inputElement.addEventListener("change", handleFiles, false); + + async function handleFiles() { + const fileList = inputElement.files; + + moonlight.toasts.create("selectUploadProgress", "Preparing to upload"); + + for (const file of fileList) { + moonlight.toasts.modify("selectUploadProgress", `Uploading '${file.name}'`); + await uploadFile(file); + } + + moonlight.toasts.remove("selectUploadProgress"); + moonlight.toasts.success("", "Successfully uploaded files", 5000); + + progressReporter.invokeMethodAsync("UpdateStatus"); + + inputElement.value = ""; + } + + async function uploadFile(file) { + // Upload the file to the server + let formData = new FormData(); + formData.append('file', file); + formData.append("path", file.name); + + var url = filemanager.urlCache.get(urlId); + + // Create a new fetch request + let request = new Request(url, { + method: 'POST', + body: formData + }); + + request.onprogress = function (event) { + if (event.lengthComputable) { + let percentComplete = (event.loaded / event.total) * 100; + console.log(`Upload progress: ${percentComplete.toFixed(2)}%`); + console.log(`Bytes transferred: ${event.loaded} of ${event.total}`); + } + }; + + try { + // Use the fetch API to send the request + var response = await fetch(request); + + if (!response.ok) { + var errorText = await response.text(); + + moonlight.toasts.danger(`Failed to upload '${file.name}'`, errorText, 5000); + } + } catch (error) { + moonlight.toasts.danger(`Failed to upload '${file.name}'`, error.toString(), 5000); + } + } + } + }, + updateUrl: function (urlId, url) { + filemanager.urlCache.set(urlId, url); + } +}; \ No newline at end of file diff --git a/Moonlight/Core/Helpers/HostFileActions.cs b/Moonlight/Core/Helpers/HostFileActions.cs index 57ea20e..13e411b 100644 --- a/Moonlight/Core/Helpers/HostFileActions.cs +++ b/Moonlight/Core/Helpers/HostFileActions.cs @@ -1,4 +1,5 @@ -using Moonlight.Features.FileManager.Models.Abstractions.FileAccess; +using MoonCore.Helpers; +using Moonlight.Features.FileManager.Models.Abstractions.FileAccess; namespace Moonlight.Core.Helpers; @@ -87,6 +88,9 @@ public class HostFileActions : IFileActions public Task WriteFile(string name, string content) { var fullPath = GetFullPath(name); + + EnsureDir(fullPath); + File.WriteAllText(fullPath, content); return Task.CompletedTask; } @@ -101,12 +105,20 @@ public class HostFileActions : IFileActions { var fullPath = GetFullPath(name); + EnsureDir(fullPath); + using (var fileStream = File.Create(fullPath)) dataStream.CopyTo(fileStream); return Task.CompletedTask; } + private void EnsureDir(string path) + { + var pathWithoutFileName = Formatter.ReplaceEnd(path, Path.GetFileName(path), ""); + Directory.CreateDirectory(pathWithoutFileName); + } + public IFileActions Clone() { return new HostFileActions(RootDirectory); diff --git a/Moonlight/Core/Services/PluginService.cs b/Moonlight/Core/Services/PluginService.cs index e47f7f8..20c66fe 100644 --- a/Moonlight/Core/Services/PluginService.cs +++ b/Moonlight/Core/Services/PluginService.cs @@ -127,7 +127,7 @@ public class PluginService { try { - var assembly = Assembly.LoadFile(dllFile); + var assembly = Assembly.LoadFile(Path.GetFullPath(dllFile)); var pluginTypes = assembly .GetTypes() diff --git a/Moonlight/Features/FileManager/FileManagerFeature.cs b/Moonlight/Features/FileManager/FileManagerFeature.cs index 21c2370..107c30d 100644 --- a/Moonlight/Features/FileManager/FileManagerFeature.cs +++ b/Moonlight/Features/FileManager/FileManagerFeature.cs @@ -3,6 +3,8 @@ using MoonCore.Services; using Moonlight.Core.Configuration; using Moonlight.Core.Models.Abstractions.Feature; using Moonlight.Core.Services; +using Moonlight.Features.FileManager.Implementations; +using Moonlight.Features.FileManager.Interfaces; using Moonlight.Features.FileManager.Models.Enums; namespace Moonlight.Features.FileManager; @@ -25,14 +27,39 @@ public class FileManagerFeature : MoonlightFeature context.Builder.Services.AddSingleton(new JwtService(config.Get().Security.Token)); context.AddAsset("FileManager", "js/dropzone.js"); + context.AddAsset("FileManager", "js/filemanager.js"); context.AddAsset("FileManager", "editor/ace.css"); context.AddAsset("FileManager", "editor/ace.js"); + // Add blazor context menu + context.Builder.Services.AddBlazorContextMenu(builder => + { + /* + builder.ConfigureTemplate(template => + { + + });*/ + }); + + context.AddAsset("FileManager", "js/blazorContextMenu.js"); + context.AddAsset("FileManager", "css/blazorContextMenu.css"); + return Task.CompletedTask; } + public override async Task OnInitialized(InitContext context) + { + // Register default file manager actions in plugin service + var pluginService = context.Application.Services.GetRequiredService(); + + await pluginService.RegisterImplementation(new RenameFileManagerAction()); + await pluginService.RegisterImplementation(new DownloadFileManagerAction()); + await pluginService.RegisterImplementation(new DeleteFileManagerAction()); + } + public override async Task OnSessionInitialized(SessionInitContext context) { + // Register hotkeys var hotKeyService = context.ServiceProvider.GetRequiredService(); await hotKeyService.RegisterHotkey("KeyS", "ctrl", "save"); diff --git a/Moonlight/Features/FileManager/Http/Controllers/UploadController.cs b/Moonlight/Features/FileManager/Http/Controllers/UploadController.cs index 73a560d..93255e0 100644 --- a/Moonlight/Features/FileManager/Http/Controllers/UploadController.cs +++ b/Moonlight/Features/FileManager/Http/Controllers/UploadController.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Mvc; +using MoonCore.Helpers; using MoonCore.Services; using Moonlight.Core.Services; using Moonlight.Features.FileManager.Models.Enums; @@ -46,6 +47,20 @@ public class UploadController : Controller if (Request.Form.Files.Count > 1) return BadRequest("Too many files sent"); + if (!Request.Form.ContainsKey("path")) + return BadRequest("Path is missing"); + + var path = Request.Form["path"].First() ?? ""; + + if (string.IsNullOrEmpty(path)) + return BadRequest("Path is missing"); + + if (path.Contains("..")) + { + Logger.Warn("A path transversal attack has been detected while processing upload path", "security"); + return BadRequest("Invalid path. This attempt has been logged ;)"); + } + // Validate request if (!await JwtService.Validate(uploadToken, FileManagerJwtType.FileAccess)) return StatusCode(403); @@ -66,7 +81,7 @@ public class UploadController : Controller // Actually upload the file var file = Request.Form.Files.First(); - await fileAccess.WriteFileStream(file.FileName, file.OpenReadStream()); + await fileAccess.WriteFileStream(path, file.OpenReadStream()); // Cleanup fileAccess.Dispose(); diff --git a/Moonlight/Features/FileManager/Implementations/DeleteFileManagerAction.cs b/Moonlight/Features/FileManager/Implementations/DeleteFileManagerAction.cs new file mode 100644 index 0000000..5660a0c --- /dev/null +++ b/Moonlight/Features/FileManager/Implementations/DeleteFileManagerAction.cs @@ -0,0 +1,24 @@ +using MoonCoreUI.Services; +using Moonlight.Features.FileManager.Interfaces; +using Moonlight.Features.FileManager.Models.Abstractions.FileAccess; +using Moonlight.Features.FileManager.UI.NewFileManager; + +namespace Moonlight.Features.FileManager.Implementations; + +public class DeleteFileManagerAction : IFileManagerAction +{ + public string Name => "Delete"; + public string Icon => "bxs-trash"; + public string Color => "danger"; + public Func Filter => _ => true; + + public async Task Execute(BaseFileAccess access, FileView view, FileEntry entry, IServiceProvider serviceProvider) + { + await access.Delete(entry); + + await view.Refresh(); + + var toastService = serviceProvider.GetRequiredService(); + await toastService.Success("Successfully deleted item"); + } +} \ No newline at end of file diff --git a/Moonlight/Features/FileManager/Implementations/DownloadFileManagerAction.cs b/Moonlight/Features/FileManager/Implementations/DownloadFileManagerAction.cs new file mode 100644 index 0000000..5083b27 --- /dev/null +++ b/Moonlight/Features/FileManager/Implementations/DownloadFileManagerAction.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Components; +using MoonCore.Helpers; +using MoonCoreUI.Services; +using Moonlight.Features.FileManager.Interfaces; +using Moonlight.Features.FileManager.Models.Abstractions.FileAccess; +using Moonlight.Features.FileManager.Services; +using Moonlight.Features.FileManager.UI.NewFileManager; + +namespace Moonlight.Features.FileManager.Implementations; + +public class DownloadFileManagerAction : IFileManagerAction +{ + public string Name => "Download"; + public string Icon => "bxs-cloud-download"; + public string Color => "primary"; + public Func Filter => entry => entry.IsFile; + + public async Task Execute(BaseFileAccess access, FileView view, FileEntry entry, IServiceProvider serviceProvider) + { + var fileAccessService = serviceProvider.GetRequiredService(); + var navigation = serviceProvider.GetRequiredService(); + var toastService = serviceProvider.GetRequiredService(); + + try + { + var token = await fileAccessService.GenerateToken(access); + var url = $"/api/download?token={token}&name={entry.Name}"; + + await toastService.Info("Starting download..."); + navigation.NavigateTo(url, true); + } + catch (Exception e) + { + Logger.Warn("Unable to start download"); + Logger.Warn(e); + + await toastService.Danger("Failed to start download"); + } + } +} \ No newline at end of file diff --git a/Moonlight/Features/FileManager/Implementations/RenameFileManagerAction.cs b/Moonlight/Features/FileManager/Implementations/RenameFileManagerAction.cs new file mode 100644 index 0000000..dc22037 --- /dev/null +++ b/Moonlight/Features/FileManager/Implementations/RenameFileManagerAction.cs @@ -0,0 +1,30 @@ +using MoonCoreUI.Services; +using Moonlight.Features.FileManager.Interfaces; +using Moonlight.Features.FileManager.Models.Abstractions.FileAccess; +using Moonlight.Features.FileManager.UI.NewFileManager; + +namespace Moonlight.Features.FileManager.Implementations; + +public class RenameFileManagerAction : IFileManagerAction +{ + public string Name => "Rename"; + public string Icon => "bxs-rename"; + public string Color => "info"; + public Func Filter => _ => true; + + public async Task Execute(BaseFileAccess access, FileView view, FileEntry entry, IServiceProvider provider) + { + var alertService = provider.GetRequiredService(); + var toastService = provider.GetRequiredService(); + + var newName = await alertService.Text($"Enter a new name for '{entry.Name}'", "", entry.Name); + + if (string.IsNullOrEmpty(newName)) + return; + + await access.Rename(entry.Name, newName); + + await view.Refresh(); + await toastService.Success("Successfully renamed file"); + } +} \ No newline at end of file diff --git a/Moonlight/Features/FileManager/Interfaces/IFileManagerAction.cs b/Moonlight/Features/FileManager/Interfaces/IFileManagerAction.cs new file mode 100644 index 0000000..c2e0598 --- /dev/null +++ b/Moonlight/Features/FileManager/Interfaces/IFileManagerAction.cs @@ -0,0 +1,14 @@ +using Moonlight.Features.FileManager.Models.Abstractions.FileAccess; +using Moonlight.Features.FileManager.UI.NewFileManager; + +namespace Moonlight.Features.FileManager.Interfaces; + +public interface IFileManagerAction +{ + public string Name { get; } + public string Icon { get; } + public string Color { get; } + public Func Filter { get; } + + public Task Execute(BaseFileAccess access, FileView view, FileEntry entry, IServiceProvider provider); +} \ No newline at end of file diff --git a/Moonlight/Features/FileManager/Models/Abstractions/FileAccess/BaseFileAccess.cs b/Moonlight/Features/FileManager/Models/Abstractions/FileAccess/BaseFileAccess.cs index 08c5ee4..9d2e02e 100644 --- a/Moonlight/Features/FileManager/Models/Abstractions/FileAccess/BaseFileAccess.cs +++ b/Moonlight/Features/FileManager/Models/Abstractions/FileAccess/BaseFileAccess.cs @@ -2,7 +2,7 @@ namespace Moonlight.Features.FileManager.Models.Abstractions.FileAccess; -public class BaseFileAccess : IFileAccess +public class BaseFileAccess : IDisposable { private readonly IFileActions Actions; @@ -46,20 +46,28 @@ public class BaseFileAccess : IFileAccess return Task.FromResult(CurrentDirectory); } - public async Task Delete(string path) + public async Task Delete(FileEntry entry) { - var finalPath = CurrentDirectory + path; + var finalPath = CurrentDirectory + entry.Name; await Actions.Delete(finalPath); } - public async Task Move(string from, string to) + public async Task Move(FileEntry entry, string to) { - var finalPathFrom = CurrentDirectory + from; + var finalPathFrom = CurrentDirectory + entry.Name; await Actions.Move(finalPathFrom, to); } + public async Task Rename(string from, string to) + { + var finalPathFrom = CurrentDirectory + from; + var finalPathTo = CurrentDirectory + to; + + await Actions.Move(finalPathFrom, finalPathTo); + } + public async Task CreateDirectory(string name) { var finalPath = CurrentDirectory + name; @@ -102,7 +110,7 @@ public class BaseFileAccess : IFileAccess await Actions.WriteFileStream(finalPath, dataStream); } - public IFileAccess Clone() + public BaseFileAccess Clone() { return new BaseFileAccess(Actions.Clone()) { diff --git a/Moonlight/Features/FileManager/Services/FileManagerInteropService.cs b/Moonlight/Features/FileManager/Services/FileManagerInteropService.cs new file mode 100644 index 0000000..977f868 --- /dev/null +++ b/Moonlight/Features/FileManager/Services/FileManagerInteropService.cs @@ -0,0 +1,41 @@ +using Microsoft.JSInterop; +using MoonCore.Attributes; +using MoonCore.Helpers; + +namespace Moonlight.Features.FileManager.Services; + +[Scoped] +public class FileManagerInteropService +{ + private readonly IJSRuntime JsRuntime; + + public SmartEventHandler OnUploadStateChanged { get; set; } = new(); + + public FileManagerInteropService(IJSRuntime jsRuntime) + { + JsRuntime = jsRuntime; + } + + public async Task InitDropzone(string id, string urlId) + { + var reference = DotNetObjectReference.Create(this); + await JsRuntime.InvokeVoidAsync("filemanager.dropzone.init", id, urlId, reference); + } + + public async Task InitFileSelect(string id, string urlId) + { + var reference = DotNetObjectReference.Create(this); + await JsRuntime.InvokeVoidAsync("filemanager.fileselect.init", id, urlId, reference); + } + + public async Task UpdateUrl(string urlId, string url) + { + await JsRuntime.InvokeVoidAsync("filemanager.updateUrl", urlId, url); + } + + [JSInvokable] + public async Task UpdateStatus() + { + await OnUploadStateChanged.Invoke(); + } +} \ No newline at end of file diff --git a/Moonlight/Features/FileManager/Services/SharedFileAccessService.cs b/Moonlight/Features/FileManager/Services/SharedFileAccessService.cs index ca6515a..e03b0e3 100644 --- a/Moonlight/Features/FileManager/Services/SharedFileAccessService.cs +++ b/Moonlight/Features/FileManager/Services/SharedFileAccessService.cs @@ -10,14 +10,14 @@ namespace Moonlight.Features.FileManager.Services; public class SharedFileAccessService { private readonly JwtService JwtService; - private readonly List FileAccesses = new(); + private readonly List FileAccesses = new(); public SharedFileAccessService(JwtService jwtService) { JwtService = jwtService; } - public Task Register(IFileAccess fileAccess) + public Task Register(BaseFileAccess fileAccess) { lock (FileAccesses) { @@ -28,7 +28,7 @@ public class SharedFileAccessService return Task.FromResult(fileAccess.GetHashCode()); } - public Task Unregister(IFileAccess fileAccess) + public Task Unregister(BaseFileAccess fileAccess) { lock (FileAccesses) { @@ -39,20 +39,20 @@ public class SharedFileAccessService return Task.CompletedTask; } - public Task Get(int id) + public Task Get(int id) { lock (FileAccesses) { var fileAccess = FileAccesses.FirstOrDefault(x => x.GetHashCode() == id); if (fileAccess == null) - return Task.FromResult(null); + return Task.FromResult(null); - return Task.FromResult(fileAccess.Clone()); + return Task.FromResult(fileAccess.Clone()); } } - public async Task GenerateToken(IFileAccess fileAccess) + public async Task GenerateToken(BaseFileAccess fileAccess) { var token = await JwtService.Create(data => { diff --git a/Moonlight/Features/FileManager/UI/Components/FileEditor.razor b/Moonlight/Features/FileManager/UI/Components/FileEditor.razor index 9faaf0b..c63ed45 100644 --- a/Moonlight/Features/FileManager/UI/Components/FileEditor.razor +++ b/Moonlight/Features/FileManager/UI/Components/FileEditor.razor @@ -34,7 +34,7 @@ { [Parameter] public FileEntry File { get; set; } - [Parameter] public IFileAccess FileAccess { get; set; } + [Parameter] public BaseFileAccess FileAccess { get; set; } [Parameter] public bool CloseOnSave { get; set; } = false; diff --git a/Moonlight/Features/FileManager/UI/Components/FileManager.razor b/Moonlight/Features/FileManager/UI/Components/FileManager.razor index de110fa..2edf6e8 100644 --- a/Moonlight/Features/FileManager/UI/Components/FileManager.razor +++ b/Moonlight/Features/FileManager/UI/Components/FileManager.razor @@ -117,7 +117,7 @@ @code { - [Parameter] public IFileAccess FileAccess { get; set; } + [Parameter] public BaseFileAccess FileAccess { get; set; } // Navigation private string Path = "/"; @@ -134,7 +134,7 @@ // Move private FileEntry MoveEntry; private SmartModal MoveModal; - private IFileAccess MoveAccess; + private BaseFileAccess MoveAccess; private async Task OnPathChanged(string path) { @@ -267,7 +267,7 @@ MoveAccess.Dispose(); // Perform move and process ui updates - await FileAccess.Move(MoveEntry.Name, pathToMove + MoveEntry.Name); + await FileAccess.Move(MoveEntry, pathToMove + MoveEntry.Name); await MoveModal.Hide(); diff --git a/Moonlight/Features/FileManager/UI/Components/FileUploader.razor b/Moonlight/Features/FileManager/UI/Components/FileUploader.razor index b1ec27c..e9a9f51 100644 --- a/Moonlight/Features/FileManager/UI/Components/FileUploader.razor +++ b/Moonlight/Features/FileManager/UI/Components/FileUploader.razor @@ -51,7 +51,7 @@ @code { - [Parameter] public IFileAccess FileAccess { get; set; } + [Parameter] public BaseFileAccess FileAccess { get; set; } private CancellationTokenSource Cancellation = new(); private string DropzoneId; @@ -65,12 +65,12 @@ { if (firstRender) { - await SharedFileAccessService.Register(FileAccess); + //await SharedFileAccessService.Register(FileAccess); - var token = await SharedFileAccessService.GenerateToken(FileAccess); - var url = $"/api/upload?token={token}"; + //var token = await SharedFileAccessService.GenerateToken(FileAccess); + //var url = $"/api/upload?token={token}"; - await DropzoneService.Create(DropzoneId, url); + //await DropzoneService.Create(DropzoneId, url); Task.Run(async () => // Update the dropzone url every 5 minutes so the token does not expire { @@ -78,9 +78,9 @@ { await Task.Delay(TimeSpan.FromMinutes(5)); - var newToken = await SharedFileAccessService.GenerateToken(FileAccess); - var newUrl = $"/api/upload?token={newToken}"; - await DropzoneService.UpdateUrl(DropzoneId, newUrl); + //var newToken = await SharedFileAccessService.GenerateToken(FileAccess); + //var newUrl = $"/api/upload?token={newToken}"; + //await DropzoneService.UpdateUrl(DropzoneId, newUrl); } }); } @@ -89,6 +89,6 @@ public async void Dispose() { Cancellation.Cancel(); - await SharedFileAccessService.Unregister(FileAccess); + //await SharedFileAccessService.Unregister(FileAccess); } } \ No newline at end of file diff --git a/Moonlight/Features/FileManager/UI/Components/FileView.razor b/Moonlight/Features/FileManager/UI/Components/FileView.razor index e26b787..e1ef77a 100644 --- a/Moonlight/Features/FileManager/UI/Components/FileView.razor +++ b/Moonlight/Features/FileManager/UI/Components/FileView.razor @@ -210,7 +210,7 @@ @code { - [Parameter] public IFileAccess FileAccess { get; set; } + [Parameter] public BaseFileAccess FileAccess { get; set; } [Parameter] public Func? Filter { get; set; } [Parameter] public bool ShowSize { get; set; } = true; @@ -297,7 +297,7 @@ { await ToastService.ModifyProgress(toastId, $"[{i + 1}/{entries.Length}] Deleting '{entry.Name}'"); - await FileAccess.Delete(entry.Name); + await FileAccess.Delete(entry); i++; } @@ -315,7 +315,7 @@ if (string.IsNullOrEmpty(name)) return; - await FileAccess.Move(fileEntry.Name, await FileAccess.GetCurrentDirectory() + name); + await FileAccess.Move(fileEntry, await FileAccess.GetCurrentDirectory() + name); await LazyLoader.Reload(); } @@ -332,12 +332,12 @@ { try { - await SharedFileAccessService.Register(FileAccess); - var token = await SharedFileAccessService.GenerateToken(FileAccess); - var url = $"/api/download?token={token}&name={fileEntry.Name}"; + //await SharedFileAccessService.Register(FileAccess); + //var token = await SharedFileAccessService.GenerateToken(FileAccess); + //var url = $"/api/download?token={token}&name={fileEntry.Name}"; await ToastService.Info("Starting download..."); - Navigation.NavigateTo(url, true); + //Navigation.NavigateTo(url, true); } catch (Exception e) { @@ -429,6 +429,6 @@ public async void Dispose() { - await SharedFileAccessService.Unregister(FileAccess); + //await SharedFileAccessService.Unregister(FileAccess); } } \ No newline at end of file diff --git a/Moonlight/Features/FileManager/UI/NewFileManager/Editor.razor b/Moonlight/Features/FileManager/UI/NewFileManager/Editor.razor new file mode 100644 index 0000000..9b1b690 --- /dev/null +++ b/Moonlight/Features/FileManager/UI/NewFileManager/Editor.razor @@ -0,0 +1,60 @@ +@using Moonlight.Features.FileManager.Services +@using MoonCore.Helpers + +@inject EditorService EditorService + +
+ +@code +{ + [Parameter] public string InitialContent { get; set; } = ""; + [Parameter] public string Theme { get; set; } = "one_dark"; + [Parameter] public string Mode { get; set; } = "text"; + [Parameter] public int Lines { get; set; } = 30; + [Parameter] public int FontSize { get; set; } = 15; + [Parameter] public bool EnableAutoInit { get; set; } = false; + [Parameter] public Func? OnChanged { get; set; } + + private string Identifier; + + protected override void OnInitialized() + { + Identifier = "editor" + GetHashCode(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + if(EnableAutoInit) + await Initialize(); + } + } + + public async Task Initialize() + { + await EditorService.Create( + Identifier, + Theme, + Mode, + InitialContent, + Lines, + FontSize + ); + } + + public async Task GetContent() => await EditorService.GetValue(); + + public async Task SetContent(string content) => await EditorService.SetValue(content); + + public async Task SetMode(string mode) => await EditorService.SetMode(mode); + + private async Task FocusOut() + { + if (OnChanged != null) + { + var content = await GetContent(); + await OnChanged.Invoke(content); + } + } +} diff --git a/Moonlight/Features/FileManager/UI/NewFileManager/FileEditor.razor b/Moonlight/Features/FileManager/UI/NewFileManager/FileEditor.razor new file mode 100644 index 0000000..c63ed45 --- /dev/null +++ b/Moonlight/Features/FileManager/UI/NewFileManager/FileEditor.razor @@ -0,0 +1,112 @@ +@using MoonCoreUI.Services +@using Moonlight.Core.Services +@using MoonCore.Helpers +@using Moonlight.Core.Helpers +@using Moonlight.Features.FileManager.Helpers +@using Moonlight.Features.FileManager.Models.Abstractions.FileAccess + +@inject ToastService ToastService +@inject HotKeyService HotKeyService + +@implements IDisposable + +
+
+
+
+ @(File.Name) (@(Formatter.FormatSize(File.Size))) +
+
+ + Back + + + Save + +
+
+
+
+ + + +@code +{ + [Parameter] public FileEntry File { get; set; } + + [Parameter] public BaseFileAccess FileAccess { get; set; } + + [Parameter] public bool CloseOnSave { get; set; } = false; + + [Parameter] public Func? OnClosed { get; set; } + + private Editor Editor; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + // Initialize the editor + await Editor.Initialize(); + + // Load file and check the file type + var fileData = await FileAccess.ReadFile(File.Name); + var mode = EditorModeDetector.GetModeFromFile(File.Name); + + // Finalize editor + await Editor.SetMode(mode); + await Editor.SetContent(fileData); + + HotKeyService.HotKeyPressed += OnHotKeyPressed; + } + } + + private async Task OnClose() + { + if (OnClosed != null) + await OnClosed.Invoke(); + } + + private async Task OnSave() + { + try + { + var content = await Editor.GetContent(); + await FileAccess.WriteFile(File.Name, content); + } + catch (Exception e) + { + Logger.Warn($"An unhandled error has occured while saving a file using access type {FileAccess.GetType().FullName}"); + Logger.Warn(e); + + await ToastService.Danger("An unknown error has occured while saving the file. Please try again later"); + return; + } + + await ToastService.Success("Successfully saved file"); + + if (CloseOnSave) + { + if (OnClosed != null) + await OnClosed.Invoke(); + } + } + + private async Task OnHotKeyPressed(string hotKey) + { + switch (hotKey) + { + case "save": + await OnSave(); + break; + case "close": + await OnClose(); + break; + } + } + + public void Dispose() + { + HotKeyService.HotKeyPressed -= OnHotKeyPressed; + } +} \ No newline at end of file diff --git a/Moonlight/Features/FileManager/UI/NewFileManager/FileManager.razor b/Moonlight/Features/FileManager/UI/NewFileManager/FileManager.razor new file mode 100644 index 0000000..da385a7 --- /dev/null +++ b/Moonlight/Features/FileManager/UI/NewFileManager/FileManager.razor @@ -0,0 +1,440 @@ +@using MoonCore.Helpers +@using MoonCore.Services +@using MoonCoreUI.Services +@using Moonlight.Core.Configuration +@using Moonlight.Core.Services +@using Moonlight.Features.FileManager.Interfaces +@using Moonlight.Features.FileManager.Models.Abstractions.FileAccess +@using Moonlight.Features.FileManager.Services + +@inject AlertService AlertService +@inject ToastService ToastService +@inject FileManagerInteropService FileManagerInteropService +@inject SharedFileAccessService FileAccessService +@inject ConfigService ConfigService +@inject NavigationManager Navigation +@inject PluginService PluginService +@inject IServiceProvider ServiceProvider + +@implements IDisposable + +
+
+
+
+ @{ + var parts = Path + .Split("/") + .Where(x => !string.IsNullOrEmpty(x)) + .ToArray(); + + var i = 1; + } + + / + + @foreach (var part in parts) + { + var x = i + 0; + + @(part) +
/
+ + i++; + } +
+
+
+ + + + + + +
+
+
+ +@if (ShowEditor) +{ +
+ +
+} +else +{ +
+ + + @foreach (var action in Actions) + { + if(!action.Filter.Invoke(context)) + continue; + + + + @action.Name + + } + + + + Move + + + +
+ + + + + + +} + +@code +{ + [Parameter] public BaseFileAccess FileAccess { get; set; } + + private FileView View; + private string Path = "/"; + + private IFileManagerAction[] Actions; + + // Editor + private FileEditor Editor; + private FileEntry FileToEdit; + private bool ShowEditor = false; + + // Move + private SmartModal MoveModal; + private BaseFileAccess MoveAccess; + private FileView MoveView; + private bool InMoveState = false; + private Func FolderOnlyFilter = entry => entry.IsDirectory; + private Func OnFolderClicked; + private Func OnMoveUpClicked; + private List FilesToMove = new(); + + private Timer? UploadTokenTimer; + + protected override void OnInitialized() + { + OnFolderClicked = async entry => + { + await MoveAccess.ChangeDirectory(entry.Name); + await MoveView.Refresh(); + }; + OnMoveUpClicked = async () => + { + await MoveAccess.ChangeDirectory(".."); + await MoveView.Refresh(); + }; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + return; + + // Load plugin ui and options + Actions = await PluginService.GetImplementations(); + + + // Setup upload url update timer + UploadTokenTimer = new(async _ => + { + await FileAccessService.Register(FileAccess); + var token = await FileAccessService.GenerateToken(FileAccess); + var url = $"/api/upload?token={token}"; + + await FileManagerInteropService.UpdateUrl("fileManager", url); + }, null, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(5)); + + // Create initial url + await FileAccessService.Register(FileAccess); + var token = await FileAccessService.GenerateToken(FileAccess); + var url = $"/api/upload?token={token}"; + + // Refresh the file view when a upload is completed + FileManagerInteropService.OnUploadStateChanged += async () => { await View.Refresh(); }; + + // Initialize drop area & file select + await FileManagerInteropService.UpdateUrl("fileManager", url); + await FileManagerInteropService.InitDropzone("fileManagerUpload", "fileManager"); + await FileManagerInteropService.InitFileSelect("fileManagerSelect", "fileManager"); + } + + private async Task OnEntryClicked(FileEntry entry) + { + if (entry.IsFile) + { + var fileSizeInKilobytes = ByteSizeValue.FromBytes(entry.Size).KiloBytes; + + if (fileSizeInKilobytes > ConfigService.Get().Customisation.FileManager.MaxFileOpenSize) + { + await ToastService.Danger("Unable to open file as it exceeds the max file size limit"); + return; + } + + await OpenEditor(entry); + } + else + { + await FileAccess.ChangeDirectory(entry.Name); + await View.Refresh(); + + await Refresh(); + } + } + + private async Task InvokeContextAction(IFileManagerAction action, FileEntry entry) + { + await View.HideContextMenu(); + + await action.Execute(FileAccess, View, entry, ServiceProvider); + } + + #region Navigation & Refreshing + + private async Task OnNavigateUpClicked() + { + await FileAccess.ChangeDirectory(".."); + await View.Refresh(); + + await Refresh(); + } + + private async Task NavigateBackToLevel(int level) + { + if (ShowEditor) // Ignore navigation events while the editor is open + return; + + var path = await FileAccess.GetCurrentDirectory(); + + var parts = path.Split("/"); + var pathToNavigate = string.Join("/", parts.Take(level + 1)) + "/"; + + await FileAccess.SetDirectory(pathToNavigate); + await View.Refresh(); + await Refresh(); + } + + private async Task ManualRefresh() + { + if (ShowEditor) // Ignore refresh while editor is open + return; + + await View.Refresh(); + await Refresh(); + + await ToastService.Info("Refreshed"); + } + + private async Task Refresh() + { + Path = await FileAccess.GetCurrentDirectory(); + + await InvokeAsync(StateHasChanged); + } + + #endregion + + #region Actions + + private async Task DeleteSelection() + { + var itemsToDelete = View.Selection; + + await ToastService.CreateProgress("fileManagerDeleteFile", "Deleting items"); + + var i = 1; + foreach (var entry in itemsToDelete) + { + await ToastService.ModifyProgress("fileManagerDeleteFile", $"[{i}/{FilesToMove.Count}] Deleting items"); + await FileAccess.Delete(entry); + + i++; + } + + await ToastService.RemoveProgress("fileManagerDeleteFile"); + + await ToastService.Success($"Successfully deleted {FilesToMove.Count} items"); + } + + #endregion + + #region Create Dir / File + + private async Task CreateDirectory() + { + var name = await AlertService.Text("Enter a name for the new directory"); + + if (string.IsNullOrEmpty(name) || name.Contains("..")) + return; + + await FileAccess.CreateDirectory(name); + + await ToastService.Success("Successfully created directory"); + await View.Refresh(); + } + + private async Task CreateFile() + { + var name = await AlertService.Text("Enter a name for the new file"); + + if (string.IsNullOrEmpty(name) || name.Contains("..")) + return; + + await FileAccess.CreateFile(name); + + // We build a virtual entry here so we dont need to fetch one + await OpenEditor(new() + { + Name = name, + Size = 0, + IsFile = true, + IsDirectory = false, + LastModifiedAt = DateTime.UtcNow + }); + } + + #endregion + + #region File Editor + + private async Task OpenEditor(FileEntry entry) + { + FileToEdit = entry; + ShowEditor = true; + + await InvokeAsync(StateHasChanged); + } + + private async Task CloseEditor() + { + ShowEditor = false; + await InvokeAsync(StateHasChanged); + } + + #endregion + + #region Move + + private async Task Move(FileEntry entry) + { + await View.HideContextMenu(); + + FilesToMove.Clear(); + + FilesToMove.Add(entry); + + await StartMove(); + } + + private async Task MoveSelection() + { + FilesToMove.Clear(); + + FilesToMove.AddRange(View.Selection); + + await StartMove(); + } + + private async Task StartMove() + { + // Cleanup if modal was removed in any other way + if (InMoveState) + await HideMove(); + + // Prepare file access and show modal + InMoveState = true; + await InvokeAsync(StateHasChanged); + + MoveAccess = FileAccess.Clone(); + await MoveAccess.SetDirectory("/"); + + await MoveModal.Show(); + } + + private async Task HideMove() + { + await MoveModal.Hide(); + MoveAccess.Dispose(); + + InMoveState = false; + await InvokeAsync(StateHasChanged); + } + + private async Task FinishMove() + { + var target = await MoveAccess.GetCurrentDirectory(); + + await HideMove(); + + await ToastService.CreateProgress("fileManagerMoveFile", "Moving items"); + + var i = 1; + foreach (var entry in FilesToMove) + { + await ToastService.ModifyProgress("fileManagerMoveFile", $"[{i}/{FilesToMove.Count}] Moving items"); + await FileAccess.Move(entry, target + entry.Name); + + i++; + } + + await ToastService.RemoveProgress("fileManagerMoveFile"); + + await ToastService.Success($"Successfully moved {FilesToMove.Count} items"); + + await View.Refresh(); + } + + #endregion + + public async void Dispose() + { + if (UploadTokenTimer != null) + await UploadTokenTimer.DisposeAsync(); + + await FileAccessService.Unregister(FileAccess); + } +} \ No newline at end of file diff --git a/Moonlight/Features/FileManager/UI/NewFileManager/FileView.razor b/Moonlight/Features/FileManager/UI/NewFileManager/FileView.razor new file mode 100644 index 0000000..088ab16 --- /dev/null +++ b/Moonlight/Features/FileManager/UI/NewFileManager/FileView.razor @@ -0,0 +1,387 @@ +@using MoonCore.Helpers +@using Moonlight.Features.FileManager.Models.Abstractions.FileAccess +@using BlazorContextMenu + +@inject IJSRuntime JsRuntime + +
+ @if (IsLoading) + { +
+ Loading... +
+ } + + + + @if (ShowSelect) + { + + } + + + @if (ShowSize) + { + + } + @if (ShowDate) + { + + } + @if (EnableContextMenu) + { + + } + @if (AdditionTemplate != null) + { + + } + + + @if (Path != "/" && ShowNavigateUp) + { + + @if (ShowSelect) + { + + } + + + @if (ShowSize) + { + + } + @if (ShowDate) + { + + } + @if (EnableContextMenu) + { + + } + @if (AdditionTemplate != null) + { + + } + + } + + @foreach (var entry in Entries) + { + if (EnableContextMenu) + { + + @if (ShowSelect) + { + + } + + + @if (ShowSize) + { + + } + @if (ShowDate) + { + + } + + @if (AdditionTemplate != null) + { + @AdditionTemplate.Invoke(entry) + } + + } + else + { + + @if (ShowSelect) + { + + } + + + @if (ShowSize) + { + + } + @if (ShowDate) + { + + } + @if (AdditionTemplate != null) + { + @AdditionTemplate.Invoke(entry) + } + + } + } + +
+
+ @if (IsAllSelected) + { + + } + else + { + + } +
+
NameSizeLast modified
+ + + + Back to parent folder + +
+
+ @if (SelectionCache.ContainsKey(entry) && SelectionCache[entry]) + { + + } + else + { + + } +
+
+ @if (entry.IsFile) + { + + } + else + { + + } + + + @entry.Name + + + @if (entry.IsFile) + { + @Formatter.FormatSize(entry.Size) + } + + @Formatter.FormatDate(entry.LastModifiedAt) + +
+ + +
+
+
+ @if (SelectionCache.ContainsKey(entry) && SelectionCache[entry]) + { + + } + else + { + + } +
+
+ @if (entry.IsFile) + { + + } + else + { + + } + + + @entry.Name + + + @if (entry.IsFile) + { + @Formatter.FormatSize(entry.Size) + } + + @Formatter.FormatDate(entry.LastModifiedAt) +
+
+ +@if (EnableContextMenu && ContextMenuTemplate != null) +{ + + @if (ShowContextMenu) + { + + } + +} + +@code +{ + [Parameter] public RenderFragment? AdditionTemplate { get; set; } + + [Parameter] public bool ShowSize { get; set; } = true; + [Parameter] public bool ShowDate { get; set; } = true; + [Parameter] public bool ShowSelect { get; set; } = true; + [Parameter] public bool ShowNavigateUp { get; set; } = true; + + [Parameter] public RenderFragment? ContextMenuTemplate { get; set; } + [Parameter] public bool EnableContextMenu { get; set; } = false; + private bool ShowContextMenu = false; + private FileEntry ContextMenuItem; + private string ContextMenuId = "fileManagerContextMenu"; + private ContextMenu? CurrentContextMenu; + + [Parameter] public BaseFileAccess FileAccess { get; set; } + [Parameter] public Func? Filter { get; set; } + + [Parameter] public Func? OnEntryClicked { get; set; } + [Parameter] public Func? OnSelectionChanged { get; set; } + + [Parameter] public Func? OnNavigateUpClicked { get; set; } + + private bool IsLoading = false; + private string LoadingText = ""; + + private FileEntry[] Entries = Array.Empty(); + private string Path = "/"; + + private Dictionary SelectionCache = new(); + public FileEntry[] Selection => SelectionCache.Where(x => x.Value).Select(x => x.Key).ToArray(); + private bool IsAllSelected => Entries.Length != 0 && SelectionCache.Count(x => x.Value) == Entries.Length; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + await Refresh(); + } + + public async Task Refresh() + { + IsLoading = true; + LoadingText = "Loading"; + await InvokeAsync(StateHasChanged); + + // Load current directory + Path = await FileAccess.GetCurrentDirectory(); + + // Load entries + LoadingText = "Loading files and folders"; + await InvokeAsync(StateHasChanged); + + Entries = await FileAccess.List(); + + // Sort entries + LoadingText = "Sorting files and folders"; + await InvokeAsync(StateHasChanged); + + if (Filter != null) + { + Entries = Entries + .Where(x => Filter.Invoke(x)) + .ToArray(); + } + + Entries = Entries + .GroupBy(x => x.IsFile) + .OrderBy(x => x.Key) + .SelectMany(x => x.OrderBy(y => y.Name)) + .ToArray(); + + // Build selection cache + SelectionCache.Clear(); + + foreach (var entry in Entries) + SelectionCache.Add(entry, false); + + if (OnSelectionChanged != null) + await OnSelectionChanged.Invoke(Array.Empty()); + + IsLoading = false; + await InvokeAsync(StateHasChanged); + } + + private async Task HandleEntryClick(FileEntry entry) + { + if (OnEntryClicked == null) + return; + + await OnEntryClicked.Invoke(entry); + } + + private async Task NavigateUp() + { + if (OnNavigateUpClicked == null) + return; + + await OnNavigateUpClicked.Invoke(); + } + + #region Selection + + private async Task ChangeSelection(FileEntry entry, bool selectionState) + { + SelectionCache[entry] = selectionState; + await InvokeAsync(StateHasChanged); + + if (OnSelectionChanged != null) + { + await OnSelectionChanged.Invoke(SelectionCache + .Where(x => x.Value) + .Select(x => x.Key) + .ToArray() + ); + } + } + + private async Task ChangeAllSelection(bool toggle) + { + foreach (var key in SelectionCache.Keys) + SelectionCache[key] = toggle; + + await InvokeAsync(StateHasChanged); + + if (OnSelectionChanged != null) + { + await OnSelectionChanged.Invoke(SelectionCache + .Where(x => x.Value) + .Select(x => x.Key) + .ToArray() + ); + } + } + + #endregion + + #region Context Menu + + private async Task OnContextMenuAppear(MenuAppearingEventArgs data) + { + ContextMenuItem = (data.Data as FileEntry)!; + + ShowContextMenu = true; + await InvokeAsync(StateHasChanged); + } + + private async Task OnContextMenuHide() + { + ShowContextMenu = false; + await InvokeAsync(StateHasChanged); + } + + public async Task HideContextMenu() + { + ShowContextMenu = false; + await InvokeAsync(StateHasChanged); + } + + #endregion + +} \ No newline at end of file diff --git a/Moonlight/Features/Servers/Services/ServerService.cs b/Moonlight/Features/Servers/Services/ServerService.cs index b1400e6..04752a2 100644 --- a/Moonlight/Features/Servers/Services/ServerService.cs +++ b/Moonlight/Features/Servers/Services/ServerService.cs @@ -208,7 +208,7 @@ public class ServerService await Backup.Delete(serverWithBackups, backup, false); } - public async Task OpenFileAccess(Server s) + public async Task OpenFileAccess(Server s) { using var scope = ServiceProvider.CreateScope(); diff --git a/Moonlight/Features/Servers/UI/UserViews/Files.razor b/Moonlight/Features/Servers/UI/UserViews/Files.razor index 942a21a..6f05f72 100644 --- a/Moonlight/Features/Servers/UI/UserViews/Files.razor +++ b/Moonlight/Features/Servers/UI/UserViews/Files.razor @@ -19,7 +19,7 @@ { [CascadingParameter] public Server Server { get; set; } - private IFileAccess FileAccess; + private BaseFileAccess FileAccess; private async Task Load(LazyLoader lazyLoader) { diff --git a/Moonlight/Features/Servers/UI/UserViews/Reset.razor b/Moonlight/Features/Servers/UI/UserViews/Reset.razor index 63888ef..740e760 100644 --- a/Moonlight/Features/Servers/UI/UserViews/Reset.razor +++ b/Moonlight/Features/Servers/UI/UserViews/Reset.razor @@ -101,7 +101,7 @@ i++; await ToastService.ModifyProgress("serverReset", $"Reset: Deleting files [{i} / {files.Length}]"); - await fileAccess.Delete(fileEntry.Name); + await fileAccess.Delete(fileEntry); } await ToastService.ModifyProgress("serverReset", "Reset: Starting install script"); diff --git a/Moonlight/Features/Servers/UI/Views/Test.razor b/Moonlight/Features/Servers/UI/Views/Test.razor new file mode 100644 index 0000000..1c4746b --- /dev/null +++ b/Moonlight/Features/Servers/UI/Views/Test.razor @@ -0,0 +1,12 @@ +@page "/test" + +@using Moonlight.Features.FileManager.UI.NewFileManager +@using Moonlight.Features.FileManager.Models.Abstractions.FileAccess +@using Moonlight.Core.Helpers + + + +@code +{ + private BaseFileAccess FileAccess = new(new HostFileActions("storage")); +} diff --git a/Moonlight/Moonlight.csproj b/Moonlight/Moonlight.csproj index 4ca87ec..3cba371 100644 --- a/Moonlight/Moonlight.csproj +++ b/Moonlight/Moonlight.csproj @@ -74,7 +74,7 @@ - + @@ -83,6 +83,7 @@ +