diff --git a/Directory.Build.props b/Directory.Build.props index 08749c48..09fa8d1d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -7,7 +7,7 @@ VirtoCommerce - 6.23.0 + 6.24.0 $(VersionSuffix)-$(BuildNumber) diff --git a/VirtoCommerce.Storefront.Model/ISeoInfoService.cs b/VirtoCommerce.Storefront.Model/ISeoInfoService.cs index ee11239e..b2968cde 100644 --- a/VirtoCommerce.Storefront.Model/ISeoInfoService.cs +++ b/VirtoCommerce.Storefront.Model/ISeoInfoService.cs @@ -1,4 +1,5 @@ using System.Threading.Tasks; +using VirtoCommerce.Storefront.Model.StaticContent; using VirtoCommerce.Storefront.Model.Stores; namespace VirtoCommerce.Storefront.Model @@ -8,5 +9,7 @@ public interface ISeoInfoService Task GetSeoInfosBySlug(string slug); Task GetBestMatchingSeoInfos(string slug, Store store, string currentCulture); + + ContentItem GetContentItem(string slug, WorkContext context); } } diff --git a/VirtoCommerce.Storefront.Model/ISpaRouteService.cs b/VirtoCommerce.Storefront.Model/ISpaRouteService.cs new file mode 100644 index 00000000..99269d2c --- /dev/null +++ b/VirtoCommerce.Storefront.Model/ISpaRouteService.cs @@ -0,0 +1,9 @@ +using System.Threading.Tasks; + +namespace VirtoCommerce.Storefront.Model +{ + public interface ISpaRouteService + { + Task IsSpaRoute(string route); + } +} diff --git a/VirtoCommerce.Storefront/Controllers/Api/ApiCommonController.cs b/VirtoCommerce.Storefront/Controllers/Api/ApiCommonController.cs index 56926462..5e0dc391 100644 --- a/VirtoCommerce.Storefront/Controllers/Api/ApiCommonController.cs +++ b/VirtoCommerce.Storefront/Controllers/Api/ApiCommonController.cs @@ -89,8 +89,8 @@ public async Task GetInfoBySlugAsync(string slug, [FromQuery] st ); var page = pages.FirstOrDefault(x => x.Language.CultureName.EqualsInvariant(culture)) - ?? pages.FirstOrDefault(x => x.Language.IsInvariant) - ?? pages.FirstOrDefault(x => x.AliasesUrls.Contains(pageUrl, StringComparer.OrdinalIgnoreCase)); + ?? pages.FirstOrDefault(x => x.Language.IsInvariant) + ?? pages.FirstOrDefault(x => x.AliasesUrls.Contains(pageUrl, StringComparer.OrdinalIgnoreCase)); result.ContentItem = page; } diff --git a/VirtoCommerce.Storefront/Controllers/ErrorController.cs b/VirtoCommerce.Storefront/Controllers/ErrorController.cs index 01c6c939..ff7092eb 100644 --- a/VirtoCommerce.Storefront/Controllers/ErrorController.cs +++ b/VirtoCommerce.Storefront/Controllers/ErrorController.cs @@ -1,28 +1,70 @@ +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using VirtoCommerce.Storefront.Infrastructure; using VirtoCommerce.Storefront.Model; +using VirtoCommerce.Storefront.Model.Common; using VirtoCommerce.Storefront.Model.Common.Exceptions; namespace VirtoCommerce.Storefront.Controllers { [StorefrontRoute("error")] - public class ErrorController : Controller + public class ErrorController : StorefrontControllerBase { - private readonly IWorkContextAccessor _workContextAccessor; - public ErrorController(IWorkContextAccessor workContextAccessor) + private readonly ISeoInfoService _seoInfoService; + private readonly ISpaRouteService _spaRouteService; + + public ErrorController( + IWorkContextAccessor workContextAccessor, + IStorefrontUrlBuilder urlBuilder, + ISeoInfoService seoInfoService, + ISpaRouteService spaRouteService) + : base(workContextAccessor, urlBuilder) { - _workContextAccessor = workContextAccessor; + _seoInfoService = seoInfoService; + _spaRouteService = spaRouteService; } [Route("{errCode}")] - public IActionResult Error(int? errCode) + public async Task Error(int? errCode) { //Returns index page on 404 error when the store.IsSpa flag is activated - if (errCode == StatusCodes.Status404NotFound && _workContextAccessor.WorkContext.CurrentStore.IsSpa) + if (errCode == StatusCodes.Status404NotFound && WorkContext.CurrentStore.IsSpa) { - Response.StatusCode = StatusCodes.Status200OK; + var path = TrimTwoLetterLangSegment(Request.HttpContext.Features.Get()?.OriginalPath); + Response.StatusCode = StatusCodes.Status404NotFound; + + if (path == "/") + { + Response.StatusCode = StatusCodes.Status200OK; + return View("index"); + } + + if (string.IsNullOrEmpty(path)) + { + return View("index"); + } + + var slug = path.Split('/').Last(); + if (!string.IsNullOrEmpty(slug)) + { + var seoInfos = await _seoInfoService.GetBestMatchingSeoInfos(slug, WorkContext.CurrentStore, WorkContext.CurrentLanguage.CultureName); + Response.StatusCode = seoInfos.Any() ? StatusCodes.Status200OK : StatusCodes.Status404NotFound; + } + + if (Response.StatusCode == StatusCodes.Status404NotFound) + { + Response.StatusCode = _seoInfoService.GetContentItem($"/{slug}", WorkContext) != null ? StatusCodes.Status200OK : StatusCodes.Status404NotFound; + } + + if (Response.StatusCode == StatusCodes.Status404NotFound) + { + Response.StatusCode = await _spaRouteService.IsSpaRoute(path) ? StatusCodes.Status200OK : StatusCodes.Status404NotFound; + } + return View("index"); } var exceptionFeature = base.HttpContext.Features.Get(); @@ -43,5 +85,17 @@ public IActionResult AccessDenied() Response.StatusCode = StatusCodes.Status403Forbidden; return View("AccessDenied"); } + + private string TrimTwoLetterLangSegment(string path) + { + var language = WorkContext.CurrentStore.Languages.FirstOrDefault(x => Regex.IsMatch(path, @"^/\b" + x.TwoLetterLanguageName + @"\b/", RegexOptions.IgnoreCase)); + + if (language != null) + { + path = Regex.Replace(path, @"/\b" + language.TwoLetterLanguageName + @"\b/", "/", RegexOptions.IgnoreCase); + } + + return path; + } } } diff --git a/VirtoCommerce.Storefront/Domain/SeoInfoService.cs b/VirtoCommerce.Storefront/Domain/SeoInfoService.cs new file mode 100644 index 00000000..99e4b8a2 --- /dev/null +++ b/VirtoCommerce.Storefront/Domain/SeoInfoService.cs @@ -0,0 +1,61 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using VirtoCommerce.Storefront.AutoRestClients.CoreModuleApi; +using VirtoCommerce.Storefront.Common; +using VirtoCommerce.Storefront.Model; +using VirtoCommerce.Storefront.Model.Common; +using VirtoCommerce.Storefront.Model.StaticContent; +using VirtoCommerce.Storefront.Model.Stores; + +namespace VirtoCommerce.Storefront.Domain +{ + public class SeoInfoService : ISeoInfoService + { + private readonly ICommerce _coreModuleApi; + + public SeoInfoService(ICommerce coreModuleApi) + { + _coreModuleApi = coreModuleApi; + } + + public async Task GetSeoInfosBySlug(string slug) + { + var result = (await _coreModuleApi.GetSeoInfoBySlugAsync(slug)).Select(x => x.ToSeoInfo()).ToArray(); + + return result; + } + + public async Task GetBestMatchingSeoInfos(string slug, Store store, string currentCulture) + { + var result = (await _coreModuleApi.GetSeoInfoBySlugAsync(slug)).GetBestMatchingSeoInfos(store, currentCulture, slug).Select(x => x.ToSeoInfo()).ToArray(); + + return result; + } + + public ContentItem GetContentItem(string slug, WorkContext context) + { + ContentItem result = null; + var pageUrl = slug == "__index__home__page__" ? "/" : $"/{slug}"; + try + { + var pages = context.Pages.Where(p => + string.Equals(p.Url, pageUrl, StringComparison.OrdinalIgnoreCase) + || string.Equals(p.Url, slug, StringComparison.OrdinalIgnoreCase) + ); + + var page = pages.FirstOrDefault(x => x.Language.CultureName.EqualsInvariant(context.CurrentLanguage.CultureName)) + ?? pages.FirstOrDefault(x => x.Language.IsInvariant) + ?? pages.FirstOrDefault(x => x.AliasesUrls.Contains(pageUrl, StringComparer.OrdinalIgnoreCase)); + result = page; + + } + catch + { + //do nothing + } + + return result; + } + } +} diff --git a/VirtoCommerce.Storefront/Domain/SeoInfoServise.cs b/VirtoCommerce.Storefront/Domain/SeoInfoServise.cs deleted file mode 100644 index e5099eb4..00000000 --- a/VirtoCommerce.Storefront/Domain/SeoInfoServise.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Linq; -using System.Threading.Tasks; -using VirtoCommerce.Storefront.AutoRestClients.CoreModuleApi; -using VirtoCommerce.Storefront.Common; -using VirtoCommerce.Storefront.Model; -using VirtoCommerce.Storefront.Model.Stores; - -namespace VirtoCommerce.Storefront.Domain -{ - public class SeoInfoServise : ISeoInfoService - { - private readonly ICommerce _coreModuleApi; - public SeoInfoServise(ICommerce coreModuleApi) - { - _coreModuleApi = coreModuleApi; - } - - public async Task GetSeoInfosBySlug(string slug) - { - var result = (await _coreModuleApi.GetSeoInfoBySlugAsync(slug)).Select(x => x.ToSeoInfo()).ToArray(); - - return result; - } - - public async Task GetBestMatchingSeoInfos(string slug, Store store, string currentCulture) - { - var result = (await _coreModuleApi.GetSeoInfoBySlugAsync(slug)).GetBestMatchingSeoInfos(store, currentCulture, slug).Select(x => x.ToSeoInfo()).ToArray(); - - return result; - } - } -} diff --git a/VirtoCommerce.Storefront/Domain/SpaRouteService.cs b/VirtoCommerce.Storefront/Domain/SpaRouteService.cs new file mode 100644 index 00000000..ab6730ca --- /dev/null +++ b/VirtoCommerce.Storefront/Domain/SpaRouteService.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Newtonsoft.Json; +using VirtoCommerce.Storefront.Model; +using VirtoCommerce.Storefront.Model.Caching; +using VirtoCommerce.Storefront.Model.Common; +using VirtoCommerce.Storefront.Model.Common.Caching; +using VirtoCommerce.Storefront.Model.StaticContent; + +namespace VirtoCommerce.Storefront.Domain +{ + public class SpaRouteService : ISpaRouteService + { + private readonly IContentBlobProvider _contentBlobProvider; + private readonly IStorefrontMemoryCache _memoryCache; + private readonly IWorkContextAccessor _workContextAccessor; + + public SpaRouteService( + IContentBlobProvider contentBlobProvider, + IStorefrontMemoryCache memoryCache, + IWorkContextAccessor workContextAccessor) + { + _contentBlobProvider = contentBlobProvider; + _memoryCache = memoryCache; + _workContextAccessor = workContextAccessor; + } + + public virtual async Task IsSpaRoute(string route) + { + var workContext = _workContextAccessor.WorkContext; + var cacheKey = CacheKey.With(GetType(), nameof(IsSpaRoute), workContext.CurrentStore.Id, route); + var result = await _memoryCache.GetOrCreateExclusiveAsync(cacheKey, async (_) => + { + var routes = await GetSpaRoutes(); + + var isSpaRoute = routes.Any(jsPattern => + { + // Input sample: jsPattern = "/^\\/account\\/profile\\/?$/i" + // Only the char "i" can be an ending. The others chars are not used + // when generating RegExp patterns in the `routes.json` file. + var options = jsPattern.EndsWith("i") ? RegexOptions.IgnoreCase : RegexOptions.None; + var pattern = Regex.Replace(jsPattern, @"^\/|\/i?$", string.Empty); + + return Regex.IsMatch(route, pattern, options); + }); + + return isSpaRoute; + }); + + return result; + } + + protected virtual async Task> GetSpaRoutes() + { + var workContext = _workContextAccessor.WorkContext; + var cacheKey = CacheKey.With(GetType(), nameof(GetSpaRoutes), workContext.CurrentStore.Id); + var routes = await _memoryCache.GetOrCreateExclusiveAsync(cacheKey, async (_) => + { + var result = new List(); + var currentThemeName = !string.IsNullOrEmpty(workContext.CurrentStore.ThemeName) ? workContext.CurrentStore.ThemeName : "default"; + var currentThemePath = Path.Combine("Themes", workContext.CurrentStore.Id, currentThemeName); + var currentThemeSettingPath = Path.Combine(currentThemePath, "config", "routes.json"); + + if (_contentBlobProvider.PathExists(currentThemeSettingPath)) + { + await using var stream = _contentBlobProvider.OpenRead(currentThemeSettingPath); + result = JsonConvert.DeserializeObject>(await stream.ReadToStringAsync()); + } + + return result; + }); + + return routes; + } + } +} diff --git a/VirtoCommerce.Storefront/Startup.cs b/VirtoCommerce.Storefront/Startup.cs index e45956f3..d109dbec 100644 --- a/VirtoCommerce.Storefront/Startup.cs +++ b/VirtoCommerce.Storefront/Startup.cs @@ -93,7 +93,8 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton();