diff --git a/.trivyignore b/.trivyignore
index f1e2311a70..2420e6ad58 100644
--- a/.trivyignore
+++ b/.trivyignore
@@ -1,10 +1,2 @@
# Findings accepted after review. Each entry expires and is reported again
# on that date, so it has to be reviewed rather than forgotten.
-
-# SixLabors.ImageSharp 3.1.12 (fixed in 4.1.2), a transitive dependency of
-# elFinder.Net.Core 1.5.0, lifted from 2.0.0 by Grand.Web.AdminShared.
-# GrandNode does not reference ImageSharp in code. 4.x is not verified with
-# elFinder. Drop these entries once ImageSharp moves to 4.1.2 or elFinder goes.
-CVE-2026-106110 exp:2027-01-16
-CVE-2026-106113 exp:2027-01-16
-CVE-2026-106115 exp:2027-01-16
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 399609b088..b82a1d2e1d 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -8,7 +8,6 @@
-
@@ -47,10 +46,6 @@
-
-
-
-
diff --git a/src/Business/Grand.Business.Storage/Services/FileSystemStore.cs b/src/Business/Grand.Business.Storage/Services/FileSystemStore.cs
index 2b01ebc52f..4f5c82bea5 100644
--- a/src/Business/Grand.Business.Storage/Services/FileSystemStore.cs
+++ b/src/Business/Grand.Business.Storage/Services/FileSystemStore.cs
@@ -104,7 +104,9 @@ public Task TryRenameDirectory(string path, string newName)
if (!Directory.Exists(physicalPath)) return Task.FromResult(false);
var directoryInfo = new DirectoryInfo(physicalPath);
- var newphysicalPath = GetPhysicalPath(Path.Combine(directoryInfo.Parent.FullName, newName));
+ //the new path is built from the store path, not from Parent.FullName: an absolute path loses its leading
+ //slash in NormalizePath and lands under the root a second time on Linux
+ var newphysicalPath = GetPhysicalPath(SiblingPath(path, newName));
if (Directory.Exists(newphysicalPath)) return Task.FromResult(false);
directoryInfo.MoveTo(newphysicalPath);
@@ -165,17 +167,17 @@ public async Task CopyFile(string srcPath, string dstPath)
File.Copy(physicalSrcPath, physicalDstPath);
}
- public async Task RenameFile(string file, string newName)
+ public Task RenameFile(string file, string newName)
{
var physicalSrcFile = GetPhysicalPath(file);
if (!File.Exists(physicalSrcFile)) throw new Exception($"The file '{file}' does not exist.");
- var physicalFile = await GetFileInfo(file);
-
- var newphysicalFile = GetPhysicalPath(Path.Combine(physicalFile.DirectoryPath, newName));
+ var newphysicalFile = GetPhysicalPath(SiblingPath(file, newName));
if (File.Exists(newphysicalFile)) throw new Exception($"The file '{newName}' does exist.");
File.Move(physicalSrcFile, newphysicalFile);
+
+ return Task.CompletedTask;
}
public Task GetFileStream(string path)
@@ -250,12 +252,30 @@ private string GetPhysicalPath(string path)
var physicalPath = string.IsNullOrEmpty(path) ? _fileSystemPath : Path.Combine(_fileSystemPath, path);
- // Verify that the resulting path is inside the root file system path.
- var pathIsAllowed = Path.GetFullPath(physicalPath)
- .StartsWith(_fileSystemPath, StringComparison.OrdinalIgnoreCase);
+ // Verify that the resulting path is inside the root file system path - the root itself or below a separator,
+ // so a sibling folder sharing the root's name as a prefix ("wwwroot-old") is outside.
+ var fullPath = Path.GetFullPath(physicalPath);
+ var root = Path.TrimEndingDirectorySeparator(_fileSystemPath);
+ var pathIsAllowed = fullPath.Equals(root, StringComparison.OrdinalIgnoreCase) ||
+ fullPath.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
if (!pathIsAllowed)
throw new Exception($"The path '{path}' resolves to a physical path outside the file system store root.");
return physicalPath;
}
+
+ ///
+ /// The store path of an entry named next to . The new name is
+ /// a single name: a separator or a dot segment would move the entry instead of renaming it.
+ ///
+ private string SiblingPath(string path, string newName)
+ {
+ if (string.IsNullOrWhiteSpace(newName) || newName is "." or ".." ||
+ newName.IndexOfAny(['/', '\\']) >= 0)
+ throw new ArgumentException($"'{newName}' is not a valid name.", nameof(newName));
+
+ var normalized = this.NormalizePath(path) ?? "";
+ var separator = normalized.LastIndexOf('/');
+ return separator < 0 ? newName : $"{normalized[..separator]}/{newName}";
+ }
}
\ No newline at end of file
diff --git a/src/Core/Grand.Domain/Media/MediaSettings.cs b/src/Core/Grand.Domain/Media/MediaSettings.cs
index 5b166ebe1d..722989292f 100644
--- a/src/Core/Grand.Domain/Media/MediaSettings.cs
+++ b/src/Core/Grand.Domain/Media/MediaSettings.cs
@@ -36,9 +36,4 @@ public class MediaSettings : ISettings
public int ImageQuality { get; set; } = 80;
public string AllowedFileTypes { get; set; }
-
- public string FileManagerEnabledCommands { get; set; } =
- "abort,open,file,mkdir,mkfile,parents,tmb,dim,paste,duplicate,get,rm,ls,put,size,rename,tree,resize,search,upload";
-
- public string FileManagerDisabledUICommands { get; set; } = "ping,hide,archive,extract,netmount,netunmount,zipdl";
}
\ No newline at end of file
diff --git a/src/Core/Grand.SharedKernel/Extensions/AllowedFileExtensions.cs b/src/Core/Grand.SharedKernel/Extensions/AllowedFileExtensions.cs
index ccd740693d..e5fe9afa59 100644
--- a/src/Core/Grand.SharedKernel/Extensions/AllowedFileExtensions.cs
+++ b/src/Core/Grand.SharedKernel/Extensions/AllowedFileExtensions.cs
@@ -6,10 +6,13 @@ public static IList GetAllowedMediaFileTypes(string allowedFileTypes)
{
if (string.IsNullOrEmpty(allowedFileTypes))
return new List { ".gif", ".jpg", ".jpeg", ".png", ".bmp", ".webp" };
- return allowedFileTypes.Split(',').Select(x => x.Trim().ToLowerInvariant()).ToList();
+ //a trailing or doubled comma leaves an empty entry, which would allow a file without an extension
+ return allowedFileTypes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Select(x => x.ToLowerInvariant()).ToList();
}
public static bool IsAllowedMediaFileType(this IEnumerable allowedFileTypes, string fileExtension)
{
- return allowedFileTypes.Any(ft => ft.Equals(fileExtension, StringComparison.OrdinalIgnoreCase));
+ return !string.IsNullOrEmpty(fileExtension) &&
+ allowedFileTypes.Any(ft => ft.Equals(fileExtension, StringComparison.OrdinalIgnoreCase));
}
}
\ No newline at end of file
diff --git a/src/Tests/Grand.Business.Storage.Tests/Services/FileSystemStoreTests.cs b/src/Tests/Grand.Business.Storage.Tests/Services/FileSystemStoreTests.cs
index 085d8fbbe3..1bc53a90a6 100644
--- a/src/Tests/Grand.Business.Storage.Tests/Services/FileSystemStoreTests.cs
+++ b/src/Tests/Grand.Business.Storage.Tests/Services/FileSystemStoreTests.cs
@@ -68,16 +68,70 @@ public void TryCreateDirectoryTest()
}
[TestMethod]
- [Ignore] //TMP
public async Task TryRenameDirectoryTest()
{
//Arrange
- _store.TryCreateDirectory("Test");
- await _store.TryDeleteDirectory("Test2");
+ await _store.TryDeleteDirectory("RenameTest");
+ await _store.TryDeleteDirectory("RenameTest2");
+ _store.TryCreateDirectory("RenameTest");
+ //Act
+ var result = await _store.TryRenameDirectory("RenameTest", "RenameTest2");
+ //Assert
+ Assert.IsTrue(result);
+ Assert.IsNotNull(_store.GetDirectoryInfo("RenameTest2"));
+ Assert.IsNull(_store.GetDirectoryInfo("RenameTest"));
+ }
+
+ [TestMethod]
+ public async Task TryRenameDirectory_Nested_StaysInItsParent()
+ {
+ //Arrange
+ await _store.TryDeleteDirectory("RenameNested");
+ _store.TryCreateDirectory("RenameNested/a/old");
//Act
- var result = await _store.TryRenameDirectory("Test", "Test2");
+ var result = await _store.TryRenameDirectory("RenameNested/a/old", "new");
//Assert
Assert.IsTrue(result);
+ Assert.IsNotNull(_store.GetDirectoryInfo("RenameNested/a/new"));
+ await _store.TryDeleteDirectory("RenameNested");
+ }
+
+ [TestMethod]
+ [DataRow("../moved")]
+ [DataRow("a/b")]
+ [DataRow("a\\b")]
+ [DataRow("..")]
+ public async Task Rename_NewNameWithAPath_Throws(string newName)
+ {
+ //Arrange
+ await _store.TryDeleteDirectory("RenameGuard");
+ _store.TryCreateDirectory("RenameGuard/folder");
+ await _store.WriteAllText("RenameGuard/file.txt", "test");
+ //Act & Assert
+ await Assert.ThrowsAsync(() => _store.TryRenameDirectory("RenameGuard/folder", newName));
+ await Assert.ThrowsAsync(() => _store.RenameFile("RenameGuard/file.txt", newName));
+ await _store.TryDeleteDirectory("RenameGuard");
+ }
+
+ [TestMethod]
+ public void GetDirectoryInfo_SiblingSharingTheRootPrefix_Throws()
+ {
+ //Arrange - "-old" starts with the root's path but lies outside it
+ var root = Path.Combine(Path.GetTempPath(), $"store-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(root);
+ Directory.CreateDirectory(root + "-old");
+ try
+ {
+ var store = new FileSystemStore(root);
+ //Act & Assert
+ Assert.ThrowsExactly(() => store.GetDirectoryInfo($"../{Path.GetFileName(root)}-old"));
+ Assert.IsNotNull(store.GetDirectoryInfo(""));
+ }
+ finally
+ {
+ Directory.Delete(root, true);
+ Directory.Delete(root + "-old", true);
+ }
}
[TestMethod]
diff --git a/src/Tests/Grand.Business.Storage.Tests/Services/LayeredFileStoreTests.cs b/src/Tests/Grand.Business.Storage.Tests/Services/LayeredFileStoreTests.cs
index dbea273a2f..4c18554f22 100644
--- a/src/Tests/Grand.Business.Storage.Tests/Services/LayeredFileStoreTests.cs
+++ b/src/Tests/Grand.Business.Storage.Tests/Services/LayeredFileStoreTests.cs
@@ -100,7 +100,7 @@ public void GetDirectoryInfo_OnlyInFallback_CreatesItInPrimary()
[TestMethod]
public void GetDirectoryInfo_UploadedShippedInWebRoot_FileManagerRootIsOnVolume()
{
- //Arrange - wwwroot ships assets/images/uploaded with a placeholder.txt; elFinder takes PhysicalPath as its root
+ //Arrange - wwwroot ships assets/images/uploaded with a placeholder.txt; the file manager writes under it
Directory.CreateDirectory(Path.Combine(_webRoot, "assets", "images", "uploaded"));
File.WriteAllText(Path.Combine(_webRoot, "assets", "images", "uploaded", "legacy.jpg"), "old");
//Act
diff --git a/src/Tests/Grand.Mapping.Tests/AdminShared/SettingsMappingTests.MediaSettings_ToModel.verified.txt b/src/Tests/Grand.Mapping.Tests/AdminShared/SettingsMappingTests.MediaSettings_ToModel.verified.txt
index 3e8f69f85b..75bd80bf9a 100644
--- a/src/Tests/Grand.Mapping.Tests/AdminShared/SettingsMappingTests.MediaSettings_ToModel.verified.txt
+++ b/src/Tests/Grand.Mapping.Tests/AdminShared/SettingsMappingTests.MediaSettings_ToModel.verified.txt
@@ -12,7 +12,5 @@
ImageQuality: 80,
DefaultPictureZoomEnabled: false,
AutoCompleteSearchThumbPictureSize: 20,
- FileManagerEnabledCommands: abort,open,file,mkdir,mkfile,parents,tmb,dim,paste,duplicate,get,rm,ls,put,size,rename,tree,resize,search,upload,
- FileManagerDisabledUICommands: ping,hide,archive,extract,netmount,netunmount,zipdl,
DefaultImageName: no-image.png
}
\ No newline at end of file
diff --git a/src/Tests/Grand.SharedKernel.Tests/Extensions/FileExtensionsTests.cs b/src/Tests/Grand.SharedKernel.Tests/Extensions/FileExtensionsTests.cs
new file mode 100644
index 0000000000..478088fa1a
--- /dev/null
+++ b/src/Tests/Grand.SharedKernel.Tests/Extensions/FileExtensionsTests.cs
@@ -0,0 +1,37 @@
+using Grand.SharedKernel.Extensions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Grand.SharedKernel.Tests.Extensions;
+
+[TestClass]
+public class FileExtensionsTests
+{
+ [TestMethod]
+ public void GetAllowedMediaFileTypes_Empty_DefaultImageTypes()
+ {
+ var result = FileExtensions.GetAllowedMediaFileTypes(null);
+
+ CollectionAssert.AreEquivalent(new[] { ".gif", ".jpg", ".jpeg", ".png", ".bmp", ".webp" }, result.ToArray());
+ }
+
+ [TestMethod]
+ public void GetAllowedMediaFileTypes_TrailingAndDoubledComma_NoEmptyEntry()
+ {
+ var result = FileExtensions.GetAllowedMediaFileTypes(" .JPG, .png,, ,");
+
+ CollectionAssert.AreEqual(new[] { ".jpg", ".png" }, result.ToArray());
+ }
+
+ [TestMethod]
+ [DataRow(".jpg", true)]
+ [DataRow(".PNG", true)]
+ [DataRow(".svg", false)]
+ [DataRow("", false)]
+ [DataRow(null, false)]
+ public void IsAllowedMediaFileType_ChecksTheExtension(string extension, bool expected)
+ {
+ var allowed = FileExtensions.GetAllowedMediaFileTypes(".jpg,.png,");
+
+ Assert.AreEqual(expected, allowed.IsAllowedMediaFileType(extension));
+ }
+}
diff --git a/src/Tests/Grand.Web.Admin.Tests/Services/BaseInfoResponseConverterTests.cs b/src/Tests/Grand.Web.Admin.Tests/Services/BaseInfoResponseConverterTests.cs
deleted file mode 100644
index e65cd8ba3a..0000000000
--- a/src/Tests/Grand.Web.Admin.Tests/Services/BaseInfoResponseConverterTests.cs
+++ /dev/null
@@ -1,63 +0,0 @@
-using elFinder.Net.Core.Models.FileInfo;
-using Grand.Web.AdminShared.Services;
-using Microsoft.VisualStudio.TestTools.UnitTesting;
-using System.Text.Json;
-using Assert = Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
-
-namespace Grand.Web.Admin.Tests.Services;
-
-///
-/// OpenResponse.cwd is declared as BaseInfoResponse, so these tests serialize through that same
-/// declared type - the condition under which the properties go missing.
-///
-[TestClass]
-public class BaseInfoResponseConverterTests
-{
- private JsonSerializerOptions _options;
-
- [TestInitialize]
- public void Setup()
- {
- _options = new JsonSerializerOptions();
- _options.Converters.Add(new BaseInfoResponseConverter());
- }
-
- ///
- /// Losing phash costs the browser the link from the current directory to its parent - and with
- /// it every file URL below the volume root, because elFinder builds those by walking that chain.
- ///
- [TestMethod]
- public void Directory_KeepsTheParentHashOfItsRuntimeType()
- {
- BaseInfoResponse cwd = new DirectoryInfoResponse {
- name = "test", hash = "v1_XHRlc3Q", phash = "v1_", volumeid = "v1_", mime = "directory"
- };
-
- var json = JsonSerializer.Serialize(cwd, _options);
-
- StringAssert.Contains(json, "\"phash\":\"v1_\"");
- StringAssert.Contains(json, "\"volumeid\":\"v1_\"");
- }
-
- [TestMethod]
- public void Root_KeepsTheRootMarkerOfItsRuntimeType()
- {
- BaseInfoResponse cwd = new RootInfoResponse {
- name = "Volume", hash = "v1_", volumeid = "v1_", isroot = 1, mime = "directory"
- };
-
- var json = JsonSerializer.Serialize(cwd, _options);
-
- StringAssert.Contains(json, "\"isroot\":1");
- }
-
- [TestMethod]
- public void DeclaredTypeAloneStillDropsTheParentHash()
- {
- BaseInfoResponse cwd = new DirectoryInfoResponse { name = "test", hash = "v1_XHRlc3Q", phash = "v1_" };
-
- var json = JsonSerializer.Serialize(cwd, new JsonSerializerOptions());
-
- Assert.IsFalse(json.Contains("phash"), "Guards the premise of the converter - remove it and the fix is moot");
- }
-}
diff --git a/src/Tests/Grand.Web.Admin.Tests/Services/FileManagerServiceTests.cs b/src/Tests/Grand.Web.Admin.Tests/Services/FileManagerServiceTests.cs
new file mode 100644
index 0000000000..bf60d9e210
--- /dev/null
+++ b/src/Tests/Grand.Web.Admin.Tests/Services/FileManagerServiceTests.cs
@@ -0,0 +1,312 @@
+using Grand.Business.Storage.Services;
+using Grand.Domain.Media;
+using Grand.SharedKernel.Extensions;
+using Grand.Web.AdminShared.Models.FileManager;
+using Grand.Web.AdminShared.Services;
+using Microsoft.Extensions.Configuration;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Moq;
+using System.Text;
+
+namespace Grand.Web.Admin.Tests.Services;
+
+[TestClass]
+public class FileManagerServiceTests
+{
+ private string _webRoot;
+ private string _uploaded;
+ private FileManagerService _service;
+
+ [TestInitialize]
+ public void Init()
+ {
+ _webRoot = Path.Combine(Path.GetTempPath(), "filemanager-" + Guid.NewGuid().ToString("N"));
+ _uploaded = Path.Combine(_webRoot, "assets", "images", "uploaded");
+ Directory.CreateDirectory(_uploaded);
+ //what a path that escapes the root would reach: the site's own scripts
+ Directory.CreateDirectory(Path.Combine(_webRoot, "assets", "custom"));
+ File.WriteAllText(Path.Combine(_webRoot, "assets", "custom", "site.js"), "script");
+
+ _service = Create(null);
+ }
+
+ [TestCleanup]
+ public void Cleanup()
+ {
+ if (Directory.Exists(_webRoot)) Directory.Delete(_webRoot, true);
+ }
+
+ private FileManagerService Create(string directory)
+ {
+ var configuration = new Mock();
+ configuration.Setup(c => c[CommonPath.DirectoryParam]).Returns(directory);
+ var store = new DefaultMediaFileStore(new FileSystemStore(_webRoot));
+ return new FileManagerService(store, new MediaSettings { AllowedFileTypes = ".jpg, .png, .mp4" }, configuration.Object);
+ }
+
+ private static FileManagerUpload Upload(string name, string content = "data")
+ {
+ var bytes = Encoding.UTF8.GetBytes(content);
+ return new FileManagerUpload(name, bytes.Length, () => new MemoryStream(bytes));
+ }
+
+ [TestMethod]
+ [DataRow("..")]
+ [DataRow("../custom")]
+ [DataRow("a/../../custom")]
+ [DataRow("..\\..\\custom")]
+ [DataRow("./a")]
+ [DataRow("a//b")]
+ [DataRow("C:")]
+ [DataRow(".git")]
+ [DataRow("con")]
+ [DataRow("a/nul.txt")]
+ [DataRow("a/b?")]
+ [DataRow("trailing.")]
+ public void TryGetSegments_PathThatCouldLeaveTheRoot_Fails(string path)
+ {
+ Assert.IsFalse(FileManagerService.TryGetSegments(path, out _));
+ }
+
+ [TestMethod]
+ [DataRow(null, "")]
+ [DataRow("", "")]
+ [DataRow("/", "")]
+ [DataRow("banners", "banners")]
+ [DataRow("/banners/2026/", "banners|2026")]
+ [DataRow("banners\\2026", "banners|2026")]
+ [DataRow("summer sale/top.jpg", "summer sale|top.jpg")]
+ public void TryGetSegments_PathUnderTheRoot_Splits(string path, string expected)
+ {
+ Assert.IsTrue(FileManagerService.TryGetSegments(path, out var segments));
+ Assert.AreEqual(expected, string.Join('|', segments));
+ }
+
+ [TestMethod]
+ public void List_Root_FoldersFirstAndOnlyAllowedTypes()
+ {
+ Directory.CreateDirectory(Path.Combine(_uploaded, "zeta"));
+ File.WriteAllText(Path.Combine(_uploaded, "a.png"), "x");
+ File.WriteAllText(Path.Combine(_uploaded, "placeholder.txt"), "x");
+ File.WriteAllText(Path.Combine(_uploaded, ".hidden.png"), "x");
+
+ var listing = _service.List("");
+
+ Assert.AreEqual(FileManagerError.None, listing.Error);
+ CollectionAssert.AreEqual(new[] { "zeta", "a.png" }, listing.Entries.Select(e => e.Name).ToArray());
+ Assert.IsTrue(listing.Entries[0].IsFolder);
+ Assert.AreEqual("image", listing.Entries[1].Kind);
+ Assert.AreEqual("/assets/images/uploaded/a.png", listing.Entries[1].Url);
+ }
+
+ [TestMethod]
+ public void List_MissingRoot_IsCreated()
+ {
+ Directory.Delete(_uploaded, true);
+
+ var listing = _service.List(null);
+
+ Assert.AreEqual(FileManagerError.None, listing.Error);
+ Assert.IsTrue(Directory.Exists(_uploaded));
+ }
+
+ [TestMethod]
+ public void List_NestedFolder_RelativePathsCrumbsAndEscapedUrls()
+ {
+ Directory.CreateDirectory(Path.Combine(_uploaded, "summer sale"));
+ File.WriteAllText(Path.Combine(_uploaded, "summer sale", "top #1.jpg"), "x");
+
+ var listing = Create("tenant").List("summer sale");
+
+ Assert.AreEqual("summer sale", listing.Path);
+ CollectionAssert.AreEqual(new[] { "", "summer sale" }, listing.Crumbs.Select(c => c.Path).ToArray());
+ Assert.AreEqual("summer sale/top #1.jpg", listing.Entries.Single().Path);
+ Assert.AreEqual("/tenant/assets/images/uploaded/summer%20sale/top%20%231.jpg", listing.Entries.Single().Url);
+ }
+
+ [TestMethod]
+ public void List_Traversal_IsRefused()
+ {
+ Assert.AreEqual(FileManagerError.InvalidPath, _service.List("../custom").Error);
+ }
+
+ [TestMethod]
+ public void List_MissingFolder_NotFound()
+ {
+ Assert.AreEqual(FileManagerError.NotFound, _service.List("nope").Error);
+ }
+
+ [TestMethod]
+ public async Task Upload_AllowedFile_IsWrittenInTheFolder()
+ {
+ Directory.CreateDirectory(Path.Combine(_uploaded, "banners"));
+
+ var result = await _service.Upload("banners", [Upload("top.jpg")]);
+
+ Assert.AreEqual(FileManagerError.None, result.Error);
+ Assert.AreEqual("banners/top.jpg", result.Uploaded.Single().Path);
+ Assert.IsTrue(File.Exists(Path.Combine(_uploaded, "banners", "top.jpg")));
+ }
+
+ [TestMethod]
+ public async Task Upload_TakenName_GetsANumberAndNothingIsReplaced()
+ {
+ File.WriteAllText(Path.Combine(_uploaded, "top.jpg"), "original");
+
+ var result = await _service.Upload("", [Upload("top.jpg", "new"), Upload("top.jpg", "newer")]);
+
+ CollectionAssert.AreEqual(new[] { "top (1).jpg", "top (2).jpg" }, result.Uploaded.Select(u => u.Name).ToArray());
+ Assert.AreEqual("original", File.ReadAllText(Path.Combine(_uploaded, "top.jpg")));
+ }
+
+ [TestMethod]
+ public async Task Upload_ClientPathInTheName_KeepsOnlyTheName()
+ {
+ var result = await _service.Upload("", [Upload("..\\..\\custom\\x.png")]);
+
+ Assert.AreEqual("x.png", result.Uploaded.Single().Name);
+ Assert.IsTrue(File.Exists(Path.Combine(_uploaded, "x.png")));
+ }
+
+ [TestMethod]
+ public async Task Upload_TypeNotAllowedTooLargeOrBadName_IsRejected()
+ {
+ var big = new FileManagerUpload("big.png", FileManagerService.MaxUploadBytes + 1, () => new MemoryStream());
+
+ var result = await _service.Upload("", [Upload("page.cshtml"), Upload("run.js"), big, Upload(".png")]);
+
+ Assert.AreEqual(0, result.Uploaded.Count);
+ CollectionAssert.AreEqual(
+ new[] { FileManagerError.NotAllowedType, FileManagerError.NotAllowedType, FileManagerError.TooLarge, FileManagerError.InvalidName },
+ result.Rejected.Select(r => r.Error).ToArray());
+ Assert.AreEqual(0, Directory.GetFiles(_uploaded).Length);
+ }
+
+ [TestMethod]
+ public async Task Upload_Traversal_IsRefused()
+ {
+ var result = await _service.Upload("../custom", [Upload("x.png")]);
+
+ Assert.AreEqual(FileManagerError.InvalidPath, result.Error);
+ Assert.IsFalse(File.Exists(Path.Combine(_webRoot, "assets", "custom", "x.png")));
+ }
+
+ [TestMethod]
+ public async Task CreateFolder_ValidName_IsCreated()
+ {
+ var result = await _service.CreateFolder("", " banners ");
+
+ Assert.AreEqual(FileManagerError.None, result.Error);
+ Assert.AreEqual("banners", result.Entry.Path);
+ Assert.IsTrue(Directory.Exists(Path.Combine(_uploaded, "banners")));
+ }
+
+ [TestMethod]
+ [DataRow("..")]
+ [DataRow("a/b")]
+ [DataRow("")]
+ [DataRow(".git")]
+ public async Task CreateFolder_InvalidName_IsRefused(string name)
+ {
+ Assert.AreEqual(FileManagerError.InvalidName, (await _service.CreateFolder("", name)).Error);
+ }
+
+ [TestMethod]
+ public async Task CreateFolder_TakenName_AlreadyExists()
+ {
+ File.WriteAllText(Path.Combine(_uploaded, "banners"), "a file");
+
+ Assert.AreEqual(FileManagerError.AlreadyExists, (await _service.CreateFolder("", "banners")).Error);
+ }
+
+ [TestMethod]
+ public async Task Rename_File_KeepsAnAllowedType()
+ {
+ File.WriteAllText(Path.Combine(_uploaded, "a.png"), "x");
+
+ Assert.AreEqual(FileManagerError.NotAllowedType, (await _service.Rename("a.png", "a.js")).Error);
+ Assert.AreEqual(FileManagerError.NotAllowedType, (await _service.Rename("a.png", "a.cshtml")).Error);
+ var ok = await _service.Rename("a.png", "b.png");
+
+ Assert.AreEqual(FileManagerError.None, ok.Error);
+ Assert.AreEqual("b.png", ok.Entry.Path);
+ Assert.IsTrue(File.Exists(Path.Combine(_uploaded, "b.png")));
+ }
+
+ [TestMethod]
+ public async Task Rename_Folder_Moves()
+ {
+ Directory.CreateDirectory(Path.Combine(_uploaded, "old", "inner"));
+
+ var result = await _service.Rename("old", "new");
+
+ Assert.AreEqual(FileManagerError.None, result.Error);
+ Assert.IsTrue(Directory.Exists(Path.Combine(_uploaded, "new", "inner")));
+ Assert.IsFalse(Directory.Exists(Path.Combine(_uploaded, "old")));
+ }
+
+ [TestMethod]
+ public async Task Rename_OntoAnotherEntry_AlreadyExists()
+ {
+ File.WriteAllText(Path.Combine(_uploaded, "a.png"), "a");
+ File.WriteAllText(Path.Combine(_uploaded, "b.png"), "b");
+
+ Assert.AreEqual(FileManagerError.AlreadyExists, (await _service.Rename("a.png", "b.png")).Error);
+ Assert.AreEqual("b", File.ReadAllText(Path.Combine(_uploaded, "b.png")));
+ }
+
+ [TestMethod]
+ public async Task Rename_CaseOnly_RenamesTheFileAndTheFolder()
+ {
+ File.WriteAllText(Path.Combine(_uploaded, "Logo.png"), "x");
+ Directory.CreateDirectory(Path.Combine(_uploaded, "Banners"));
+
+ var file = await _service.Rename("Logo.png", "logo.png");
+ var folder = await _service.Rename("Banners", "banners");
+
+ Assert.AreEqual(FileManagerError.None, file.Error);
+ Assert.AreEqual(FileManagerError.None, folder.Error);
+ CollectionAssert.AreEquivalent(new[] { "logo.png" }, Directory.GetFiles(_uploaded).Select(Path.GetFileName).ToArray());
+ CollectionAssert.AreEquivalent(new[] { "banners" }, Directory.GetDirectories(_uploaded).Select(Path.GetFileName).ToArray());
+ }
+
+ [TestMethod]
+ public async Task Rename_NameWithAPath_IsRefused()
+ {
+ File.WriteAllText(Path.Combine(_uploaded, "a.png"), "a");
+
+ Assert.AreEqual(FileManagerError.InvalidName, (await _service.Rename("a.png", "../../custom/a.png")).Error);
+ }
+
+ [TestMethod]
+ public async Task RenameAndDelete_Root_AreLocked()
+ {
+ Assert.AreEqual(FileManagerError.RootLocked, (await _service.Rename("", "x")).Error);
+ Assert.AreEqual(FileManagerError.RootLocked, (await _service.Delete("/")).Error);
+ Assert.IsTrue(Directory.Exists(_uploaded));
+ }
+
+ [TestMethod]
+ public async Task Delete_FolderWithContent_IsRemoved()
+ {
+ Directory.CreateDirectory(Path.Combine(_uploaded, "old"));
+ File.WriteAllText(Path.Combine(_uploaded, "old", "a.png"), "x");
+
+ Assert.AreEqual(FileManagerError.None, (await _service.Delete("old")).Error);
+ Assert.IsFalse(Directory.Exists(Path.Combine(_uploaded, "old")));
+ }
+
+ [TestMethod]
+ public async Task Delete_Traversal_LeavesTheSiteAlone()
+ {
+ Assert.AreEqual(FileManagerError.InvalidPath, (await _service.Delete("../../custom/site.js")).Error);
+ Assert.AreEqual(FileManagerError.InvalidPath, (await _service.Delete("..")).Error);
+ Assert.IsTrue(File.Exists(Path.Combine(_webRoot, "assets", "custom", "site.js")));
+ }
+
+ [TestMethod]
+ public async Task Delete_Missing_NotFound()
+ {
+ Assert.AreEqual(FileManagerError.NotFound, (await _service.Delete("nope.png")).Error);
+ }
+}
diff --git a/src/Web/Grand.SharedUIResources/Views/Shared/EditorTemplates/Editor.cshtml b/src/Web/Grand.SharedUIResources/Views/Shared/EditorTemplates/Editor.cshtml
index b786f4dc7b..5e464508c0 100644
--- a/src/Web/Grand.SharedUIResources/Views/Shared/EditorTemplates/Editor.cshtml
+++ b/src/Web/Grand.SharedUIResources/Views/Shared/EditorTemplates/Editor.cshtml
@@ -19,54 +19,52 @@
ViewData.TemplateInfo.FormattedModelValue /* Initial value */
)
-
diff --git a/src/Web/Grand.Web.Admin/Controllers/ElFinderController.cs b/src/Web/Grand.Web.Admin/Controllers/ElFinderController.cs
deleted file mode 100644
index ad37af58d1..0000000000
--- a/src/Web/Grand.Web.Admin/Controllers/ElFinderController.cs
+++ /dev/null
@@ -1,50 +0,0 @@
-using Grand.Domain.Permissions;
-using Grand.Web.AdminShared.Interfaces;
-using Grand.Web.Common.Security.Authorization;
-using Microsoft.AspNetCore.Mvc;
-
-namespace Grand.Web.Admin.Controllers;
-
-[PermissionAuthorize(PermissionSystemName.HtmlEditor)]
-public class ElFinderController : BaseAdminController
-{
- #region Ctor
-
- public ElFinderController(
- IRequestPermissionService permissionService,
- IElFinderViewModelService elFinderViewModelService
- )
- {
- _elFinderViewModelService = elFinderViewModelService;
- _permissionService = permissionService;
- }
-
- #endregion
-
- #region Fields
-
- private readonly IRequestPermissionService _permissionService;
- private readonly IElFinderViewModelService _elFinderViewModelService;
-
- #endregion
-
- #region Methods
-
- public virtual async Task Connector()
- {
- if (!await _permissionService.Authorize(StandardPermission.HtmlEditorManagePictures))
- return new JsonResult(new { error = "You don't have required permission" });
-
- return await _elFinderViewModelService.Connector();
- }
-
- public async Task Thumb(string id)
- {
- if (!await _permissionService.Authorize(StandardPermission.HtmlEditorManagePictures))
- return new JsonResult(new { error = "You don't have required permission" });
-
- return await _elFinderViewModelService.Thumbs(id);
- }
-
- #endregion
-}
\ No newline at end of file
diff --git a/src/Web/Grand.Web.Admin/Controllers/FileManagerController.cs b/src/Web/Grand.Web.Admin/Controllers/FileManagerController.cs
new file mode 100644
index 0000000000..f0af382df6
--- /dev/null
+++ b/src/Web/Grand.Web.Admin/Controllers/FileManagerController.cs
@@ -0,0 +1,156 @@
+using Grand.Domain.Permissions;
+using Grand.Web.AdminShared.Interfaces;
+using Grand.Web.AdminShared.Models.FileManager;
+using Grand.Web.AdminShared.Services;
+using Grand.Web.Common.Localization;
+using Grand.Web.Common.Security.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Grand.Web.Admin.Controllers;
+
+/// The file manager of the rich text editor ("Manage pictures"); every answer is JSON
+[PermissionAuthorize(PermissionSystemName.HtmlEditor)]
+public class FileManagerController : BaseAdminController
+{
+ #region Ctor
+
+ public FileManagerController(
+ IFileManagerService fileManagerService,
+ IRequestPermissionService permissionService,
+ IRequestTranslationService translationService)
+ {
+ _fileManagerService = fileManagerService;
+ _permissionService = permissionService;
+ _translationService = translationService;
+ }
+
+ #endregion
+
+ #region Fields
+
+ private readonly IFileManagerService _fileManagerService;
+ private readonly IRequestPermissionService _permissionService;
+ private readonly IRequestTranslationService _translationService;
+
+ #endregion
+
+ #region Methods
+
+ [HttpGet]
+ public async Task List(string path)
+ {
+ if (!await CanManage())
+ return Denied();
+
+ var listing = _fileManagerService.List(path);
+ if (listing.Error != FileManagerError.None)
+ return Failed(listing.Error);
+
+ return Json(new {
+ success = true,
+ path = listing.Path,
+ crumbs = listing.Crumbs.Select(c => new { name = c.Name, path = c.Path }),
+ entries = listing.Entries.Select(ToJson)
+ });
+ }
+
+ //the form is read whole before any file's size is checked, so the request itself has a ceiling
+ [HttpPost]
+ [RequestSizeLimit(FileManagerService.MaxUploadRequestBytes)]
+ [RequestFormLimits(MultipartBodyLengthLimit = FileManagerService.MaxUploadRequestBytes)]
+ public async Task Upload(string path)
+ {
+ if (!await CanManage())
+ return Denied();
+
+ var files = Request.HasFormContentType
+ ? Request.Form.Files.Select(f => new FileManagerUpload(f.FileName, f.Length, f.OpenReadStream)).ToList()
+ : [];
+ var result = await _fileManagerService.Upload(path, files);
+ if (result.Error != FileManagerError.None)
+ return Failed(result.Error);
+
+ return Json(new {
+ success = true,
+ uploaded = result.Uploaded.Select(ToJson),
+ rejected = result.Rejected.Select(r => new { name = r.Name, message = Message(r.Error) })
+ });
+ }
+
+ [HttpPost]
+ public async Task CreateFolder(string path, string name)
+ {
+ if (!await CanManage())
+ return Denied();
+
+ return Answer(await _fileManagerService.CreateFolder(path, name));
+ }
+
+ [HttpPost]
+ public async Task Rename(string path, string name)
+ {
+ if (!await CanManage())
+ return Denied();
+
+ return Answer(await _fileManagerService.Rename(path, name));
+ }
+
+ [HttpPost]
+ public async Task Delete(string path)
+ {
+ if (!await CanManage())
+ return Denied();
+
+ return Answer(await _fileManagerService.Delete(path));
+ }
+
+ #endregion
+
+ #region Utilities
+
+ private Task CanManage()
+ {
+ return _permissionService.Authorize(StandardPermission.HtmlEditorManagePictures);
+ }
+
+ private IActionResult Denied()
+ {
+ return Json(new { success = false, message = _translationService.GetResource("Admin.FileManager.Error.AccessDenied") });
+ }
+
+ private IActionResult Answer(FileManagerResult result)
+ {
+ return result.Error == FileManagerError.None
+ ? Json(new { success = true, entry = result.Entry == null ? null : ToJson(result.Entry) })
+ : Failed(result.Error);
+ }
+
+ //the panel's serializer keeps C# names (PascalCase) for the grids; the file manager script reads camelCase
+ private static object ToJson(FileManagerEntry entry)
+ {
+ return new {
+ name = entry.Name,
+ path = entry.Path,
+ isFolder = entry.IsFolder,
+ url = entry.Url,
+ kind = entry.Kind,
+ size = entry.Size,
+ modifiedUtc = entry.ModifiedUtc
+ };
+ }
+
+ private IActionResult Failed(FileManagerError error)
+ {
+ return Json(new { success = false, message = Message(error) });
+ }
+
+ private string Message(FileManagerError error)
+ {
+ var message = _translationService.GetResource($"Admin.FileManager.Error.{error}");
+ return error == FileManagerError.TooLarge
+ ? string.Format(message, FileManagerService.MaxUploadBytes / (1024 * 1024))
+ : message;
+ }
+
+ #endregion
+}
diff --git a/src/Web/Grand.Web.Admin/Startup/StartupApplication.cs b/src/Web/Grand.Web.Admin/Startup/StartupApplication.cs
index 23cdc2c441..784c2b9f6b 100644
--- a/src/Web/Grand.Web.Admin/Startup/StartupApplication.cs
+++ b/src/Web/Grand.Web.Admin/Startup/StartupApplication.cs
@@ -1,5 +1,3 @@
-using elFinder.Net.AspNetCore.Extensions;
-using elFinder.Net.Drivers.FileSystem.Extensions;
using Grand.Infrastructure;
using Grand.Web.Admin.Infrastructure;
using Grand.Web.Common.View;
diff --git a/src/Web/Grand.Web.AdminShared/Grand.Web.AdminShared.csproj b/src/Web/Grand.Web.AdminShared/Grand.Web.AdminShared.csproj
index 29d97d48e7..f2086946eb 100644
--- a/src/Web/Grand.Web.AdminShared/Grand.Web.AdminShared.csproj
+++ b/src/Web/Grand.Web.AdminShared/Grand.Web.AdminShared.csproj
@@ -11,17 +11,4 @@
-
-
-
-
-
-
-
-
-
diff --git a/src/Web/Grand.Web.AdminShared/Interfaces/IElFinderViewModelService.cs b/src/Web/Grand.Web.AdminShared/Interfaces/IElFinderViewModelService.cs
deleted file mode 100644
index c5f904fdb8..0000000000
--- a/src/Web/Grand.Web.AdminShared/Interfaces/IElFinderViewModelService.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-using Microsoft.AspNetCore.Mvc;
-
-namespace Grand.Web.AdminShared.Interfaces;
-
-public interface IElFinderViewModelService
-{
- Task SetupConnectorAsync();
- Task Connector();
- Task Thumbs(string id);
-}
\ No newline at end of file
diff --git a/src/Web/Grand.Web.AdminShared/Interfaces/IFileManagerService.cs b/src/Web/Grand.Web.AdminShared/Interfaces/IFileManagerService.cs
new file mode 100644
index 0000000000..771decc020
--- /dev/null
+++ b/src/Web/Grand.Web.AdminShared/Interfaces/IFileManagerService.cs
@@ -0,0 +1,16 @@
+using Grand.Web.AdminShared.Models.FileManager;
+
+namespace Grand.Web.AdminShared.Interfaces;
+
+///
+/// The files the rich text editor picks from: a folder tree under assets/images/uploaded. Every path is relative to
+/// that root and never leaves it.
+///
+public interface IFileManagerService
+{
+ FileManagerListing List(string path);
+ Task Upload(string path, IReadOnlyList files);
+ Task CreateFolder(string path, string name);
+ Task Rename(string path, string name);
+ Task Delete(string path);
+}
diff --git a/src/Web/Grand.Web.AdminShared/Models/FileManager/FileManagerModels.cs b/src/Web/Grand.Web.AdminShared/Models/FileManager/FileManagerModels.cs
new file mode 100644
index 0000000000..fac7930e38
--- /dev/null
+++ b/src/Web/Grand.Web.AdminShared/Models/FileManager/FileManagerModels.cs
@@ -0,0 +1,45 @@
+namespace Grand.Web.AdminShared.Models.FileManager;
+
+/// Why a file manager operation was refused
+public enum FileManagerError
+{
+ None,
+ InvalidPath,
+ InvalidName,
+ NotFound,
+ AlreadyExists,
+ NotAllowedType,
+ TooLarge,
+ RootLocked
+}
+
+/// A folder or a file, its path relative to the root of the file manager
+public record FileManagerEntry(string Name, string Path, bool IsFolder, string Url, string Kind, long Size, DateTime ModifiedUtc);
+
+/// One step of the breadcrumb, the root first
+public record FileManagerCrumb(string Name, string Path);
+
+public record FileManagerListing(
+ FileManagerError Error,
+ string Path,
+ IReadOnlyList Crumbs,
+ IReadOnlyList Entries)
+{
+ public static FileManagerListing Failed(FileManagerError error) => new(error, null, [], []);
+}
+
+public record FileManagerResult(FileManagerError Error, FileManagerEntry Entry = null)
+{
+ public static readonly FileManagerResult Done = new(FileManagerError.None);
+}
+
+/// A file that was not uploaded, and why
+public record FileManagerRejectedFile(string Name, FileManagerError Error);
+
+public record FileManagerUploadResult(
+ FileManagerError Error,
+ IReadOnlyList Uploaded,
+ IReadOnlyList Rejected);
+
+/// A file to upload, read once
+public record FileManagerUpload(string FileName, long Length, Func OpenReadStream);
diff --git a/src/Web/Grand.Web.AdminShared/Models/Settings/MediaSettingsModel.cs b/src/Web/Grand.Web.AdminShared/Models/Settings/MediaSettingsModel.cs
index 91b655fb9b..fa8d9711e6 100644
--- a/src/Web/Grand.Web.AdminShared/Models/Settings/MediaSettingsModel.cs
+++ b/src/Web/Grand.Web.AdminShared/Models/Settings/MediaSettingsModel.cs
@@ -78,12 +78,6 @@ public class MediaSettingsModel : BaseModel
[GrandResourceDisplayName("Admin.Settings.Media.AllowedFileTypes")]
public string AllowedFileTypes { get; set; }
- [GrandResourceDisplayName("Admin.Settings.Media.FileManagerEnabledCommands")]
- public string FileManagerEnabledCommands { get; set; }
-
- [GrandResourceDisplayName("Admin.Settings.Media.FileManagerDisabledUICommands")]
- public string FileManagerDisabledUICommands { get; set; }
-
[GrandResourceDisplayName("Admin.Settings.Media.DefaultImageName")]
public string DefaultImageName { get; set; } = "no-image.png";
diff --git a/src/Web/Grand.Web.AdminShared/Services/BaseInfoResponseConverter.cs b/src/Web/Grand.Web.AdminShared/Services/BaseInfoResponseConverter.cs
deleted file mode 100644
index 6d001f896b..0000000000
--- a/src/Web/Grand.Web.AdminShared/Services/BaseInfoResponseConverter.cs
+++ /dev/null
@@ -1,37 +0,0 @@
-using elFinder.Net.Core.Models.FileInfo;
-using System.Text.Json;
-using System.Text.Json.Serialization;
-
-namespace Grand.Web.AdminShared.Services;
-
-///
-/// Writes elFinder file info by its runtime type.
-/// OpenResponse.cwd is declared as BaseInfoResponse while the instance is a DirectoryInfoResponse
-/// or a RootInfoResponse, and System.Text.Json serializes the declared type - so phash, volumeid
-/// and dirs never reach the browser. elFinder caches cwd in its file map, overwriting the complete
-/// entry it got from files[] with a parentless one, and can no longer walk a file back to the
-/// volume root. It then builds URLs as volume url + file name, dropping every subdirectory, so
-/// picking a picture out of a subfolder yields a 404.
-///
-public class BaseInfoResponseConverter : JsonConverter
-{
- ///
- /// Only the declared base type is intercepted; the nested Write call resolves the runtime type
- /// through the default converter and does not re-enter this one.
- ///
- public override bool CanConvert(Type typeToConvert)
- {
- return typeToConvert == typeof(BaseInfoResponse);
- }
-
- public override BaseInfoResponse Read(ref Utf8JsonReader reader, Type typeToConvert,
- JsonSerializerOptions options)
- {
- throw new NotSupportedException("The elFinder connector response is write-only");
- }
-
- public override void Write(Utf8JsonWriter writer, BaseInfoResponse value, JsonSerializerOptions options)
- {
- JsonSerializer.Serialize(writer, value, value.GetType(), options);
- }
-}
diff --git a/src/Web/Grand.Web.AdminShared/Services/ElFinderViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/ElFinderViewModelService.cs
deleted file mode 100644
index bc7dc69954..0000000000
--- a/src/Web/Grand.Web.AdminShared/Services/ElFinderViewModelService.cs
+++ /dev/null
@@ -1,154 +0,0 @@
-using elFinder.Net.AspNetCore.Extensions;
-using elFinder.Net.AspNetCore.Helper;
-using elFinder.Net.Core;
-using Grand.Business.Core.Interfaces.Storage;
-using Grand.Domain.Media;
-using Grand.SharedKernel.Extensions;
-using Grand.Web.AdminShared.Interfaces;
-using Microsoft.AspNetCore.Http;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.AspNetCore.Routing;
-using Microsoft.Extensions.Configuration;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Options;
-using System.Text.Json;
-
-namespace Grand.Web.AdminShared.Services;
-
-public class ElFinderViewModelService : IElFinderViewModelService
-{
- private readonly JsonSerializerOptions _connectorJsonOptions;
- private readonly IConnector _connector;
- private readonly IDriver _driver;
- private readonly string _fullPathToThumbs;
-
- private readonly string _fullPathToUpload;
- private readonly IHttpContextAccessor _httpContextAccessor;
- private readonly LinkGenerator _linkGenerator;
- private readonly IMediaFileStore _mediaFileStore;
- private readonly MediaSettings _mediaSettings;
- private readonly string _urlpathUploded;
- private readonly string _urlThumb;
-
- private static string ImageThumbPath => Path.Combine("assets", "images", "thumbs");
- private static string ImageUploadedPath => Path.Combine("assets", "images", "uploaded");
-
- public ElFinderViewModelService(
- IDriver driver,
- IConnector connector,
- IHttpContextAccessor httpContextAccessor,
- IMediaFileStore mediaFileStore,
- LinkGenerator linkGenerator,
- MediaSettings mediaSettings,
- IOptions jsonOptions)
- {
- _connectorJsonOptions = new JsonSerializerOptions(jsonOptions.Value.JsonSerializerOptions);
- _connectorJsonOptions.Converters.Add(new BaseInfoResponseConverter());
-
- _driver = driver;
- _connector = connector;
- _httpContextAccessor = httpContextAccessor;
- _linkGenerator = linkGenerator;
- _mediaFileStore = mediaFileStore;
- _mediaSettings = mediaSettings;
-
- if (!string.IsNullOrEmpty(_mediaSettings.FileManagerEnabledCommands))
- _connector.Options.EnabledCommands =
- _mediaSettings.FileManagerEnabledCommands.Split(',').Select(x => x.Trim()).ToList();
-
- if (!string.IsNullOrEmpty(_mediaSettings.FileManagerDisabledUICommands))
- _connector.Options.DisabledUICommands = _mediaSettings.FileManagerDisabledUICommands.Split(',')
- .Select(x => x.Trim()).ToList();
-
- var uploaded = _mediaFileStore.GetDirectoryInfo(ImageUploadedPath);
- if (uploaded == null)
- {
- _mediaFileStore.TryCreateDirectory(ImageUploadedPath);
- uploaded = _mediaFileStore.GetDirectoryInfo(ImageUploadedPath);
- }
-
- _fullPathToUpload = uploaded.PhysicalPath;
- var configuration = httpContextAccessor.HttpContext!.RequestServices.GetRequiredService();
-
- _urlpathUploded = (string.IsNullOrEmpty(configuration[CommonPath.DirectoryParam]) ? "/" : $"/{configuration[CommonPath.DirectoryParam]}/")
- + uploaded.Path.Replace("\\", "/") + "/";
-
- var thumbs = _mediaFileStore.GetDirectoryInfo(ImageThumbPath);
- if (thumbs == null)
- {
- _mediaFileStore.TryCreateDirectory(ImageThumbPath);
- thumbs = _mediaFileStore.GetDirectoryInfo(ImageThumbPath);
- }
-
- _fullPathToThumbs = thumbs.PhysicalPath;
-
- _urlThumb =
- $"{_linkGenerator.GetPathByAction(_httpContextAccessor.HttpContext!, "Thumb", "ElFinder", new { area = "Admin" })}/";
- }
-
- public virtual async Task SetupConnectorAsync()
- {
- var volume = new Volume(_driver,
- _fullPathToUpload,
- _fullPathToThumbs,
- _urlpathUploded,
- _urlThumb) {
- Name = "Volume",
- MaxUploadConnections = 3,
- MaxUploadSizeInMb = 4,
- ObjectAttributes = new List {
- new() {
- FileFilter = file => NotAllowedExtensions(file.Extension),
- ObjectFilter = obj =>
- {
- var extensions = Path.GetExtension(obj.FullName);
- if (!string.IsNullOrEmpty(extensions))
- return NotAllowedExtensions(extensions);
-
- return false;
- },
- ShowOnly = false, Access = false, Visible = false, Write = false, Read = false
- }
- }
- };
- _connector.AddVolume(volume);
- await volume.Driver.SetupVolumeAsync(volume);
- }
-
- public virtual async Task Connector()
- {
- await SetupConnectorAsync();
- var cmd = ConnectorHelper.ParseCommand(_httpContextAccessor.HttpContext.Request);
- if (cmd.Cmd == "upload" && cmd.Files != null && cmd.Files.Any())
- cmd.Files = cmd.Files.Where(x => !NotAllowedExtensions(Path.GetExtension(x.FileName))).ToList();
-
- var ccTokenSource = ConnectorHelper.RegisterCcTokenSource(_httpContextAccessor.HttpContext);
- var conResult = await _connector.ProcessAsync(cmd, ccTokenSource);
- var actionResult = conResult.ToActionResult(_httpContextAccessor.HttpContext);
-
- //serialize file info by its runtime type, otherwise cwd loses phash - see BaseInfoResponseConverter
- if (actionResult is JsonResult jsonResult)
- jsonResult.SerializerSettings = _connectorJsonOptions;
-
- return actionResult;
- }
-
- public virtual async Task Thumbs(string id)
- {
- await SetupConnectorAsync();
-
- var thumb = await _connector.GetThumbAsync(id, _httpContextAccessor.HttpContext.RequestAborted);
- var actionResult = ConnectorHelper.GetThumbResult(thumb);
- return actionResult;
- }
-
- protected virtual bool NotAllowedExtensions(string extensions)
- {
- var allowedFileTypes = new List();
- allowedFileTypes = string.IsNullOrEmpty(_mediaSettings.AllowedFileTypes)
- ? [".gif", ".jpg", ".jpeg", ".png", ".bmp", ".webp"]
- : _mediaSettings.AllowedFileTypes.Split(',').Select(x => x.Trim().ToLowerInvariant()).ToList();
-
- return !allowedFileTypes.Contains(extensions);
- }
-}
\ No newline at end of file
diff --git a/src/Web/Grand.Web.AdminShared/Services/FileManagerService.cs b/src/Web/Grand.Web.AdminShared/Services/FileManagerService.cs
new file mode 100644
index 0000000000..3f00f0ef29
--- /dev/null
+++ b/src/Web/Grand.Web.AdminShared/Services/FileManagerService.cs
@@ -0,0 +1,282 @@
+using Grand.Business.Core.Interfaces.Storage;
+using Grand.Domain.Media;
+using Grand.SharedKernel.Extensions;
+using Grand.Web.AdminShared.Interfaces;
+using Grand.Web.AdminShared.Models.FileManager;
+using Microsoft.AspNetCore.StaticFiles;
+using Microsoft.Extensions.Configuration;
+
+namespace Grand.Web.AdminShared.Services;
+
+public class FileManagerService : IFileManagerService
+{
+ /// The root of the file manager, relative to the media store
+ public const string RootPath = "assets/images/uploaded";
+
+ /// The largest file accepted, as elFinder's volume did before
+ public const long MaxUploadBytes = 4 * 1024 * 1024;
+
+ /// The largest upload request, several files at once
+ public const long MaxUploadRequestBytes = 8 * MaxUploadBytes;
+
+ private const int MaxNameLength = 255;
+
+ //the characters Windows refuses in a name, checked on every platform so a name valid here is valid on any host
+ private static readonly char[] InvalidNameChars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|'];
+
+ private static readonly HashSet ReservedNames = new(StringComparer.OrdinalIgnoreCase) {
+ "CON", "PRN", "AUX", "NUL",
+ "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
+ "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"
+ };
+
+ private static readonly FileExtensionContentTypeProvider ContentTypes = new();
+
+ private readonly IMediaFileStore _mediaFileStore;
+ private readonly MediaSettings _mediaSettings;
+ private readonly string _urlPrefix;
+
+ public FileManagerService(IMediaFileStore mediaFileStore, MediaSettings mediaSettings, IConfiguration configuration)
+ {
+ _mediaFileStore = mediaFileStore;
+ _mediaSettings = mediaSettings;
+ var directory = configuration[CommonPath.DirectoryParam];
+ _urlPrefix = string.IsNullOrEmpty(directory) ? "/" : $"/{directory.Trim('/')}/";
+ }
+
+ public virtual FileManagerListing List(string path)
+ {
+ if (!TryGetSegments(path, out var segments))
+ return FileManagerListing.Failed(FileManagerError.InvalidPath);
+
+ EnsureRoot();
+ var storePath = StorePath(segments);
+ if (_mediaFileStore.GetDirectoryInfo(storePath) is not { IsDirectory: true })
+ return FileManagerListing.Failed(FileManagerError.NotFound);
+
+ var allowed = AllowedTypes();
+ var entries = _mediaFileStore.GetDirectoryContent(storePath)
+ .Where(e => !e.Name.StartsWith('.'))
+ .Where(e => e.IsDirectory || allowed.IsAllowedMediaFileType(Path.GetExtension(e.Name)))
+ .Select(e => ToEntry(segments.Append(e.Name).ToArray(), e))
+ .OrderByDescending(e => e.IsFolder)
+ .ThenBy(e => e.Name, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ var crumbs = new List { new("", "") };
+ for (var i = 0; i < segments.Length; i++)
+ crumbs.Add(new FileManagerCrumb(segments[i], string.Join('/', segments[..(i + 1)])));
+
+ return new FileManagerListing(FileManagerError.None, string.Join('/', segments), crumbs, entries);
+ }
+
+ public virtual async Task Upload(string path, IReadOnlyList files)
+ {
+ if (!TryGetSegments(path, out var segments))
+ return new FileManagerUploadResult(FileManagerError.InvalidPath, [], []);
+
+ EnsureRoot();
+ if (_mediaFileStore.GetDirectoryInfo(StorePath(segments)) is not { IsDirectory: true })
+ return new FileManagerUploadResult(FileManagerError.NotFound, [], []);
+
+ var allowed = AllowedTypes();
+ var uploaded = new List();
+ var rejected = new List();
+ foreach (var file in files ?? [])
+ {
+ //browsers send a bare name, older ones the whole client path
+ var name = Path.GetFileName((file.FileName ?? "").Replace('\\', '/'));
+ var error = !IsValidName(name) ? FileManagerError.InvalidName
+ : !allowed.IsAllowedMediaFileType(Path.GetExtension(name)) ? FileManagerError.NotAllowedType
+ : file.Length > MaxUploadBytes ? FileManagerError.TooLarge
+ : FileManagerError.None;
+ if (error != FileManagerError.None)
+ {
+ rejected.Add(new FileManagerRejectedFile(name, error));
+ continue;
+ }
+
+ //an upload never replaces a file: a taken name gets a number
+ var target = await FreeName(segments, name);
+ var targetSegments = segments.Append(target).ToArray();
+ await using (var stream = file.OpenReadStream())
+ {
+ await _mediaFileStore.CreateFileFromStream(StorePath(targetSegments), stream);
+ }
+
+ uploaded.Add(ToEntry(targetSegments, await _mediaFileStore.GetFileInfo(StorePath(targetSegments))));
+ }
+
+ return new FileManagerUploadResult(FileManagerError.None, uploaded, rejected);
+ }
+
+ public virtual async Task CreateFolder(string path, string name)
+ {
+ if (!TryGetSegments(path, out var segments))
+ return new FileManagerResult(FileManagerError.InvalidPath);
+ name = name?.Trim();
+ if (!IsValidName(name))
+ return new FileManagerResult(FileManagerError.InvalidName);
+
+ EnsureRoot();
+ if (_mediaFileStore.GetDirectoryInfo(StorePath(segments)) is not { IsDirectory: true })
+ return new FileManagerResult(FileManagerError.NotFound);
+
+ var target = segments.Append(name).ToArray();
+ if (await Exists(target))
+ return new FileManagerResult(FileManagerError.AlreadyExists);
+
+ _mediaFileStore.TryCreateDirectory(StorePath(target));
+ return new FileManagerResult(FileManagerError.None, ToEntry(target, _mediaFileStore.GetDirectoryInfo(StorePath(target))));
+ }
+
+ public virtual async Task Rename(string path, string name)
+ {
+ if (!TryGetSegments(path, out var segments))
+ return new FileManagerResult(FileManagerError.InvalidPath);
+ if (segments.Length == 0)
+ return new FileManagerResult(FileManagerError.RootLocked);
+ name = name?.Trim();
+ if (!IsValidName(name))
+ return new FileManagerResult(FileManagerError.InvalidName);
+
+ var storePath = StorePath(segments);
+ var folder = _mediaFileStore.GetDirectoryInfo(storePath);
+ var file = folder == null ? await _mediaFileStore.GetFileInfo(storePath) : null;
+ if (folder == null && file == null)
+ return new FileManagerResult(FileManagerError.NotFound);
+
+ //a file keeps an allowed type: renaming must not turn a picture into a script the site serves
+ if (file != null && !AllowedTypes().IsAllowedMediaFileType(Path.GetExtension(name)))
+ return new FileManagerResult(FileManagerError.NotAllowedType);
+
+ var target = segments[..^1].Append(name).ToArray();
+ if (string.Equals(segments[^1], name, StringComparison.Ordinal))
+ return new FileManagerResult(FileManagerError.None, ToEntry(target, folder ?? file));
+ //a change of case only is a rename of the same entry; a case-insensitive file system sees the new name as taken,
+ //so that rename goes through a temporary name
+ var caseOnly = string.Equals(segments[^1], name, StringComparison.OrdinalIgnoreCase);
+ if (!caseOnly && await Exists(target))
+ return new FileManagerResult(FileManagerError.AlreadyExists);
+
+ if (caseOnly)
+ {
+ var temporary = $"{Guid.NewGuid():N}{Path.GetExtension(name)}";
+ await RenameEntry(folder != null, storePath, temporary);
+ storePath = StorePath(segments[..^1].Append(temporary));
+ }
+
+ await RenameEntry(folder != null, storePath, name);
+ return new FileManagerResult(FileManagerError.None, folder != null
+ ? ToEntry(target, _mediaFileStore.GetDirectoryInfo(StorePath(target)))
+ : ToEntry(target, await _mediaFileStore.GetFileInfo(StorePath(target))));
+ }
+
+ private Task RenameEntry(bool isFolder, string storePath, string name)
+ {
+ return isFolder ? _mediaFileStore.TryRenameDirectory(storePath, name) : _mediaFileStore.RenameFile(storePath, name);
+ }
+
+ public virtual async Task Delete(string path)
+ {
+ if (!TryGetSegments(path, out var segments))
+ return new FileManagerResult(FileManagerError.InvalidPath);
+ if (segments.Length == 0)
+ return new FileManagerResult(FileManagerError.RootLocked);
+
+ var storePath = StorePath(segments);
+ if (_mediaFileStore.GetDirectoryInfo(storePath) != null)
+ return await _mediaFileStore.TryDeleteDirectory(storePath)
+ ? FileManagerResult.Done
+ : new FileManagerResult(FileManagerError.NotFound);
+
+ return await _mediaFileStore.TryDeleteFile(storePath)
+ ? FileManagerResult.Done
+ : new FileManagerResult(FileManagerError.NotFound);
+ }
+
+ ///
+ /// Splits a path relative to the root into names, each one checked. Anything that could step out of the root -
+ /// "..", ".", an empty segment, a drive or a reserved name - fails, so the result always lies under the root.
+ ///
+ public static bool TryGetSegments(string path, out string[] segments)
+ {
+ segments = [];
+ if (string.IsNullOrEmpty(path))
+ return true;
+
+ var parts = path.Replace('\\', '/').Trim('/').Split('/');
+ if (parts.Length == 1 && parts[0] == "")
+ return true;
+ if (!parts.All(IsValidName))
+ return false;
+
+ segments = parts;
+ return true;
+ }
+
+ /// A single file or folder name the store can hold on any platform and the site can serve
+ public static bool IsValidName(string name)
+ {
+ if (string.IsNullOrWhiteSpace(name) || name.Length > MaxNameLength)
+ return false;
+ //".", ".." and hidden files; Windows drops a trailing dot or space, so the stored name would differ
+ if (name.StartsWith('.') || name.EndsWith('.') || name.EndsWith(' ') || name.StartsWith(' '))
+ return false;
+ if (name.IndexOfAny(InvalidNameChars) >= 0 || name.Any(char.IsControl))
+ return false;
+
+ return !ReservedNames.Contains(Path.GetFileNameWithoutExtension(name));
+ }
+
+ protected virtual IList AllowedTypes()
+ {
+ return FileExtensions.GetAllowedMediaFileTypes(_mediaSettings.AllowedFileTypes);
+ }
+
+ private static string StorePath(IEnumerable segments)
+ {
+ return string.Join('/', new[] { RootPath }.Concat(segments));
+ }
+
+ private void EnsureRoot()
+ {
+ if (_mediaFileStore.GetDirectoryInfo(RootPath) == null)
+ _mediaFileStore.TryCreateDirectory(RootPath);
+ }
+
+ private async Task Exists(string[] segments)
+ {
+ var storePath = StorePath(segments);
+ return _mediaFileStore.GetDirectoryInfo(storePath) != null || await _mediaFileStore.GetFileInfo(storePath) != null;
+ }
+
+ private async Task FreeName(string[] folder, string name)
+ {
+ var baseName = Path.GetFileNameWithoutExtension(name);
+ var extension = Path.GetExtension(name);
+ var candidate = name;
+ for (var i = 1; await Exists(folder.Append(candidate).ToArray()); i++)
+ candidate = $"{baseName} ({i}){extension}";
+ return candidate;
+ }
+
+ private FileManagerEntry ToEntry(string[] segments, IFileStoreEntry entry)
+ {
+ var relative = string.Join('/', segments);
+ if (entry.IsDirectory)
+ return new FileManagerEntry(entry.Name, relative, true, null, "folder", 0, entry.LastModifiedUtc);
+
+ var url = _urlPrefix + string.Join('/', StorePath(segments).Split('/').Select(Uri.EscapeDataString));
+ return new FileManagerEntry(entry.Name, relative, false, url, Kind(entry.Name), entry.Length, entry.LastModifiedUtc);
+ }
+
+ private static string Kind(string name)
+ {
+ if (!ContentTypes.TryGetContentType(name, out var contentType))
+ return "file";
+ if (contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
+ return "image";
+ return contentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase) ? "video" : "file";
+ }
+}
diff --git a/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs b/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs
index 5046f8fa63..36ccc12f2e 100644
--- a/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs
+++ b/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs
@@ -1,5 +1,3 @@
-using elFinder.Net.AspNetCore.Extensions;
-using elFinder.Net.Drivers.FileSystem.Extensions;
using Grand.Domain.Blogs;
using Grand.Domain.Catalog;
using Grand.Domain.Common;
@@ -28,12 +26,6 @@ public class StartupApplication : IStartupApplication
{
public void ConfigureServices(IServiceCollection services, IConfiguration configuration)
{
- #region elFinder
-
- services.AddElFinderAspNetCore().AddFileSystemDriver();
-
- #endregion
-
services.AddScoped();
services.AddScoped();
services.AddScoped();
@@ -90,7 +82,7 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config
services.AddScoped();
services.AddScoped();
services.AddScoped();
- services.AddScoped();
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
diff --git a/src/Web/Grand.Web.Store/Controllers/SettingController.cs b/src/Web/Grand.Web.Store/Controllers/SettingController.cs
index 2ed5355e8c..d042d9409b 100644
--- a/src/Web/Grand.Web.Store/Controllers/SettingController.cs
+++ b/src/Web/Grand.Web.Store/Controllers/SettingController.cs
@@ -597,17 +597,13 @@ public async Task Media(MediaSettingsModel model)
var storeScope = GetStoreScope();
var mediaSettings = await settingService.LoadSetting(storeScope);
- // Preserve file manager settings - store owners cannot change these
+ // Preserve the allowed file types - store owners cannot change them
var allowedFileTypes = mediaSettings.AllowedFileTypes;
- var fileManagerEnabledCommands = mediaSettings.FileManagerEnabledCommands;
- var fileManagerDisabledUICommands = mediaSettings.FileManagerDisabledUICommands;
mediaSettings = model.ToEntity(mediaSettings);
- // Restore preserved file manager settings
+ // Restore the preserved allowed file types
mediaSettings.AllowedFileTypes = allowedFileTypes;
- mediaSettings.FileManagerEnabledCommands = fileManagerEnabledCommands;
- mediaSettings.FileManagerDisabledUICommands = fileManagerDisabledUICommands;
await settingService.SaveSetting(mediaSettings, storeScope);
await ClearCache();
diff --git a/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml
index ed2806ad8a..9c81057e3e 100644
Binary files a/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml and b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml differ
diff --git a/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml b/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml
index bc54049a0d..c3a246c9ab 100644
--- a/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml
+++ b/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml
@@ -135,4 +135,67 @@
This product is not available in the order's store.
+
+ Up
+
+
+ New folder
+
+
+ Upload
+
+
+ Rename
+
+
+ Insert
+
+
+ This folder is empty
+
+
+ Files
+
+
+ Folder name
+
+
+ New name
+
+
+ Delete {0}?
+
+
+ Delete the folder {0} and everything in it?
+
+
+ Uploading...
+
+
+ The folder path is not valid.
+
+
+ This name cannot be used. A name cannot start or end with a dot or a space, or contain \ / : * ? " < > |.
+
+
+ The file or folder no longer exists.
+
+
+ A file or folder with this name already exists.
+
+
+ This file type is not allowed. The allowed types are set in Configuration > Settings > Media.
+
+
+ The file is larger than {0} MB.
+
+
+ The main folder cannot be renamed or deleted.
+
+
+ You do not have permission to manage pictures.
+
+
+ The request failed. Try again.
+