using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.AspNetCore.Routing; using Nop.Core; using Nop.Core.Domain.Blogs; using Nop.Core.Domain.Catalog; using Nop.Core.Domain.Localization; using Nop.Core.Domain.News; using Nop.Core.Domain.Seo; using Nop.Core.Domain.Topics; using Nop.Core.Domain.Vendors; using Nop.Core.Events; using Nop.Core.Http; using Nop.Services.Catalog; using Nop.Services.Localization; using Nop.Services.Seo; using Nop.Web.Framework.Events; namespace Nop.Web.Framework.Mvc.Routing; /// /// Represents slug route transformer /// public partial class SlugRouteTransformer : DynamicRouteValueTransformer { #region Fields protected readonly CatalogSettings _catalogSettings; protected readonly ICategoryService _categoryService; protected readonly IEventPublisher _eventPublisher; protected readonly ILanguageService _languageService; protected readonly IManufacturerService _manufacturerService; protected readonly IStoreContext _storeContext; protected readonly IUrlRecordService _urlRecordService; protected readonly LocalizationSettings _localizationSettings; #endregion #region Ctor public SlugRouteTransformer(CatalogSettings catalogSettings, ICategoryService categoryService, IEventPublisher eventPublisher, ILanguageService languageService, IManufacturerService manufacturerService, IStoreContext storeContext, IUrlRecordService urlRecordService, LocalizationSettings localizationSettings) { _catalogSettings = catalogSettings; _categoryService = categoryService; _eventPublisher = eventPublisher; _languageService = languageService; _manufacturerService = manufacturerService; _storeContext = storeContext; _urlRecordService = urlRecordService; _localizationSettings = localizationSettings; } #endregion #region Utilities /// /// Transform route values according to the passed URL record /// /// HTTP context /// The route values associated with the current match /// Record found by the URL slug /// URL catalog path /// A task that represents the asynchronous operation protected virtual async Task SingleSlugRoutingAsync(HttpContext httpContext, RouteValueDictionary values, UrlRecord urlRecord, string catalogPath) { //if URL record is not active let's find the latest one var slug = urlRecord.IsActive ? urlRecord.Slug : await _urlRecordService.GetActiveSlugAsync(urlRecord.EntityId, urlRecord.EntityName, urlRecord.LanguageId); if (string.IsNullOrEmpty(slug)) return; if (!urlRecord.IsActive || !string.IsNullOrEmpty(catalogPath)) { //permanent redirect to new URL with active single slug InternalRedirect(httpContext, values, $"/{slug}", true); return; } //Ensure that the slug is the same for the current language, //otherwise it can cause some issues when customers choose a new language but a slug stays the same if (_localizationSettings.SeoFriendlyUrlsForLanguagesEnabled && values.TryGetValue(NopRoutingDefaults.RouteValue.Language, out var langValue)) { var store = await _storeContext.GetCurrentStoreAsync(); var languages = await _languageService.GetAllLanguagesAsync(storeId: store.Id); var language = languages .FirstOrDefault(lang => lang.Published && lang.UniqueSeoCode.Equals(langValue?.ToString(), StringComparison.InvariantCultureIgnoreCase)) ?? languages.FirstOrDefault(); var slugLocalized = await _urlRecordService.GetActiveSlugAsync(urlRecord.EntityId, urlRecord.EntityName, language.Id); if (!string.IsNullOrEmpty(slugLocalized) && !slugLocalized.Equals(slug, StringComparison.InvariantCultureIgnoreCase)) { //we should make validation above because some entities does not have SeName for standard (Id = 0) language (e.g. news, blog posts) //redirect to the page for current language InternalRedirect(httpContext, values, $"/{language.UniqueSeoCode}/{slugLocalized}", false); return; } } //since we are here, all is ok with the slug, so process URL switch (urlRecord.EntityName) { case var name when name.Equals(nameof(Product), StringComparison.InvariantCultureIgnoreCase): RouteToAction(values, "Product", "ProductDetails", slug, (NopRoutingDefaults.RouteValue.ProductId, urlRecord.EntityId)); return; case var name when name.Equals(nameof(ProductTag), StringComparison.InvariantCultureIgnoreCase): RouteToAction(values, "Catalog", "ProductsByTag", slug, (NopRoutingDefaults.RouteValue.ProductTagId, urlRecord.EntityId)); return; case var name when name.Equals(nameof(Category), StringComparison.InvariantCultureIgnoreCase): RouteToAction(values, "Catalog", "Category", slug, (NopRoutingDefaults.RouteValue.CategoryId, urlRecord.EntityId)); return; case var name when name.Equals(nameof(Manufacturer), StringComparison.InvariantCultureIgnoreCase): RouteToAction(values, "Catalog", "Manufacturer", slug, (NopRoutingDefaults.RouteValue.ManufacturerId, urlRecord.EntityId)); return; case var name when name.Equals(nameof(Vendor), StringComparison.InvariantCultureIgnoreCase): RouteToAction(values, "Catalog", "Vendor", slug, (NopRoutingDefaults.RouteValue.VendorId, urlRecord.EntityId)); return; case var name when name.Equals(nameof(NewsItem), StringComparison.InvariantCultureIgnoreCase): RouteToAction(values, "News", "NewsItem", slug, (NopRoutingDefaults.RouteValue.NewsItemId, urlRecord.EntityId)); return; case var name when name.Equals(nameof(BlogPost), StringComparison.InvariantCultureIgnoreCase): RouteToAction(values, "Blog", "BlogPost", slug, (NopRoutingDefaults.RouteValue.BlogPostId, urlRecord.EntityId)); return; case var name when name.Equals(nameof(Topic), StringComparison.InvariantCultureIgnoreCase): RouteToAction(values, "Topic", "TopicDetails", slug, (NopRoutingDefaults.RouteValue.TopicId, urlRecord.EntityId)); return; } } /// /// Try transforming the route values, assuming the passed URL record is of a product type /// /// HTTP context /// The route values associated with the current match /// Record found by the URL slug /// URL catalog path /// /// A task that represents the asynchronous operation /// The task result contains a value whether the route values were processed /// protected virtual async Task TryProductCatalogRoutingAsync(HttpContext httpContext, RouteValueDictionary values, UrlRecord urlRecord, string catalogPath) { //ensure it's a product URL record if (!urlRecord.EntityName.Equals(nameof(Product), StringComparison.InvariantCultureIgnoreCase)) return false; //if the product URL structure type is product seName only, it will be processed later by a single slug if (_catalogSettings.ProductUrlStructureTypeId == (int)ProductUrlStructureType.Product) return false; //get active slug for the product var slug = urlRecord.IsActive ? urlRecord.Slug : await _urlRecordService.GetActiveSlugAsync(urlRecord.EntityId, urlRecord.EntityName, urlRecord.LanguageId); if (string.IsNullOrEmpty(slug)) return false; //try to get active catalog (e.g. category or manufacturer) seName for the product var catalogSeName = string.Empty; var isCategoryProductUrl = _catalogSettings.ProductUrlStructureTypeId == (int)ProductUrlStructureType.CategoryProduct; if (isCategoryProductUrl) { var productCategory = (await _categoryService.GetProductCategoriesByProductIdAsync(urlRecord.EntityId)).FirstOrDefault(); var category = await _categoryService.GetCategoryByIdAsync(productCategory?.CategoryId ?? 0); catalogSeName = category is not null ? await _urlRecordService.GetSeNameAsync(category) : string.Empty; } var isManufacturerProductUrl = _catalogSettings.ProductUrlStructureTypeId == (int)ProductUrlStructureType.ManufacturerProduct; if (isManufacturerProductUrl) { var productManufacturer = (await _manufacturerService.GetProductManufacturersByProductIdAsync(urlRecord.EntityId)).FirstOrDefault(); var manufacturer = await _manufacturerService.GetManufacturerByIdAsync(productManufacturer?.ManufacturerId ?? 0); catalogSeName = manufacturer is not null ? await _urlRecordService.GetSeNameAsync(manufacturer) : string.Empty; } if (string.IsNullOrEmpty(catalogSeName)) return false; //get URL record by the specified catalog path var catalogUrlRecord = await _urlRecordService.GetBySlugAsync(catalogPath); if (catalogUrlRecord is null || (isCategoryProductUrl && !catalogUrlRecord.EntityName.Equals(nameof(Category), StringComparison.InvariantCultureIgnoreCase)) || (isManufacturerProductUrl && !catalogUrlRecord.EntityName.Equals(nameof(Manufacturer), StringComparison.InvariantCultureIgnoreCase)) || !urlRecord.IsActive) { //permanent redirect to new URL with active catalog seName and active slug InternalRedirect(httpContext, values, $"/{catalogSeName}/{slug}", true); return true; } //ensure the catalog seName and slug are the same for the current language if (_localizationSettings.SeoFriendlyUrlsForLanguagesEnabled && values.TryGetValue(NopRoutingDefaults.RouteValue.Language, out var langValue)) { var store = await _storeContext.GetCurrentStoreAsync(); var languages = await _languageService.GetAllLanguagesAsync(storeId: store.Id); var language = languages .FirstOrDefault(lang => lang.Published && lang.UniqueSeoCode.Equals(langValue?.ToString(), StringComparison.InvariantCultureIgnoreCase)) ?? languages.FirstOrDefault(); var slugLocalized = await _urlRecordService.GetActiveSlugAsync(urlRecord.EntityId, urlRecord.EntityName, language.Id); var catalogSlugLocalized = await _urlRecordService.GetActiveSlugAsync(catalogUrlRecord.EntityId, catalogUrlRecord.EntityName, language.Id); if ((!string.IsNullOrEmpty(slugLocalized) && !slugLocalized.Equals(slug, StringComparison.InvariantCultureIgnoreCase)) || (!string.IsNullOrEmpty(catalogSlugLocalized) && !catalogSlugLocalized.Equals(catalogUrlRecord.Slug, StringComparison.InvariantCultureIgnoreCase))) { //redirect to localized URL for the current language var activeSlug = !string.IsNullOrEmpty(slugLocalized) ? slugLocalized : slug; var activeCatalogSlug = !string.IsNullOrEmpty(catalogSlugLocalized) ? catalogSlugLocalized : catalogUrlRecord.Slug; InternalRedirect(httpContext, values, $"/{language.UniqueSeoCode}/{activeCatalogSlug}/{activeSlug}", false); return true; } } //ensure the specified catalog path is equal to the active catalog seName //we do it here after localization check to avoid double redirect if (!catalogSeName.Equals(catalogUrlRecord.Slug, StringComparison.InvariantCultureIgnoreCase)) { //permanent redirect to new URL with active catalog seName and active slug InternalRedirect(httpContext, values, $"/{catalogSeName}/{slug}", true); return true; } //all is ok, so select the appropriate action RouteToAction(values, "Product", "ProductDetails", slug, (NopRoutingDefaults.RouteValue.ProductId, urlRecord.EntityId), (NopRoutingDefaults.RouteValue.CatalogSeName, catalogSeName)); return true; } /// /// Transform route values to redirect the request /// /// HTTP context /// The route values associated with the current match /// Path /// Whether the redirect should be permanent protected virtual void InternalRedirect(HttpContext httpContext, RouteValueDictionary values, string path, bool permanent) { values[NopRoutingDefaults.RouteValue.Controller] = "Common"; values[NopRoutingDefaults.RouteValue.Action] = "InternalRedirect"; values[NopRoutingDefaults.RouteValue.Url] = $"{httpContext.Request.PathBase}{path}{httpContext.Request.QueryString}"; values[NopRoutingDefaults.RouteValue.PermanentRedirect] = permanent; httpContext.Items[NopHttpDefaults.GenericRouteInternalRedirect] = true; } /// /// Transform route values to set controller, action and action parameters /// /// The route values associated with the current match /// Controller name /// Action name /// URL slug /// Action parameters protected virtual void RouteToAction(RouteValueDictionary values, string controller, string action, string slug, params (string Key, object Value)[] parameters) { values[NopRoutingDefaults.RouteValue.Controller] = controller; values[NopRoutingDefaults.RouteValue.Action] = action; values[NopRoutingDefaults.RouteValue.SeName] = slug; foreach (var (key, value) in parameters) { values[key] = value; } } #endregion #region Methods /// /// Create a set of transformed route values that will be used to select an action /// /// HTTP context /// The route values associated with the current match /// /// A task that represents the asynchronous operation /// The task result contains the set of values /// public override async ValueTask TransformAsync(HttpContext httpContext, RouteValueDictionary routeValues) { //get values to transform for action selection var values = new RouteValueDictionary(routeValues); if (values is null) return values; if (!values.TryGetValue(NopRoutingDefaults.RouteValue.SeName, out var slug)) return values; //find record by the URL slug if (await _urlRecordService.GetBySlugAsync(slug.ToString()) is not UrlRecord urlRecord) return values; //allow third-party handlers to select an action by the found URL record var routingEvent = new GenericRoutingEvent(httpContext, values, urlRecord); await _eventPublisher.PublishAsync(routingEvent); if (routingEvent.Handled) return values; //then try to select an action by the found URL record and the catalog path var catalogPath = values.TryGetValue(NopRoutingDefaults.RouteValue.CatalogSeName, out var catalogPathValue) ? catalogPathValue.ToString() : string.Empty; if (await TryProductCatalogRoutingAsync(httpContext, values, urlRecord, catalogPath)) return values; //finally, select an action by the URL record only await SingleSlugRoutingAsync(httpContext, values, urlRecord, catalogPath); return values; } #endregion }