Merge pull request #374 from Moonlight-Panel/v2_FinalizeFileManager

Finalized file manager
This commit is contained in:
Masu Baumgartner 2024-04-01 20:32:42 +02:00 committed by GitHub
commit feddea1a69
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 1805 additions and 907 deletions

View file

@ -15,3 +15,15 @@ tr:hover .table-row-hover-content {
-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);
}

View file

@ -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;
}
}

View file

@ -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();

View file

@ -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);
}
};

View file

@ -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;
@ -39,13 +40,21 @@ public class HostFileActions : IFileActions
return Task.FromResult(entries.ToArray());
}
public Task Delete(string path)
public Task DeleteFile(string path)
{
var fullPath = GetFullPath(path);
if (File.Exists(fullPath))
File.Delete(fullPath);
else if (Directory.Exists(fullPath))
return Task.CompletedTask;
}
public Task DeleteDirectory(string path)
{
var fullPath = GetFullPath(path);
if (Directory.Exists(fullPath))
Directory.Delete(fullPath, true);
return Task.CompletedTask;
@ -64,42 +73,47 @@ public class HostFileActions : IFileActions
return Task.CompletedTask;
}
public Task CreateDirectory(string name)
public Task CreateDirectory(string path)
{
var fullPath = GetFullPath(name);
var fullPath = GetFullPath(path);
Directory.CreateDirectory(fullPath);
return Task.CompletedTask;
}
public Task CreateFile(string name)
public Task CreateFile(string path)
{
var fullPath = GetFullPath(name);
var fullPath = GetFullPath(path);
File.Create(fullPath).Close();
return Task.CompletedTask;
}
public Task<string> ReadFile(string name)
public Task<string> ReadFile(string path)
{
var fullPath = GetFullPath(name);
var fullPath = GetFullPath(path);
return File.ReadAllTextAsync(fullPath);
}
public Task WriteFile(string name, string content)
public Task WriteFile(string path, string content)
{
var fullPath = GetFullPath(name);
var fullPath = GetFullPath(path);
EnsureDir(fullPath);
File.WriteAllText(fullPath, content);
return Task.CompletedTask;
}
public Task<Stream> ReadFileStream(string name)
public Task<Stream> ReadFileStream(string path)
{
var fullPath = GetFullPath(name);
var fullPath = GetFullPath(path);
return Task.FromResult<Stream>(File.OpenRead(fullPath));
}
public Task WriteFileStream(string name, Stream dataStream)
public Task WriteFileStream(string path, Stream dataStream)
{
var fullPath = GetFullPath(name);
var fullPath = GetFullPath(path);
EnsureDir(fullPath);
using (var fileStream = File.Create(fullPath))
dataStream.CopyTo(fileStream);
@ -107,6 +121,12 @@ public class HostFileActions : IFileActions
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);

View file

@ -127,7 +127,7 @@ public class PluginService
{
try
{
var assembly = Assembly.LoadFile(dllFile);
var assembly = Assembly.LoadFile(Path.GetFullPath(dllFile));
var pluginTypes = assembly
.GetTypes()

View file

@ -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,46 @@ public class FileManagerFeature : MoonlightFeature
context.Builder.Services.AddSingleton(new JwtService<FileManagerJwtType>(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<PluginService>();
await pluginService.RegisterImplementation<IFileManagerContextAction>(new RenameContextAction());
await pluginService.RegisterImplementation<IFileManagerContextAction>(new MoveContextAction());
await pluginService.RegisterImplementation<IFileManagerContextAction>(new DownloadContextAction());
await pluginService.RegisterImplementation<IFileManagerContextAction>(new DeleteContextAction());
await pluginService.RegisterImplementation<IFileManagerSelectionAction>(new MoveSelectionAction());
await pluginService.RegisterImplementation<IFileManagerSelectionAction>(new DeleteSelectionAction());
await pluginService.RegisterImplementation<IFileManagerCreateAction>(new CreateFileAction());
await pluginService.RegisterImplementation<IFileManagerCreateAction>(new CreateFolderAction());
}
public override async Task OnSessionInitialized(SessionInitContext context)
{
// Register hotkeys
var hotKeyService = context.ServiceProvider.GetRequiredService<HotKeyService>();
await hotKeyService.RegisterHotkey("KeyS", "ctrl", "save");

View file

@ -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();

View file

@ -0,0 +1,34 @@
using MoonCoreUI.Services;
using Moonlight.Features.FileManager.Interfaces;
using Moonlight.Features.FileManager.Models.Abstractions.FileAccess;
namespace Moonlight.Features.FileManager.Implementations;
public class CreateFileAction : IFileManagerCreateAction
{
public string Name => "File";
public string Icon => "bx-file";
public string Color => "primary";
public async Task Execute(BaseFileAccess access, UI.Components.FileManager fileManager, IServiceProvider provider)
{
var alertService = provider.GetRequiredService<AlertService>();
var name = await alertService.Text("Enter a name for the new file");
if (string.IsNullOrEmpty(name) || name.Contains(".."))
return;
await access.CreateFile(name);
// We build a virtual entry here so we dont need to fetch one
await fileManager.OpenEditor(new()
{
Name = name,
Size = 0,
IsFile = true,
IsDirectory = false,
LastModifiedAt = DateTime.UtcNow
});
}
}

View file

@ -0,0 +1,29 @@
using MoonCoreUI.Services;
using Moonlight.Features.FileManager.Interfaces;
using Moonlight.Features.FileManager.Models.Abstractions.FileAccess;
using Moonlight.Features.FileManager.UI.Components;
namespace Moonlight.Features.FileManager.Implementations;
public class CreateFolderAction : IFileManagerCreateAction
{
public string Name => "Folder";
public string Icon => "bx-folder";
public string Color => "primary";
public async Task Execute(BaseFileAccess access, UI.Components.FileManager fileManager, IServiceProvider provider)
{
var alertService = provider.GetRequiredService<AlertService>();
var toastService = provider.GetRequiredService<ToastService>();
var name = await alertService.Text("Enter a name for the new directory");
if (string.IsNullOrEmpty(name) || name.Contains(".."))
return;
await access.CreateDirectory(name);
await toastService.Success("Successfully created directory");
await fileManager.View.Refresh();
}
}

View file

@ -0,0 +1,24 @@
using MoonCoreUI.Services;
using Moonlight.Features.FileManager.Interfaces;
using Moonlight.Features.FileManager.Models.Abstractions.FileAccess;
using Moonlight.Features.FileManager.UI.Components;
namespace Moonlight.Features.FileManager.Implementations;
public class DeleteContextAction : IFileManagerContextAction
{
public string Name => "Delete";
public string Icon => "bxs-trash";
public string Color => "danger";
public Func<FileEntry, bool> Filter => _ => true;
public async Task Execute(BaseFileAccess access, UI.Components.FileManager fileManager, FileEntry entry, IServiceProvider serviceProvider)
{
await access.Delete(entry);
await fileManager.View.Refresh();
var toastService = serviceProvider.GetRequiredService<ToastService>();
await toastService.Success("Successfully deleted item");
}
}

View file

@ -0,0 +1,35 @@
using MoonCoreUI.Services;
using Moonlight.Features.FileManager.Interfaces;
using Moonlight.Features.FileManager.Models.Abstractions.FileAccess;
using Moonlight.Features.FileManager.UI.Components;
namespace Moonlight.Features.FileManager.Implementations;
public class DeleteSelectionAction : IFileManagerSelectionAction
{
public string Name => "Delete";
public string Color => "danger";
public async Task Execute(BaseFileAccess access, UI.Components.FileManager fileManager, FileEntry[] entries, IServiceProvider provider)
{
var alertService = provider.GetRequiredService<AlertService>();
var toastService = provider.GetRequiredService<ToastService>();
if(!await alertService.YesNo($"Do you really want to delete {entries.Length} item(s)?"))
return;
await toastService.CreateProgress("fileManagerSelectionDelete", "Deleting items");
foreach (var entry in entries)
{
await toastService.ModifyProgress("fileManagerSelectionDelete", $"Deleting '{entry.Name}'");
await access.Delete(entry);
}
await toastService.RemoveProgress("fileManagerSelectionDelete");
await toastService.Success("Successfully deleted selection");
await fileManager.View.Refresh();
}
}

View file

@ -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.Components;
namespace Moonlight.Features.FileManager.Implementations;
public class DownloadContextAction : IFileManagerContextAction
{
public string Name => "Download";
public string Icon => "bxs-cloud-download";
public string Color => "primary";
public Func<FileEntry, bool> Filter => entry => entry.IsFile;
public async Task Execute(BaseFileAccess access, UI.Components.FileManager fileManager, FileEntry entry, IServiceProvider serviceProvider)
{
var fileAccessService = serviceProvider.GetRequiredService<SharedFileAccessService>();
var navigation = serviceProvider.GetRequiredService<NavigationManager>();
var toastService = serviceProvider.GetRequiredService<ToastService>();
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");
}
}
}

View file

@ -0,0 +1,26 @@
using MoonCoreUI.Services;
using Moonlight.Features.FileManager.Interfaces;
using Moonlight.Features.FileManager.Models.Abstractions.FileAccess;
namespace Moonlight.Features.FileManager.Implementations;
public class MoveContextAction : IFileManagerContextAction
{
public string Name => "Move";
public string Icon => "bx-move";
public string Color => "info";
public Func<FileEntry, bool> Filter => _ => true;
public async Task Execute(BaseFileAccess access, UI.Components.FileManager fileManager, FileEntry entry, IServiceProvider provider)
{
await fileManager.OpenFolderSelect("Select the location to move the item to", async path =>
{
var toastService = provider.GetRequiredService<ToastService>();
await access.Move(entry, path + entry.Name);
await toastService.Success("Successfully moved item");
await fileManager.View.Refresh();
});
}
}

View file

@ -0,0 +1,33 @@
using MoonCoreUI.Services;
using Moonlight.Features.FileManager.Interfaces;
using Moonlight.Features.FileManager.Models.Abstractions.FileAccess;
namespace Moonlight.Features.FileManager.Implementations;
public class MoveSelectionAction : IFileManagerSelectionAction
{
public string Name => "Move";
public string Color => "primary";
public async Task Execute(BaseFileAccess access, UI.Components.FileManager fileManager, FileEntry[] entries, IServiceProvider provider)
{
await fileManager.OpenFolderSelect("Select the location to move the items to", async path =>
{
var toastService = provider.GetRequiredService<ToastService>();
await toastService.CreateProgress("fileManagerSelectionMove", "Moving items");
foreach (var entry in entries)
{
await toastService.ModifyProgress("fileManagerSelectionMove", $"Moving '{entry.Name}'");
await access.Move(entry, path + entry.Name);
}
await toastService.RemoveProgress("fileManagerSelectionMove");
await toastService.Success("Successfully moved selection");
await fileManager.View.Refresh();
});
}
}

View file

@ -0,0 +1,30 @@
using MoonCoreUI.Services;
using Moonlight.Features.FileManager.Interfaces;
using Moonlight.Features.FileManager.Models.Abstractions.FileAccess;
using Moonlight.Features.FileManager.UI.Components;
namespace Moonlight.Features.FileManager.Implementations;
public class RenameContextAction : IFileManagerContextAction
{
public string Name => "Rename";
public string Icon => "bxs-rename";
public string Color => "info";
public Func<FileEntry, bool> Filter => _ => true;
public async Task Execute(BaseFileAccess access, UI.Components.FileManager fileManager, FileEntry entry, IServiceProvider provider)
{
var alertService = provider.GetRequiredService<AlertService>();
var toastService = provider.GetRequiredService<ToastService>();
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 fileManager.View.Refresh();
await toastService.Success("Successfully renamed file");
}
}

View file

@ -0,0 +1,14 @@
using Moonlight.Features.FileManager.Models.Abstractions.FileAccess;
using Moonlight.Features.FileManager.UI.Components;
namespace Moonlight.Features.FileManager.Interfaces;
public interface IFileManagerContextAction
{
public string Name { get; }
public string Icon { get; }
public string Color { get; }
public Func<FileEntry, bool> Filter { get; }
public Task Execute(BaseFileAccess access, UI.Components.FileManager fileManager, FileEntry entry, IServiceProvider provider);
}

View file

@ -0,0 +1,13 @@
using Moonlight.Features.FileManager.Models.Abstractions.FileAccess;
using Moonlight.Features.FileManager.UI.Components;
namespace Moonlight.Features.FileManager.Interfaces;
public interface IFileManagerCreateAction
{
public string Name { get; }
public string Icon { get; }
public string Color { get; }
public Task Execute(BaseFileAccess access, UI.Components.FileManager fileManager, IServiceProvider provider);
}

View file

@ -0,0 +1,12 @@
using Moonlight.Features.FileManager.Models.Abstractions.FileAccess;
using Moonlight.Features.FileManager.UI.Components;
namespace Moonlight.Features.FileManager.Interfaces;
public interface IFileManagerSelectionAction
{
public string Name { get; }
public string Color { get; }
public Task Execute(BaseFileAccess access, UI.Components.FileManager fileManager, FileEntry[] entries, IServiceProvider provider);
}

View file

@ -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,31 @@ 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);
if(entry.IsFile)
await Actions.DeleteFile(finalPath);
else
await Actions.DeleteDirectory(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 +113,7 @@ public class BaseFileAccess : IFileAccess
await Actions.WriteFileStream(finalPath, dataStream);
}
public IFileAccess Clone()
public BaseFileAccess Clone()
{
return new BaseFileAccess(Actions.Clone())
{

View file

@ -3,13 +3,14 @@
public interface IFileActions : IDisposable
{
public Task<FileEntry[]> List(string path);
public Task Delete(string path);
public Task DeleteFile(string path);
public Task DeleteDirectory(string path);
public Task Move(string from, string to);
public Task CreateDirectory(string name);
public Task CreateFile(string name);
public Task<string> ReadFile(string name);
public Task WriteFile(string name, string content);
public Task<Stream> ReadFileStream(string name);
public Task WriteFileStream(string name, Stream dataStream);
public Task CreateDirectory(string path);
public Task CreateFile(string path);
public Task<string> ReadFile(string path);
public Task WriteFile(string path, string content);
public Task<Stream> ReadFileStream(string path);
public Task WriteFileStream(string path, Stream dataStream);
public IFileActions Clone();
}

View file

@ -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();
}
}

View file

@ -10,14 +10,14 @@ namespace Moonlight.Features.FileManager.Services;
public class SharedFileAccessService
{
private readonly JwtService<FileManagerJwtType> JwtService;
private readonly List<IFileAccess> FileAccesses = new();
private readonly List<BaseFileAccess> FileAccesses = new();
public SharedFileAccessService(JwtService<FileManagerJwtType> jwtService)
{
JwtService = jwtService;
}
public Task<int> Register(IFileAccess fileAccess)
public Task<int> 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<IFileAccess?> Get(int id)
public Task<BaseFileAccess?> Get(int id)
{
lock (FileAccesses)
{
var fileAccess = FileAccesses.FirstOrDefault(x => x.GetHashCode() == id);
if (fileAccess == null)
return Task.FromResult<IFileAccess?>(null);
return Task.FromResult<BaseFileAccess?>(null);
return Task.FromResult<IFileAccess?>(fileAccess.Clone());
return Task.FromResult<BaseFileAccess?>(fileAccess.Clone());
}
}
public async Task<string> GenerateToken(IFileAccess fileAccess)
public async Task<string> GenerateToken(BaseFileAccess fileAccess)
{
var token = await JwtService.Create(data =>
{

View file

@ -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;

View file

@ -1,282 +1,363 @@
@using Moonlight.Core.Configuration
@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 ConfigService<CoreConfiguration> ConfigService
@inject ToastService ToastService
@inject FileManagerInteropService FileManagerInteropService
@inject SharedFileAccessService FileAccessService
@inject ConfigService<CoreConfiguration> ConfigService
@inject PluginService PluginService
@inject IServiceProvider ServiceProvider
<div class="card">
<div class="card-header">
<div class="card-title">
<div class="badge badge-primary badge-lg fs-5 py-2">
@implements IDisposable
<div class="card card-body px-5">
<div class="d-flex justify-content-center justify-content-md-between">
<div class="d-none d-md-flex justify-content-start align-items-center">
<div class="badge badge-primary badge-lg fs-5 py-2 text-center">
@{
var elements = Path
var parts = Path
.Split("/")
.Where(x => !string.IsNullOrEmpty(x))
.ToList();
.ToArray();
int i = 1;
var root = "/";
var i = 1;
}
<a href="#" @onclick:preventDefault @onclick="() => NavigateToPath(root)" class="invisible-a mx-2 text-white">/</a>
@foreach (var element in elements)
{
var pathToCd = "/" + string.Join('/', elements.Take(i));
<a href="#" class="text-white mx-1" @onclick:preventDefault @onclick="() => NavigateBackToLevel(0)">/</a>
<a href="#" @onclick:preventDefault @onclick="() => NavigateToPath(pathToCd)" class="invisible-a text-white">@(element)</a>
@foreach (var part in parts)
{
var x = i + 0;
<a href="#" class="text-white mx-1" @onclick:preventDefault @onclick="() => NavigateBackToLevel(x)">@(part)</a>
<div class="mx-2 text-white">/</div>
i++;
}
</div>
</div>
<div class="card-toolbar">
@if (ShowFileUploader)
<div class="d-flex justify-content-center justify-content-md-end align-items-center">
@if (View != null && View.Selection.Any())
{
<button type="button" @onclick="ToggleFileUploader" class="btn btn-light-primary me-3">
Back
</button>
foreach (var action in SelectionActions)
{
var cssClass = $"btn btn-{action.Color} mx-2";
<WButton Text="@action.Name" CssClasses="@cssClass" OnClick="() => InvokeSelectionAction(action)"/>
}
}
else
{
<a href="javascript:void(0)" class="btn btn-secondary me-3">
<i class="bx bx-sm bx-link-external me-2"></i>
Launch
</a>
<button type="button" @onclick="ToggleFileUploader" class="btn btn-light-primary me-3">
<i class="bx bx-sm bx-upload me-2"></i>
Upload
</button>
<WButton OnClick="ManualRefresh" CssClasses="btn btn-icon btn-light-info">
<i class="bx bx-sm bx-refresh"></i>
</WButton>
<label for="fileManagerSelect" class="btn btn-light-primary mx-2">Upload</label>
<input id="fileManagerSelect" type="file" hidden="hidden" multiple/>
<div class="dropdown">
<a class="btn btn-primary dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
New
</a>
<ul class="dropdown-menu" aria-labelledby="dropdownMenuLink">
<li>
<a href="#" @onclick:preventDefault @onclick="CreateFile" class="dropdown-item">
<i class="bx bx-sm bx-file text-primary me-2 align-middle"></i>
<span class="align-middle fs-6">File</span>
</a>
</li>
<li>
<a href="#" @onclick:preventDefault @onclick="CreateDirectory" class="dropdown-item">
<i class="bx bx-sm bx-folder text-primary me-2 align-middle"></i>
<span class="align-middle fs-6">Folder</span>
</a>
</li>
@foreach (var action in CreateActions)
{
<li>
<a href="#" class="dropdown-item" @onclick:preventDefault @onclick="() => InvokeCreateAction(action)">
<i class="bx bx-sm @action.Icon text-@action.Color me-2 align-middle"></i>
<span class="align-middle fs-6">@action.Name</span>
</a>
</li>
}
</ul>
</div>
}
</div>
</div>
<div class="card-body" @ondragenter="() => ToggleFileUploader(true)">
@if (ShowFileUploader)
{
<FileUploader @ref="FileUploader" FileAccess="FileAccess"/>
}
else if (ShowFileEditor)
{
<FileEditor File="EditorOpenFile" FileAccess="FileAccess" OnClosed="OnEditorClosed"/>
}
else
{
<FileView @ref="FileView"
FileAccess="FileAccess"
OnPathChanged="OnPathChanged"
OnFileClicked="OnFileClicked"
OnMoveRequested="StartMove"/>
}
</div>
</div>
<SmartModal @ref="MoveModal" CssClasses="modal-dialog-centered">
<div class="modal-header">
<h5 class="modal-title">Select the location to move '@(MoveEntry.Name)'</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
@if (ShowEditor)
{
<div class="card card-body px-2 py-2 mt-5">
<FileEditor @ref="Editor" FileAccess="FileAccess" File="FileToEdit" OnClosed="CloseEditor"/>
</div>
<div class="modal-body" style="overflow-y: scroll; max-height: 80vh">
<FileView
FileAccess="MoveAccess"
ShowActions="false"
ShowHeader="false"
ShowSelect="false"
ShowSize="false"
ShowLastModified="false"/>
}
else
{
<div id="fileManagerUpload" class="card card-body px-5 py-3 mt-5">
<FileView @ref="View"
FileAccess="FileAccess"
OnEntryClicked="OnEntryClicked"
OnNavigateUpClicked="OnNavigateUpClicked"
OnSelectionChanged="OnSelectionChanged"
EnableContextMenu="true">
<ContextMenuTemplate>
@foreach (var action in ContextActions)
{
if (!action.Filter.Invoke(context))
continue;
<a class="dropdown-item" href="#" @onclick:preventDefault @onclick="() => InvokeContextAction(action, context)">
<i class="bx bx-sm @action.Icon text-@action.Color align-middle"></i>
<span class="align-middle ms-3">@action.Name</span>
</a>
}
</ContextMenuTemplate>
</FileView>
</div>
<div class="modal-footer p-3">
<div class="btn-group w-100">
<WButton OnClick="FinishMove" Text="Move" CssClasses="btn btn-primary w-50 me-3"/>
<button class="btn btn-secondary w-50" data-bs-dismiss="modal">Cancel</button>
<SmartModal @ref="FolderSelectModal" CssClasses="modal-lg modal-dialog-centered">
<div class="modal-header">
<h5 class="modal-title">@FolderSelectTitle</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" @onclick="HideFolderSelect"></button>
</div>
</div>
</SmartModal>
<div class="modal-body">
<FileView @ref="FolderSelectView"
FileAccess="FolderSelectFileAccess"
Filter="FolderSelectFilter"
ShowDate="false"
ShowSelect="false"
ShowSize="false"
OnEntryClicked="EntryClickFolderSelect"
OnNavigateUpClicked="NavigateUpFolderSelect"
EnableContextMenu="false"/>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" @onclick="HideFolderSelect">Cancel</button>
<button type="button" class="btn btn-primary" @onclick="SubmitFolderSelect">Submit</button>
</div>
</SmartModal>
}
@code
{
[Parameter] public IFileAccess FileAccess { get; set; }
[Parameter] public BaseFileAccess FileAccess { get; set; }
// Navigation
public FileView View { get; private set; }
private string Path = "/";
private FileView? FileView;
// Uploading
private bool ShowFileUploader = false;
private FileUploader? FileUploader;
private IFileManagerContextAction[] ContextActions;
private IFileManagerSelectionAction[] SelectionActions;
private IFileManagerCreateAction[] CreateActions;
// Editing
private bool ShowFileEditor = false;
private FileEntry EditorOpenFile;
// Editor
private FileEditor Editor;
private FileEntry FileToEdit;
private bool ShowEditor = false;
// Move
private FileEntry MoveEntry;
private SmartModal MoveModal;
private IFileAccess MoveAccess;
// Folder select dialog
private bool FolderSelectIsOpen = false;
private SmartModal FolderSelectModal;
private BaseFileAccess FolderSelectFileAccess;
private string FolderSelectTitle;
private Func<string, Task> FolderSelectResult;
private FileView FolderSelectView;
private Func<FileEntry, bool> FolderSelectFilter => entry => entry.IsDirectory;
private async Task OnPathChanged(string path)
private Timer? UploadTokenTimer;
protected override async Task OnInitializedAsync()
{
Path = path;
await InvokeAsync(StateHasChanged);
// Load plugin ui and options
ContextActions = await PluginService.GetImplementations<IFileManagerContextAction>();
SelectionActions = await PluginService.GetImplementations<IFileManagerSelectionAction>();
CreateActions = await PluginService.GetImplementations<IFileManagerCreateAction>();
}
private async Task NavigateToPath(string path)
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (ShowFileUploader)
await ToggleFileUploader(false);
if (FileView == null)
if (!firstRender)
return;
await FileView.NavigateToPath(path);
}
#region Uploader
private async Task ToggleFileUploader() => await ToggleFileUploader(!ShowFileUploader);
private async Task ToggleFileUploader(bool b)
{
ShowFileUploader = b;
await InvokeAsync(StateHasChanged);
}
#endregion
#region mkdir / touch
private async Task CreateFile()
{
if (FileView == null)
return;
var name = await AlertService.Text("Enter the filename", "");
if(string.IsNullOrEmpty(name))
return;
if (name.Contains(".."))
// Setup upload url update timer
UploadTokenTimer = new(async _ =>
{
Logger.Warn($"Someone tried to use path transversal to create a file: '{name}'", "security");
return;
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);
}
await FileAccess.CreateFile(name);
await FileView.Refresh();
// Open editor to start editing
await OpenEditor(new FileEntry()
else
{
Size = 0,
Name = name,
IsFile = true,
IsDirectory = false,
LastModifiedAt = DateTime.UtcNow
});
}
await FileAccess.ChangeDirectory(entry.Name);
await View.Refresh();
private async Task CreateDirectory()
{
if (FileView == null)
return;
var name = await AlertService.Text("Enter the folder name", "");
if(string.IsNullOrEmpty(name))
return;
if (name.Contains(".."))
{
Logger.Warn($"Someone tried to use path transversal to create a file: '{name}'", "security");
return;
await Refresh();
}
await FileAccess.CreateDirectory(name);
await FileView.Refresh();
}
#endregion
#region Editor
private async Task OnFileClicked(FileEntry fileEntry) => await OpenEditor(fileEntry);
private async Task OpenEditor(FileEntry fileEntry)
private async Task InvokeContextAction(IFileManagerContextAction contextAction, FileEntry entry)
{
var fileSizeInKilobytes = ByteSizeValue.FromBytes(fileEntry.Size).KiloBytes;
await View.HideContextMenu();
if (fileSizeInKilobytes > ConfigService.Get().Customisation.FileManager.MaxFileOpenSize)
{
await ToastService.Danger("Unable to open file as it exceeds the max file size limit");
await contextAction.Execute(FileAccess, this, entry, ServiceProvider);
}
private async Task InvokeSelectionAction(IFileManagerSelectionAction action)
{
await action.Execute(FileAccess, this, View.Selection, ServiceProvider);
// Refresh resets the selection
await View.Refresh();
}
private async Task InvokeCreateAction(IFileManagerCreateAction action)
{
await action.Execute(FileAccess, this, ServiceProvider);
}
private async Task OnSelectionChanged(FileEntry[] _) => await InvokeAsync(StateHasChanged);
#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;
}
EditorOpenFile = fileEntry;
var path = await FileAccess.GetCurrentDirectory();
// Prepare editor
ShowFileEditor = true;
await InvokeAsync(StateHasChanged);
var parts = path.Split("/");
var pathToNavigate = string.Join("/", parts.Take(level + 1)) + "/";
await FileAccess.SetDirectory(pathToNavigate);
await View.Refresh();
await Refresh();
}
private async Task OnEditorClosed()
private async Task ManualRefresh()
{
ShowFileEditor = false;
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 Move
#region File Editor
private async Task StartMove(FileEntry fileEntry)
public async Task OpenEditor(FileEntry entry)
{
MoveEntry = fileEntry;
MoveAccess = FileAccess.Clone();
FileToEdit = entry;
ShowEditor = true;
await MoveAccess.SetDirectory("/");
await MoveModal.Show();
await InvokeAsync(StateHasChanged);
}
private async Task FinishMove()
public async Task CloseEditor()
{
var pathToMove = await MoveAccess.GetCurrentDirectory();
MoveAccess.Dispose();
// Perform move and process ui updates
await FileAccess.Move(MoveEntry.Name, pathToMove + MoveEntry.Name);
await MoveModal.Hide();
if (FileView == null)
return;
await FileView.Refresh();
ShowEditor = false;
await InvokeAsync(StateHasChanged);
}
#endregion
#region Selects
public async Task OpenFolderSelect(string title, Func<string, Task> onResult)
{
if (FolderSelectIsOpen)
await HideFolderSelect();
FolderSelectResult = onResult;
FolderSelectTitle = title;
FolderSelectFileAccess = FileAccess.Clone();
await FolderSelectFileAccess.SetDirectory("/");
await FolderSelectModal.Show();
}
public async Task HideFolderSelect()
{
await FolderSelectModal.Hide();
FolderSelectIsOpen = false;
FolderSelectFileAccess.Dispose();
}
private async Task SubmitFolderSelect()
{
var path = await FolderSelectFileAccess.GetCurrentDirectory();
await HideFolderSelect();
await FolderSelectResult.Invoke(path);
}
private async Task NavigateUpFolderSelect()
{
await FolderSelectFileAccess.ChangeDirectory("..");
await FolderSelectView.Refresh();
}
private async Task EntryClickFolderSelect(FileEntry entry)
{
await FolderSelectFileAccess.ChangeDirectory(entry.Name);
await FolderSelectView.Refresh();
}
#endregion
public async void Dispose()
{
if (UploadTokenTimer != null)
await UploadTokenTimer.DisposeAsync();
await FileAccessService.Unregister(FileAccess);
}
}

View file

@ -1,94 +0,0 @@
@using Moonlight.Core.Services
@using Moonlight.Features.FileManager.Models.Abstractions.FileAccess
@using Moonlight.Features.FileManager.Services
@inject DropzoneService DropzoneService
@inject SharedFileAccessService SharedFileAccessService
@implements IDisposable
<div class="d-flex justify-content-center">
<div class="col-md-8" @ondrop:preventDefault>
<div class="dropzone dropzone-queue" id="@DropzoneId">
<div class="dropzone-panel mb-lg-0 mb-2">
<div class="card border border-1 border-primary bg-secondary" style="pointer-events: none">
<div class="card-body">
<IconAlert Title="" Color="primary" Icon="bx-cloud-upload">
<div class="text-center fs-1 fw-bold">
Drag a file or folder or <a class="dropzone-select" style="pointer-events: all">click to upload files</a>
</div>
</IconAlert>
</div>
</div>
</div>
<div class="dropzone-items wm-200px">
<div class="dropzone-item" style="display:none">
<div class="dropzone-file">
<div class="dropzone-filename" title="some_image_file_name.jpg">
<span data-dz-name>some_image_file_name.jpg</span>
<strong>(<span data-dz-size>340kb</span>)</strong>
</div>
<div class="dropzone-error" data-dz-errormessage></div>
</div>
<div class="dropzone-progress">
<div class="progress">
<div
class="progress-bar bg-primary"
role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" data-dz-uploadprogress>
</div>
</div>
</div>
<div class="dropzone-toolbar">
<span class="dropzone-delete" data-dz-remove>
<i class="bx bx-x fs-1"></i>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
@code
{
[Parameter] public IFileAccess FileAccess { get; set; }
private CancellationTokenSource Cancellation = new();
private string DropzoneId;
protected override void OnInitialized()
{
DropzoneId = $"dropzone{GetHashCode()}";
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await SharedFileAccessService.Register(FileAccess);
var token = await SharedFileAccessService.GenerateToken(FileAccess);
var url = $"/api/upload?token={token}";
await DropzoneService.Create(DropzoneId, url);
Task.Run(async () => // Update the dropzone url every 5 minutes so the token does not expire
{
while (!Cancellation.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromMinutes(5));
var newToken = await SharedFileAccessService.GenerateToken(FileAccess);
var newUrl = $"/api/upload?token={newToken}";
await DropzoneService.UpdateUrl(DropzoneId, newUrl);
}
});
}
}
public async void Dispose()
{
Cancellation.Cancel();
await SharedFileAccessService.Unregister(FileAccess);
}
}

View file

@ -1,247 +1,287 @@
@using MoonCoreUI.Services
@using MoonCore.Helpers
@using Moonlight.Core.Services
@using Moonlight.Features.FileManager.Models.Abstractions.FileAccess
@using Moonlight.Features.FileManager.Services
@using BlazorContextMenu
@inject ToastService ToastService
@inject AlertService AlertService
@inject SharedFileAccessService SharedFileAccessService
@inject NavigationManager Navigation
@inject IJSRuntime JsRuntime
@implements IDisposable
<LazyLoader @ref="LazyLoader" Load="Load">
<table class="w-100 table table-responsive table-row-bordered">
<div class="@(IsLoading ? "table-loading" : "")">
@if (IsLoading)
{
<div class="table-loading-message table-loading-message fs-3 fw-bold text-white">
Loading...
</div>
}
<table class="w-100 table table-row-bordered @(IsLoading ? "blur" : "table-hover") fs-6">
<tbody>
@if (ShowHeader)
{
<tr>
@if (ShowSelect)
{
<td class="w-10px align-middle">
<div class="form-check">
<input class="form-check-input" type="checkbox" @oninput="args => ToggleAll(args)">
</div>
</td>
}
@if (ShowIcons)
{
<td></td>
}
<td class="align-middle fs-6 text-muted">
Name
<tr class="text-muted">
@if (ShowSelect)
{
<td class="w-10px align-middle">
<div class="form-check">
@if (IsAllSelected)
{
<input class="form-check-input" type="checkbox" value="1" checked="checked" @oninput="() => ChangeAllSelection(false)">
}
else
{
<input class="form-check-input" type="checkbox" value="0" @oninput="() => ChangeAllSelection(true)">
}
</div>
</td>
@if (ShowSize)
{
<td class="align-middle fs-6 text-muted d-none d-sm-table-cell text-end">
Size
</td>
}
@if (ShowLastModified)
{
<td class="align-middle fs-6 text-muted d-none d-sm-table-cell text-end">
Last modified at
</td>
}
@if (SelectedEntries.Count == 0)
{
<td></td>
}
else
{
<td class="w-50 fs-6 text-end">
<span class="text-primary">@SelectedEntries.Count</span> element(s) selected
<div class="ms-2 btn-group">
<WButton OnClick="() => Delete(SelectedEntries.ToArray())" CssClasses="btn btn-icon btn-danger">
<i class="text-white bx bx-sm bx-trash"></i>
</WButton>
</div>
</td>
}
</tr>
}
}
<td class="w-10px"></td>
<td>Name</td>
@if (ShowSize)
{
<td class="d-none d-md-table-cell">Size</td>
}
@if (ShowDate)
{
<td class="d-none d-md-table-cell">Last modified</td>
}
@if (EnableContextMenu)
{
<td></td>
}
@if (AdditionTemplate != null)
{
<td></td>
}
</tr>
@if (ShowGoUp && Path != "/" && !DisableNavigation)
@if (Path != "/" && ShowNavigateUp)
{
<tr>
<tr class="fw-semibold">
@if (ShowSelect)
{
<td class="w-10px align-middle">
</td>
<td class="align-middle w-10px"></td>
}
@if (ShowIcons)
{
<td class="w-10px align-middle">
</td>
}
<td class="align-middle fs-6">
@{
var upPath = "..";
}
<a href="#"
@onclick:preventDefault
@onclick="() => Navigate(upPath)">
Go up
<td class="w-10px">
<i class="bx bx-sm bx-chevrons-left"></i>
</td>
<td>
<a href="#" @onclick:preventDefault @onclick="NavigateUp">
Back to parent folder
</a>
</td>
@if (ShowSize)
{
<td class="align-middle fs-6 d-none d-sm-table-cell text-end">
<span>-</span>
</td>
<td></td>
}
@if (ShowLastModified)
@if (ShowDate)
{
<td class="align-middle fs-6 d-none d-sm-table-cell text-end">
-
</td>
<td></td>
}
@if (ShowActions)
@if (EnableContextMenu)
{
<td class="w-50 text-end">
</td>
<td></td>
}
@if (AdditionTemplate != null)
{
<td></td>
}
</tr>
}
@foreach (var entry in Entries)
{
<tr>
@if (ShowSelect)
{
<td class="w-10px align-middle">
<div class="form-check">
@if (SelectedEntries.Contains(entry))
{
<input class="form-check-input" type="checkbox" value="1" checked="checked" @oninput="args => HandleSelected(entry, args)">
}
else
{
<input class="form-check-input" type="checkbox" value="0" @oninput="args => HandleSelected(entry, args)">
}
</div>
</td>
}
@if (ShowIcons)
{
<td class="w-10px align-middle">
if (EnableContextMenu)
{
<ContextMenuTrigger MenuId="@ContextMenuId" WrapperTag="tr" Data="entry">
@if (ShowSelect)
{
<td class="w-10px align-middle">
<div class="form-check">
@if (SelectionCache.ContainsKey(entry) && SelectionCache[entry])
{
<input class="form-check-input" type="checkbox" value="1" checked="checked" @oninput="() => ChangeSelection(entry, false)">
}
else
{
<input class="form-check-input" type="checkbox" value="0" @oninput="() => ChangeSelection(entry, true)">
}
</div>
</td>
}
<td class="align-middle w-10px">
@if (entry.IsFile)
{
<i class="bx bx-md bx-file"></i>
<i class="bx bx-md bxs-file-blank text-white"></i>
}
else
{
<i class="bx bx-md bx-folder"></i>
<i class="bx bx-md bxs-folder text-primary"></i>
}
</td>
}
<td class="align-middle fs-6">
@if (DisableNavigation)
{
<span>@(entry.Name)</span>
}
else
{
<a href="#"
@onclick:preventDefault
@onclick="() => HandleClick(entry)">
@(entry.Name)
<td class="align-middle">
<a href="#" @onclick:preventDefault @onclick="() => HandleEntryClick(entry)">
@entry.Name
</a>
</td>
@if (ShowSize)
{
<td class="align-middle d-none d-md-table-cell">
@if (entry.IsFile)
{
@Formatter.FormatSize(entry.Size)
}
</td>
}
</td>
@if (ShowSize)
{
<td class="align-middle fs-6 d-none d-sm-table-cell text-end">
@if (entry.IsFile)
{
@(Formatter.FormatSize(entry.Size))
}
else
{
<span>-</span>
}
</td>
}
@if (ShowLastModified)
{
<td class="align-middle fs-6 d-none d-sm-table-cell text-end">
@(Formatter.FormatDate(entry.LastModifiedAt))
</td>
}
@if (ShowActions)
{
<td class="w-50 text-end">
<div class="btn-group">
<WButton OnClick="() => Delete(entry)" CssClasses="btn btn-icon btn-danger">
<i class="text-white bx bx-sm bx-trash"></i>
</WButton>
<div class="dropdown">
<button class="btn btn-icon btn-secondary rounded-start-0" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="text-white bx bx-sm bx-dots-horizontal-rounded"></i>
</button>
<ul class="dropdown-menu">
<li>
<a href="#" @onclick:preventDefault @onclick="() => Rename(entry)" class="dropdown-item">Rename</a>
</li>
<li>
<a href="#" @onclick:preventDefault @onclick="() => Download(entry)" class="dropdown-item">Download</a>
</li>
@if (OnMoveRequested != null)
{
<li>
<a href="#" @onclick:preventDefault @onclick="() => RequestMove(entry)" class="dropdown-item">Move</a>
</li>
}
</ul>
@if (ShowDate)
{
<td class="align-middle d-none d-md-table-cell">
@Formatter.FormatDate(entry.LastModifiedAt)
</td>
}
<td class="d-table-cell d-md-none">
<div class="dropstart">
<button class="btn btn-icon btn-secondary" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bx bx-sm bx-dots-horizontal"></i>
</button>
<div class="dropdown-menu fs-6">
@if (ContextMenuTemplate != null)
{
@ContextMenuTemplate.Invoke(entry)
}
</div>
</div>
</td>
}
</tr>
@if (AdditionTemplate != null)
{
@AdditionTemplate.Invoke(entry)
}
</ContextMenuTrigger>
}
else
{
<tr>
@if (ShowSelect)
{
<td class="w-10px align-middle">
<div class="form-check">
@if (SelectionCache.ContainsKey(entry) && SelectionCache[entry])
{
<input class="form-check-input" type="checkbox" value="1" checked="checked" @oninput="() => ChangeSelection(entry, false)">
}
else
{
<input class="form-check-input" type="checkbox" value="0" @oninput="() => ChangeSelection(entry, true)">
}
</div>
</td>
}
<td class="align-middle w-10px">
@if (entry.IsFile)
{
<i class="bx bx-md bxs-file-blank text-white"></i>
}
else
{
<i class="bx bx-md bxs-folder text-primary"></i>
}
</td>
<td class="align-middle">
<a href="#" @onclick:preventDefault @onclick="() => HandleEntryClick(entry)">
@entry.Name
</a>
</td>
@if (ShowSize)
{
<td class="align-middle d-none d-md-table-cell">
@if (entry.IsFile)
{
@Formatter.FormatSize(entry.Size)
}
</td>
}
@if (ShowDate)
{
<td class="align-middle d-none d-md-table-cell">
@Formatter.FormatDate(entry.LastModifiedAt)
</td>
}
@if (AdditionTemplate != null)
{
@AdditionTemplate.Invoke(entry)
}
</tr>
}
}
</tbody>
</table>
</LazyLoader>
</div>
@if (EnableContextMenu && ContextMenuTemplate != null)
{
<ContextMenu @ref="CurrentContextMenu" Id="@ContextMenuId" OnAppearing="OnContextMenuAppear" OnHiding="OnContextMenuHide">
@if (ShowContextMenu)
{
<div class="dropdown-menu show fs-6">
@ContextMenuTemplate.Invoke(ContextMenuItem)
</div>
}
</ContextMenu>
}
@code
{
[Parameter] public IFileAccess FileAccess { get; set; }
[Parameter] public RenderFragment<FileEntry>? AdditionTemplate { get; set; }
[Parameter] public Func<FileEntry, bool>? Filter { get; set; }
[Parameter] public bool ShowSize { get; set; } = true;
[Parameter] public bool ShowLastModified { get; set; } = true;
[Parameter] public bool ShowIcons { get; set; } = true;
[Parameter] public bool ShowActions { get; set; } = true;
[Parameter] public bool ShowDate { get; set; } = true;
[Parameter] public bool ShowSelect { get; set; } = true;
[Parameter] public bool ShowGoUp { get; set; } = true;
[Parameter] public bool ShowHeader { get; set; } = true;
[Parameter] public bool DisableNavigation { get; set; } = false;
[Parameter] public Func<FileEntry, Task>? OnFileClicked { get; set; }
[Parameter] public Func<Task>? OnSelectionChanged { get; set; }
[Parameter] public Func<string, Task>? OnPathChanged { get; set; }
[Parameter] public Func<FileEntry, Task>? OnMoveRequested { get; set; }
[Parameter] public bool ShowNavigateUp { get; set; } = true;
public readonly List<FileEntry> SelectedEntries = new();
[Parameter] public RenderFragment<FileEntry>? ContextMenuTemplate { get; set; }
[Parameter] public bool EnableContextMenu { get; set; } = false;
private bool ShowContextMenu = false;
private FileEntry ContextMenuItem;
private string ContextMenuId = "fileManagerContextMenu";
private ContextMenu? CurrentContextMenu;
private LazyLoader LazyLoader;
private FileEntry[] Entries;
[Parameter] public BaseFileAccess FileAccess { get; set; }
[Parameter] public Func<FileEntry, bool>? Filter { get; set; }
[Parameter] public Func<FileEntry, Task>? OnEntryClicked { get; set; }
[Parameter] public Func<FileEntry[], Task>? OnSelectionChanged { get; set; }
[Parameter] public Func<Task>? OnNavigateUpClicked { get; set; }
private bool IsLoading = false;
private string LoadingText = "";
private FileEntry[] Entries = Array.Empty<FileEntry>();
private string Path = "/";
private async Task Load(LazyLoader lazyLoader)
{
await lazyLoader.SetText("Loading files and folders");
private Dictionary<FileEntry, bool> 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);
// Load all entries
Entries = await FileAccess.List();
await lazyLoader.SetText("Sorting files and folders");
// Sort entries
LoadingText = "Sorting files and folders";
await InvokeAsync(StateHasChanged);
// Perform sorting and filtering
if (Filter != null)
{
Entries = Entries
@ -255,180 +295,93 @@
.SelectMany(x => x.OrderBy(y => y.Name))
.ToArray();
SelectedEntries.Clear();
// Build selection cache
SelectionCache.Clear();
Path = await FileAccess.GetCurrentDirectory();
foreach (var entry in Entries)
SelectionCache.Add(entry, false);
if (OnPathChanged != null)
await OnPathChanged.Invoke(Path);
if (OnSelectionChanged != null)
await OnSelectionChanged.Invoke(Array.Empty<FileEntry>());
IsLoading = false;
await InvokeAsync(StateHasChanged);
}
private async Task HandleClick(FileEntry fileEntry)
private async Task HandleEntryClick(FileEntry entry)
{
if (fileEntry.IsDirectory && !DisableNavigation)
{
await Navigate(fileEntry.Name);
}
else
{
if (OnFileClicked != null)
await OnFileClicked.Invoke(fileEntry);
}
}
#region Actions
private async Task Delete(params FileEntry[] entries)
{
if (entries.Length == 0)
if (OnEntryClicked == null)
return;
var fileNameDesc = entries.Length == 1 ? entries.First().Name : $"{entries.Length} files";
var confirm = await AlertService.YesNo($"Do you really want to delete '{fileNameDesc}'?", "Yes", "No");
await OnEntryClicked.Invoke(entry);
}
if(!confirm)
private async Task NavigateUp()
{
if (OnNavigateUpClicked == null)
return;
var toastId = "fileDelete" + GetHashCode();
await ToastService.CreateProgress(toastId, $"[0/{entries.Length}] Deleting items");
int i = 0;
foreach (var entry in entries)
{
await ToastService.ModifyProgress(toastId, $"[{i + 1}/{entries.Length}] Deleting '{entry.Name}'");
await FileAccess.Delete(entry.Name);
i++;
}
await ToastService.RemoveProgress(toastId);
await ToastService.Success($"Successfully deleted {i} item(s)");
await LazyLoader.Reload();
await OnNavigateUpClicked.Invoke();
}
private async Task Rename(FileEntry fileEntry)
{
var name = await AlertService.Text($"Rename '{fileEntry.Name}'", "", fileEntry.Name);
if (string.IsNullOrEmpty(name))
return;
await FileAccess.Move(fileEntry.Name, await FileAccess.GetCurrentDirectory() + name);
await LazyLoader.Reload();
}
private async Task RequestMove(FileEntry fileEntry)
{
if (OnMoveRequested == null)
return;
await OnMoveRequested.Invoke(fileEntry);
}
private async Task Download(FileEntry fileEntry)
{
try
{
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);
}
catch (Exception e)
{
Logger.Warn("Unable to start download");
Logger.Warn(e);
await ToastService.Danger("Failed to start download");
}
}
#endregion
#region Selection
private async Task HandleSelected(FileEntry fileEntry, ChangeEventArgs args)
private async Task ChangeSelection(FileEntry entry, bool selectionState)
{
if (args.Value == null) // This should never be called. Still i want to handle it
return;
if (args.Value.ToString() == "True")
{
if (!SelectedEntries.Contains(fileEntry))
SelectedEntries.Add(fileEntry);
}
else
{
if (SelectedEntries.Contains(fileEntry))
SelectedEntries.Remove(fileEntry);
}
SelectionCache[entry] = selectionState;
await InvokeAsync(StateHasChanged);
if (OnSelectionChanged != null)
await OnSelectionChanged.Invoke();
{
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 ToggleAll(ChangeEventArgs args)
private async Task OnContextMenuHide()
{
if (args.Value == null)
return;
if (args.Value.ToString() == "True")
{
foreach (var entry in Entries)
{
if (!SelectedEntries.Contains(entry))
SelectedEntries.Add(entry);
}
}
else
{
SelectedEntries.Clear();
}
ShowContextMenu = false;
await InvokeAsync(StateHasChanged);
}
public async Task HideContextMenu()
{
ShowContextMenu = false;
await InvokeAsync(StateHasChanged);
}
#endregion
#region Navigation
public async Task Navigate(string name)
{
await LazyLoader.Reload(async loader =>
{
await loader.SetText("Switching directory on target");
await FileAccess.ChangeDirectory(name);
if (OnPathChanged != null)
await OnPathChanged.Invoke(await FileAccess.GetCurrentDirectory());
});
}
public async Task NavigateToPath(string path)
{
await LazyLoader.Reload(async loader =>
{
await loader.SetText("Switching directory on target");
await FileAccess.SetDirectory(path);
if (OnPathChanged != null)
await OnPathChanged.Invoke(await FileAccess.GetCurrentDirectory());
});
}
#endregion
public async Task Refresh() => await LazyLoader.Reload();
public async void Dispose()
{
await SharedFileAccessService.Unregister(FileAccess);
}
}

View file

@ -0,0 +1,49 @@
using MoonCore.Helpers;
using Moonlight.Features.FileManager.Models.Abstractions.FileAccess;
using Moonlight.Features.Servers.Exceptions;
namespace Moonlight.Features.Servers.Helpers;
public class ServerApiFileActions : IFileActions
{
private readonly string Endpoint;
private readonly string Token;
private readonly int ServerId;
private readonly HttpApiClient<NodeException> ApiClient;
public ServerApiFileActions(string endpoint, string token, int serverId)
{
Endpoint = endpoint;
Token = token;
ServerId = serverId;
ApiClient = new(endpoint + $"files/{ServerId}/", token);
}
public async Task<FileEntry[]> List(string path) => await ApiClient.Get<FileEntry[]>($"list?path={path}");
public async Task DeleteFile(string path) => await ApiClient.DeleteAsString($"deleteFile?path={path}");
public async Task DeleteDirectory(string path) => await ApiClient.DeleteAsString($"deleteDirectory?path={path}");
public async Task Move(string from, string to) => await ApiClient.Post($"move?from={from}&to={to}");
public async Task CreateDirectory(string path) => await ApiClient.Post($"createDirectory?path={path}");
public async Task CreateFile(string path) => await ApiClient.Post($"createFile?path={path}");
public async Task<string> ReadFile(string path) => await ApiClient.GetAsString($"readFile?path={path}");
public async Task WriteFile(string path, string content) =>
await ApiClient.PostAsString($"writeFile?path={path}", content);
public async Task<Stream> ReadFileStream(string path) => await ApiClient.GetAsStream($"readFileStream?path={path}");
public async Task WriteFileStream(string path, Stream dataStream) =>
await ApiClient.PostFile($"writeFileStream?path={path}", dataStream, "upload");
public IFileActions Clone() => new ServerApiFileActions(Endpoint, Token, ServerId);
public void Dispose() => ApiClient.Dispose();
}

View file

@ -1,224 +0,0 @@
using System.Net;
using System.Text;
using FluentFTP;
using Moonlight.Features.FileManager.Models.Abstractions.FileAccess;
namespace Moonlight.Features.Servers.Helpers;
public class ServerFtpFileActions : IFileActions
{
private FtpClient Client;
private readonly string Host;
private readonly int Port;
private readonly string Username;
private readonly string Password;
private readonly int OperationTimeout;
public ServerFtpFileActions(string host, int port, string username, string password, int operationTimeout = 5)
{
Host = host;
Port = port;
Username = username;
Password = password;
OperationTimeout = (int)TimeSpan.FromSeconds(5).TotalMilliseconds;
Client = CreateClient();
}
public async Task<FileEntry[]> List(string path)
{
return await ExecuteHandled(() =>
{
var items = Client.GetListing(path) ?? Array.Empty<FtpListItem>();
var result = items.Select(item => new FileEntry
{
Name = item.Name,
IsDirectory = item.Type == FtpObjectType.Directory,
IsFile = item.Type == FtpObjectType.File,
LastModifiedAt = item.Modified,
Size = item.Size
}).ToArray();
return Task.FromResult(result);
});
}
public async Task Delete(string path)
{
await ExecuteHandled(() =>
{
if (Client.FileExists(path))
Client.DeleteFile(path);
else
Client.DeleteDirectory(path);
return Task.CompletedTask;
});
}
public async Task Move(string from, string to)
{
await ExecuteHandled(() =>
{
var fromEntry = Client.GetObjectInfo(from);
string dest;
if (to.EndsWith("/"))
dest = to + Path.GetFileName(from);
else
dest = to;
//from = CurrentDirectory + from;
//var fromWithSlash = from.StartsWith("/") ? from : "/" + from;
if (from == dest)
return Task.CompletedTask;
if (fromEntry.Type == FtpObjectType.Directory)
// We need to add the folder name here, because some ftp servers would refuse to move the folder if its missing
Client.MoveDirectory(from, dest);
else
// We need to add the file name here, because some ftp servers would refuse to move the file if its missing
Client.MoveFile(from, dest);
return Task.CompletedTask;
});
}
public async Task CreateDirectory(string name)
{
await ExecuteHandled(() =>
{
Client.CreateDirectory(name);
return Task.CompletedTask;
});
}
public async Task CreateFile(string name)
{
await ExecuteHandled(() =>
{
using var stream = new MemoryStream();
Client.UploadStream(stream, name);
return Task.CompletedTask;
});
}
public async Task<string> ReadFile(string name)
{
return await ExecuteHandled(async () =>
{
await using var stream = Client.OpenRead(name);
using var reader = new StreamReader(stream, Encoding.UTF8);
return await reader.ReadToEndAsync();
});
}
public async Task WriteFile(string name, string content)
{
await ExecuteHandled(() =>
{
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(content));
Client.UploadStream(stream, name);
return Task.CompletedTask;
});
}
public async Task<Stream> ReadFileStream(string name)
{
return await ExecuteHandled(() =>
{
var stream = Client.OpenRead(name);
return Task.FromResult(stream);
});
}
public async Task WriteFileStream(string name, Stream dataStream)
{
await ExecuteHandled(() =>
{
Client.UploadStream(dataStream, name, FtpRemoteExists.Overwrite);
return Task.CompletedTask;
});
}
public IFileActions Clone()
{
return new ServerFtpFileActions(Host, Port, Username, Password);
}
public void Dispose()
{
Client.Dispose();
}
#region Helpers
private Task EnsureConnected()
{
if (!Client.IsConnected)
Client.Connect();
return Task.CompletedTask;
}
private async Task ExecuteHandled(Func<Task> func)
{
try
{
await EnsureConnected();
await func.Invoke();
}
catch (TimeoutException)
{
Client.Dispose();
Client = CreateClient();
await EnsureConnected();
await func.Invoke();
}
}
private async Task<T> ExecuteHandled<T>(Func<Task<T>> func)
{
try
{
await EnsureConnected();
return await func.Invoke();
}
catch (TimeoutException)
{
Client.Dispose();
Client = CreateClient();
await EnsureConnected();
return await func.Invoke();
}
}
private FtpClient CreateClient()
{
var client = new FtpClient();
client.Host = Host;
client.Port = Port;
client.Credentials = new NetworkCredential(Username, Password);
client.Config.DataConnectionType = FtpDataConnectionType.AutoPassive;
client.Config.ConnectTimeout = OperationTimeout;
client.Config.ReadTimeout = OperationTimeout;
client.Config.DataConnectionConnectTimeout = OperationTimeout;
client.Config.DataConnectionReadTimeout = OperationTimeout;
return client;
}
#endregion
}

View file

@ -208,12 +208,10 @@ public class ServerService
await Backup.Delete(serverWithBackups, backup, false);
}
public async Task<IFileAccess> OpenFileAccess(Server s)
public Task<BaseFileAccess> OpenFileAccess(Server s)
{
using var scope = ServiceProvider.CreateScope();
var jwtService = ServiceProvider.GetRequiredService<JwtService<ServersJwtType>>();
var configService = ServiceProvider.GetRequiredService<ConfigService<CoreConfiguration>>();
var serverRepo = scope.ServiceProvider.GetRequiredService<Repository<Server>>();
var server = serverRepo
@ -221,18 +219,14 @@ public class ServerService
.Include(x => x.Node)
.First(x => x.Id == s.Id);
var ftpLoginJwt = await jwtService.Create(data => { data.Add("ServerId", s.Id.ToString()); },
ServersJwtType.FtpServerLogin, TimeSpan.FromMinutes(5));
var protocol = server.Node.Ssl ? "https" : "http";
var remoteUrl = $"{protocol}://{server.Node.Fqdn}:{server.Node.HttpPort}/";
return new BaseFileAccess(
new ServerFtpFileActions(
server.Node.Fqdn,
server.Node.FtpPort,
$"moonlight.{server.Id}",
ftpLoginJwt,
configService.Get().Customisation.FileManager.OperationTimeout
)
var result = new BaseFileAccess(
new ServerApiFileActions(remoteUrl, server.Node.Token, server.Id)
);
return Task.FromResult(result);
}
public async Task<ServerListItem[]> GetServersList(ServerNode node, bool includeOffline = false)

View file

@ -19,7 +19,7 @@
{
[CascadingParameter] public Server Server { get; set; }
private IFileAccess FileAccess;
private BaseFileAccess FileAccess;
private async Task Load(LazyLoader lazyLoader)
{

View file

@ -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");

View file

@ -74,7 +74,7 @@
<Folder Include="Features\FileManager\Http\Requests\" />
<Folder Include="Features\FileManager\Http\Resources\" />
<Folder Include="Features\Servers\Http\Resources\" />
<Folder Include="storage\assetOverrides\" />
<Folder Include="storage\assetOverrides\x\y\" />
<Folder Include="storage\logs\" />
<Folder Include="Styles\" />
<Folder Include="wwwroot\css\" />
@ -83,14 +83,14 @@
<ItemGroup>
<PackageReference Include="Ben.Demystifier" Version="0.4.1" />
<PackageReference Include="Blazor-ApexCharts" Version="2.3.3" />
<PackageReference Include="Blazor.ContextMenu" Version="1.17.0" />
<PackageReference Include="BlazorTable" Version="1.17.0" />
<PackageReference Include="FluentFTP" Version="49.0.2" />
<PackageReference Include="JWT" Version="10.1.1" />
<PackageReference Include="MimeTypes" Version="2.4.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="MoonCore" Version="1.1.6" />
<PackageReference Include="MoonCore" Version="1.1.9" />
<PackageReference Include="MoonCoreUI" Version="1.1.4" />
<PackageReference Include="Otp.NET" Version="1.3.0" />
<PackageReference Include="QRCoder" Version="1.4.3" />
@ -109,6 +109,11 @@
<_ContentIncludedByDefault Remove="Features\ScheduleDesigner\UI\Components\ScheduleLinkItem.razor" />
<_ContentIncludedByDefault Remove="Features\ScheduleDesigner\UI\Components\ScheduleNodeItem.razor" />
<_ContentIncludedByDefault Remove="wwwroot\svg\logo.svg" />
<_ContentIncludedByDefault Remove="Features\FileManager\UI\Components\Editor.razor" />
<_ContentIncludedByDefault Remove="Features\FileManager\UI\Components\FileEditor.razor" />
<_ContentIncludedByDefault Remove="Features\FileManager\UI\Components\FileManager.razor" />
<_ContentIncludedByDefault Remove="Features\FileManager\UI\Components\FileUploader.razor" />
<_ContentIncludedByDefault Remove="Features\FileManager\UI\Components\FileView.razor" />
</ItemGroup>
</Project>