diff --git a/OrchardCore.sln b/OrchardCore.sln index 172cbd5cfc9..962062771b3 100644 --- a/OrchardCore.sln +++ b/OrchardCore.sln @@ -527,6 +527,12 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OrchardCore.Queries.Core", EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OrchardCore.Sms.Azure", "src\OrchardCore.Modules\OrchardCore.Sms.Azure\OrchardCore.Sms.Azure.csproj", "{013C8BBF-6879-4B47-80C9-A466923E45E5}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OrchardCore.UrlRewriting", "src\OrchardCore.Modules\OrchardCore.UrlRewriting\OrchardCore.UrlRewriting.csproj", "{D0F8B342-BDA8-44CB-AA43-7A65C79636A2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrchardCore.UrlRewriting.Abstractions", "src\OrchardCore\OrchardCore.UrlRewriting.Abstractions\OrchardCore.UrlRewriting.Abstractions.csproj", "{675C8A76-C64F-47EC-B4F5-06D4F2D9662A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrchardCore.UrlRewriting.Core", "src\OrchardCore\OrchardCore.UrlRewriting.Core\OrchardCore.UrlRewriting.Core.csproj", "{7B18DD99-A7BB-4297-8679-D87289758756}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1395,6 +1401,18 @@ Global {013C8BBF-6879-4B47-80C9-A466923E45E5}.Debug|Any CPU.Build.0 = Debug|Any CPU {013C8BBF-6879-4B47-80C9-A466923E45E5}.Release|Any CPU.ActiveCfg = Release|Any CPU {013C8BBF-6879-4B47-80C9-A466923E45E5}.Release|Any CPU.Build.0 = Release|Any CPU + {D0F8B342-BDA8-44CB-AA43-7A65C79636A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D0F8B342-BDA8-44CB-AA43-7A65C79636A2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D0F8B342-BDA8-44CB-AA43-7A65C79636A2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D0F8B342-BDA8-44CB-AA43-7A65C79636A2}.Release|Any CPU.Build.0 = Release|Any CPU + {675C8A76-C64F-47EC-B4F5-06D4F2D9662A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {675C8A76-C64F-47EC-B4F5-06D4F2D9662A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {675C8A76-C64F-47EC-B4F5-06D4F2D9662A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {675C8A76-C64F-47EC-B4F5-06D4F2D9662A}.Release|Any CPU.Build.0 = Release|Any CPU + {7B18DD99-A7BB-4297-8679-D87289758756}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7B18DD99-A7BB-4297-8679-D87289758756}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7B18DD99-A7BB-4297-8679-D87289758756}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7B18DD99-A7BB-4297-8679-D87289758756}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1636,6 +1654,9 @@ Global {4BAA08A2-878C-4B96-86BF-5B3DB2B6C2C7} = {F23AC6C2-DE44-4699-999D-3C478EF3D691} {61B358F2-702C-40AA-9DF7-7121248FE6DE} = {F23AC6C2-DE44-4699-999D-3C478EF3D691} {013C8BBF-6879-4B47-80C9-A466923E45E5} = {A066395F-6F73-45DC-B5A6-B4E306110DCE} + {D0F8B342-BDA8-44CB-AA43-7A65C79636A2} = {A066395F-6F73-45DC-B5A6-B4E306110DCE} + {675C8A76-C64F-47EC-B4F5-06D4F2D9662A} = {F23AC6C2-DE44-4699-999D-3C478EF3D691} + {7B18DD99-A7BB-4297-8679-D87289758756} = {F23AC6C2-DE44-4699-999D-3C478EF3D691} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {46A1D25A-78D1-4476-9CBF-25B75E296341} diff --git a/mkdocs.yml b/mkdocs.yml index 1a8c392cb2a..d22589e4125 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -262,6 +262,7 @@ nav: - Workflows: reference/modules/Workflows/README.md - DataProtection (Azure Storage): reference/modules/DataProtection.Azure/README.md - Background Tasks: reference/modules/BackgroundTasks/README.md + - URL Rewriting: reference/modules/UrlRewriting/README.md - Reverse Proxy: reference/modules/ReverseProxy/README.md - Resources: - Learning: resources/README.md diff --git a/src/OrchardCore.Modules/OrchardCore.UrlRewriting/AdminMenu.cs b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/AdminMenu.cs new file mode 100644 index 00000000000..36a784eb8ac --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/AdminMenu.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.Localization; +using OrchardCore.Navigation; + +namespace OrchardCore.UrlRewriting; + +public sealed class AdminMenu : AdminNavigationProvider +{ + internal readonly IStringLocalizer S; + + public AdminMenu(IStringLocalizer stringLocalizer) + { + S = stringLocalizer; + } + + protected override ValueTask BuildAsync(NavigationBuilder builder) + { + builder + .Add(S["Configuration"], configuration => configuration + .Add(S["URL Rewriting"], S["URL Rewriting"].PrefixPosition(), rewriting => rewriting + .AddClass("url-rewriting") + .Id("urlRewriting") + .Permission(UrlRewritingPermissions.ManageUrlRewritingRules) + .Action("Index", "Admin", "OrchardCore.UrlRewriting") + .LocalNav() + ) + ); + + return ValueTask.CompletedTask; + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Assets.json b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Assets.json new file mode 100644 index 00000000000..86611620a8a --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Assets.json @@ -0,0 +1,14 @@ +[ + { + "inputs": [ + "Assets/js/sortable-rules.js" + ], + "output": "wwwroot/Scripts/sortable-rules.js" + }, + { + "inputs": [ + "Assets/js/admin-ui.js" + ], + "output": "wwwroot/Scripts/admin-ui.js" + } +] diff --git a/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Assets/js/admin-ui.js b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Assets/js/admin-ui.js new file mode 100644 index 00000000000..fe90d8a89af --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Assets/js/admin-ui.js @@ -0,0 +1,169 @@ +urlRewritingAdmin = function () { + + const initialize = (url, errorLabel, selectedLabel) => { + + // Create the sortable UI. + sortingListManager.create('#rewrite-rules-sortable-list', url, errorLabel); + + let searchBox = document.getElementById('search-box'); + let searchAlert = document.getElementById('list-alert'); + + const filterElements = document.querySelectorAll('[data-filter-value]'); + + // If the user press Enter, don't submit. + searchBox.addEventListener('keydown', function (e) { + if (e.key === 'Enter') { + e.preventDefault(); + } + }); + + searchBox.addEventListener('keyup', e => { + + var search = e.target.value.toLowerCase(); + // On ESC, clear the search box and display all rules. + if (e.key == 'Escape' || search == '') { + searchAlert.classList.add('d-none'); + searchBox.value = ''; + for (let i = 0; i < filterElements.length; i++) { + filterElements[i].classList.remove("d-none"); + filterElements[i].classList.remove("first-child-visible"); + filterElements[i].classList.remove("last-child-visible"); + } + + if (filterElements.length > 0) { + filterElements[0].classList.add('first-child-visible'); + filterElements[filterElements.length - 1].classList.add('last-child-visible'); + } + } else { + let visibleElements = []; + for (let i = 0; i < filterElements.length; i++) { + let filter = filterElements[i]; + + var text = filter.getAttribute('data-filter-value'); + + if (!text) { + filter.classList.add("d-none"); + continue; + } + + var found = text.indexOf(search) > -1; + + if (found) { + filter.classList.remove("d-none"); + filter.classList.remove("first-child-visible"); + filter.classList.remove("last-child-visible"); + visibleElements.push(filter); + } else { + filter.classList.add("d-none"); + } + } + + if (visibleElements.length > 0) { + visibleElements[0].classList.add('first-child-visible'); + visibleElements[visibleElements.length - 1].classList.add('last-child-visible'); + searchAlert.classList.add('d-none'); + } else { + searchAlert.classList.remove('d-none'); + } + } + }); + + var actions = document.getElementById('actions'); + var items = document.getElementById('items'); + var filters = document.querySelectorAll('.filter'); + var selectAllCtrl = document.getElementById('select-all'); + var selectedItems = document.getElementById('selected-items'); + var itemsCheckboxes = document.querySelectorAll("input[type='checkbox'][name='ruleIds']"); + + function displayActionsOrFilters() { + // Select all checked checkboxes with name 'ruleIds' + var checkedCheckboxes = document.querySelectorAll("input[type='checkbox'][name='ruleIds']:checked"); + + if (checkedCheckboxes.length > 1) { + actions.classList.remove('d-none'); + for (let i = 0; i < filters.length; i++) { + filters[i].classList.add('d-none'); + } + selectedItems.classList.remove('d-none'); + items.classList.add('d-none'); + } else { + actions.classList.add('d-none'); + + for (let i = 0; i < filters.length; i++) { + filters[i].classList.remove('d-none'); + } + selectedItems.classList.add('d-none'); + items.classList.remove('d-none'); + } + } + + var dropdownItems = document.querySelectorAll(".dropdown-menu .dropdown-item"); + + // Add click event listeners to each dropdown item + dropdownItems.forEach(function (item) { + // Check if the item has a data-action attribute + if (item.dataset.action) { + item.addEventListener("click", function () { + // Get all checked checkboxes + var checkedCheckboxes = document.querySelectorAll("input[type='checkbox'][name='ruleIds']:checked"); + + // Check if more than one checkbox is checked + if (checkedCheckboxes.length > 1) { + // Get data attributes from the clicked item + var actionData = Object.assign({}, item.dataset); + + confirmDialog({ + ...actionData, + callback: function (r) { + if (r) { + // Set the value of the BulkAction option + document.querySelector("[name='Options.BulkAction']").value = actionData.action; + // Trigger the submit action + document.querySelector("[name='submit.BulkAction']").click(); + } + } + }); + } + }); + } + }); + + selectAllCtrl.addEventListener("click", function () { + itemsCheckboxes.forEach(function (checkbox) { + if (checkbox !== selectAllCtrl) { + checkbox.checked = selectAllCtrl.checked; // Set the checked state of all checkboxes + } + }); + + // Update the selected items text + updateSelectedItemsText(); + displayActionsOrFilters(); + }); + + // Event listener for individual checkboxes + itemsCheckboxes.forEach(function (checkbox) { + checkbox.addEventListener("click", function () { + var itemsCount = itemsCheckboxes.length; + var selectedItemsCount = document.querySelectorAll("input[type='checkbox'][name='ruleIds']:checked").length; + + // Update selectAllCtrl state + selectAllCtrl.checked = selectedItemsCount === itemsCount; + selectAllCtrl.indeterminate = selectedItemsCount > 0 && selectedItemsCount < itemsCount; + + // Update the selected items text + updateSelectedItemsText(); + displayActionsOrFilters(); + }); + }); + + // Function to update selected items text + function updateSelectedItemsText() { + var selectedCount = document.querySelectorAll("input[type='checkbox'][name='ruleIds']:checked").length; + selectedItems.textContent = selectedCount + ' ' + selectedLabel; + } + } + + return { + initialize: initialize + } +}(); diff --git a/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Assets/js/sortable-rules.js b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Assets/js/sortable-rules.js new file mode 100644 index 00000000000..2df472106c9 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Assets/js/sortable-rules.js @@ -0,0 +1,57 @@ +sortingListManager = function () { + + const saveOrders = (evt, url, errorMessage) => { + + var data = { + oldIndex: evt.oldIndex, + newIndex: evt.newIndex + }; + fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(data) + }).catch(error => { + console.log(error); + alert(errorMessage || 'Unable to sort the list'); + }); + } + + const create = (selector, sortUrl, errorMessage) => { + + var sortable = document.querySelector(selector); + + if (!sortable) { + console.log('Unable to find the sortable element. The given selector is: ' + selector); + + return; + } + + if (sortUrl) { + orderUrl = sortUrl; + } else { + orderUrl = sortable.getAttribute('data-sort-uri'); + } + + if (!orderUrl) { + console.log('Unable to determine the sort post URI. Either pass it to the create function or set it as data-sort-uri to the sorting element.'); + + return; + } + + var sortable = Sortable.create(sortable, { + handle: ".ui-sortable-handle", + animation: 150, + filter: ".ignore-elements", + draggable: ".item", + onUpdate: function (evt) { + saveOrders(evt, orderUrl, errorMessage); + } + }); + }; + + return { + create: create + } +}(); diff --git a/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Controllers/AdminController.cs b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Controllers/AdminController.cs new file mode 100644 index 00000000000..9f71044c47e --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Controllers/AdminController.cs @@ -0,0 +1,301 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Localization; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Logging; +using OrchardCore.Admin; +using OrchardCore.DisplayManagement; +using OrchardCore.DisplayManagement.ModelBinding; +using OrchardCore.DisplayManagement.Notify; +using OrchardCore.Environment.Shell; +using OrchardCore.Routing; +using OrchardCore.UrlRewriting.Models; +using OrchardCore.UrlRewriting.ViewModels; + +namespace OrchardCore.UrlRewriting.Controllers; + +[Admin("UrlRewriting/{action}/{id?}", "UrlRewriting{action}")] +public sealed class AdminController : Controller +{ + private const string _optionsSearch = "Options.Search"; + + private readonly IAuthorizationService _authorizationService; + private readonly INotifier _notifier; + private readonly IDisplayManager _rewriteRuleDisplayManager; + private readonly IUpdateModelAccessor _updateModelAccessor; + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private readonly IShellReleaseManager _shellReleaseManager; + private readonly IEnumerable _urlRewritingRuleSources; + private readonly IRewriteRulesManager _rewriteRulesManager; + + internal readonly IStringLocalizer S; + internal readonly IHtmlLocalizer H; + + public AdminController( + IDisplayManager rewriteRuleDisplayManager, + IAuthorizationService authorizationService, + INotifier notifier, + IShellReleaseManager shellReleaseManager, + IEnumerable urlRewritingRuleSources, + IRewriteRulesManager rewriteRulesManager, + IUpdateModelAccessor updateModelAccessor, + IServiceProvider serviceProvider, + ILogger logger, + IStringLocalizer stringLocalizer, + IHtmlLocalizer htmlLocalizer + ) + { + _rewriteRuleDisplayManager = rewriteRuleDisplayManager; + _authorizationService = authorizationService; + _notifier = notifier; + _shellReleaseManager = shellReleaseManager; + _urlRewritingRuleSources = urlRewritingRuleSources; + _rewriteRulesManager = rewriteRulesManager; + _updateModelAccessor = updateModelAccessor; + _serviceProvider = serviceProvider; + _logger = logger; + S = stringLocalizer; + H = htmlLocalizer; + } + + public async Task Index(RewriteRuleOptions options) + { + if (!await _authorizationService.AuthorizeAsync(User, UrlRewritingPermissions.ManageUrlRewritingRules)) + { + return Forbid(); + } + + var rules = await _rewriteRulesManager.GetAllAsync(); + + var model = new ListRewriteRuleViewModel + { + Rules = [], + Options = options, + SourceNames = _urlRewritingRuleSources.Select(x => x.TechnicalName), + }; + + foreach (var rule in rules) + { + model.Rules.Add(new RewriteRuleEntry + { + Rule = rule, + Shape = await _rewriteRuleDisplayManager.BuildDisplayAsync(rule, _updateModelAccessor.ModelUpdater, "SummaryAdmin") + }); + } + + model.Options.BulkActions = + [ + new SelectListItem(S["Delete"], nameof(RewriteRuleAction.Remove)), + ]; + + return View(model); + } + + public async Task Create(string id) + { + if (!await _authorizationService.AuthorizeAsync(User, UrlRewritingPermissions.ManageUrlRewritingRules)) + { + return Forbid(); + } + + var ruleSource = _serviceProvider.GetKeyedService(id); + + if (ruleSource == null) + { + await _notifier.ErrorAsync(H["Unable to find a rule-source that can handle the source '{Source}'.", id]); + + return RedirectToAction(nameof(Index)); + } + + var rule = await _rewriteRulesManager.NewAsync(id); + + if (rule == null) + { + await _notifier.ErrorAsync(H["Invalid rule source."]); + + return RedirectToAction(nameof(Index)); + } + + var model = new RewriteRuleViewModel + { + DisplayName = ruleSource.DisplayName, + Editor = await _rewriteRuleDisplayManager.BuildEditorAsync(rule, _updateModelAccessor.ModelUpdater, isNew: true), + }; + + return View(model); + } + + [HttpPost] + [ActionName(nameof(Create))] + public async Task CreatePOST(string id) + { + if (!await _authorizationService.AuthorizeAsync(User, UrlRewritingPermissions.ManageUrlRewritingRules)) + { + return Forbid(); + } + + var ruleSource = _serviceProvider.GetKeyedService(id); + + if (ruleSource == null) + { + await _notifier.ErrorAsync(H["Unable to find a rule-source that can handle the source '{Source}'.", id]); + + return RedirectToAction(nameof(Index)); + } + + var rule = await _rewriteRulesManager.NewAsync(id); + + if (rule == null) + { + await _notifier.ErrorAsync(H["Invalid rule source."]); + + return RedirectToAction(nameof(Index)); + } + + var model = new RewriteRuleViewModel + { + DisplayName = ruleSource.DisplayName, + Editor = await _rewriteRuleDisplayManager.UpdateEditorAsync(rule, _updateModelAccessor.ModelUpdater, isNew: true), + }; + + if (ModelState.IsValid) + { + await _rewriteRulesManager.SaveAsync(rule); + + await _notifier.SuccessAsync(H["Rule created successfully."]); + + return RedirectToAction(nameof(Index)); + } + + _shellReleaseManager.SuspendReleaseRequest(); + + return View(model); + } + + public async Task Edit(string id) + { + if (!await _authorizationService.AuthorizeAsync(User, UrlRewritingPermissions.ManageUrlRewritingRules)) + { + return Forbid(); + } + + var rule = await _rewriteRulesManager.FindByIdAsync(id); + + if (rule == null) + { + return NotFound(); + } + + var model = new RewriteRuleViewModel + { + DisplayName = rule.Name, + Editor = await _rewriteRuleDisplayManager.BuildEditorAsync(rule, _updateModelAccessor.ModelUpdater, isNew: false), + }; + + return View(model); + } + + [HttpPost] + [ActionName(nameof(Edit))] + public async Task EditPOST(string id) + { + if (!await _authorizationService.AuthorizeAsync(User, UrlRewritingPermissions.ManageUrlRewritingRules)) + { + return Forbid(); + } + + var rule = await _rewriteRulesManager.FindByIdAsync(id); + + if (rule == null) + { + return NotFound(); + } + + // Clone the rule to prevent modifying the original instance in the store. + var ruleToUpdate = rule.Clone(); + + var model = new RewriteRuleViewModel + { + DisplayName = ruleToUpdate.Name, + Editor = await _rewriteRuleDisplayManager.UpdateEditorAsync(ruleToUpdate, _updateModelAccessor.ModelUpdater, isNew: false), + }; + + if (ModelState.IsValid) + { + await _rewriteRulesManager.SaveAsync(ruleToUpdate); + + await _notifier.SuccessAsync(H["Rule updated successfully."]); + + return RedirectToAction(nameof(Index)); + } + + _shellReleaseManager.SuspendReleaseRequest(); + + return View(model); + } + + [HttpPost] + public async Task Delete(string id) + { + if (!await _authorizationService.AuthorizeAsync(User, UrlRewritingPermissions.ManageUrlRewritingRules)) + { + return Forbid(); + } + + var rule = await _rewriteRulesManager.FindByIdAsync(id); + + if (rule == null) + { + return NotFound(); + } + + await _rewriteRulesManager.DeleteAsync(rule); + + _shellReleaseManager.RequestRelease(); + + await _notifier.SuccessAsync(H["Rule deleted successfully."]); + + return RedirectToAction(nameof(Index)); + } + + [HttpPost] + [ActionName(nameof(Index))] + [FormValueRequired("submit.BulkAction")] + public async Task IndexPost(RewriteRuleOptions options, IEnumerable ruleIds) + { + if (!await _authorizationService.AuthorizeAsync(User, UrlRewritingPermissions.ManageUrlRewritingRules)) + { + return Forbid(); + } + + if (ruleIds?.Count() > 0) + { + switch (options.BulkAction) + { + case RewriteRuleAction.None: + break; + case RewriteRuleAction.Remove: + foreach (var id in ruleIds) + { + var rule = await _rewriteRulesManager.FindByIdAsync(id); + + if (rule == null) + { + continue; + } + + await _rewriteRulesManager.DeleteAsync(rule); + } + await _notifier.SuccessAsync(H["Rules removed successfully."]); + break; + default: + return BadRequest(); + } + } + + return RedirectToAction(nameof(Index)); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Drivers/RewriteRulesDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Drivers/RewriteRulesDisplayDriver.cs new file mode 100644 index 00000000000..3639932e9a9 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Drivers/RewriteRulesDisplayDriver.cs @@ -0,0 +1,63 @@ +using Microsoft.Extensions.Localization; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.Environment.Shell; +using OrchardCore.Mvc.ModelBinding; +using OrchardCore.UrlRewriting.Models; +using OrchardCore.UrlRewriting.ViewModels; + +namespace OrchardCore.UrlRewriting.Drivers; + +public sealed class RewriteRulesDisplayDriver : DisplayDriver +{ + private readonly IShellReleaseManager _shellReleaseManager; + + internal readonly IStringLocalizer S; + + public RewriteRulesDisplayDriver( + IShellReleaseManager shellReleaseManager, + IStringLocalizer stringLocalizer) + { + _shellReleaseManager = shellReleaseManager; + S = stringLocalizer; + } + + public override Task DisplayAsync(RewriteRule rule, BuildDisplayContext context) + { + return CombineAsync( + View("RewriteRule_Fields_SummaryAdmin", rule).Location("Content:1"), + View("RewriteRule_Buttons_SummaryAdmin", rule).Location("Actions:5"), + View("RewriteRule_DefaultTags_SummaryAdmin", rule).Location("Tags:5"), + View("RewriteRule_DefaultMeta_SummaryAdmin", rule).Location("Meta:5") + ); + } + + public override IDisplayResult Edit(RewriteRule rule, BuildEditorContext context) + { + context.AddTenantReloadWarningWrapper(); + + return Initialize("RewriteRule_Fields_Edit", model => + { + model.Name = rule.Name; + }).Location("Content:1"); + } + + public override async Task UpdateAsync(RewriteRule rule, UpdateEditorContext context) + { + var model = new EditRewriteRuleViewModel(); + + await context.Updater.TryUpdateModelAsync(model, Prefix, + m => m.Name); + + if (string.IsNullOrEmpty(model.Name)) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.Name), S["Name is required"]); + } + + rule.Name = model.Name; + + _shellReleaseManager.RequestRelease(); + + return Edit(rule, context); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Drivers/UrlRedirectRuleDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Drivers/UrlRedirectRuleDisplayDriver.cs new file mode 100644 index 00000000000..24e9e95249c --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Drivers/UrlRedirectRuleDisplayDriver.cs @@ -0,0 +1,100 @@ +using Microsoft.Extensions.Localization; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.Entities; +using OrchardCore.Mvc.ModelBinding; +using OrchardCore.UrlRewriting.Models; +using OrchardCore.UrlRewriting.Services; +using OrchardCore.UrlRewriting.ViewModels; + +namespace OrchardCore.UrlRewriting.Drivers; + +public sealed class UrlRedirectRuleDisplayDriver : DisplayDriver +{ + internal readonly IStringLocalizer S; + + public UrlRedirectRuleDisplayDriver(IStringLocalizer stringLocalizer) + { + S = stringLocalizer; + } + + public override IDisplayResult Edit(RewriteRule rule, BuildEditorContext context) + { + if (rule.Source != UrlRedirectRuleSource.SourceName) + { + return null; + } + + return Initialize("UrlRedirectRule_Edit", model => + { + var metadata = rule.As(); + + model.Pattern = metadata.Pattern; + model.SubstitutionPattern = metadata.SubstitutionPattern; + model.IsCaseInsensitive = metadata.IsCaseInsensitive; + model.QueryStringPolicy = metadata.QueryStringPolicy; + model.RedirectType = Enum.IsDefined(typeof(RedirectType), metadata.RedirectType) + ? metadata.RedirectType + : RedirectType.Found; + + model.QueryStringPolicies = + [ + new(S["Append Query String from Original Request"], nameof(QueryStringPolicy.Append)), + new(S["Exclude Query String from Original Request"], nameof(QueryStringPolicy.Drop)), + ]; + model.RedirectTypes = + [ + new(S["Found (302)"], nameof(RedirectType.Found)), + new(S["Moved Permanently (301)"], nameof(RedirectType.MovedPermanently)), + new(S["Temporary Redirect (307)"], nameof(RedirectType.TemporaryRedirect)), + new(S["Permanent Redirect (308)"], nameof(RedirectType.PermanentRedirect)), + ]; + }).Location("Content:5"); + } + + public override async Task UpdateAsync(RewriteRule rule, UpdateEditorContext context) + { + if (rule.Source != UrlRedirectRuleSource.SourceName) + { + return null; + } + + var model = new UrlRedirectRuleViewModel(); + + await context.Updater.TryUpdateModelAsync(model, Prefix, + m => m.Pattern, + m => m.SubstitutionPattern, + m => m.IsCaseInsensitive, + m => m.QueryStringPolicy, + m => m.RedirectType); + + if (string.IsNullOrWhiteSpace(model.Pattern)) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.Pattern), S["The Match URL Pattern is required."]); + } + else if (!PatternHelper.IsValidRegex(model.Pattern)) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.Pattern), S["A valid Match URL Pattern is required."]); + } + + if (string.IsNullOrWhiteSpace(model.SubstitutionPattern)) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.SubstitutionPattern), S["The Substitution URL Pattern is required."]); + } + else if (!PatternHelper.IsValidRegex(model.SubstitutionPattern)) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.SubstitutionPattern), S["A valid Substitution URL Pattern is required."]); + } + + rule.Put(new UrlRedirectSourceMetadata() + { + Pattern = model.Pattern?.Trim(), + SubstitutionPattern = model.SubstitutionPattern?.Trim(), + IsCaseInsensitive = model.IsCaseInsensitive, + QueryStringPolicy = model.QueryStringPolicy, + RedirectType = model.RedirectType, + }); + + return Edit(rule, context); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Drivers/UrlRewriteRuleDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Drivers/UrlRewriteRuleDisplayDriver.cs new file mode 100644 index 00000000000..09e4234663c --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Drivers/UrlRewriteRuleDisplayDriver.cs @@ -0,0 +1,91 @@ +using Microsoft.Extensions.Localization; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.Entities; +using OrchardCore.Mvc.ModelBinding; +using OrchardCore.UrlRewriting.Models; +using OrchardCore.UrlRewriting.Services; +using OrchardCore.UrlRewriting.ViewModels; + +namespace OrchardCore.UrlRewriting.Drivers; + +public sealed class UrlRewriteRuleDisplayDriver : DisplayDriver +{ + internal readonly IStringLocalizer S; + + public UrlRewriteRuleDisplayDriver(IStringLocalizer stringLocalizer) + { + S = stringLocalizer; + } + + public override IDisplayResult Edit(RewriteRule rule, BuildEditorContext context) + { + if (rule.Source != UrlRewriteRuleSource.SourceName) + { + return null; + } + + return Initialize("UrlRewriteRule_Edit", model => + { + var metadata = rule.As(); + + model.Pattern = metadata.Pattern; + model.SubstitutionPattern = metadata.SubstitutionPattern; + model.IsCaseInsensitive = metadata.IsCaseInsensitive; + model.QueryStringPolicy = metadata.QueryStringPolicy; + model.SkipFurtherRules = metadata.SkipFurtherRules; + + model.QueryStringPolicies = + [ + new(S["Append Query String from Original Request"], nameof(QueryStringPolicy.Append)), + new(S["Exclude Query String from Original Request"], nameof(QueryStringPolicy.Drop)), + ]; + }).Location("Content:5"); + } + + public override async Task UpdateAsync(RewriteRule rule, UpdateEditorContext context) + { + if (rule.Source != UrlRewriteRuleSource.SourceName) + { + return null; + } + + var model = new UrlRewriteRuleViewModel(); + + await context.Updater.TryUpdateModelAsync(model, Prefix, + m => m.Pattern, + m => m.SubstitutionPattern, + m => m.IsCaseInsensitive, + m => m.QueryStringPolicy, + m => m.SkipFurtherRules); + + if (string.IsNullOrWhiteSpace(model.Pattern)) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.Pattern), "The Match URL Pattern is required."); + } + else if (!PatternHelper.IsValidRegex(model.Pattern)) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.Pattern), S["A valid Match URL Pattern is required."]); + } + + if (string.IsNullOrWhiteSpace(model.SubstitutionPattern)) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.SubstitutionPattern), S["The Substitution URL Pattern is required."]); + } + else if (!PatternHelper.IsValidRegex(model.SubstitutionPattern)) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.SubstitutionPattern), S["A valid Substitution URL Pattern is required."]); + } + + rule.Put(new UrlRewriteSourceMetadata() + { + Pattern = model.Pattern, + SubstitutionPattern = model.SubstitutionPattern, + IsCaseInsensitive = model.IsCaseInsensitive, + QueryStringPolicy = model.QueryStringPolicy, + SkipFurtherRules = model.SkipFurtherRules + }); + + return Edit(rule, context); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Endpoints/Rules/SortRulesEndpoint.cs b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Endpoints/Rules/SortRulesEndpoint.cs new file mode 100644 index 00000000000..ffcb09ec44e --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Endpoints/Rules/SortRulesEndpoint.cs @@ -0,0 +1,50 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace OrchardCore.UrlRewriting.Endpoints.Rules; + +public static class SortRulesEndpoint +{ + public const string RouteName = "ResortUrlRewritingRules"; + + public static IEndpointRouteBuilder AddSortRulesEndpoint(this IEndpointRouteBuilder builder) + { + builder.MapPost("url-rewriting/resort", HandleAsync) + .AllowAnonymous() + .WithName(RouteName) + .DisableAntiforgery(); + + return builder; + } + + private static async Task HandleAsync( + IAuthorizationService authorizationService, + HttpContext httpContext, + IRewriteRulesManager rewriteRulesManager, + ResortingRequest model) + { + + if (!model.OldIndex.HasValue || !model.NewIndex.HasValue) + { + return TypedResults.BadRequest(); + } + + if (!await authorizationService.AuthorizeAsync(httpContext.User, UrlRewritingPermissions.ManageUrlRewritingRules)) + { + return TypedResults.Forbid(); + } + + await rewriteRulesManager.ResortOrderAsync(model.OldIndex.Value, model.NewIndex.Value); + + return TypedResults.Ok(); + } + + private sealed class ResortingRequest + { + public int? OldIndex { get; set; } + + public int? NewIndex { get; set; } + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Manifest.cs b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Manifest.cs new file mode 100644 index 00000000000..8b6108ab510 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Manifest.cs @@ -0,0 +1,10 @@ +using OrchardCore.Modules.Manifest; + +[assembly: Module( + Name = "URL Rewriting", + Author = ManifestConstants.OrchardCoreTeam, + Website = ManifestConstants.OrchardCoreWebsite, + Version = ManifestConstants.OrchardCoreVersion, + Description = "Enables URL rewriting for incoming requests.", + Category = "Infrastructure" +)] diff --git a/src/OrchardCore.Modules/OrchardCore.UrlRewriting/OrchardCore.UrlRewriting.csproj b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/OrchardCore.UrlRewriting.csproj new file mode 100644 index 00000000000..12a46540458 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/OrchardCore.UrlRewriting.csproj @@ -0,0 +1,27 @@ + + + + true + + OrchardCore UrlRewriting + + $(OCCMSDescription) + + Provides url rewriting capabilities. + + $(PackageTags) OrchardCoreCMS + + + + + + + + + + + + + + + diff --git a/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Recipes/UrlRewritingStep.cs b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Recipes/UrlRewritingStep.cs new file mode 100644 index 00000000000..e1de790a5a4 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Recipes/UrlRewritingStep.cs @@ -0,0 +1,94 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Options; +using OrchardCore.Recipes.Models; +using OrchardCore.Recipes.Services; +using OrchardCore.UrlRewriting.Models; + +namespace OrchardCore.UrlRewriting.Recipes; + +/// +/// This recipe step creates or updates a set of URL rewrite rule. +/// +public sealed class UrlRewritingStep : NamedRecipeStepHandler +{ + private readonly JsonSerializerOptions _jsonSerializerOptions; + private readonly IRewriteRulesManager _rewriteRulesManager; + + internal readonly IStringLocalizer S; + + public UrlRewritingStep( + IRewriteRulesManager rewriteRulesManager, + IOptions jsonSerializerOptions, + IStringLocalizer stringLocalizer) + : base("UrlRewriting") + { + _rewriteRulesManager = rewriteRulesManager; + _jsonSerializerOptions = jsonSerializerOptions.Value; + S = stringLocalizer; + } + + protected override async Task HandleAsync(RecipeExecutionContext context) + { + var model = context.Step.ToObject(_jsonSerializerOptions); + var tokens = model.Rules.Cast() ?? []; + + foreach (var token in tokens) + { + RewriteRule rule = null; + + var id = token[nameof(RewriteRule.Id)]?.GetValue(); + + if (!string.IsNullOrEmpty(id)) + { + rule = await _rewriteRulesManager.FindByIdAsync(id); + + if (rule != null) + { + await _rewriteRulesManager.UpdateAsync(rule, token); + } + } + + if (rule == null) + { + var sourceName = token[nameof(RewriteRule.Source)]?.GetValue(); + + if (string.IsNullOrEmpty(sourceName)) + { + context.Errors.Add(S["Could not find rule source value. The rule will not be imported"]); + + continue; + } + + rule = await _rewriteRulesManager.NewAsync(sourceName, token); + + if (rule == null) + { + context.Errors.Add(S["Unable to find a rule-source that can handle the source '{Source}'.", sourceName]); + + continue; + } + } + + var validationResult = await _rewriteRulesManager.ValidateAsync(rule); + + if (!validationResult.Succeeded) + { + foreach (var error in validationResult.Errors) + { + context.Errors.Add(error.ErrorMessage); + } + + continue; + } + + await _rewriteRulesManager.SaveAsync(rule); + } + } +} + +public sealed class UrlRewritingStepModel +{ + public JsonArray Rules { get; set; } +} diff --git a/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Services/ResourceManagementOptionsConfiguration.cs b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Services/ResourceManagementOptionsConfiguration.cs new file mode 100644 index 00000000000..f527f8b01ad --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Services/ResourceManagementOptionsConfiguration.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.Options; +using OrchardCore.ResourceManagement; + +namespace OrchardCore.UrlRewriting.Services; + +internal sealed class ResourceManagementOptionsConfiguration : IConfigureOptions +{ + private static readonly ResourceManifest _manifest; + + static ResourceManagementOptionsConfiguration() + { + _manifest = new ResourceManifest(); + + _manifest + .DefineScript("sortable-rules") + .SetDependencies("Sortable") + .SetUrl("~/OrchardCore.UrlRewriting/Scripts/sortable-rules.min.js", "~/OrchardCore.UrlRewriting/Scripts/sortable-rules.js"); + } + + public void Configure(ResourceManagementOptions options) + { + options.ResourceManifests.Add(_manifest); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Services/UrlRewritingPermissionProvider.cs b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Services/UrlRewritingPermissionProvider.cs new file mode 100644 index 00000000000..d1eff63a6ae --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Services/UrlRewritingPermissionProvider.cs @@ -0,0 +1,23 @@ +using OrchardCore.Security.Permissions; + +namespace OrchardCore.UrlRewriting.Services; + +public sealed class UrlRewritingPermissionProvider : IPermissionProvider +{ + private readonly IEnumerable _allPermissions = + [ + UrlRewritingPermissions.ManageUrlRewritingRules, + ]; + + public Task> GetPermissionsAsync() + => Task.FromResult(_allPermissions); + + public IEnumerable GetDefaultStereotypes() => + [ + new PermissionStereotype + { + Name = OrchardCoreConstants.Roles.Administrator, + Permissions = _allPermissions, + }, + ]; +} diff --git a/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Startup.cs b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Startup.cs new file mode 100644 index 00000000000..8ab52021986 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Startup.cs @@ -0,0 +1,60 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.Modules; +using OrchardCore.Navigation; +using OrchardCore.Recipes; +using OrchardCore.ResourceManagement; +using OrchardCore.Security.Permissions; +using OrchardCore.UrlRewriting.Drivers; +using OrchardCore.UrlRewriting.Endpoints.Rules; +using OrchardCore.UrlRewriting.Extensions; +using OrchardCore.UrlRewriting.Handlers; +using OrchardCore.UrlRewriting.Models; +using OrchardCore.UrlRewriting.Recipes; +using OrchardCore.UrlRewriting.Services; + +namespace OrchardCore.UrlRewriting; + +public sealed class Startup : StartupBase +{ + public override int Order + => OrchardCoreConstants.ConfigureOrder.UrlRewriting; + + public override void ConfigureServices(IServiceCollection services) + { + services.AddUrlRewritingServices() + .AddNavigationProvider() + .AddPermissionProvider() + .AddTransient, ResourceManagementOptionsConfiguration>() + .AddScoped, RewriteRulesDisplayDriver>(); + + // Add Apache Mod Redirect Rule. + services.AddRewriteRuleSource(UrlRedirectRuleSource.SourceName) + .AddScoped() + .AddScoped, UrlRedirectRuleDisplayDriver>(); + + // Add Apache Mod Rewrite Rule. + services.AddRewriteRuleSource(UrlRewriteRuleSource.SourceName) + .AddScoped() + .AddScoped, UrlRewriteRuleDisplayDriver>(); + } + + public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) + { + routes.AddSortRulesEndpoint(); + + app.UseUrlRewriting(serviceProvider); + } +} + +[RequireFeatures("OrchardCore.Recipes.Core")] +public sealed class RecipesStartup : StartupBase +{ + public override void ConfigureServices(IServiceCollection services) + { + services.AddRecipeExecutionStep(); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.UrlRewriting/ViewModels/EditRewriteRuleViewModel.cs b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/ViewModels/EditRewriteRuleViewModel.cs new file mode 100644 index 00000000000..b6012c97bec --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/ViewModels/EditRewriteRuleViewModel.cs @@ -0,0 +1,6 @@ +namespace OrchardCore.UrlRewriting.ViewModels; + +public class EditRewriteRuleViewModel +{ + public string Name { get; set; } +} diff --git a/src/OrchardCore.Modules/OrchardCore.UrlRewriting/ViewModels/ListRewriteRuleViewModel.cs b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/ViewModels/ListRewriteRuleViewModel.cs new file mode 100644 index 00000000000..8075f3b9c94 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/ViewModels/ListRewriteRuleViewModel.cs @@ -0,0 +1,10 @@ +namespace OrchardCore.UrlRewriting.ViewModels; + +public class ListRewriteRuleViewModel +{ + public IList Rules { get; set; } + + public RewriteRuleOptions Options { get; set; } + + public IEnumerable SourceNames { get; set; } +} diff --git a/src/OrchardCore.Modules/OrchardCore.UrlRewriting/ViewModels/RewriteRuleAction.cs b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/ViewModels/RewriteRuleAction.cs new file mode 100644 index 00000000000..28d2fcb83d6 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/ViewModels/RewriteRuleAction.cs @@ -0,0 +1,7 @@ +namespace OrchardCore.UrlRewriting.ViewModels; + +public enum RewriteRuleAction +{ + None, + Remove, +} diff --git a/src/OrchardCore.Modules/OrchardCore.UrlRewriting/ViewModels/RewriteRuleEntry.cs b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/ViewModels/RewriteRuleEntry.cs new file mode 100644 index 00000000000..832937b3866 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/ViewModels/RewriteRuleEntry.cs @@ -0,0 +1,11 @@ +using OrchardCore.DisplayManagement; +using OrchardCore.UrlRewriting.Models; + +namespace OrchardCore.UrlRewriting.ViewModels; + +public class RewriteRuleEntry +{ + public RewriteRule Rule { get; set; } + + public IShape Shape { get; set; } +} diff --git a/src/OrchardCore.Modules/OrchardCore.UrlRewriting/ViewModels/RewriteRuleOptions.cs b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/ViewModels/RewriteRuleOptions.cs new file mode 100644 index 00000000000..18560a96182 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/ViewModels/RewriteRuleOptions.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.Rendering; + +namespace OrchardCore.UrlRewriting.ViewModels; + +public class RewriteRuleOptions +{ + public string Search { get; set; } + + public RewriteRuleAction BulkAction { get; set; } + + [BindNever] + public List BulkActions { get; set; } +} diff --git a/src/OrchardCore.Modules/OrchardCore.UrlRewriting/ViewModels/RewriteRuleViewModel.cs b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/ViewModels/RewriteRuleViewModel.cs new file mode 100644 index 00000000000..58d8356ec6e --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/ViewModels/RewriteRuleViewModel.cs @@ -0,0 +1,8 @@ +namespace OrchardCore.UrlRewriting.ViewModels; + +public class RewriteRuleViewModel +{ + public string DisplayName { get; set; } + + public dynamic Editor { get; set; } +} diff --git a/src/OrchardCore.Modules/OrchardCore.UrlRewriting/ViewModels/UrlRedirectRuleViewModel.cs b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/ViewModels/UrlRedirectRuleViewModel.cs new file mode 100644 index 00000000000..296cf40a0cf --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/ViewModels/UrlRedirectRuleViewModel.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.Rendering; +using OrchardCore.UrlRewriting.Models; + +namespace OrchardCore.UrlRewriting.ViewModels; + +public class UrlRedirectRuleViewModel +{ + public string Pattern { get; set; } + + public string SubstitutionPattern { get; set; } + + public bool IsCaseInsensitive { get; set; } + + public QueryStringPolicy QueryStringPolicy { get; set; } + + public RedirectType RedirectType { get; set; } + + [BindNever] + public List RedirectTypes { get; set; } + + [BindNever] + public List QueryStringPolicies { get; set; } +} diff --git a/src/OrchardCore.Modules/OrchardCore.UrlRewriting/ViewModels/UrlRewriteRuleViewModel.cs b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/ViewModels/UrlRewriteRuleViewModel.cs new file mode 100644 index 00000000000..969f7848589 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/ViewModels/UrlRewriteRuleViewModel.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.Rendering; +using OrchardCore.UrlRewriting.Models; + +namespace OrchardCore.UrlRewriting.ViewModels; + +public class UrlRewriteRuleViewModel +{ + public string Pattern { get; set; } + + public string SubstitutionPattern { get; set; } + + public bool IsCaseInsensitive { get; set; } + + public QueryStringPolicy QueryStringPolicy { get; set; } + + public bool SkipFurtherRules { get; set; } + + [BindNever] + public List QueryStringPolicies { get; set; } +} diff --git a/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/Admin/Create.cshtml b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/Admin/Create.cshtml new file mode 100644 index 00000000000..ae1682855d0 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/Admin/Create.cshtml @@ -0,0 +1,16 @@ +@model RewriteRuleViewModel + +

@RenderTitleSegments(T["New '{0}' rule", Model.DisplayName])

+ +
+ @Html.ValidationSummary() + @await DisplayAsync(Model.Editor) + + + + @T["Cancel"] +
diff --git a/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/Admin/Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/Admin/Edit.cshtml new file mode 100644 index 00000000000..cbe465fb439 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/Admin/Edit.cshtml @@ -0,0 +1,16 @@ +@model RewriteRuleViewModel + +

@RenderTitleSegments(T["Edit '{0}' rule", Model.DisplayName])

+ +
+ @Html.ValidationSummary() + @await DisplayAsync(Model.Editor) + + + + @T["Cancel"] +
diff --git a/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/Admin/Index.cshtml b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/Admin/Index.cshtml new file mode 100644 index 00000000000..107f7a9d820 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/Admin/Index.cshtml @@ -0,0 +1,137 @@ +@using Microsoft.AspNetCore.Mvc.Localization +@using OrchardCore.UrlRewriting.Endpoints.Rules + +@model ListRewriteRuleViewModel + +

@RenderTitleSegments(T["URL Rewriting Rules"])

+ +@* the form is necessary to generate an antiforgery token for the delete and toggle actions *@ +
+ + + + +
+
+
+
+ +
+
+ +
+
+
+
+ +
    + @if (Model.Rules.Count > 0) + { + int startIndex = 0; + int endIndex = startIndex + Model.Rules.Count - 1; + +
  • +
    +
    +
    + + + + +
    +
    +
    + +
    +
    +
  • + @foreach (var entry in Model.Rules) + { +
  • +
    +
    + +
    + +
    + + +
    +
    + +
    + @await DisplayAsync(entry.Shape) +
    +
  • + } + } + else + { +
  • + @T["Nothing here! There are no rewrite rules at the moment."] +
  • + } +
+ + + + + +
+ + + + + diff --git a/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/NavigationItemText-urlrewriting.Id.cshtml b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/NavigationItemText-urlrewriting.Id.cshtml new file mode 100644 index 00000000000..316c0cb2935 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/NavigationItemText-urlrewriting.Id.cshtml @@ -0,0 +1,4 @@ + + + +@T["URL Rewriting"] diff --git a/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/RewriteRule.Buttons.SummaryAdmin.cshtml b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/RewriteRule.Buttons.SummaryAdmin.cshtml new file mode 100644 index 00000000000..4f14d50c8e6 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/RewriteRule.Buttons.SummaryAdmin.cshtml @@ -0,0 +1,15 @@ +@using OrchardCore.DisplayManagement.Views +@using OrchardCore.UrlRewriting.Models + +@model ShapeViewModel + +@T["Edit"] + +@T["Delete"] diff --git a/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/RewriteRule.DefaultMeta.SummaryAdmin.cshtml b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/RewriteRule.DefaultMeta.SummaryAdmin.cshtml new file mode 100644 index 00000000000..bcb93b0d425 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/RewriteRule.DefaultMeta.SummaryAdmin.cshtml @@ -0,0 +1,20 @@ +@using System.Globalization +@using OrchardCore.DisplayManagement.Views +@using OrchardCore.UrlRewriting.Models + +@model ShapeViewModel + +@{ + var createdAt = Model.Value.CreatedUtc.ToString("yyyy-MM-ddTHH:mm:sszzz", CultureInfo.InvariantCulture); +} + + + + + +@if (!string.IsNullOrEmpty(Model.Value.Author)) +{ + + @Model.Value.Author + +} diff --git a/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/RewriteRule.DefaultTags.SummaryAdmin.cshtml b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/RewriteRule.DefaultTags.SummaryAdmin.cshtml new file mode 100644 index 00000000000..03f1fad540d --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/RewriteRule.DefaultTags.SummaryAdmin.cshtml @@ -0,0 +1,8 @@ +@using OrchardCore.DisplayManagement.Views +@using OrchardCore.UrlRewriting.Models + +@model ShapeViewModel + + + @Model.Value.Source + diff --git a/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/RewriteRule.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/RewriteRule.Edit.cshtml new file mode 100644 index 00000000000..ad1bf3a8cc6 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/RewriteRule.Edit.cshtml @@ -0,0 +1 @@ +@await DisplayAsync(Model.Content) diff --git a/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/RewriteRule.Fields.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/RewriteRule.Fields.Edit.cshtml new file mode 100644 index 00000000000..ff71e37eb44 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/RewriteRule.Fields.Edit.cshtml @@ -0,0 +1,8 @@ +@model EditRewriteRuleViewModel + +
+ + + + @T["A display name for the rule."] +
diff --git a/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/RewriteRule.Fields.SummaryAdmin.cshtml b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/RewriteRule.Fields.SummaryAdmin.cshtml new file mode 100644 index 00000000000..3c603d0af13 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/RewriteRule.Fields.SummaryAdmin.cshtml @@ -0,0 +1,6 @@ +@using OrchardCore.DisplayManagement.Views +@using OrchardCore.UrlRewriting.Models + +@model ShapeViewModel + +
@Model.Value.Name
diff --git a/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/RewriteRule.Link.cshtml b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/RewriteRule.Link.cshtml new file mode 100644 index 00000000000..754cea1f011 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/RewriteRule.Link.cshtml @@ -0,0 +1,22 @@ +@using Microsoft.Extensions.DependencyInjection +@using OrchardCore.UrlRewriting + +@{ + var service = ViewContext.HttpContext.RequestServices.GetKeyedService(Model.Source as string); +} + +
+
+
+

@(service?.DisplayName ?? Model.Source)

+

@(service?.Description)

+
+ +
+
diff --git a/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/RewriteRule.SummaryAdmin.cshtml b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/RewriteRule.SummaryAdmin.cshtml new file mode 100644 index 00000000000..64e92cd317b --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/RewriteRule.SummaryAdmin.cshtml @@ -0,0 +1,46 @@ +
+
+ +
+
+ @if (Model.Content != null) + { + @await DisplayAsync(Model.Content) + } +
+ + @if (Model.Tags != null) + { +
+ @await DisplayAsync(Model.Tags) +
+ } + @if (Model.Meta != null) + { + + } +
+
+
+
+ @if (Model.Actions != null) + { + @await DisplayAsync(Model.Actions) + } + + @if (Model.ActionsMenu != null && Model.ActionsMenu.HasItems) + { +
+ + +
+ } +
+
+
diff --git a/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/UrlRedirectRule.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/UrlRedirectRule.Edit.cshtml new file mode 100644 index 00000000000..064977c6201 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/UrlRedirectRule.Edit.cshtml @@ -0,0 +1,32 @@ +@model UrlRedirectRuleViewModel + +
+ + + @T["Specifies a pattern using regular expressions to match incoming URLs, following the syntax defined in ECMA-262."] +
+ +
+
+ + + @T["When checked, the pattern will be case insensitive."] +
+
+ +
+ + +
+ +
+ + +
+ +
+ + + + @T["Defines a URL structure with placeholders that will be replaced by actual values during the request."] +
diff --git a/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/UrlRewriteRule.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/UrlRewriteRule.Edit.cshtml new file mode 100644 index 00000000000..bfd87ae139e --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/UrlRewriteRule.Edit.cshtml @@ -0,0 +1,35 @@ +@model UrlRewriteRuleViewModel + +
+ + + @T["Specifies a pattern using regular expressions to match incoming URLs, following the syntax defined in ECMA-262."] +
+ +
+
+ + + @T["When checked, the pattern will be case insensitive."] +
+
+ +
+ + +
+ +
+
+ + + @T["When selected, subsequent rules will be ignored if this rule is matched."] +
+
+ +
+ + + + @T["Defines a URL structure with placeholders that will be replaced by actual values during the request."] +
diff --git a/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/_ViewImports.cshtml b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/_ViewImports.cshtml new file mode 100644 index 00000000000..37881b14765 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/Views/_ViewImports.cshtml @@ -0,0 +1,7 @@ +@inherits OrchardCore.DisplayManagement.Razor.RazorPage + +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, OrchardCore.DisplayManagement +@addTagHelper *, OrchardCore.ResourceManagement + +@using OrchardCore.UrlRewriting.ViewModels diff --git a/src/OrchardCore.Modules/OrchardCore.UrlRewriting/wwwroot/Scripts/admin-ui.js b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/wwwroot/Scripts/admin-ui.js new file mode 100644 index 00000000000..105fb539bcc --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/wwwroot/Scripts/admin-ui.js @@ -0,0 +1,159 @@ +/* +** NOTE: This file is generated by Gulp and should not be edited directly! +** Any changes made directly to this file will be overwritten next time its asset group is processed by Gulp. +*/ + +function _typeof(o) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (o) { return typeof o; } : function (o) { return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o; }, _typeof(o); } +function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; } +function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { _defineProperty(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; } +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return _typeof(key) === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (_typeof(input) !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (_typeof(res) !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } +urlRewritingAdmin = function () { + var initialize = function initialize(url, errorLabel, selectedLabel) { + // Create the sortable UI. + sortingListManager.create('#rewrite-rules-sortable-list', url, errorLabel); + var searchBox = document.getElementById('search-box'); + var searchAlert = document.getElementById('list-alert'); + var filterElements = document.querySelectorAll('[data-filter-value]'); + + // If the user press Enter, don't submit. + searchBox.addEventListener('keydown', function (e) { + if (e.key === 'Enter') { + e.preventDefault(); + } + }); + searchBox.addEventListener('keyup', function (e) { + var search = e.target.value.toLowerCase(); + // On ESC, clear the search box and display all rules. + if (e.key == 'Escape' || search == '') { + searchAlert.classList.add('d-none'); + searchBox.value = ''; + for (var i = 0; i < filterElements.length; i++) { + filterElements[i].classList.remove("d-none"); + filterElements[i].classList.remove("first-child-visible"); + filterElements[i].classList.remove("last-child-visible"); + } + if (filterElements.length > 0) { + filterElements[0].classList.add('first-child-visible'); + filterElements[filterElements.length - 1].classList.add('last-child-visible'); + } + } else { + var visibleElements = []; + for (var _i = 0; _i < filterElements.length; _i++) { + var filter = filterElements[_i]; + var text = filter.getAttribute('data-filter-value'); + if (!text) { + filter.classList.add("d-none"); + continue; + } + var found = text.indexOf(search) > -1; + if (found) { + filter.classList.remove("d-none"); + filter.classList.remove("first-child-visible"); + filter.classList.remove("last-child-visible"); + visibleElements.push(filter); + } else { + filter.classList.add("d-none"); + } + } + if (visibleElements.length > 0) { + visibleElements[0].classList.add('first-child-visible'); + visibleElements[visibleElements.length - 1].classList.add('last-child-visible'); + searchAlert.classList.add('d-none'); + } else { + searchAlert.classList.remove('d-none'); + } + } + }); + var actions = document.getElementById('actions'); + var items = document.getElementById('items'); + var filters = document.querySelectorAll('.filter'); + var selectAllCtrl = document.getElementById('select-all'); + var selectedItems = document.getElementById('selected-items'); + var itemsCheckboxes = document.querySelectorAll("input[type='checkbox'][name='ruleIds']"); + function displayActionsOrFilters() { + // Select all checked checkboxes with name 'ruleIds' + var checkedCheckboxes = document.querySelectorAll("input[type='checkbox'][name='ruleIds']:checked"); + if (checkedCheckboxes.length > 1) { + actions.classList.remove('d-none'); + for (var i = 0; i < filters.length; i++) { + filters[i].classList.add('d-none'); + } + selectedItems.classList.remove('d-none'); + items.classList.add('d-none'); + } else { + actions.classList.add('d-none'); + for (var _i2 = 0; _i2 < filters.length; _i2++) { + filters[_i2].classList.remove('d-none'); + } + selectedItems.classList.add('d-none'); + items.classList.remove('d-none'); + } + } + var dropdownItems = document.querySelectorAll(".dropdown-menu .dropdown-item"); + + // Add click event listeners to each dropdown item + dropdownItems.forEach(function (item) { + // Check if the item has a data-action attribute + if (item.dataset.action) { + item.addEventListener("click", function () { + // Get all checked checkboxes + var checkedCheckboxes = document.querySelectorAll("input[type='checkbox'][name='ruleIds']:checked"); + + // Check if more than one checkbox is checked + if (checkedCheckboxes.length > 1) { + // Get data attributes from the clicked item + var actionData = Object.assign({}, item.dataset); + confirmDialog(_objectSpread(_objectSpread({}, actionData), {}, { + callback: function callback(r) { + if (r) { + // Set the value of the BulkAction option + document.querySelector("[name='Options.BulkAction']").value = actionData.action; + // Trigger the submit action + document.querySelector("[name='submit.BulkAction']").click(); + } + } + })); + } + }); + } + }); + selectAllCtrl.addEventListener("click", function () { + itemsCheckboxes.forEach(function (checkbox) { + if (checkbox !== selectAllCtrl) { + checkbox.checked = selectAllCtrl.checked; // Set the checked state of all checkboxes + } + }); + + // Update the selected items text + updateSelectedItemsText(); + displayActionsOrFilters(); + }); + + // Event listener for individual checkboxes + itemsCheckboxes.forEach(function (checkbox) { + checkbox.addEventListener("click", function () { + var itemsCount = itemsCheckboxes.length; + var selectedItemsCount = document.querySelectorAll("input[type='checkbox'][name='ruleIds']:checked").length; + + // Update selectAllCtrl state + selectAllCtrl.checked = selectedItemsCount === itemsCount; + selectAllCtrl.indeterminate = selectedItemsCount > 0 && selectedItemsCount < itemsCount; + + // Update the selected items text + updateSelectedItemsText(); + displayActionsOrFilters(); + }); + }); + + // Function to update selected items text + function updateSelectedItemsText() { + var selectedCount = document.querySelectorAll("input[type='checkbox'][name='ruleIds']:checked").length; + selectedItems.textContent = selectedCount + ' ' + selectedLabel; + } + }; + return { + initialize: initialize + }; +}(); \ No newline at end of file diff --git a/src/OrchardCore.Modules/OrchardCore.UrlRewriting/wwwroot/Scripts/admin-ui.min.js b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/wwwroot/Scripts/admin-ui.min.js new file mode 100644 index 00000000000..0df68759c32 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.UrlRewriting/wwwroot/Scripts/admin-ui.min.js @@ -0,0 +1 @@ +function _typeof(e){return _typeof="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},_typeof(e)}function ownKeys(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function _objectSpread(e){for(var t=1;t0&&(i[0].classList.add("first-child-visible"),i[i.length-1].classList.add("last-child-visible"))}else{for(var c=[],l=0;l-1?(s.classList.remove("d-none"),s.classList.remove("first-child-visible"),s.classList.remove("last-child-visible"),c.push(s)):s.classList.add("d-none")}c.length>0?(c[0].classList.add("first-child-visible"),c[c.length-1].classList.add("last-child-visible"),o.classList.add("d-none")):o.classList.remove("d-none")}}));var c=document.getElementById("actions"),l=document.getElementById("items"),s=document.querySelectorAll(".filter"),a=document.getElementById("select-all"),d=document.getElementById("selected-items"),u=document.querySelectorAll("input[type='checkbox'][name='ruleIds']");function f(){if(document.querySelectorAll("input[type='checkbox'][name='ruleIds']:checked").length>1){c.classList.remove("d-none");for(var e=0;e1){var t=Object.assign({},e.dataset);confirmDialog(_objectSpread(_objectSpread({},t),{},{callback:function(e){e&&(document.querySelector("[name='Options.BulkAction']").value=t.action,document.querySelector("[name='submit.BulkAction']").click())}}))}}))})),a.addEventListener("click",(function(){u.forEach((function(e){e!==a&&(e.checked=a.checked)})),m(),f()})),u.forEach((function(e){e.addEventListener("click",(function(){var e=u.length,t=document.querySelectorAll("input[type='checkbox'][name='ruleIds']:checked").length;a.checked=t===e,a.indeterminate=t>0&&t(this JsonNode node) + where TEnum : struct, Enum + { + if (node.TryGetValue(out var stringValue) && Enum.TryParse(stringValue, out var value)) + { + return value; + } + + if (node.TryGetValue(out var intValue) && Enum.IsDefined(typeof(TEnum), intValue)) + { + return (TEnum)Enum.ToObject(typeof(TEnum), intValue); + } + + return default; + } + + public static bool TryGetEnumValue(this JsonNode node, out Enum value) + where TEnum : struct, Enum + { + value = node.GetEnumValue(); + + return value != null; + } +} diff --git a/src/OrchardCore/OrchardCore.Abstractions/Modules/Extensions/StringBuilderExtensions.cs b/src/OrchardCore/OrchardCore.Abstractions/Modules/Extensions/StringBuilderExtensions.cs new file mode 100644 index 00000000000..342bb529602 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Abstractions/Modules/Extensions/StringBuilderExtensions.cs @@ -0,0 +1,60 @@ +using System.Text; + +namespace OrchardCore.Modules.Extensions; + +public static class StringBuilderExtensions +{ + private const char _comma = ','; + + public static StringBuilder AppendComma(this StringBuilder builder) + { + builder.Append(_comma); + + return builder; + } + + public static StringBuilder AppendCommaSeparatedValues(this StringBuilder builder, params string[] values) + { + if (values == null || values.Length == 0) + { + return builder; + } + + foreach (var value in values) + { + if (value == null) + { + continue; + } + + if (builder.Length > 0) + { + builder.AppendComma(); + } + + builder.Append(value); + } + + return builder; + } + + public static StringBuilder AppendCommaSeparatedValues(this StringBuilder builder, params char[] values) + { + if (values == null || values.Length == 0) + { + return builder; + } + + foreach (var value in values) + { + if (builder.Length > 0) + { + builder.AppendComma(); + } + + builder.Append(value); + } + + return builder; + } +} diff --git a/src/OrchardCore/OrchardCore.Abstractions/OrchardCoreConstants.cs b/src/OrchardCore/OrchardCore.Abstractions/OrchardCoreConstants.cs index 3e18723abe3..f7013183c23 100644 --- a/src/OrchardCore/OrchardCore.Abstractions/OrchardCoreConstants.cs +++ b/src/OrchardCore/OrchardCore.Abstractions/OrchardCoreConstants.cs @@ -73,5 +73,8 @@ public static class ConfigureOrder // Services that should always be registered before everything else. public const int InfrastructureService = int.MinValue + 100; + + // The UrlRewriting module should be registered before any other module that deals with URLs. + public const int UrlRewriting = InfrastructureService + 100; } } diff --git a/src/OrchardCore/OrchardCore.Application.Cms.Core.Targets/OrchardCore.Application.Cms.Core.Targets.csproj b/src/OrchardCore/OrchardCore.Application.Cms.Core.Targets/OrchardCore.Application.Cms.Core.Targets.csproj index 252fde56425..5612c380c41 100644 --- a/src/OrchardCore/OrchardCore.Application.Cms.Core.Targets/OrchardCore.Application.Cms.Core.Targets.csproj +++ b/src/OrchardCore/OrchardCore.Application.Cms.Core.Targets/OrchardCore.Application.Cms.Core.Targets.csproj @@ -99,6 +99,7 @@ + diff --git a/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/IRewriteRuleHandler.cs b/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/IRewriteRuleHandler.cs new file mode 100644 index 00000000000..10977343671 --- /dev/null +++ b/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/IRewriteRuleHandler.cs @@ -0,0 +1,72 @@ +using OrchardCore.UrlRewriting.Models; + +namespace OrchardCore.UrlRewriting; + +public interface IRewriteRuleHandler +{ + /// + /// This method in invoked during rule initializing. + /// + /// An instance of . + Task InitializingAsync(InitializingRewriteRuleContext context); + + /// + /// This method in invoked after the rule was initialized. + /// + /// An instance of . + Task InitializedAsync(InitializedRewriteRuleContext context); + + /// + /// This method in invoked after the rule was loaded from the store. + /// + /// An instance of . + Task LoadedAsync(LoadedRewriteRuleContext context); + + /// + /// This method in invoked during rule validating. + /// + /// An instance of . + Task ValidatingAsync(ValidatingRewriteRuleContext context); + + /// + /// This method in invoked after the rule was validated. + /// + /// An instance of . + Task ValidatedAsync(ValidatedRewriteRuleContext context); + + /// + /// This method in invoked during rule removing. + /// + /// An instance of . + Task DeletingAsync(DeletingRewriteRuleContext context); + + /// + /// This method in invoked after the rule was removed. + /// + /// An instance of . + Task DeletedAsync(DeletedRewriteRuleContext context); + + /// + /// This method in invoked during rule updating. + /// + /// An instance of . + Task UpdatingAsync(UpdatingRewriteRuleContext context); + + /// + /// This method in invoked after the rule was updated. + /// + /// An instance of . + Task UpdatedAsync(UpdatedRewriteRuleContext context); + + /// + /// This method in invoked during rule saving. + /// + /// An instance of . + Task SavingAsync(SavingRewriteRuleContext context); + + /// + /// This method in invoked after the rule was saved. + /// + /// An instance of . + Task SavedAsync(SavedRewriteRuleContext context); +} diff --git a/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/IRewriteRulesManager.cs b/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/IRewriteRulesManager.cs new file mode 100644 index 00000000000..138f3faf827 --- /dev/null +++ b/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/IRewriteRulesManager.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Nodes; +using OrchardCore.UrlRewriting.Models; + +namespace OrchardCore.UrlRewriting; + +public interface IRewriteRulesManager +{ + Task NewAsync(string source, JsonNode data = null); + + Task ValidateAsync(RewriteRule rule); + + Task FindByIdAsync(string id); + + Task SaveAsync(RewriteRule rule); + + Task DeleteAsync(RewriteRule rule); + + Task UpdateAsync(RewriteRule rule, JsonNode data = null); + + Task ResortOrderAsync(int oldOrder, int newOrder); + + Task> GetAllAsync(); +} diff --git a/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/IRewriteRulesStore.cs b/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/IRewriteRulesStore.cs new file mode 100644 index 00000000000..cceb85be378 --- /dev/null +++ b/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/IRewriteRulesStore.cs @@ -0,0 +1,16 @@ +using OrchardCore.UrlRewriting.Models; + +namespace OrchardCore.UrlRewriting; + +public interface IRewriteRulesStore +{ + Task DeleteAsync(RewriteRule rule); + + Task FindByIdAsync(string id); + + Task> GetAllAsync(); + + Task SaveAsync(RewriteRule rule); + + Task> UpdateOrderAndSaveAsync(IEnumerable rules); +} diff --git a/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/IUrlRewriteRuleSource.cs b/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/IUrlRewriteRuleSource.cs new file mode 100644 index 00000000000..891e9e16ec6 --- /dev/null +++ b/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/IUrlRewriteRuleSource.cs @@ -0,0 +1,45 @@ +using Microsoft.AspNetCore.Rewrite; +using Microsoft.Extensions.Localization; +using OrchardCore.UrlRewriting.Models; + +namespace OrchardCore.UrlRewriting; + +public interface IUrlRewriteRuleSource +{ + /// + /// Gets the unique technical name of the rule source. + /// + /// This name is used to identify the source of the URL rewrite rules. + /// It should be unique across different sources to avoid conflicts. + /// + /// + string TechnicalName { get; } + + /// + /// Gets a localized display name for the rule source. + /// + LocalizedString DisplayName { get; } + + /// + /// Gets a localized description for the rule source. + /// + /// This description provides more information about the source and its purpose. + /// It is intended for display in user interfaces where users can select or configure + /// URL rewrite rules. + /// + /// + LocalizedString Description { get; } + + /// + /// Configures the rewrite options for a specific URL rewrite rule. + /// + /// This method is called to apply the settings and configurations specific to the + /// URL rewrite rule defined by the parameter. + /// The parameter can be used to set various rewrite options, + /// such as the URL patterns to match or the actions to take on matches. + /// + /// + /// The rewrite options to configure. + /// The specific URL rewrite rule to configure. + void Configure(RewriteOptions options, RewriteRule rule); +} diff --git a/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/Models/DeletedRewriteRuleContext.cs b/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/Models/DeletedRewriteRuleContext.cs new file mode 100644 index 00000000000..91c21f796de --- /dev/null +++ b/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/Models/DeletedRewriteRuleContext.cs @@ -0,0 +1,9 @@ +namespace OrchardCore.UrlRewriting.Models; + +public sealed class DeletedRewriteRuleContext : RewriteRuleContextBase +{ + public DeletedRewriteRuleContext(RewriteRule rule) + : base(rule) + { + } +} diff --git a/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/Models/DeletingRewriteRuleContext.cs b/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/Models/DeletingRewriteRuleContext.cs new file mode 100644 index 00000000000..ec369cd50d4 --- /dev/null +++ b/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/Models/DeletingRewriteRuleContext.cs @@ -0,0 +1,9 @@ +namespace OrchardCore.UrlRewriting.Models; + +public sealed class DeletingRewriteRuleContext : RewriteRuleContextBase +{ + public DeletingRewriteRuleContext(RewriteRule rule) + : base(rule) + { + } +} diff --git a/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/Models/InitializedRewriteRuleContext.cs b/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/Models/InitializedRewriteRuleContext.cs new file mode 100644 index 00000000000..5504418eadb --- /dev/null +++ b/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/Models/InitializedRewriteRuleContext.cs @@ -0,0 +1,9 @@ +namespace OrchardCore.UrlRewriting.Models; + +public sealed class InitializedRewriteRuleContext : RewriteRuleContextBase +{ + public InitializedRewriteRuleContext(RewriteRule rule) + : base(rule) + { + } +} diff --git a/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/Models/InitializingRewriteRuleContext.cs b/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/Models/InitializingRewriteRuleContext.cs new file mode 100644 index 00000000000..753fd11b9cb --- /dev/null +++ b/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/Models/InitializingRewriteRuleContext.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Nodes; + +namespace OrchardCore.UrlRewriting.Models; + +public sealed class InitializingRewriteRuleContext : RewriteRuleContextBase +{ + public JsonNode Data { get; } + + public InitializingRewriteRuleContext(RewriteRule rule, JsonNode data) + : base(rule) + { + Data = data ?? new JsonObject(); + } +} diff --git a/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/Models/LoadedRewriteRuleContext.cs b/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/Models/LoadedRewriteRuleContext.cs new file mode 100644 index 00000000000..21bda78a20d --- /dev/null +++ b/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/Models/LoadedRewriteRuleContext.cs @@ -0,0 +1,9 @@ +namespace OrchardCore.UrlRewriting.Models; + +public sealed class LoadedRewriteRuleContext : RewriteRuleContextBase +{ + public LoadedRewriteRuleContext(RewriteRule rule) + : base(rule) + { + } +} diff --git a/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/Models/RewriteRule.cs b/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/Models/RewriteRule.cs new file mode 100644 index 00000000000..1d728012824 --- /dev/null +++ b/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/Models/RewriteRule.cs @@ -0,0 +1,35 @@ +using OrchardCore.Entities; + +namespace OrchardCore.UrlRewriting.Models; + +public sealed class RewriteRule : Entity +{ + public string Id { get; set; } + + public string Source { get; set; } + + public string Name { get; set; } + + public int Order { get; set; } + + public DateTime CreatedUtc { get; set; } + + public string OwnerId { get; set; } + + public string Author { get; set; } + + public RewriteRule Clone() + { + return new RewriteRule + { + Id = Id, + Source = Source, + Name = Name, + Order = Order, + CreatedUtc = CreatedUtc, + OwnerId = OwnerId, + Author = Author, + Properties = Properties, + }; + } +} diff --git a/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/Models/RewriteRuleContextBase.cs b/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/Models/RewriteRuleContextBase.cs new file mode 100644 index 00000000000..58ebaf25eb8 --- /dev/null +++ b/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/Models/RewriteRuleContextBase.cs @@ -0,0 +1,13 @@ +namespace OrchardCore.UrlRewriting.Models; + +public abstract class RewriteRuleContextBase +{ + public RewriteRule Rule { get; } + + public RewriteRuleContextBase(RewriteRule rule) + { + ArgumentNullException.ThrowIfNull(rule); + + Rule = rule; + } +} diff --git a/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/Models/RewriteValidateResult.cs b/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/Models/RewriteValidateResult.cs new file mode 100644 index 00000000000..593d80f0a3b --- /dev/null +++ b/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/Models/RewriteValidateResult.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace OrchardCore.UrlRewriting.Models; + +public class RewriteValidateResult +{ + private readonly List _errors = []; + + public IReadOnlyList Errors + => _errors; + + /// + /// Success may be altered by a handler during the validating async event. + /// + public bool Succeeded { get; set; } = true; + + public void Fail(ValidationResult error) + { + Succeeded = false; + + _errors.Add(error); + } +} diff --git a/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/Models/SavedRewriteRuleContext.cs b/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/Models/SavedRewriteRuleContext.cs new file mode 100644 index 00000000000..928a4aeb909 --- /dev/null +++ b/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/Models/SavedRewriteRuleContext.cs @@ -0,0 +1,9 @@ +namespace OrchardCore.UrlRewriting.Models; + +public sealed class SavedRewriteRuleContext : RewriteRuleContextBase +{ + public SavedRewriteRuleContext(RewriteRule rule) + : base(rule) + { + } +} diff --git a/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/Models/SavingRewriteRuleContext.cs b/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/Models/SavingRewriteRuleContext.cs new file mode 100644 index 00000000000..120c2ae3dc6 --- /dev/null +++ b/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/Models/SavingRewriteRuleContext.cs @@ -0,0 +1,9 @@ +namespace OrchardCore.UrlRewriting.Models; + +public sealed class SavingRewriteRuleContext : RewriteRuleContextBase +{ + public SavingRewriteRuleContext(RewriteRule rule) + : base(rule) + { + } +} diff --git a/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/Models/UpdatedRewriteRuleContext.cs b/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/Models/UpdatedRewriteRuleContext.cs new file mode 100644 index 00000000000..4a266e7ccca --- /dev/null +++ b/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/Models/UpdatedRewriteRuleContext.cs @@ -0,0 +1,9 @@ +namespace OrchardCore.UrlRewriting.Models; + +public sealed class UpdatedRewriteRuleContext : RewriteRuleContextBase +{ + public UpdatedRewriteRuleContext(RewriteRule rule) + : base(rule) + { + } +} diff --git a/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/Models/UpdatingRewriteRuleContext.cs b/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/Models/UpdatingRewriteRuleContext.cs new file mode 100644 index 00000000000..0697fb66cff --- /dev/null +++ b/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/Models/UpdatingRewriteRuleContext.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Nodes; + +namespace OrchardCore.UrlRewriting.Models; + +public sealed class UpdatingRewriteRuleContext : RewriteRuleContextBase +{ + public JsonNode Data { get; } + + public UpdatingRewriteRuleContext(RewriteRule rule, JsonNode data) + : base(rule) + { + Data = data ?? new JsonObject(); + } +} diff --git a/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/Models/ValidatedRewriteRuleContext.cs b/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/Models/ValidatedRewriteRuleContext.cs new file mode 100644 index 00000000000..44ac96ee556 --- /dev/null +++ b/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/Models/ValidatedRewriteRuleContext.cs @@ -0,0 +1,12 @@ +namespace OrchardCore.UrlRewriting.Models; + +public sealed class ValidatedRewriteRuleContext : RewriteRuleContextBase +{ + public readonly RewriteValidateResult Result; + + public ValidatedRewriteRuleContext(RewriteRule rule, RewriteValidateResult result) + : base(rule) + { + Result = result ?? new(); + } +} diff --git a/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/Models/ValidatingRewriteRuleContext.cs b/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/Models/ValidatingRewriteRuleContext.cs new file mode 100644 index 00000000000..2c544bbf3ae --- /dev/null +++ b/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/Models/ValidatingRewriteRuleContext.cs @@ -0,0 +1,11 @@ +namespace OrchardCore.UrlRewriting.Models; + +public sealed class ValidatingRewriteRuleContext : RewriteRuleContextBase +{ + public RewriteValidateResult Result { get; } = new(); + + public ValidatingRewriteRuleContext(RewriteRule rule) + : base(rule) + { + } +} diff --git a/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/OrchardCore.UrlRewriting.Abstractions.csproj b/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/OrchardCore.UrlRewriting.Abstractions.csproj new file mode 100644 index 00000000000..9b4cd6c2859 --- /dev/null +++ b/src/OrchardCore/OrchardCore.UrlRewriting.Abstractions/OrchardCore.UrlRewriting.Abstractions.csproj @@ -0,0 +1,23 @@ + + + + OrchardCore.UrlRewriting + + OrchardCore UrlRewriting Abstractions + + $(OCCMSDescription) + + Abstractions for the UrlRewriting module. + + $(PackageTags) OrchardCore Abstractions + + + + + + + + + + + diff --git a/src/OrchardCore/OrchardCore.UrlRewriting.Core/Extensions/ApplicationBuilderExtensions.cs b/src/OrchardCore/OrchardCore.UrlRewriting.Core/Extensions/ApplicationBuilderExtensions.cs new file mode 100644 index 00000000000..e1b344f5220 --- /dev/null +++ b/src/OrchardCore/OrchardCore.UrlRewriting.Core/Extensions/ApplicationBuilderExtensions.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Rewrite; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace OrchardCore.UrlRewriting.Extensions; + +public static class ApplicationBuilderExtensions +{ + public static IApplicationBuilder UseUrlRewriting(this IApplicationBuilder builder, IServiceProvider serviceProvider) + { + var rewriteOptions = serviceProvider.GetRequiredService>().Value; + + builder.UseRewriter(rewriteOptions); + + return builder; + } +} diff --git a/src/OrchardCore/OrchardCore.UrlRewriting.Core/Extensions/ServiceCollectionExtensions.cs b/src/OrchardCore/OrchardCore.UrlRewriting.Core/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 00000000000..8834dfd521f --- /dev/null +++ b/src/OrchardCore/OrchardCore.UrlRewriting.Core/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Rewrite; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using OrchardCore.UrlRewriting.Handlers; +using OrchardCore.UrlRewriting.Services; + +namespace OrchardCore.UrlRewriting.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddUrlRewritingServices(this IServiceCollection services) + { + services.AddTransient, RewriteOptionsConfiguration>(); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/src/OrchardCore/OrchardCore.UrlRewriting.Core/Handlers/RewriteRuleHandler.cs b/src/OrchardCore/OrchardCore.UrlRewriting.Core/Handlers/RewriteRuleHandler.cs new file mode 100644 index 00000000000..a8d3b46ab5c --- /dev/null +++ b/src/OrchardCore/OrchardCore.UrlRewriting.Core/Handlers/RewriteRuleHandler.cs @@ -0,0 +1,75 @@ +using System.ComponentModel.DataAnnotations; +using System.Security.Claims; +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Localization; +using OrchardCore.Modules; +using OrchardCore.UrlRewriting.Models; + +namespace OrchardCore.UrlRewriting.Handlers; + +public sealed class RewriteRuleHandler : RewriteRuleHandlerBase +{ + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IClock _clock; + + internal readonly IStringLocalizer S; + + public RewriteRuleHandler( + IHttpContextAccessor httpContextAccessor, + IClock clock, + IStringLocalizer stringLocalizer) + { + _httpContextAccessor = httpContextAccessor; + _clock = clock; + S = stringLocalizer; + } + + public override Task InitializingAsync(InitializingRewriteRuleContext context) + => PopulateAsync(context.Rule, context.Data); + + public override Task UpdatingAsync(UpdatingRewriteRuleContext context) + => PopulateAsync(context.Rule, context.Data); + + public override Task ValidatingAsync(ValidatingRewriteRuleContext context) + { + if (string.IsNullOrWhiteSpace(context.Rule.Name)) + { + context.Result.Fail(new ValidationResult(S["Rule name is required"], [nameof(RewriteRule.Name)])); + } + + if (string.IsNullOrWhiteSpace(context.Rule.Source)) + { + context.Result.Fail(new ValidationResult(S["Source name is required"], [nameof(RewriteRule.Source)])); + } + + return Task.CompletedTask; + } + + public override Task InitializedAsync(InitializedRewriteRuleContext context) + { + context.Rule.CreatedUtc = _clock.UtcNow; + + var user = _httpContextAccessor.HttpContext?.User; + + if (user != null) + { + context.Rule.OwnerId = user.FindFirstValue(ClaimTypes.NameIdentifier); + context.Rule.Author = user.Identity.Name; + } + + return Task.CompletedTask; + } + + private static Task PopulateAsync(RewriteRule rule, JsonNode data) + { + var name = data[nameof(RewriteRule.Name)]?.GetValue(); + + if (!string.IsNullOrEmpty(name)) + { + rule.Name = name; + } + + return Task.CompletedTask; + } +} diff --git a/src/OrchardCore/OrchardCore.UrlRewriting.Core/Handlers/RewriteRuleHandlerBase.cs b/src/OrchardCore/OrchardCore.UrlRewriting.Core/Handlers/RewriteRuleHandlerBase.cs new file mode 100644 index 00000000000..49867aef574 --- /dev/null +++ b/src/OrchardCore/OrchardCore.UrlRewriting.Core/Handlers/RewriteRuleHandlerBase.cs @@ -0,0 +1,39 @@ +using OrchardCore.UrlRewriting.Models; + +namespace OrchardCore.UrlRewriting.Handlers; + +public abstract class RewriteRuleHandlerBase : IRewriteRuleHandler +{ + public virtual Task DeletedAsync(DeletedRewriteRuleContext context) + => Task.CompletedTask; + + public virtual Task DeletingAsync(DeletingRewriteRuleContext context) + => Task.CompletedTask; + + public virtual Task InitializedAsync(InitializedRewriteRuleContext context) + => Task.CompletedTask; + + public virtual Task InitializingAsync(InitializingRewriteRuleContext context) + => Task.CompletedTask; + + public virtual Task LoadedAsync(LoadedRewriteRuleContext context) + => Task.CompletedTask; + + public virtual Task SavedAsync(SavedRewriteRuleContext context) + => Task.CompletedTask; + + public virtual Task SavingAsync(SavingRewriteRuleContext context) + => Task.CompletedTask; + + public virtual Task UpdatedAsync(UpdatedRewriteRuleContext context) + => Task.CompletedTask; + + public virtual Task UpdatingAsync(UpdatingRewriteRuleContext context) + => Task.CompletedTask; + + public virtual Task ValidatedAsync(ValidatedRewriteRuleContext context) + => Task.CompletedTask; + + public virtual Task ValidatingAsync(ValidatingRewriteRuleContext context) + => Task.CompletedTask; +} diff --git a/src/OrchardCore/OrchardCore.UrlRewriting.Core/Handlers/UrlRedirectRuleHandler.cs b/src/OrchardCore/OrchardCore.UrlRewriting.Core/Handlers/UrlRedirectRuleHandler.cs new file mode 100644 index 00000000000..f73a21ebb77 --- /dev/null +++ b/src/OrchardCore/OrchardCore.UrlRewriting.Core/Handlers/UrlRedirectRuleHandler.cs @@ -0,0 +1,107 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Localization; +using OrchardCore.Entities; +using OrchardCore.UrlRewriting.Models; +using OrchardCore.UrlRewriting.Services; + +namespace OrchardCore.UrlRewriting.Handlers; + +public sealed class UrlRedirectRuleHandler : RewriteRuleHandlerBase +{ + internal readonly IStringLocalizer S; + + public UrlRedirectRuleHandler(IStringLocalizer stringLocalizer) + { + S = stringLocalizer; + } + + public override Task InitializingAsync(InitializingRewriteRuleContext context) + => PopulateAsync(context.Rule, context.Data); + + public override Task UpdatingAsync(UpdatingRewriteRuleContext context) + => PopulateAsync(context.Rule, context.Data); + + public override Task ValidatingAsync(ValidatingRewriteRuleContext context) + { + if (context.Rule.Source != UrlRedirectRuleSource.SourceName) + { + return Task.CompletedTask; + } + + var metadata = context.Rule.As(); + + if (string.IsNullOrWhiteSpace(metadata.Pattern)) + { + context.Result.Fail(new ValidationResult(S["The Match URL Pattern is required."], [nameof(UrlRedirectSourceMetadata.Pattern)])); + } + else if (!PatternHelper.IsValidRegex(metadata.Pattern)) + { + context.Result.Fail(new ValidationResult(S["A valid Match URL Pattern is required."], [nameof(UrlRedirectSourceMetadata.Pattern)])); + } + + if (string.IsNullOrWhiteSpace(metadata.SubstitutionPattern)) + { + context.Result.Fail(new ValidationResult(S["The Substitution URL Pattern is required."], [nameof(UrlRedirectSourceMetadata.SubstitutionPattern)])); + } + else if (!PatternHelper.IsValidRegex(metadata.SubstitutionPattern)) + { + context.Result.Fail(new ValidationResult(S["A valid Substitution URL Pattern is required."], [nameof(UrlRedirectSourceMetadata.SubstitutionPattern)])); + } + + return Task.CompletedTask; + } + + private static Task PopulateAsync(RewriteRule rule, JsonNode data) + { + if (rule.Source != UrlRedirectRuleSource.SourceName) + { + return Task.CompletedTask; + } + + var metadata = rule.As(); + + var pattern = data[nameof(UrlRedirectSourceMetadata.Pattern)]?.GetValue(); + + if (!string.IsNullOrEmpty(pattern)) + { + metadata.Pattern = pattern; + } + + var ignoreCase = data[nameof(UrlRedirectSourceMetadata.IsCaseInsensitive)]?.GetValue(); + + if (ignoreCase.HasValue) + { + metadata.IsCaseInsensitive = ignoreCase.Value; + } + + var substitutionPattern = data[nameof(UrlRedirectSourceMetadata.SubstitutionPattern)]?.GetValue(); + + if (!string.IsNullOrEmpty(substitutionPattern)) + { + metadata.SubstitutionPattern = substitutionPattern; + } + + var queryStringPolicy = data[nameof(UrlRewriteSourceMetadata.QueryStringPolicy)]?.GetEnumValue(); + + if (queryStringPolicy.HasValue) + { + metadata.QueryStringPolicy = queryStringPolicy.Value; + } + + var redirectType = data[nameof(UrlRedirectSourceMetadata.RedirectType)]?.GetEnumValue(); + + if (redirectType.HasValue) + { + metadata.RedirectType = redirectType.Value; + } + else if (!Enum.IsDefined(typeof(RedirectType), metadata.RedirectType)) + { + metadata.RedirectType = RedirectType.Found; + } + + rule.Put(metadata); + + return Task.CompletedTask; + } +} diff --git a/src/OrchardCore/OrchardCore.UrlRewriting.Core/Handlers/UrlRewriteRuleHandler.cs b/src/OrchardCore/OrchardCore.UrlRewriting.Core/Handlers/UrlRewriteRuleHandler.cs new file mode 100644 index 00000000000..227981713ad --- /dev/null +++ b/src/OrchardCore/OrchardCore.UrlRewriting.Core/Handlers/UrlRewriteRuleHandler.cs @@ -0,0 +1,103 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Localization; +using OrchardCore.Entities; +using OrchardCore.UrlRewriting.Models; +using OrchardCore.UrlRewriting.Services; + +namespace OrchardCore.UrlRewriting.Handlers; + +public sealed class UrlRewriteRuleHandler : RewriteRuleHandlerBase +{ + internal readonly IStringLocalizer S; + + public UrlRewriteRuleHandler(IStringLocalizer stringLocalizer) + { + S = stringLocalizer; + } + + public override Task InitializingAsync(InitializingRewriteRuleContext context) + => PopulateAsync(context.Rule, context.Data); + + public override Task UpdatingAsync(UpdatingRewriteRuleContext context) + => PopulateAsync(context.Rule, context.Data); + + public override Task ValidatingAsync(ValidatingRewriteRuleContext context) + { + if (context.Rule.Source != UrlRewriteRuleSource.SourceName) + { + return Task.CompletedTask; + } + + var metadata = context.Rule.As(); + + if (string.IsNullOrWhiteSpace(metadata.Pattern)) + { + context.Result.Fail(new ValidationResult(S["The Match URL Pattern is required."], [nameof(UrlRedirectSourceMetadata.Pattern)])); + } + else if (!PatternHelper.IsValidRegex(metadata.Pattern)) + { + context.Result.Fail(new ValidationResult(S["A valid Match URL Pattern is required."], [nameof(UrlRedirectSourceMetadata.Pattern)])); + } + + if (string.IsNullOrWhiteSpace(metadata.SubstitutionPattern)) + { + context.Result.Fail(new ValidationResult(S["The Substitution URL Pattern is required."], [nameof(UrlRedirectSourceMetadata.SubstitutionPattern)])); + } + else if (!PatternHelper.IsValidRegex(metadata.SubstitutionPattern)) + { + context.Result.Fail(new ValidationResult(S["A valid Substitution URL Pattern is required."], [nameof(UrlRedirectSourceMetadata.SubstitutionPattern)])); + } + + return Task.CompletedTask; + } + + private static Task PopulateAsync(RewriteRule rule, JsonNode data) + { + if (rule.Source != UrlRewriteRuleSource.SourceName) + { + return Task.CompletedTask; + } + + var metadata = rule.As(); + + var pattern = data[nameof(UrlRewriteSourceMetadata.Pattern)]?.GetValue(); + + if (!string.IsNullOrEmpty(pattern)) + { + metadata.Pattern = pattern; + } + + var substitutionPattern = data[nameof(UrlRewriteSourceMetadata.SubstitutionPattern)]?.GetValue(); + + if (!string.IsNullOrEmpty(substitutionPattern)) + { + metadata.SubstitutionPattern = substitutionPattern; + } + + var ignoreCase = data[nameof(UrlRewriteSourceMetadata.IsCaseInsensitive)]?.GetValue(); + + if (ignoreCase.HasValue) + { + metadata.IsCaseInsensitive = ignoreCase.Value; + } + + var queryStringPolicy = data[nameof(UrlRewriteSourceMetadata.QueryStringPolicy)]?.GetEnumValue(); + + if (queryStringPolicy.HasValue) + { + metadata.QueryStringPolicy = queryStringPolicy.Value; + } + + var skipFurtherRules = data[nameof(UrlRewriteSourceMetadata.SkipFurtherRules)]?.GetValue(); + + if (skipFurtherRules.HasValue) + { + metadata.SkipFurtherRules = skipFurtherRules.Value; + } + + rule.Put(metadata); + + return Task.CompletedTask; + } +} diff --git a/src/OrchardCore/OrchardCore.UrlRewriting.Core/Models/QueryStringPolicy.cs b/src/OrchardCore/OrchardCore.UrlRewriting.Core/Models/QueryStringPolicy.cs new file mode 100644 index 00000000000..51287bf18d7 --- /dev/null +++ b/src/OrchardCore/OrchardCore.UrlRewriting.Core/Models/QueryStringPolicy.cs @@ -0,0 +1,7 @@ +namespace OrchardCore.UrlRewriting.Models; + +public enum QueryStringPolicy +{ + Append, + Drop, +} diff --git a/src/OrchardCore/OrchardCore.UrlRewriting.Core/Models/RedirectType.cs b/src/OrchardCore/OrchardCore.UrlRewriting.Core/Models/RedirectType.cs new file mode 100644 index 00000000000..5c6bf25cf42 --- /dev/null +++ b/src/OrchardCore/OrchardCore.UrlRewriting.Core/Models/RedirectType.cs @@ -0,0 +1,9 @@ +namespace OrchardCore.UrlRewriting.Models; + +public enum RedirectType +{ + Found = 302, + MovedPermanently = 301, + TemporaryRedirect = 307, + PermanentRedirect = 308, +} diff --git a/src/OrchardCore/OrchardCore.UrlRewriting.Core/Models/RewriteRulesDocument.cs b/src/OrchardCore/OrchardCore.UrlRewriting.Core/Models/RewriteRulesDocument.cs new file mode 100644 index 00000000000..eca78b86735 --- /dev/null +++ b/src/OrchardCore/OrchardCore.UrlRewriting.Core/Models/RewriteRulesDocument.cs @@ -0,0 +1,8 @@ +using OrchardCore.Data.Documents; + +namespace OrchardCore.UrlRewriting.Models; + +public sealed class RewriteRulesDocument : Document +{ + public Dictionary Rules { get; set; } = []; +} diff --git a/src/OrchardCore/OrchardCore.UrlRewriting.Core/Models/UrlRedirectSourceMetadata.cs b/src/OrchardCore/OrchardCore.UrlRewriting.Core/Models/UrlRedirectSourceMetadata.cs new file mode 100644 index 00000000000..f57756c16cd --- /dev/null +++ b/src/OrchardCore/OrchardCore.UrlRewriting.Core/Models/UrlRedirectSourceMetadata.cs @@ -0,0 +1,14 @@ +namespace OrchardCore.UrlRewriting.Models; + +public sealed class UrlRedirectSourceMetadata +{ + public string Pattern { get; set; } + + public string SubstitutionPattern { get; set; } + + public bool IsCaseInsensitive { get; set; } + + public QueryStringPolicy QueryStringPolicy { get; set; } + + public RedirectType RedirectType { get; set; } +} diff --git a/src/OrchardCore/OrchardCore.UrlRewriting.Core/Models/UrlRewriteSourceMetadata.cs b/src/OrchardCore/OrchardCore.UrlRewriting.Core/Models/UrlRewriteSourceMetadata.cs new file mode 100644 index 00000000000..653292d3452 --- /dev/null +++ b/src/OrchardCore/OrchardCore.UrlRewriting.Core/Models/UrlRewriteSourceMetadata.cs @@ -0,0 +1,14 @@ +namespace OrchardCore.UrlRewriting.Models; + +public sealed class UrlRewriteSourceMetadata +{ + public string Pattern { get; set; } + + public bool IsCaseInsensitive { get; set; } + + public string SubstitutionPattern { get; set; } + + public QueryStringPolicy QueryStringPolicy { get; set; } + + public bool SkipFurtherRules { get; set; } +} diff --git a/src/OrchardCore/OrchardCore.UrlRewriting.Core/OrchardCore.UrlRewriting.Core.csproj b/src/OrchardCore/OrchardCore.UrlRewriting.Core/OrchardCore.UrlRewriting.Core.csproj new file mode 100644 index 00000000000..d1c2938f2a2 --- /dev/null +++ b/src/OrchardCore/OrchardCore.UrlRewriting.Core/OrchardCore.UrlRewriting.Core.csproj @@ -0,0 +1,26 @@ + + + + OrchardCore.UrlRewriting + + OrchardCore UrlRewriting Core + + $(OCCMSDescription) + + Core Services for the UrlRewriting module. + + $(PackageTags) OrchardCore Core + + + + + + + + + + + + + + diff --git a/src/OrchardCore/OrchardCore.UrlRewriting.Core/PatternHelper.cs b/src/OrchardCore/OrchardCore.UrlRewriting.Core/PatternHelper.cs new file mode 100644 index 00000000000..5c6935d96eb --- /dev/null +++ b/src/OrchardCore/OrchardCore.UrlRewriting.Core/PatternHelper.cs @@ -0,0 +1,22 @@ +using System.Text.RegularExpressions; + +namespace OrchardCore.UrlRewriting; + +public static class PatternHelper +{ + public static bool IsValidRegex(string pattern) + { + try + { + _ = new Regex(pattern); + + // If the pattern is invalid, an exception is thrown. + return true; + } + catch + { + // Any exception indicates an invalid pattern. + return false; + } + } +} diff --git a/src/OrchardCore/OrchardCore.UrlRewriting.Core/Rules/ExcludeUrlPrefixRule.cs b/src/OrchardCore/OrchardCore.UrlRewriting.Core/Rules/ExcludeUrlPrefixRule.cs new file mode 100644 index 00000000000..5bb686686e3 --- /dev/null +++ b/src/OrchardCore/OrchardCore.UrlRewriting.Core/Rules/ExcludeUrlPrefixRule.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Rewrite; + +namespace OrchardCore.UrlRewriting.Rules; + +internal sealed class ExcludeUrlPrefixRule : IRule +{ + private readonly PathString _prefix; + + public ExcludeUrlPrefixRule(PathString prefix) + { + ArgumentException.ThrowIfNullOrEmpty(prefix); + + _prefix = prefix; + } + + public void ApplyRule(RewriteContext context) + { + if (context.HttpContext.Request.Path.StartsWithSegments(_prefix)) + { + context.Result = RuleResult.SkipRemainingRules; + + return; + } + + context.Result = RuleResult.ContinueRules; + } +} diff --git a/src/OrchardCore/OrchardCore.UrlRewriting.Core/ServiceCollectionExtensions.cs b/src/OrchardCore/OrchardCore.UrlRewriting.Core/ServiceCollectionExtensions.cs new file mode 100644 index 00000000000..8d3c53ed571 --- /dev/null +++ b/src/OrchardCore/OrchardCore.UrlRewriting.Core/ServiceCollectionExtensions.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace OrchardCore.UrlRewriting; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddRewriteRuleSource(this IServiceCollection services, string sourceName) + where TSource : class, IUrlRewriteRuleSource + { + ArgumentException.ThrowIfNullOrEmpty(sourceName); + + services.AddSingleton(); + services.AddSingleton(sp => sp.GetService()); + services.AddKeyedSingleton(sourceName, (sp, key) => sp.GetService()); + + return services; + } +} diff --git a/src/OrchardCore/OrchardCore.UrlRewriting.Core/Services/RewriteOptionsConfiguration.cs b/src/OrchardCore/OrchardCore.UrlRewriting.Core/Services/RewriteOptionsConfiguration.cs new file mode 100644 index 00000000000..2ca6d14e6de --- /dev/null +++ b/src/OrchardCore/OrchardCore.UrlRewriting.Core/Services/RewriteOptionsConfiguration.cs @@ -0,0 +1,52 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Rewrite; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using OrchardCore.Admin; +using OrchardCore.UrlRewriting.Rules; + +namespace OrchardCore.UrlRewriting.Services; + +public sealed class RewriteOptionsConfiguration : IConfigureOptions +{ + private readonly IRewriteRulesStore _rewriteRulesStore; + private readonly IServiceProvider _serviceProvider; + private readonly AdminOptions _adminOptions; + + public RewriteOptionsConfiguration( + IRewriteRulesStore rewriteRulesStore, + IServiceProvider serviceProvider, + IOptions adminOptions) + { + _rewriteRulesStore = rewriteRulesStore; + _serviceProvider = serviceProvider; + _adminOptions = adminOptions.Value; + } + + public void Configure(RewriteOptions options) + { + var rules = _rewriteRulesStore.GetAllAsync() + .GetAwaiter() + .GetResult(); + + foreach (var rule in rules.OrderBy(r => r.Order).ThenBy(r => r.CreatedUtc)) + { + var source = _serviceProvider.GetKeyedService(rule.Source); + + if (source == null) + { + continue; + } + + source.Configure(options, rule); + } + + if (options.Rules.Count > 0) + { + // Exclude URIs prefixed with 'admin' to prevent accidental access restrictions caused by the provided rules. + var prefix = new PathString('/' + _adminOptions.AdminUrlPrefix.TrimStart('/')); + + options.Rules.Insert(0, new ExcludeUrlPrefixRule(prefix)); + } + } +} diff --git a/src/OrchardCore/OrchardCore.UrlRewriting.Core/Services/RewriteRulesManager.cs b/src/OrchardCore/OrchardCore.UrlRewriting.Core/Services/RewriteRulesManager.cs new file mode 100644 index 00000000000..526701967fe --- /dev/null +++ b/src/OrchardCore/OrchardCore.UrlRewriting.Core/Services/RewriteRulesManager.cs @@ -0,0 +1,188 @@ +using System.Text.Json.Nodes; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using OrchardCore.Modules; +using OrchardCore.UrlRewriting.Models; + +namespace OrchardCore.UrlRewriting.Services; + +public sealed class RewriteRulesManager : IRewriteRulesManager +{ + private readonly IRewriteRulesStore _store; + private readonly IEnumerable _rewriteRuleHandlers; + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + public RewriteRulesManager( + IRewriteRulesStore store, + IEnumerable rewriteRuleHandlers, + IServiceProvider serviceProvider, + ILogger logger) + { + _store = store; + _rewriteRuleHandlers = rewriteRuleHandlers; + _serviceProvider = serviceProvider; + _logger = logger; + } + + public async Task DeleteAsync(RewriteRule rule) + { + var deletingContext = new DeletingRewriteRuleContext(rule); + await _rewriteRuleHandlers.InvokeAsync((handler, ctx) => handler.DeletingAsync(ctx), deletingContext, _logger); + + await _store.DeleteAsync(rule); + + var deletedContext = new DeletedRewriteRuleContext(rule); + await _rewriteRuleHandlers.InvokeAsync((handler, ctx) => handler.DeletedAsync(ctx), deletedContext, _logger); + } + + public async Task ValidateAsync(RewriteRule rule) + { + var validatingContext = new ValidatingRewriteRuleContext(rule); + await _rewriteRuleHandlers.InvokeAsync((handler, ctx) => handler.ValidatingAsync(ctx), validatingContext, _logger); + + var validatedContext = new ValidatedRewriteRuleContext(rule, validatingContext.Result); + await _rewriteRuleHandlers.InvokeAsync((handler, ctx) => handler.ValidatedAsync(ctx), validatedContext, _logger); + + return validatingContext.Result; + } + + public async Task FindByIdAsync(string id) + { + var rule = await _store.FindByIdAsync(id); + + if (rule != null) + { + await LoadAsync(rule); + } + + return rule; + } + + public async Task NewAsync(string source, JsonNode data = null) + { + ArgumentException.ThrowIfNullOrEmpty(source); + + var ruleSource = _serviceProvider.GetKeyedService(source); + + if (ruleSource == null) + { + _logger.LogWarning("Unable to find a rule-source that can handle the source '{Source}'.", source); + + return null; + } + + var id = IdGenerator.GenerateId(); + + var rule = new RewriteRule() + { + Id = id, + Source = source, + Order = await GetNextOrderSequence() + }; + + var initializingContext = new InitializingRewriteRuleContext(rule, data); + await _rewriteRuleHandlers.InvokeAsync((handler, ctx) => handler.InitializingAsync(ctx), initializingContext, _logger); + + var initializedContext = new InitializedRewriteRuleContext(rule); + await _rewriteRuleHandlers.InvokeAsync((handler, ctx) => handler.InitializedAsync(ctx), initializedContext, _logger); + + // Set the source again after calling handlers to prevent handlers from updating the source during initialization. + rule.Source = source; + + if (string.IsNullOrEmpty(rule.Id)) + { + rule.Id = id; + } + + return rule; + } + + public async Task> GetAllAsync() + { + var rules = await GetSortedRuleAsync(); + + foreach (var rule in rules) + { + await LoadAsync(rule); + } + + return rules; + } + + public async Task UpdateAsync(RewriteRule rule, JsonNode data = null) + { + var updatingContext = new UpdatingRewriteRuleContext(rule, data); + await _rewriteRuleHandlers.InvokeAsync((handler, ctx) => handler.UpdatingAsync(ctx), updatingContext, _logger); + + var updatedContext = new UpdatedRewriteRuleContext(rule); + await _rewriteRuleHandlers.InvokeAsync((handler, ctx) => handler.UpdatedAsync(ctx), updatedContext, _logger); + } + + public async Task SaveAsync(RewriteRule rule) + { + var savingContext = new SavingRewriteRuleContext(rule); + await _rewriteRuleHandlers.InvokeAsync((handler, ctx) => handler.SavingAsync(ctx), savingContext, _logger); + + await _store.SaveAsync(rule); + + var savedContext = new SavedRewriteRuleContext(rule); + await _rewriteRuleHandlers.InvokeAsync((handler, ctx) => handler.SavedAsync(ctx), savedContext, _logger); + } + + public async Task ResortOrderAsync(int oldOrder, int newOrder) + { + if (oldOrder < 1 || newOrder < 1) + { + return; + } + + var rules = (await GetSortedRuleAsync()).ToList(); + + if (oldOrder > rules.Count || newOrder > rules.Count) + { + return; + } + + var zeroBasedOldOrder = oldOrder - 1; + var zeroBasedNewOrder = newOrder - 1; + + // Get the element to move. + var ruleToMove = rules[zeroBasedOldOrder]; + + // Remove the rule from its current position. + rules.RemoveAt(zeroBasedOldOrder); + + rules.Insert(zeroBasedNewOrder, ruleToMove); + + await _store.UpdateOrderAndSaveAsync(rules); + } + + private Task LoadAsync(RewriteRule rule) + { + var loadedContext = new LoadedRewriteRuleContext(rule); + + return _rewriteRuleHandlers.InvokeAsync((handler, context) => handler.LoadedAsync(context), loadedContext, _logger); + } + + private async Task> GetSortedRuleAsync() + { + var rules = await _store.GetAllAsync(); + + return rules.OrderBy(x => x.Order) + .ThenBy(x => x.CreatedUtc); + } + + private async Task GetNextOrderSequence() + { + var rules = await _store.GetAllAsync(); + + // When importing multiple rules using a recipe, the rules collection will not include the newly added rule. + // To address this, we maintain an internal counter managed by this scoped service. + return rules.Any() + ? rules.Max(x => x.Order) + ++_counter + : _counter++; + } + + private int _counter; +} diff --git a/src/OrchardCore/OrchardCore.UrlRewriting.Core/Services/RewriteRulesStore.cs b/src/OrchardCore/OrchardCore.UrlRewriting.Core/Services/RewriteRulesStore.cs new file mode 100644 index 00000000000..a955898826d --- /dev/null +++ b/src/OrchardCore/OrchardCore.UrlRewriting.Core/Services/RewriteRulesStore.cs @@ -0,0 +1,75 @@ +using OrchardCore.Documents; +using OrchardCore.UrlRewriting.Models; + +namespace OrchardCore.UrlRewriting.Services; + +public sealed class RewriteRulesStore : IRewriteRulesStore +{ + private readonly IDocumentManager _documentManager; + + public RewriteRulesStore(IDocumentManager documentManager) + { + _documentManager = documentManager; + } + + public async Task> GetAllAsync() + { + var document = await _documentManager.GetOrCreateImmutableAsync(); + + return document.Rules.Values; + } + + public async Task SaveAsync(RewriteRule rule) + { + ArgumentNullException.ThrowIfNull(rule); + + var document = await _documentManager.GetOrCreateMutableAsync(); + + document.Rules[rule.Id] = rule; + + await UpdateOrderAndSaveAsync(document.Rules.Values); + } + + public async Task DeleteAsync(RewriteRule rule) + { + ArgumentNullException.ThrowIfNull(rule); + + var document = await _documentManager.GetOrCreateMutableAsync(); + + if (document.Rules.Remove(rule.Id)) + { + await UpdateOrderAndSaveAsync(document.Rules.Values); + } + } + + public async Task FindByIdAsync(string id) + { + ArgumentException.ThrowIfNullOrEmpty(id); + + var document = await _documentManager.GetOrCreateImmutableAsync(); + + return document.Rules.TryGetValue(id, out var rule) + ? rule + : null; + } + + public async Task> UpdateOrderAndSaveAsync(IEnumerable rules) + { + ArgumentNullException.ThrowIfNull(rules); + + var order = 0; + + foreach (var rule in rules) + { + rule.Order = order++; + } + + var document = await _documentManager.GetOrCreateMutableAsync(); + + document.Rules = rules.ToDictionary(x => x.Id); + + await _documentManager.UpdateAsync(document); + + return document.Rules.Values; + } +} diff --git a/src/OrchardCore/OrchardCore.UrlRewriting.Core/Services/UrlRedirectRuleSource.cs b/src/OrchardCore/OrchardCore.UrlRewriting.Core/Services/UrlRedirectRuleSource.cs new file mode 100644 index 00000000000..3b54752d09a --- /dev/null +++ b/src/OrchardCore/OrchardCore.UrlRewriting.Core/Services/UrlRedirectRuleSource.cs @@ -0,0 +1,89 @@ +using System.Text; +using Microsoft.AspNetCore.Rewrite; +using Microsoft.Extensions.Localization; +using OrchardCore.Entities; +using OrchardCore.Modules.Extensions; +using OrchardCore.UrlRewriting.Models; + +namespace OrchardCore.UrlRewriting.Services; + +public sealed class UrlRedirectRuleSource : IUrlRewriteRuleSource +{ + public const string SourceName = "Redirect"; + + internal readonly IStringLocalizer S; + + public UrlRedirectRuleSource(IStringLocalizer stringLocalizer) + { + S = stringLocalizer; + DisplayName = S["Redirect"]; + Description = S["URL Redirect Rule"]; + } + + public string TechnicalName + => SourceName; + + public LocalizedString DisplayName { get; } + + public LocalizedString Description { get; } + + public void Configure(RewriteOptions options, RewriteRule rule) + { + if (!rule.TryGet(out var metadata) || + string.IsNullOrEmpty(metadata.Pattern) || + string.IsNullOrEmpty(metadata.SubstitutionPattern)) + { + return; + } + + var builder = new StringBuilder(); + + builder.Append("RewriteRule "); + builder.Append(metadata.Pattern); + builder.Append(' '); + builder.Append(metadata.SubstitutionPattern); + builder.Append(" ["); + + AppendFlags(builder, metadata); + + builder.Append(']'); + + using var reader = new StringReader(builder.ToString()); + + options.AddApacheModRewrite(reader); + } + + private static void AppendFlags(StringBuilder builder, UrlRedirectSourceMetadata metadata) + { + var initialLength = builder.Length; + + StringBuilder AppendFlag(StringBuilder builder, string flag) + { + if (builder.Length > initialLength) + { + builder.AppendComma(); + } + + builder.Append(flag); + + return builder; + } + + if (metadata.IsCaseInsensitive) + { + AppendFlag(builder, "NC"); + }; + + if (metadata.QueryStringPolicy == QueryStringPolicy.Append) + { + AppendFlag(builder, "QSA"); + } + else if (metadata.QueryStringPolicy == QueryStringPolicy.Drop) + { + AppendFlag(builder, "QSD"); + } + + AppendFlag(builder, "R=") + .Append((int)metadata.RedirectType); + } +} diff --git a/src/OrchardCore/OrchardCore.UrlRewriting.Core/Services/UrlRewriteRuleSource.cs b/src/OrchardCore/OrchardCore.UrlRewriting.Core/Services/UrlRewriteRuleSource.cs new file mode 100644 index 00000000000..9e49c4723b1 --- /dev/null +++ b/src/OrchardCore/OrchardCore.UrlRewriting.Core/Services/UrlRewriteRuleSource.cs @@ -0,0 +1,91 @@ +using System.Text; +using Microsoft.AspNetCore.Rewrite; +using Microsoft.Extensions.Localization; +using OrchardCore.Entities; +using OrchardCore.Modules.Extensions; +using OrchardCore.UrlRewriting.Models; + +namespace OrchardCore.UrlRewriting.Services; + +public sealed class UrlRewriteRuleSource : IUrlRewriteRuleSource +{ + public const string SourceName = "Rewrite"; + + internal readonly IStringLocalizer S; + + public UrlRewriteRuleSource(IStringLocalizer stringLocalizer) + { + S = stringLocalizer; + DisplayName = S["Rewrite"]; + Description = S["URL Rewrite Rule"]; + } + + public string TechnicalName + => SourceName; + + public LocalizedString DisplayName { get; } + + public LocalizedString Description { get; } + + public void Configure(RewriteOptions options, RewriteRule rule) + { + if (!rule.TryGet(out var metadata) || + string.IsNullOrEmpty(metadata.Pattern) || + string.IsNullOrEmpty(metadata.SubstitutionPattern)) + { + return; + } + + var builder = new StringBuilder(); + + builder.Append("RewriteRule "); + builder.Append(metadata.Pattern); + builder.Append(' '); + builder.Append(metadata.SubstitutionPattern); + builder.Append(" ["); + + AppendFlags(builder, metadata); + + builder.Append(']'); + + using var reader = new StringReader(builder.ToString()); + + options.AddApacheModRewrite(reader); + } + + private static void AppendFlags(StringBuilder builder, UrlRewriteSourceMetadata metadata) + { + var initialLength = builder.Length; + + StringBuilder AppendFlag(StringBuilder builder, string flag) + { + if (builder.Length > initialLength) + { + builder.AppendComma(); + } + + builder.Append(flag); + + return builder; + } + + if (metadata.IsCaseInsensitive) + { + AppendFlag(builder, "NC"); + }; + + if (metadata.QueryStringPolicy == QueryStringPolicy.Append) + { + AppendFlag(builder, "QSA"); + } + else if (metadata.QueryStringPolicy == QueryStringPolicy.Drop) + { + AppendFlag(builder, "QSD"); + } + + if (metadata.SkipFurtherRules) + { + AppendFlag(builder, "L"); + } + } +} diff --git a/src/OrchardCore/OrchardCore.UrlRewriting.Core/UrlRewritingPermissions.cs b/src/OrchardCore/OrchardCore.UrlRewriting.Core/UrlRewritingPermissions.cs new file mode 100644 index 00000000000..a2df16ca362 --- /dev/null +++ b/src/OrchardCore/OrchardCore.UrlRewriting.Core/UrlRewritingPermissions.cs @@ -0,0 +1,8 @@ +using OrchardCore.Security.Permissions; + +namespace OrchardCore.UrlRewriting; + +public static class UrlRewritingPermissions +{ + public static readonly Permission ManageUrlRewritingRules = new Permission("ManageUrlRewritingRules", "Manage URLs rewriting rules"); +} diff --git a/src/docs/reference/README.md b/src/docs/reference/README.md index 38bdd154314..59d4ec64c94 100644 --- a/src/docs/reference/README.md +++ b/src/docs/reference/README.md @@ -101,6 +101,7 @@ Here's a categorized overview of all built-in Orchard Core features at a glance. - [Shells](core/Shells/README.md) - [Workflows](modules/Workflows/README.md) - [Background Tasks](modules/BackgroundTasks/README.md) +- [URL Rewriting](modules/UrlRewriting/README.md) ### Hosting and Operations diff --git a/src/docs/reference/modules/UrlRewriting/README.md b/src/docs/reference/modules/UrlRewriting/README.md new file mode 100644 index 00000000000..02562a3f1a4 --- /dev/null +++ b/src/docs/reference/modules/UrlRewriting/README.md @@ -0,0 +1,99 @@ +# URL Rewriting (`OrchardCore.UrlRewriting`) + +The URL Rewriting feature allows you to configure URL rewrites and redirects for incoming HTTP requests, significantly improving your site's SEO and user experience. This feature enables you to control how URLs are presented to both users and search engines. + +Once enabled, you can manage your rewrite rules by navigating to **Configuration** >> **URL Rewriting**. The order of these rules is crucial, as they are processed sequentially based on their position. The first listed rule is evaluated first for matches. To facilitate this, the UI provides a drag-and-drop feature for easy sorting of the rules. + +## Available Rule Sources + +| Rule Type | Description | Example | +|---------------|---------------|---------------| +| **Redirect Rule** | The **Redirect Rule** is utilized to send users from one URL to another, which is particularly beneficial for maintaining SEO integrity when URLs change. | Permanently redirect users from `/about-us` to `/about`. | +| **Rewrite Rule** | The **Rewrite Rule** allows you to modify the incoming request URL without changing the URL displayed in the browser's address bar, aiding in content organization. | Change requests for media files from `/img/` to `/media/`. | + +## Creating Additional Rule Sources + +To add a new rule source, implement the `IUrlRewriteRuleSource` interface. This implementation will allow you to register a new rule source and provide a mechanism to configure these rules. + +After implementing your rule source, register it in the service collection using the following method: + +```csharp +services.AddRewriteRuleSource("SourceKey"); +``` + +If your custom rule requires additional properties or user configuration options, create a display driver by implementing `IDisplayDriver`. This driver manages the UI for editing custom rule properties. You can register the driver along with your rule source as shown below: + +```csharp +services.AddRewriteRuleSource(CustomRuleSource.SourceName) + .AddScoped, CustomRuleDisplayDriver>(); +``` + +In this example, `CustomRuleSource` represents your implementation of `IUrlRewriteRuleSource`, and `CustomRuleDisplayDriver` provides the user interface for configuring the rule within the admin interface. + +## Recipes + +The recipe will be accessible only if the OrchardCore.Recipes.Core feature is enabled. + +### Recipe for Creating and Updating Rules + +The `UrlRewriting` step allows you to create or update URL rewrite rules easily. The example below illustrates how to create a rule that permanently redirects users from `/about-us` to `/about`, along with another rule that serves all media files using the `/img/` prefix instead of `/media/`. + +```json +{ + "steps": [ + { + "name": "UrlRewriting", + "Rules": [ + { + "Source": "Redirect", + "Name": "Redirect about-us to about", + "Pattern": "^/about-us$", + "SubstitutionPattern": "/about", + "IsCaseInsensitive": true, + "QueryStringPolicy": "Append", + "RedirectType": "MovedPermanently" + }, + { + "Source": "Rewrite", + "Name": "Serve media URLs from img", + "Pattern": "^/img/(.*)$", + "SubstitutionPattern": "/media/$1", + "IsCaseInsensitive": true, + "QueryStringPolicy": "Drop", + "SkipFurtherRules": true + } + ] + } + ] +} +``` + +## Explanation of the Rule Properties + +### Redirect Rule Properties + +- **Id**: A unique identifier for the redirect rule. If the specified ID matches an existing rule, that rule will be updated with the provided properties. To create a new rule, leave the ID property empty or specify a unique value that does not match any existing rule. +- **Name**: A descriptive name for the rule (e.g., "Redirect about-us to about"). +- **Pattern**: The URL pattern to match (e.g., `^/about-us$` for an exact match). +- **SubstitutionPattern**: The target URL to which the redirect will occur (e.g., `/about`). +- **IsCaseInsensitive**: When set to `true`, the pattern matching will be case-insensitive. +- **QueryStringPolicy**: Determines how query strings are handled during the redirect: + - **Append**: Appends the original query string to the new URL. + - **Drop**: Ignores the query string during the redirect. +- **RedirectType**: Specifies the HTTP status code for the redirect. The following values are supported: + - **Found**: (HTTP 302) Indicates a temporary redirect. + - **MovedPermanently**: (HTTP 301) Indicates a permanent redirect, instructing clients to update their bookmarks or links to the new URL. + - **TemporaryRedirect**: (HTTP 307) Similar to 302 but ensures that the request method remains unchanged (e.g., a POST request remains a POST). + - **PermanentRedirect**: (HTTP 308) Indicates that the resource has been permanently moved to a new URL. + +### Rewrite Rule Properties + +- **Id**: A unique identifier for the rewrite rule. If the specified ID matches an existing rule, that rule will be updated. To create a new rule, leave the ID empty. +- **Name**: A descriptive name for the rule (e.g., "Serve media URLs from img"). +- **Pattern**: The URL pattern to match (e.g., `^/img/(.*)$` matches any URL starting with `/img/`). +- **SubstitutionPattern**: The target URL for the rewrite (e.g., `/media/$1`, where `$1` captures the matched portion of the original URL). +- **IsCaseInsensitive**: When set to `true`, the pattern matching will be case-insensitive. +- **QueryStringPolicy**: Determines how query strings are handled during the rewrite: + - **Append**: Appends the original query string to the new URL. + - **Drop**: Ignores the query string during the rewrite. +- **SkipFurtherRules**: When set to `true`, any subsequent rules will not be processed if this rule matches. diff --git a/src/docs/releases/2.1.0.md b/src/docs/releases/2.1.0.md index c2b11d7ef36..eb7267d8c2e 100644 --- a/src/docs/releases/2.1.0.md +++ b/src/docs/releases/2.1.0.md @@ -174,3 +174,7 @@ A new feature was added to allow you to send SMS messages using Azure Communicat ### External Authentication Feature The new **External Authentication** feature contains common functionality used by multiple external (i.e. non-local) authentication providers (like Microsoft or Google login). See [above](#external-authentication-logic-has-been-separated-from-the-users-feature) for details. + +### URL Rewriting + +The URL Rewriting module enables you to configure URL rewrites and redirects for incoming HTTP requests, significantly enhancing your site's SEO and user experience. This allows you to control how URLs are handled and presented to users and search engines. For more info visit [this document](../reference/modules/UrlRewriting/README.md)