From 15e2c1e580364361430bdf84cf4a8f0abc403ce4 Mon Sep 17 00:00:00 2001 From: support Date: Fri, 23 Oct 2026 23:02:39 +0200 Subject: [PATCH 1/2] Replace elFinder with an own file manager for the rich text editor The editor's "Manage pictures" opens GrandAdmin.fileManager (adminapp, Bootstrap 5 and plain JavaScript) instead of elFinder. The Admin FileManager controller lists, uploads, creates folders, renames and deletes under assets/images/uploaded through IMediaFileStore; FileManagerService keeps every path under that root, accepts only MediaSettings.AllowedFileTypes (on upload and on rename), refuses files over 4 MB and never replaces an existing file. elFinder is removed as a whole: the elFinder.Net packages (and the Microsoft.AspNetCore.Http reference that only patched their dependency graph), the connector, its JSON converter, the vendored client and its styles, and the two elFinder command settings of the media settings. --- Directory.Packages.props | 4 - src/Core/Grand.Domain/Media/MediaSettings.cs | 5 - .../Services/LayeredFileStoreTests.cs | 2 +- ...ngTests.MediaSettings_ToModel.verified.txt | 2 - .../BaseInfoResponseConverterTests.cs | 63 - .../Services/FileManagerServiceTests.cs | 312 + .../Shared/EditorTemplates/Editor.cshtml | 82 +- .../adminapp/README.md | 10 +- .../adminapp/src/admin.ui.js | 5 +- .../adminapp/src/styles/_custom.scss | 16 - .../adminapp/src/ui/filemanager.js | 428 ++ .../adminapp/src/ui/filemanager.test.js | 172 + .../adminapp/src/ui/ui.css | 173 + .../bundles/admin.bootstrap.css | 2 +- .../bundles/admin.bootstrap.rtl.css | 2 +- .../administration/bundles/admin.ui.css | 2 +- .../administration/bundles/admin.ui.js | 3 +- .../elfinder/css/elfinder.full.css | 5350 ----------------- .../elfinder/css/elfinder.full.min.css | 8 - .../elfinder/css/elfinder.min.css | 9 - .../administration/elfinder/css/theme.css | 430 -- .../administration/elfinder/css/theme.min.css | 1 - .../elfinder/img/arrows-active.png | Bin 152 -> 0 bytes .../elfinder/img/arrows-normal.png | Bin 169 -> 0 bytes .../administration/elfinder/img/crop.gif | Bin 323 -> 0 bytes .../administration/elfinder/img/dialogs.png | Bin 5420 -> 0 bytes .../elfinder/img/edit_aceeditor.png | Bin 514 -> 0 bytes .../elfinder/img/edit_ckeditor.png | Bin 729 -> 0 bytes .../elfinder/img/edit_ckeditor5.png | Bin 141 -> 0 bytes .../elfinder/img/edit_codemirror.png | Bin 528 -> 0 bytes .../elfinder/img/edit_creativecloud.png | Bin 659 -> 0 bytes .../elfinder/img/edit_onlineconvert.png | Bin 584 -> 0 bytes .../elfinder/img/edit_pixlreditor.png | Bin 882 -> 0 bytes .../elfinder/img/edit_pixlrexpress.png | Bin 927 -> 0 bytes .../elfinder/img/edit_simplemde.png | Bin 144 -> 0 bytes .../elfinder/img/edit_tinymce.png | Bin 242 -> 0 bytes .../elfinder/img/edit_tuiimgedit.png | Bin 189 -> 0 bytes .../elfinder/img/edit_zohooffice.png | Bin 414 -> 0 bytes .../elfinder/img/editor-icons.png | Bin 2719 -> 0 bytes .../administration/elfinder/img/icons-big.png | Bin 6684 -> 0 bytes .../administration/elfinder/img/icons-big.svg | 1 - .../elfinder/img/icons-small.png | Bin 3667 -> 0 bytes .../administration/elfinder/img/logo.png | Bin 4865 -> 0 bytes .../administration/elfinder/img/progress.gif | Bin 667 -> 0 bytes .../elfinder/img/quicklook-bg.png | Bin 71 -> 0 bytes .../elfinder/img/quicklook-icons.png | Bin 1604 -> 0 bytes .../administration/elfinder/img/resize.png | Bin 83 -> 0 bytes .../elfinder/img/spinner-mini.gif | Bin 1434 -> 0 bytes .../administration/elfinder/img/toolbar.png | Bin 7471 -> 0 bytes .../administration/elfinder/img/trashmesh.png | Bin 109 -> 0 bytes .../elfinder/img/tui-icon-a.svg | 235 - .../elfinder/img/tui-icon-b.svg | 224 - .../elfinder/img/tui-icon-c.svg | 224 - .../elfinder/img/tui-icon-d.svg | 224 - .../elfinder/img/ui-icons_ffffff_256x240.png | Bin 3758 -> 0 bytes .../elfinder/img/volume_icon_box.png | Bin 621 -> 0 bytes .../elfinder/img/volume_icon_box.svg | 1 - .../elfinder/img/volume_icon_dropbox.png | Bin 413 -> 0 bytes .../elfinder/img/volume_icon_dropbox.svg | 1 - .../elfinder/img/volume_icon_ftp.png | Bin 370 -> 0 bytes .../elfinder/img/volume_icon_ftp.svg | 1 - .../elfinder/img/volume_icon_googledrive.png | Bin 663 -> 0 bytes .../elfinder/img/volume_icon_googledrive.svg | 1 - .../elfinder/img/volume_icon_local.png | Bin 357 -> 0 bytes .../elfinder/img/volume_icon_local.svg | 1 - .../elfinder/img/volume_icon_network.png | Bin 715 -> 0 bytes .../elfinder/img/volume_icon_network.svg | 1 - .../elfinder/img/volume_icon_onedrive.png | Bin 204 -> 0 bytes .../elfinder/img/volume_icon_onedrive.svg | 1 - .../elfinder/img/volume_icon_sql.png | Bin 572 -> 0 bytes .../elfinder/img/volume_icon_sql.svg | 1 - .../elfinder/img/volume_icon_trash.png | Bin 704 -> 0 bytes .../elfinder/img/volume_icon_trash.svg | 1 - .../elfinder/img/volume_icon_zip.png | Bin 475 -> 0 bytes .../elfinder/img/volume_icon_zip.svg | 1 - .../elfinder/js/elfinder.min.js | 25 - .../elfinder/js/extras/editors.default.js | 2644 -------- .../elfinder/js/extras/editors.default.min.js | 2 - .../js/extras/quicklook.googledocs.js | 75 - .../js/extras/quicklook.googledocs.min.js | 1 - .../elfinder/js/i18n/elfinder.LANG.js | 587 -- .../elfinder/js/i18n/elfinder.LANG.min.js | 1 - .../elfinder/js/i18n/elfinder.ar.js | 580 -- .../elfinder/js/i18n/elfinder.ar.min.js | 1 - .../elfinder/js/i18n/elfinder.bg.js | 559 -- .../elfinder/js/i18n/elfinder.bg.min.js | 1 - .../elfinder/js/i18n/elfinder.ca.js | 375 -- .../elfinder/js/i18n/elfinder.ca.min.js | 1 - .../elfinder/js/i18n/elfinder.cs.js | 581 -- .../elfinder/js/i18n/elfinder.cs.min.js | 1 - .../elfinder/js/i18n/elfinder.da.js | 580 -- .../elfinder/js/i18n/elfinder.da.min.js | 1 - .../elfinder/js/i18n/elfinder.de.js | 582 -- .../elfinder/js/i18n/elfinder.de.min.js | 1 - .../elfinder/js/i18n/elfinder.el.js | 374 -- .../elfinder/js/i18n/elfinder.el.min.js | 1 - .../elfinder/js/i18n/elfinder.es.js | 546 -- .../elfinder/js/i18n/elfinder.es.min.js | 1 - .../elfinder/js/i18n/elfinder.fa.js | 580 -- .../elfinder/js/i18n/elfinder.fa.min.js | 1 - .../elfinder/js/i18n/elfinder.fallback.js | 11 - .../elfinder/js/i18n/elfinder.fallback.min.js | 1 - .../elfinder/js/i18n/elfinder.fo.js | 419 -- .../elfinder/js/i18n/elfinder.fo.min.js | 1 - .../elfinder/js/i18n/elfinder.fr.js | 578 -- .../elfinder/js/i18n/elfinder.fr.min.js | 1 - .../elfinder/js/i18n/elfinder.fr_CA.js | 580 -- .../elfinder/js/i18n/elfinder.fr_CA.min.js | 1 - .../elfinder/js/i18n/elfinder.he.js | 375 -- .../elfinder/js/i18n/elfinder.he.min.js | 1 - .../elfinder/js/i18n/elfinder.hr.js | 434 -- .../elfinder/js/i18n/elfinder.hr.min.js | 1 - .../elfinder/js/i18n/elfinder.hu.js | 580 -- .../elfinder/js/i18n/elfinder.hu.min.js | 1 - .../elfinder/js/i18n/elfinder.id.js | 498 -- .../elfinder/js/i18n/elfinder.id.min.js | 1 - .../elfinder/js/i18n/elfinder.it.js | 552 -- .../elfinder/js/i18n/elfinder.it.min.js | 1 - .../elfinder/js/i18n/elfinder.ja.js | 581 -- .../elfinder/js/i18n/elfinder.ja.min.js | 1 - .../elfinder/js/i18n/elfinder.ko.js | 582 -- .../elfinder/js/i18n/elfinder.ko.min.js | 1 - .../elfinder/js/i18n/elfinder.nl.js | 581 -- .../elfinder/js/i18n/elfinder.nl.min.js | 1 - .../elfinder/js/i18n/elfinder.no.js | 374 -- .../elfinder/js/i18n/elfinder.no.min.js | 1 - .../elfinder/js/i18n/elfinder.pl.js | 580 -- .../elfinder/js/i18n/elfinder.pl.min.js | 1 - .../elfinder/js/i18n/elfinder.pt_BR.js | 580 -- .../elfinder/js/i18n/elfinder.pt_BR.min.js | 1 - .../elfinder/js/i18n/elfinder.ro.js | 417 -- .../elfinder/js/i18n/elfinder.ro.min.js | 1 - .../elfinder/js/i18n/elfinder.ru.js | 581 -- .../elfinder/js/i18n/elfinder.ru.min.js | 1 - .../elfinder/js/i18n/elfinder.si.js | 537 -- .../elfinder/js/i18n/elfinder.si.min.js | 1 - .../elfinder/js/i18n/elfinder.sk.js | 581 -- .../elfinder/js/i18n/elfinder.sk.min.js | 1 - .../elfinder/js/i18n/elfinder.sl.js | 374 -- .../elfinder/js/i18n/elfinder.sl.min.js | 1 - .../elfinder/js/i18n/elfinder.sr.js | 374 -- .../elfinder/js/i18n/elfinder.sr.min.js | 1 - .../elfinder/js/i18n/elfinder.sv.js | 375 -- .../elfinder/js/i18n/elfinder.sv.min.js | 1 - .../elfinder/js/i18n/elfinder.tr.js | 583 -- .../elfinder/js/i18n/elfinder.tr.min.js | 1 - .../elfinder/js/i18n/elfinder.ug_CN.js | 374 -- .../elfinder/js/i18n/elfinder.ug_CN.min.js | 1 - .../elfinder/js/i18n/elfinder.uk.js | 580 -- .../elfinder/js/i18n/elfinder.uk.min.js | 1 - .../elfinder/js/i18n/elfinder.vi.js | 579 -- .../elfinder/js/i18n/elfinder.vi.min.js | 1 - .../elfinder/js/i18n/elfinder.zh_CN.js | 585 -- .../elfinder/js/i18n/elfinder.zh_CN.min.js | 1 - .../elfinder/js/i18n/elfinder.zh_TW.js | 582 -- .../elfinder/js/i18n/elfinder.zh_TW.min.js | 1 - .../elfinder/js/i18n/help/cs.html.js | 10 - .../elfinder/js/i18n/help/de.html.js | 15 - .../elfinder/js/i18n/help/en.html.js | 10 - .../elfinder/js/i18n/help/es.html.js | 10 - .../elfinder/js/i18n/help/ja.html.js | 10 - .../elfinder/js/i18n/help/ko.html.js | 10 - .../elfinder/js/i18n/help/pl.html.js | 10 - .../elfinder/js/i18n/help/ru.html.js | 10 - .../elfinder/js/i18n/help/sk.html.js | 10 - .../elfinder/js/i18n/help/tr.html.js | 10 - .../elfinder/js/proxy/elFinderSupportVer1.js | 408 -- .../js/proxy/elFinderSupportVer1.min.js | 1 - .../elfinder/js/worker/calcfilehash.js | 20 - .../elfinder/js/worker/calcfilehash.min.js | 1 - .../elfinder/js/worker/quicklook.tiff.js | 7 - .../elfinder/js/worker/quicklook.tiff.min.js | 1 - .../elfinder/js/worker/quicklook.unzip.js | 55 - .../elfinder/js/worker/quicklook.unzip.min.js | 1 - .../Areas/Admin/Views/Setting/Media.cshtml | 2 - .../Views/Shared/Partials/HeadAdmin.cshtml | 3 - .../Controllers/ElFinderController.cs | 50 - .../Controllers/FileManagerController.cs | 153 + .../Startup/StartupApplication.cs | 2 - .../Grand.Web.AdminShared.csproj | 9 - .../Interfaces/IElFinderViewModelService.cs | 10 - .../Interfaces/IFileManagerService.cs | 16 + .../Models/FileManager/FileManagerModels.cs | 45 + .../Models/Settings/MediaSettingsModel.cs | 6 - .../Services/BaseInfoResponseConverter.cs | 37 - .../Services/ElFinderViewModelService.cs | 154 - .../Services/FileManagerService.cs | 279 + .../Startup/StartupApplication.cs | 10 +- .../Controllers/SettingController.cs | 8 +- .../App_Data/Resources/DefaultLanguage.xml | Bin 1585030 -> 1590302 bytes .../App_Data/Resources/Upgrade/en_250.xml | 63 + 191 files changed, 1700 insertions(+), 29701 deletions(-) delete mode 100644 src/Tests/Grand.Web.Admin.Tests/Services/BaseInfoResponseConverterTests.cs create mode 100644 src/Tests/Grand.Web.Admin.Tests/Services/FileManagerServiceTests.cs create mode 100644 src/Web/Grand.SharedUIResources/adminapp/src/ui/filemanager.js create mode 100644 src/Web/Grand.SharedUIResources/adminapp/src/ui/filemanager.test.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/css/elfinder.full.css delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/css/elfinder.full.min.css delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/css/elfinder.min.css delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/css/theme.css delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/css/theme.min.css delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/arrows-active.png delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/arrows-normal.png delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/crop.gif delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/dialogs.png delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/edit_aceeditor.png delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/edit_ckeditor.png delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/edit_ckeditor5.png delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/edit_codemirror.png delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/edit_creativecloud.png delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/edit_onlineconvert.png delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/edit_pixlreditor.png delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/edit_pixlrexpress.png delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/edit_simplemde.png delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/edit_tinymce.png delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/edit_tuiimgedit.png delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/edit_zohooffice.png delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/editor-icons.png delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/icons-big.png delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/icons-big.svg delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/icons-small.png delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/logo.png delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/progress.gif delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/quicklook-bg.png delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/quicklook-icons.png delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/resize.png delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/spinner-mini.gif delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/toolbar.png delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/trashmesh.png delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/tui-icon-a.svg delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/tui-icon-b.svg delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/tui-icon-c.svg delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/tui-icon-d.svg delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/ui-icons_ffffff_256x240.png delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/volume_icon_box.png delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/volume_icon_box.svg delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/volume_icon_dropbox.png delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/volume_icon_dropbox.svg delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/volume_icon_ftp.png delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/volume_icon_ftp.svg delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/volume_icon_googledrive.png delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/volume_icon_googledrive.svg delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/volume_icon_local.png delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/volume_icon_local.svg delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/volume_icon_network.png delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/volume_icon_network.svg delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/volume_icon_onedrive.png delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/volume_icon_onedrive.svg delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/volume_icon_sql.png delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/volume_icon_sql.svg delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/volume_icon_trash.png delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/volume_icon_trash.svg delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/volume_icon_zip.png delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/img/volume_icon_zip.svg delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/elfinder.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/extras/editors.default.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/extras/editors.default.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/extras/quicklook.googledocs.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/extras/quicklook.googledocs.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.LANG.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.LANG.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.ar.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.ar.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.bg.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.bg.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.ca.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.ca.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.cs.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.cs.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.da.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.da.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.de.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.de.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.el.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.el.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.es.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.es.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.fa.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.fa.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.fallback.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.fallback.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.fo.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.fo.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.fr.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.fr.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.fr_CA.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.fr_CA.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.he.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.he.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.hr.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.hr.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.hu.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.hu.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.id.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.id.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.it.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.it.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.ja.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.ja.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.ko.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.ko.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.nl.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.nl.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.no.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.no.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.pl.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.pl.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.pt_BR.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.pt_BR.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.ro.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.ro.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.ru.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.ru.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.si.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.si.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.sk.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.sk.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.sl.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.sl.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.sr.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.sr.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.sv.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.sv.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.tr.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.tr.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.ug_CN.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.ug_CN.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.uk.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.uk.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.vi.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.vi.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.zh_CN.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.zh_CN.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.zh_TW.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/elfinder.zh_TW.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/help/cs.html.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/help/de.html.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/help/en.html.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/help/es.html.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/help/ja.html.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/help/ko.html.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/help/pl.html.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/help/ru.html.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/help/sk.html.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/i18n/help/tr.html.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/proxy/elFinderSupportVer1.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/proxy/elFinderSupportVer1.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/worker/calcfilehash.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/worker/calcfilehash.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/worker/quicklook.tiff.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/worker/quicklook.tiff.min.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/worker/quicklook.unzip.js delete mode 100644 src/Web/Grand.SharedUIResources/wwwroot/administration/elfinder/js/worker/quicklook.unzip.min.js delete mode 100644 src/Web/Grand.Web.Admin/Controllers/ElFinderController.cs create mode 100644 src/Web/Grand.Web.Admin/Controllers/FileManagerController.cs delete mode 100644 src/Web/Grand.Web.AdminShared/Interfaces/IElFinderViewModelService.cs create mode 100644 src/Web/Grand.Web.AdminShared/Interfaces/IFileManagerService.cs create mode 100644 src/Web/Grand.Web.AdminShared/Models/FileManager/FileManagerModels.cs delete mode 100644 src/Web/Grand.Web.AdminShared/Services/BaseInfoResponseConverter.cs delete mode 100644 src/Web/Grand.Web.AdminShared/Services/ElFinderViewModelService.cs create mode 100644 src/Web/Grand.Web.AdminShared/Services/FileManagerService.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 83e2417aeb..315ce8cd02 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,7 +8,6 @@ - @@ -48,9 +47,6 @@ - - - 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/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.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..56676a0f27 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Controllers/FileManagerController.cs @@ -0,0 +1,153 @@ +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) + }); + } + + [HttpPost] + 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 59d418e9a2..f2086946eb 100644 --- a/src/Web/Grand.Web.AdminShared/Grand.Web.AdminShared.csproj +++ b/src/Web/Grand.Web.AdminShared/Grand.Web.AdminShared.csproj @@ -11,13 +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..665b0254b0 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Services/FileManagerService.cs @@ -0,0 +1,279 @@ +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; + + 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 ed2806ad8af0fa17a0e0c3e3a2525d729e86672f..9c81057e3ee07061b3d77f9b3edd891a08be68e1 100644 GIT binary patch delta 1439 zcmaJ>O=uit82)BwW_R|do1ILyvq{7qLn&*C>4qM}rfsqf+Jh}5P*9=NjM+_E+)cCF zq%rye;0|jz$c~{G7w_DsohMJu*yV_tZp8x)v3`p31*E0KyW` zvSBqTway@JcE&DUDByLVq|&O$0@%5BTq6fHIzwygx)pOlP!aw9+GT5BP@WUm-V5vp z_5i~`AFvx?&50qonr^M$r{_LYf(Gh-f)G)r+yChY{X4siY$&UsnMEXs$p59*OIOY- zK|1qcnm`rjqY5<(2((P*f%Gt~G|W!wznM0u{F|Pkl>sw7#Hl&nYR*Tlz&?jYoRt+} z`3D(o-A9yk*%maxqnv zjnW%d)_@+a80(r8ZLW35Q2+0;w^oo_T1e+6<2(VBD}<8YBi zDlJA{qGDauRQ}rfQV*syIRiHffgEbSs|?ZD5!KKoDGlCNDv|}Gg;R9?9evneUXRk1 zYswUFY{*?YytDHeDsFZZ=%3GmjhrsCN8?{dUL@zIjQ07x4vjYchzA{{%#-^9@BG=N z(TzPuF!ky@{vV|k^gnWJV_(_6T4_)*22H#+vM)0RVM)? fffTRzgu;3x6%&)U5 delta 101 zcmbQ&k=S-Wp`nGbg{g(Pg{6hHg{_6Xg` 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. + From c577ce2bd86c362a36e26a59102254e8479bdbde Mon Sep 17 00:00:00 2001 From: support Date: Sat, 24 Oct 2026 06:49:28 +0200 Subject: [PATCH 2/2] Fix folder rename on Linux and tighten the file store's path guards - FileSystemStore builds a renamed entry's path from the store path; the absolute Parent.FullName lost its leading slash and landed under the root twice on Linux - a new name must be a single name, and the root check requires a separator so a sibling folder sharing the root's prefix is outside - an empty entry in the allowed file types no longer allows files without an extension - the file manager upload request has its own size ceiling --- .../Services/FileSystemStore.cs | 36 ++++++++--- .../Extensions/AllowedFileExtensions.cs | 7 ++- .../Services/FileSystemStoreTests.cs | 62 +++++++++++++++++-- .../Extensions/FileExtensionsTests.cs | 37 +++++++++++ .../Controllers/FileManagerController.cs | 3 + .../Services/FileManagerService.cs | 3 + 6 files changed, 134 insertions(+), 14 deletions(-) create mode 100644 src/Tests/Grand.SharedKernel.Tests/Extensions/FileExtensionsTests.cs 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.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.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/Web/Grand.Web.Admin/Controllers/FileManagerController.cs b/src/Web/Grand.Web.Admin/Controllers/FileManagerController.cs index 56676a0f27..f0af382df6 100644 --- a/src/Web/Grand.Web.Admin/Controllers/FileManagerController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/FileManagerController.cs @@ -54,7 +54,10 @@ public async Task List(string path) }); } + //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()) diff --git a/src/Web/Grand.Web.AdminShared/Services/FileManagerService.cs b/src/Web/Grand.Web.AdminShared/Services/FileManagerService.cs index 665b0254b0..3f00f0ef29 100644 --- a/src/Web/Grand.Web.AdminShared/Services/FileManagerService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/FileManagerService.cs @@ -16,6 +16,9 @@ public class FileManagerService : IFileManagerService /// 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