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. +