diff --git a/src/Umbraco.Cms.Api.Management/Factories/DocumentUrlFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/DocumentUrlFactory.cs index cee548ed5c..459eed6e69 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/DocumentUrlFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/DocumentUrlFactory.cs @@ -1,6 +1,8 @@ -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Umbraco.Cms.Api.Management.ViewModels.Content; using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.Routing; @@ -12,52 +14,18 @@ namespace Umbraco.Cms.Api.Management.Factories; public class DocumentUrlFactory : IDocumentUrlFactory { - private readonly IPublishedRouter _publishedRouter; - private readonly IUmbracoContextAccessor _umbracoContextAccessor; - private readonly ILanguageService _languageService; - private readonly ILocalizedTextService _localizedTextService; - private readonly IContentService _contentService; - private readonly IVariationContextAccessor _variationContextAccessor; - private readonly ILoggerFactory _loggerFactory; - private readonly UriUtility _uriUtility; - private readonly IPublishedUrlProvider _publishedUrlProvider; + private readonly IDocumentUrlService _documentUrlService; public DocumentUrlFactory( - IPublishedRouter publishedRouter, - IUmbracoContextAccessor umbracoContextAccessor, - ILanguageService languageService, - ILocalizedTextService localizedTextService, - IContentService contentService, - IVariationContextAccessor variationContextAccessor, - ILoggerFactory loggerFactory, - UriUtility uriUtility, - IPublishedUrlProvider publishedUrlProvider) + IDocumentUrlService documentUrlService) { - _publishedRouter = publishedRouter; - _umbracoContextAccessor = umbracoContextAccessor; - _languageService = languageService; - _localizedTextService = localizedTextService; - _contentService = contentService; - _variationContextAccessor = variationContextAccessor; - _loggerFactory = loggerFactory; - _uriUtility = uriUtility; - _publishedUrlProvider = publishedUrlProvider; + + _documentUrlService = documentUrlService; } public async Task> CreateUrlsAsync(IContent content) { - IUmbracoContext umbracoContext = _umbracoContextAccessor.GetRequiredUmbracoContext(); - - IEnumerable urlInfos = await content.GetContentUrlsAsync( - _publishedRouter, - umbracoContext, - _languageService, - _localizedTextService, - _contentService, - _variationContextAccessor, - _loggerFactory.CreateLogger(), - _uriUtility, - _publishedUrlProvider); + IEnumerable urlInfos = await _documentUrlService.ListUrlsAsync(content.Key); return urlInfos .Where(urlInfo => urlInfo.IsUrl) diff --git a/src/Umbraco.Cms.Persistence.Sqlite/Services/SqliteSyntaxProvider.cs b/src/Umbraco.Cms.Persistence.Sqlite/Services/SqliteSyntaxProvider.cs index 0d140feef3..24d4162da3 100644 --- a/src/Umbraco.Cms.Persistence.Sqlite/Services/SqliteSyntaxProvider.cs +++ b/src/Umbraco.Cms.Persistence.Sqlite/Services/SqliteSyntaxProvider.cs @@ -79,6 +79,7 @@ public class SqliteSyntaxProvider : SqlSyntaxProviderBase switch (indexTypes) { case IndexTypes.UniqueNonClustered: + case IndexTypes.UniqueClustered: return "UNIQUE"; default: return string.Empty; diff --git a/src/Umbraco.Core/Cache/Refreshers/Implement/ContentCacheRefresher.cs b/src/Umbraco.Core/Cache/Refreshers/Implement/ContentCacheRefresher.cs index 779b22fe68..f0a9ef93e6 100644 --- a/src/Umbraco.Core/Cache/Refreshers/Implement/ContentCacheRefresher.cs +++ b/src/Umbraco.Core/Cache/Refreshers/Implement/ContentCacheRefresher.cs @@ -1,3 +1,5 @@ +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Notifications; @@ -6,6 +8,7 @@ using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.Changes; +using Umbraco.Cms.Core.Services.Navigation; using Umbraco.Extensions; namespace Umbraco.Cms.Core.Cache; @@ -14,9 +17,12 @@ public sealed class ContentCacheRefresher : PayloadCacheRefresherBase { private readonly IDomainService _domainService; + private readonly IDocumentUrlService _documentUrlService; + private readonly IDocumentNavigationQueryService _documentNavigationQueryService; private readonly IIdKeyMap _idKeyMap; private readonly IPublishedSnapshotService _publishedSnapshotService; + [Obsolete("Use non-obsolete constructor. This will be removed in Umbraco 16")] public ContentCacheRefresher( AppCaches appCaches, IJsonSerializer serializer, @@ -25,11 +31,38 @@ public sealed class ContentCacheRefresher : PayloadCacheRefresherBase(), + StaticServiceProvider.Instance.GetRequiredService() + ) + { + + } + + public ContentCacheRefresher( + AppCaches appCaches, + IJsonSerializer serializer, + IPublishedSnapshotService publishedSnapshotService, + IIdKeyMap idKeyMap, + IDomainService domainService, + IEventAggregator eventAggregator, + ICacheRefresherNotificationFactory factory, + IDocumentUrlService documentUrlService, + IDocumentNavigationQueryService documentNavigationQueryService) : base(appCaches, serializer, eventAggregator, factory) { _publishedSnapshotService = publishedSnapshotService; _idKeyMap = idKeyMap; _domainService = domainService; + _documentUrlService = documentUrlService; + _documentNavigationQueryService = documentNavigationQueryService; } #region Indirect @@ -75,7 +108,7 @@ public sealed class ContentCacheRefresher : PayloadCacheRefresherBase(payload.Key)); - _idKeyMap.ClearCache(payload.Id); + // remove those that are in the branch if (payload.ChangeTypes.HasTypesAny(TreeChangeTypes.RefreshBranch | TreeChangeTypes.Remove)) @@ -89,6 +122,16 @@ public sealed class ContentCacheRefresher : PayloadCacheRefresherBase 0) @@ -129,6 +172,41 @@ public sealed class ContentCacheRefresher : PayloadCacheRefresherBase throw new NotSupportedException(); diff --git a/src/Umbraco.Core/Cache/Refreshers/Implement/DomainCacheRefresher.cs b/src/Umbraco.Core/Cache/Refreshers/Implement/DomainCacheRefresher.cs index 9c5030e553..fda11d6a91 100644 --- a/src/Umbraco.Core/Cache/Refreshers/Implement/DomainCacheRefresher.cs +++ b/src/Umbraco.Core/Cache/Refreshers/Implement/DomainCacheRefresher.cs @@ -10,16 +10,19 @@ namespace Umbraco.Cms.Core.Cache; public sealed class DomainCacheRefresher : PayloadCacheRefresherBase { private readonly IPublishedSnapshotService _publishedSnapshotService; + private readonly IDomainCacheService _domainCacheService; public DomainCacheRefresher( AppCaches appCaches, IJsonSerializer serializer, IPublishedSnapshotService publishedSnapshotService, IEventAggregator eventAggregator, - ICacheRefresherNotificationFactory factory) + ICacheRefresherNotificationFactory factory, + IDomainCacheService domainCacheService) : base(appCaches, serializer, eventAggregator, factory) { _publishedSnapshotService = publishedSnapshotService; + _domainCacheService = domainCacheService; } #region Json @@ -62,6 +65,8 @@ public sealed class DomainCacheRefresher : PayloadCacheRefresherBase deliveryApiSettings) + public ApiPublishedContentCache( + IRequestPreviewService requestPreviewService, + IRequestCultureService requestCultureService, + IOptionsMonitor deliveryApiSettings, + IDocumentUrlService documentUrlService, + IPublishedContentCache publishedContentCache) { - _publishedSnapshotAccessor = publishedSnapshotAccessor; _requestPreviewService = requestPreviewService; + _requestCultureService = requestCultureService; + _documentUrlService = documentUrlService; + _publishedContentCache = publishedContentCache; _deliveryApiSettings = deliveryApiSettings.CurrentValue; deliveryApiSettings.OnChange(settings => _deliveryApiSettings = settings); } + public IPublishedContent? GetByRoute(string route) { - IPublishedContentCache? contentCache = GetContentCache(); - if (contentCache == null) + var isPreviewMode = _requestPreviewService.IsPreview(); + + + // Handle the nasty logic with domain document ids in front of paths. + int? documentStartNodeId = null; + if (route.StartsWith("/") is false) { - return null; + var index = route.IndexOf('/'); + + if (index > -1 && int.TryParse(route.Substring(0, index), out var nodeId)) + { + documentStartNodeId = nodeId; + route = route.Substring(index); + } } - IPublishedContent? content = contentCache.GetByRoute(_requestPreviewService.IsPreview(), route); + Guid? documentKey = _documentUrlService.GetDocumentKeyByRoute( + route, + _requestCultureService.GetRequestedCulture(), + documentStartNodeId, + _requestPreviewService.IsPreview() + ); + IPublishedContent? content = documentKey.HasValue + ? _publishedContentCache.GetById(isPreviewMode, documentKey.Value) + : null; + return ContentOrNullIfDisallowed(content); } public IPublishedContent? GetById(Guid contentId) { - IPublishedContentCache? contentCache = GetContentCache(); - if (contentCache == null) - { - return null; - } - - IPublishedContent? content = contentCache.GetById(_requestPreviewService.IsPreview(), contentId); + IPublishedContent? content = _publishedContentCache.GetById(_requestPreviewService.IsPreview(), contentId); return ContentOrNullIfDisallowed(content); } public IEnumerable GetByIds(IEnumerable contentIds) { - IPublishedContentCache? contentCache = GetContentCache(); - if (contentCache == null) - { - return Enumerable.Empty(); - } - return contentIds - .Select(contentId => contentCache.GetById(_requestPreviewService.IsPreview(), contentId)) + .Select(contentId => _publishedContentCache.GetById(_requestPreviewService.IsPreview(), contentId)) .WhereNotNull() .Where(IsAllowedContentType) .ToArray(); } - private IPublishedContentCache? GetContentCache() => - _publishedSnapshotAccessor.TryGetPublishedSnapshot(out IPublishedSnapshot? publishedSnapshot) - ? publishedSnapshot?.Content - : null; - private IPublishedContent? ContentOrNullIfDisallowed(IPublishedContent? content) => content != null && IsAllowedContentType(content) ? content diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Collections.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Collections.cs index d27a7e676c..d6f7b480aa 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Collections.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Collections.cs @@ -36,7 +36,7 @@ public static partial class UmbracoBuilderExtensions // devs can then modify this list on application startup builder.ContentFinders() .Append() - .Append() + .Append() .Append() .Append() /*.Append() // disabled, this is an odd finder */ @@ -47,7 +47,7 @@ public static partial class UmbracoBuilderExtensions builder.HealthCheckNotificationMethods().Add(() => builder.TypeLoader.GetTypes()); builder.UrlProviders() .Append() - .Append(); + .Append(); builder.MediaUrlProviders() .Append(); diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs index c39f05cc5e..356150536d 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs @@ -412,6 +412,10 @@ namespace Umbraco.Cms.Core.DependencyInjection // add validation services Services.AddUnique(); + + // Routing + Services.AddUnique(); + Services.AddHostedService(); } } } diff --git a/src/Umbraco.Core/Models/ContentBaseExtensions.cs b/src/Umbraco.Core/Models/ContentBaseExtensions.cs index 656db0f82f..09aeee2f7d 100644 --- a/src/Umbraco.Core/Models/ContentBaseExtensions.cs +++ b/src/Umbraco.Core/Models/ContentBaseExtensions.cs @@ -17,8 +17,9 @@ public static class ContentBaseExtensions /// /// /// The culture. + /// Whether to get the published or draft. /// The URL segment. - public static string? GetUrlSegment(this IContentBase content, IShortStringHelper shortStringHelper, IEnumerable urlSegmentProviders, string? culture = null) + public static string? GetUrlSegment(this IContentBase content, IShortStringHelper shortStringHelper, IEnumerable urlSegmentProviders, string? culture = null, bool published = true) { if (content == null) { @@ -30,7 +31,7 @@ public static class ContentBaseExtensions throw new ArgumentNullException(nameof(urlSegmentProviders)); } - var url = urlSegmentProviders.Select(p => p.GetUrlSegment(content, culture)).FirstOrDefault(u => u != null); + var url = urlSegmentProviders.Select(p => p.GetUrlSegment(content, published, culture)).FirstOrDefault(u => u != null); if (url == null) { if (_defaultUrlSegmentProvider == null) diff --git a/src/Umbraco.Core/Models/ContentRepositoryExtensions.cs b/src/Umbraco.Core/Models/ContentRepositoryExtensions.cs index 38d97febd5..5d67a4a974 100644 --- a/src/Umbraco.Core/Models/ContentRepositoryExtensions.cs +++ b/src/Umbraco.Core/Models/ContentRepositoryExtensions.cs @@ -296,7 +296,12 @@ public static class ContentRepositoryExtensions /// A value indicating whether it was possible to publish the names and values for the specified /// culture(s). The method may fail if required names are not set, but it does NOT validate property data /// + /// public static bool PublishCulture(this IContent content, CultureImpact? impact) + { + return PublishCulture(content, impact, DateTime.Now); + } + public static bool PublishCulture(this IContent content, CultureImpact? impact, DateTime publishTime) { if (impact == null) { @@ -323,7 +328,7 @@ public static class ContentRepositoryExtensions return false; } - content.SetPublishInfo(culture, name, DateTime.Now); + content.SetPublishInfo(culture, name, publishTime); } } else if (impact.ImpactsOnlyInvariantCulture) @@ -342,7 +347,7 @@ public static class ContentRepositoryExtensions return false; } - content.SetPublishInfo(impact.Culture, name, DateTime.Now); + content.SetPublishInfo(impact.Culture, name, publishTime); } // set values diff --git a/src/Umbraco.Core/Models/PublishedDocumentUrlSegment.cs b/src/Umbraco.Core/Models/PublishedDocumentUrlSegment.cs new file mode 100644 index 0000000000..81451d3223 --- /dev/null +++ b/src/Umbraco.Core/Models/PublishedDocumentUrlSegment.cs @@ -0,0 +1,9 @@ +namespace Umbraco.Cms.Core.Models; + +public class PublishedDocumentUrlSegment +{ + public required Guid DocumentKey { get; set; } + public required int LanguageId { get; set; } + public required string UrlSegment { get; set; } + public required bool IsDraft { get; set; } +} diff --git a/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs b/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs index 24e1e62894..c275fdd108 100644 --- a/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs +++ b/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs @@ -32,6 +32,7 @@ public static partial class Constants public const string Document = TableNamePrefix + "Document"; public const string DocumentCultureVariation = TableNamePrefix + "DocumentCultureVariation"; public const string DocumentVersion = TableNamePrefix + "DocumentVersion"; + public const string DocumentUrl = TableNamePrefix + "DocumentUrl"; public const string MediaVersion = TableNamePrefix + "MediaVersion"; public const string ContentSchedule = TableNamePrefix + "ContentSchedule"; diff --git a/src/Umbraco.Core/Persistence/Repositories/IDocumentUrlRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IDocumentUrlRepository.cs new file mode 100644 index 0000000000..5a3786aa12 --- /dev/null +++ b/src/Umbraco.Core/Persistence/Repositories/IDocumentUrlRepository.cs @@ -0,0 +1,10 @@ +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Persistence.Repositories; + +public interface IDocumentUrlRepository +{ + void Save(IEnumerable publishedDocumentUrlSegments); + IEnumerable GetAll(); + void DeleteByDocumentKey(IEnumerable select); +} diff --git a/src/Umbraco.Core/Routing/ContentFinderByUrlNew.cs b/src/Umbraco.Core/Routing/ContentFinderByUrlNew.cs new file mode 100644 index 0000000000..eeaaeef9b9 --- /dev/null +++ b/src/Umbraco.Core/Routing/ContentFinderByUrlNew.cs @@ -0,0 +1,124 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Web; + +namespace Umbraco.Cms.Core.Routing; + +/// +/// Provides an implementation of that handles page nice URLs. +/// +/// +/// Handles /foo/bar where /foo/bar is the nice URL of a document. +/// +public class ContentFinderByUrlNew : IContentFinder +{ + private readonly ILogger _logger; + private readonly IPublishedContentCache _publishedContentCache; + private readonly IDocumentUrlService _documentUrlService; + + /// + /// Initializes a new instance of the class. + /// + public ContentFinderByUrlNew( + ILogger logger, + IUmbracoContextAccessor umbracoContextAccessor, + IDocumentUrlService documentUrlService, + IPublishedContentCache publishedContentCache) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _publishedContentCache = publishedContentCache; + _documentUrlService = documentUrlService; + UmbracoContextAccessor = + umbracoContextAccessor ?? throw new ArgumentNullException(nameof(umbracoContextAccessor)); + } + + /// + /// Gets the + /// + protected IUmbracoContextAccessor UmbracoContextAccessor { get; } + + /// + /// Tries to find and assign an Umbraco document to a PublishedRequest. + /// + /// The PublishedRequest. + /// A value indicating whether an Umbraco document was found and assigned. + public virtual Task TryFindContent(IPublishedRequestBuilder frequest) + { + if (!UmbracoContextAccessor.TryGetUmbracoContext(out IUmbracoContext? _)) + { + return Task.FromResult(false); + } + + string route; + if (frequest.Domain != null) + { + route = frequest.Domain.ContentId + + DomainUtilities.PathRelativeToDomain(frequest.Domain.Uri, frequest.AbsolutePathDecoded); + } + else + { + route = frequest.AbsolutePathDecoded; + } + + IPublishedContent? node = FindContent(frequest, route); + return Task.FromResult(node != null); + } + + /// + /// Tries to find an Umbraco document for a PublishedRequest and a route. + /// + /// The document node, or null. + protected IPublishedContent? FindContent(IPublishedRequestBuilder docreq, string route) + { + if (!UmbracoContextAccessor.TryGetUmbracoContext(out IUmbracoContext? umbracoContext)) + { + return null; + } + + if (docreq == null) + { + throw new ArgumentNullException(nameof(docreq)); + } + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Test route {Route}", route); + } + + var documentKey = _documentUrlService.GetDocumentKeyByRoute( + docreq.Domain is null ? route : route.Substring(docreq.Domain.ContentId.ToString().Length), + docreq.Culture, + docreq.Domain?.ContentId, + umbracoContext.InPreviewMode + ); + + IPublishedContent? node = null; + if (documentKey.HasValue) + { + node = _publishedContentCache.GetById(umbracoContext.InPreviewMode, documentKey.Value); + //node = umbracoContext.Content?.GetById(umbracoContext.InPreviewMode, documentKey.Value); + } + + if (node != null) + { + docreq.SetPublishedContent(node); + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Got content, id={NodeId}", node.Id); + } + } + else + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("No match."); + } + } + + return node; + } +} diff --git a/src/Umbraco.Core/Routing/NewDefaultUrlProvider.cs b/src/Umbraco.Core/Routing/NewDefaultUrlProvider.cs new file mode 100644 index 0000000000..c4fa6cfe1d --- /dev/null +++ b/src/Umbraco.Core/Routing/NewDefaultUrlProvider.cs @@ -0,0 +1,284 @@ +using System.Globalization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Web; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.Routing; + +/// +/// Provides urls. +/// +public class NewDefaultUrlProvider : IUrlProvider +{ + private readonly ILocalizationService _localizationService; + private readonly IPublishedContentCache _publishedContentCache; + private readonly IDomainCache _domainCache; + private readonly IIdKeyMap _idKeyMap; + private readonly IDocumentUrlService _documentUrlService; + private readonly ILocalizedTextService? _localizedTextService; + private readonly ILogger _logger; + private readonly ISiteDomainMapper _siteDomainMapper; + private readonly IUmbracoContextAccessor _umbracoContextAccessor; + private readonly UriUtility _uriUtility; + private RequestHandlerSettings _requestSettings; + + public NewDefaultUrlProvider( + IOptionsMonitor requestSettings, + ILogger logger, + ISiteDomainMapper siteDomainMapper, + IUmbracoContextAccessor umbracoContextAccessor, + UriUtility uriUtility, + ILocalizationService localizationService, + IPublishedContentCache publishedContentCache, + IDomainCache domainCache, + IIdKeyMap idKeyMap, + IDocumentUrlService documentUrlService) + { + _requestSettings = requestSettings.CurrentValue; + _logger = logger; + _siteDomainMapper = siteDomainMapper; + _umbracoContextAccessor = umbracoContextAccessor; + _uriUtility = uriUtility; + _localizationService = localizationService; + _publishedContentCache = publishedContentCache; + _domainCache = domainCache; + _idKeyMap = idKeyMap; + _documentUrlService = documentUrlService; + + requestSettings.OnChange(x => _requestSettings = x); + } + + #region GetOtherUrls + + /// + /// Gets the other URLs of a published content. + /// + /// The published content id. + /// The current absolute URL. + /// The other URLs for the published content. + /// + /// + /// Other URLs are those that GetUrl would not return in the current context, but would be valid + /// URLs for the node in other contexts (different domain for current request, umbracoUrlAlias...). + /// + /// + public virtual IEnumerable GetOtherUrls(int id, Uri current) + { + var keyAttempt = _idKeyMap.GetKeyForId(id, UmbracoObjectTypes.Document); + + if (keyAttempt.Success is false) + { + yield break; + } + + var key = keyAttempt.Result; + + IPublishedContent? node = _publishedContentCache.GetById(key); + if (node == null) + { + yield break; + } + + + + // look for domains, walking up the tree + IPublishedContent? n = node; + IEnumerable? domainUris = + DomainUtilities.DomainsForNode(_domainCache, _siteDomainMapper, n.Id, current, false); + + // n is null at root + while (domainUris == null && n != null) + { + n = n.Parent; // move to parent node + domainUris = n == null + ? null + : DomainUtilities.DomainsForNode(_domainCache, _siteDomainMapper, n.Id, current); + } + + // no domains = exit + if (domainUris == null) + { + yield break; + } + + foreach (DomainAndUri d in domainUris) + { + var culture = d.Culture; + + // although we are passing in culture here, if any node in this path is invariant, it ignores the culture anyways so this is ok + var route = GetLegacyRouteFormatById(key, culture); + if (route == null) + { + continue; + } + + // need to strip off the leading ID for the route if it exists (occurs if the route is for a node with a domain assigned) + var pos = route.IndexOf('/'); + var path = pos == 0 ? route : route.Substring(pos); + + var uri = new Uri(CombinePaths(d.Uri.GetLeftPart(UriPartial.Path), path)); + uri = _uriUtility.UriFromUmbraco(uri, _requestSettings); + yield return UrlInfo.Url(uri.ToString(), culture); + } + } + + /// + /// Gets the legacy route format by id + /// + /// + /// + /// + /// + /// When no domain is set the route can be something like /child/grandchild + /// When a domain is set, the route can be something like 1234/grandchild + /// + + private string GetLegacyRouteFormatById(Guid key, string? culture) + { + + return _documentUrlService.GetLegacyRouteFormat(key, culture, _umbracoContextAccessor.GetRequiredUmbracoContext().InPreviewMode); + + + } + + #endregion + + #region GetUrl + + /// + public virtual UrlInfo? GetUrl(IPublishedContent content, UrlMode mode, string? culture, Uri current) + { + if (!current.IsAbsoluteUri) + { + throw new ArgumentException("Current URL must be absolute.", nameof(current)); + } + + + // will not use cache if previewing + var route = GetLegacyRouteFormatById(content.Key, culture); + + return GetUrlFromRoute(route, content.Id, current, mode, culture); + } + + internal UrlInfo? GetUrlFromRoute( + string? route, + int id, + Uri current, + UrlMode mode, + string? culture) + { + if (string.IsNullOrWhiteSpace(route)) + { + if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug)) + { + _logger.LogDebug( + "Couldn't find any page with nodeId={NodeId}. This is most likely caused by the page not being published.", + id); + } + return null; + } + + // extract domainUri and path + // route is / or / + var pos = route.IndexOf('/'); + var path = pos == 0 ? route : route[pos..]; + DomainAndUri? domainUri = pos == 0 + ? null + : DomainUtilities.DomainForNode( + _domainCache, + _siteDomainMapper, + int.Parse(route[..pos], CultureInfo.InvariantCulture), + current, + culture); + + var defaultCulture = _localizationService.GetDefaultLanguageIsoCode(); + if (domainUri is not null || string.IsNullOrEmpty(culture) || + culture.Equals(defaultCulture, StringComparison.InvariantCultureIgnoreCase)) + { + var url = AssembleUrl(domainUri, path, current, mode).ToString(); + return UrlInfo.Url(url, culture); + } + + return null; + } + + #endregion + + #region Utilities + + private Uri AssembleUrl(DomainAndUri? domainUri, string path, Uri current, UrlMode mode) + { + Uri uri; + + // ignore vdir at that point, UriFromUmbraco will do it + // no domain was found + if (domainUri == null) + { + if (current == null) + { + mode = UrlMode.Relative; // best we can do + } + + switch (mode) + { + case UrlMode.Absolute: + uri = new Uri(current!.GetLeftPart(UriPartial.Authority) + path); + break; + case UrlMode.Relative: + case UrlMode.Auto: + uri = new Uri(path, UriKind.Relative); + break; + default: + throw new ArgumentOutOfRangeException(nameof(mode)); + } + } + + // a domain was found + else + { + if (mode == UrlMode.Auto) + { + // this check is a little tricky, we can't just compare domains + if (current != null && domainUri.Uri.GetLeftPart(UriPartial.Authority) == + current.GetLeftPart(UriPartial.Authority)) + { + mode = UrlMode.Relative; + } + else + { + mode = UrlMode.Absolute; + } + } + + switch (mode) + { + case UrlMode.Absolute: + uri = new Uri(CombinePaths(domainUri.Uri.GetLeftPart(UriPartial.Path), path)); + break; + case UrlMode.Relative: + uri = new Uri(CombinePaths(domainUri.Uri.AbsolutePath, path), UriKind.Relative); + break; + default: + throw new ArgumentOutOfRangeException(nameof(mode)); + } + } + + // UriFromUmbraco will handle vdir + // meaning it will add vdir into domain URLs too! + return _uriUtility.UriFromUmbraco(uri, _requestSettings); + } + + private string CombinePaths(string path1, string path2) + { + var path = path1.TrimEnd(Constants.CharArrays.ForwardSlash) + path2; + return path == "/" ? path : path.TrimEnd(Constants.CharArrays.ForwardSlash); + } + + #endregion +} diff --git a/src/Umbraco.Core/Routing/PublishedRouter.cs b/src/Umbraco.Core/Routing/PublishedRouter.cs index df1d459327..28cd4323eb 100644 --- a/src/Umbraco.Core/Routing/PublishedRouter.cs +++ b/src/Umbraco.Core/Routing/PublishedRouter.cs @@ -24,6 +24,7 @@ public class PublishedRouter : IPublishedRouter private readonly IContentLastChanceFinder _contentLastChanceFinder; private readonly IContentTypeService _contentTypeService; private readonly IEventAggregator _eventAggregator; + private readonly IDomainCache _domainCache; private readonly IFileService _fileService; private readonly ILogger _logger; private readonly IProfilingLogger _profilingLogger; @@ -50,7 +51,8 @@ public class PublishedRouter : IPublishedRouter IFileService fileService, IContentTypeService contentTypeService, IUmbracoContextAccessor umbracoContextAccessor, - IEventAggregator eventAggregator) + IEventAggregator eventAggregator, + IDomainCache domainCache) { _webRoutingSettings = webRoutingSettings.CurrentValue ?? throw new ArgumentNullException(nameof(webRoutingSettings)); @@ -68,6 +70,7 @@ public class PublishedRouter : IPublishedRouter _contentTypeService = contentTypeService; _umbracoContextAccessor = umbracoContextAccessor; _eventAggregator = eventAggregator; + _domainCache = domainCache; webRoutingSettings.OnChange(x => _webRoutingSettings = x); } @@ -404,11 +407,10 @@ public class PublishedRouter : IPublishedRouter } var rootNodeId = request.Domain != null ? request.Domain.ContentId : (int?)null; - IUmbracoContext umbracoContext = _umbracoContextAccessor.GetRequiredUmbracoContext(); Domain? domain = - DomainUtilities.FindWildcardDomainInPath(umbracoContext.PublishedSnapshot.Domains?.GetAll(true), nodePath, rootNodeId); + DomainUtilities.FindWildcardDomainInPath(_domainCache.GetAll(true), nodePath, rootNodeId); + - // always has a contentId and a culture if (domain != null) { request.SetCulture(domain.Culture); diff --git a/src/Umbraco.Core/Routing/UrlProvider.cs b/src/Umbraco.Core/Routing/UrlProvider.cs index f6c8691622..067c748da1 100644 --- a/src/Umbraco.Core/Routing/UrlProvider.cs +++ b/src/Umbraco.Core/Routing/UrlProvider.cs @@ -133,10 +133,10 @@ namespace Umbraco.Cms.Core.Routing public string GetUrlFromRoute(int id, string? route, string? culture) { IUmbracoContext umbracoContext = _umbracoContextAccessor.GetRequiredUmbracoContext(); - DefaultUrlProvider? provider = _urlProviders.OfType().FirstOrDefault(); + NewDefaultUrlProvider? provider = _urlProviders.OfType().FirstOrDefault(); var url = provider == null ? route // what else? - : provider.GetUrlFromRoute(route, umbracoContext, id, umbracoContext.CleanedUmbracoUrl, Mode, culture)?.Text; + : provider.GetUrlFromRoute(route, id, umbracoContext.CleanedUmbracoUrl, Mode, culture)?.Text; return url ?? "#"; } diff --git a/src/Umbraco.Core/Services/ContentService.cs b/src/Umbraco.Core/Services/ContentService.cs index 9c2af6f5bc..c667ea0b35 100644 --- a/src/Umbraco.Core/Services/ContentService.cs +++ b/src/Umbraco.Core/Services/ContentService.cs @@ -1060,7 +1060,14 @@ public class ContentService : RepositoryService, IContentService // Updates in-memory navigation structure - we only handle new items, other updates are not a concern UpdateInMemoryNavigationStructure( "Umbraco.Cms.Core.Services.ContentService.Save-with-contentSchedule", - () => _documentNavigationManagementService.Add(content.Key, GetParent(content)?.Key)); + () => + { + _documentNavigationManagementService.Add(content.Key, GetParent(content)?.Key); + if (content.Trashed) + { + _documentNavigationManagementService.MoveToBin(content.Key); + } + }); if (contentSchedule != null) { @@ -1129,7 +1136,14 @@ public class ContentService : RepositoryService, IContentService // Updates in-memory navigation structure - we only handle new items, other updates are not a concern UpdateInMemoryNavigationStructure( "Umbraco.Cms.Core.Services.ContentService.Save", - () => _documentNavigationManagementService.Add(content.Key, GetParent(content)?.Key)); + () => + { + _documentNavigationManagementService.Add(content.Key, GetParent(content)?.Key); + if (content.Trashed) + { + _documentNavigationManagementService.MoveToBin(content.Key); + } + }); } scope.Notifications.Publish( @@ -1227,9 +1241,10 @@ public class ContentService : RepositoryService, IContentService // publish the culture(s) // we don't care about the response here, this response will be rechecked below but we need to set the culture info values now. + var publishTime = DateTime.Now; foreach (CultureImpact? impact in impacts) { - content.PublishCulture(impact); + content.PublishCulture(impact, publishTime); } // Change state to publishing @@ -1866,7 +1881,7 @@ public class ContentService : RepositoryService, IContentService // publish the culture values and validate the property values, if validation fails, log the invalid properties so the develeper has an idea of what has failed IProperty[]? invalidProperties = null; CultureImpact impact = _cultureImpactFactory.ImpactExplicit(culture, IsDefaultCulture(allLangs.Value, culture)); - var tryPublish = d.PublishCulture(impact) && + var tryPublish = d.PublishCulture(impact, date) && _propertyValidationService.Value.IsPropertyDataValid(d, out invalidProperties, impact); if (invalidProperties != null && invalidProperties.Length > 0) { @@ -1943,17 +1958,19 @@ public class ContentService : RepositoryService, IContentService { // variant content type - publish specified cultures // invariant content type - publish only the invariant culture + + var publishTime = DateTime.Now; if (content.ContentType.VariesByCulture()) { return culturesToPublish.All(culture => { CultureImpact? impact = _cultureImpactFactory.Create(culture, IsDefaultCulture(allLangs, culture), content); - return content.PublishCulture(impact) && + return content.PublishCulture(impact, publishTime) && _propertyValidationService.Value.IsPropertyDataValid(content, out _, impact); }); } - return content.PublishCulture(_cultureImpactFactory.ImpactInvariant()) + return content.PublishCulture(_cultureImpactFactory.ImpactInvariant(), publishTime) && _propertyValidationService.Value.IsPropertyDataValid(content, out _, _cultureImpactFactory.ImpactInvariant()); } @@ -3179,7 +3196,8 @@ public class ContentService : RepositoryService, IContentService .ToArray(); // publish the culture(s) - if (!impactsToPublish.All(content.PublishCulture)) + var publishTime = DateTime.Now; + if (!impactsToPublish.All(impact => content.PublishCulture(impact, publishTime))) { return new PublishResult(PublishResultType.FailedPublishContentInvalid, evtMsgs, content); } diff --git a/src/Umbraco.Core/Services/DocumentUrlService.cs b/src/Umbraco.Core/Services/DocumentUrlService.cs new file mode 100644 index 0000000000..bcc0cbed47 --- /dev/null +++ b/src/Umbraco.Core/Services/DocumentUrlService.cs @@ -0,0 +1,670 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Routing; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services.Navigation; +using Umbraco.Cms.Core.Strings; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.Services; + +public class DocumentUrlService : IDocumentUrlService +{ + private const string RebuildKey = "UmbracoUrlGeneration"; + + private readonly ILogger _logger; + private readonly IDocumentUrlRepository _documentUrlRepository; + private readonly IDocumentRepository _documentRepository; + private readonly ICoreScopeProvider _coreScopeProvider; + private readonly GlobalSettings _globalSettings; + private readonly UrlSegmentProviderCollection _urlSegmentProviderCollection; + private readonly IContentService _contentService; + private readonly IShortStringHelper _shortStringHelper; + private readonly ILanguageService _languageService; + private readonly IKeyValueService _keyValueService; + private readonly IIdKeyMap _idKeyMap; + private readonly IDocumentNavigationQueryService _documentNavigationQueryService; + private readonly IDomainService _domainService; + + private readonly ConcurrentDictionary _cache = new(); + private bool _isInitialized = false; + + public DocumentUrlService( + ILogger logger, + IDocumentUrlRepository documentUrlRepository, + IDocumentRepository documentRepository, + ICoreScopeProvider coreScopeProvider, + IOptions globalSettings, + UrlSegmentProviderCollection urlSegmentProviderCollection, + IContentService contentService, + IShortStringHelper shortStringHelper, + ILanguageService languageService, + IKeyValueService keyValueService, + IIdKeyMap idKeyMap, + IDocumentNavigationQueryService documentNavigationQueryService, + IDomainService domainService) + { + _logger = logger; + _documentUrlRepository = documentUrlRepository; + _documentRepository = documentRepository; + _coreScopeProvider = coreScopeProvider; + _globalSettings = globalSettings.Value; + _urlSegmentProviderCollection = urlSegmentProviderCollection; + _contentService = contentService; + _shortStringHelper = shortStringHelper; + _languageService = languageService; + _keyValueService = keyValueService; + _idKeyMap = idKeyMap; + _documentNavigationQueryService = documentNavigationQueryService; + _domainService = domainService; + } + + public async Task InitAsync(bool forceEmpty, CancellationToken cancellationToken) + { + if (forceEmpty) + { + // We have this use case when umbraco is installing, we know there is no routes. And we can execute the normal logic because the connection string is missing. + _isInitialized = true; + return; + } + + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + if (await ShouldRebuildUrlsAsync()) + { + _logger.LogInformation("Rebuilding all urls."); + await RebuildAllUrlsAsync(); + } + + IEnumerable publishedDocumentUrlSegments = _documentUrlRepository.GetAll(); + + IEnumerable languages = await _languageService.GetAllAsync(); + var languageIdToIsoCode = languages.ToDictionary(x => x.Id, x => x.IsoCode); + foreach (PublishedDocumentUrlSegment publishedDocumentUrlSegment in publishedDocumentUrlSegments) + { + if (cancellationToken.IsCancellationRequested) + { + break; + } + + if (languageIdToIsoCode.TryGetValue(publishedDocumentUrlSegment.LanguageId, out var isoCode)) + { + UpdateCache(_coreScopeProvider.Context!, publishedDocumentUrlSegment, isoCode); + } + } + _isInitialized = true; + scope.Complete(); + } + + private void UpdateCache(IScopeContext scopeContext, PublishedDocumentUrlSegment publishedDocumentUrlSegment, string isoCode) + { + var cacheKey = CreateCacheKey(publishedDocumentUrlSegment.DocumentKey, isoCode, publishedDocumentUrlSegment.IsDraft); + + scopeContext.Enlist("UpdateCache_" + cacheKey, () => + { + PublishedDocumentUrlSegment? existingValue = null; + _cache.TryGetValue(cacheKey, out existingValue); + + if (existingValue is null) + { + if (_cache.TryAdd(cacheKey, publishedDocumentUrlSegment) is false) + { + _logger.LogError("Could not add the document url cache."); + return false; + } + } + else + { + if (_cache.TryUpdate(cacheKey, publishedDocumentUrlSegment, existingValue) is false) + { + _logger.LogError("Could not update the document url cache."); + return false; + } + } + + return true; + }); + + + } + + private void RemoveFromCache(IScopeContext scopeContext, Guid documentKey, string isoCode) + { + var cacheKeyDraft = CreateCacheKey(documentKey, isoCode, true); + + scopeContext.Enlist("RemoveFromCache_" + cacheKeyDraft, () => + { + if (_cache.TryRemove(cacheKeyDraft, out _) is false) + { + _logger.LogDebug("Could not remove the document url cache. But the important thing is that it is not there."); + return false; + } + + return true; + }); + + var cacheKeyPublished = CreateCacheKey(documentKey, isoCode, false); + + scopeContext.Enlist("RemoveFromCache_" + cacheKeyPublished, () => + { + if (_cache.TryRemove(cacheKeyPublished, out _) is false) + { + _logger.LogDebug("Could not remove the document url cache. But the important thing is that it is not there."); + return false; + } + + return true; + }); + + } + + public async Task RebuildAllUrlsAsync() + { + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + scope.ReadLock(Constants.Locks.ContentTree); + + IEnumerable documents = _documentRepository.GetMany(Array.Empty()); + + await CreateOrUpdateUrlSegmentsAsync(documents); + + _keyValueService.SetValue(RebuildKey, GetCurrentRebuildValue()); + + scope.Complete(); + } + + public Task ShouldRebuildUrlsAsync() + { + var persistedValue = GetPersistedRebuildValue(); + var currentValue = GetCurrentRebuildValue(); + + return Task.FromResult(string.Equals(persistedValue, currentValue) is false); + } + + private string GetCurrentRebuildValue() + { + return string.Join("|", _urlSegmentProviderCollection.Select(x => x.GetType().Name)); + } + + private string? GetPersistedRebuildValue() => _keyValueService.GetValue(RebuildKey); + + public string? GetUrlSegment(Guid documentKey, string culture, bool isDraft) + { + ThrowIfNotInitialized(); + var cacheKey = CreateCacheKey(documentKey, culture, isDraft); + + _cache.TryGetValue(cacheKey, out PublishedDocumentUrlSegment? urlSegment); + + return urlSegment?.UrlSegment; + } + + private void ThrowIfNotInitialized() + { + if (_isInitialized is false) + { + throw new InvalidOperationException("The service needs to be initialized before it can be used."); + } + } + + public async Task CreateOrUpdateUrlSegmentsAsync(IEnumerable documents) + { + if(documents.Any() is false) + { + return; + } + + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + + var toSave = new List(); + var toDelete = new List(); + var allCultures = documents.SelectMany(x => x.AvailableCultures ).Distinct(); + + var languages = await _languageService.GetMultipleAsync(allCultures); + var languageDictionary = languages.ToDictionary(x=>x.IsoCode); + + foreach (IContent document in documents) + { + if (_logger.IsEnabled(LogLevel.Trace)) + { + _logger.LogTrace("Rebuilding urls for document with key {DocumentKey}", document.Key); + } + + if (document.AvailableCultures.Any()) + { + foreach (var culture in document.AvailableCultures) + { + var language = languageDictionary[culture]; + + HandleCaching(_coreScopeProvider.Context!, document, culture, language, toDelete, toSave); + } + } + else + { + var language = await _languageService.GetDefaultLanguageAsync(); + + HandleCaching(_coreScopeProvider.Context!, document, null, language!, toDelete, toSave); + } + } + + if(toSave.Any()) + { + _documentUrlRepository.Save(toSave); + } + + if(toDelete.Any()) + { + _documentUrlRepository.DeleteByDocumentKey(toDelete); + } + + scope.Complete(); + } + + private void HandleCaching(IScopeContext scopeContext, IContent document, string? culture, ILanguage language, List toDelete, List toSave) + { + var models = GenerateModels(document, culture, language); + + foreach (PublishedDocumentUrlSegment model in models) + { + if (document.Published is false && model.IsDraft is false) + { + continue; + } + + if (document.Trashed) + { + toDelete.Add(model.DocumentKey); + RemoveFromCache(scopeContext, model.DocumentKey, language.IsoCode); + } + else + { + toSave.Add(model); + UpdateCache(scopeContext, model, language.IsoCode); + } + } + } + + private IEnumerable GenerateModels(IContent document, string? culture, ILanguage language) + { + var publishedUrlSegment = document.GetUrlSegment(_shortStringHelper, _urlSegmentProviderCollection, culture, true); + if(publishedUrlSegment.IsNullOrWhiteSpace()) + { + _logger.LogWarning("No published url segment found for document {DocumentKey} in culture {Culture}", document.Key, culture ?? "{null}"); + } + else + { + yield return new PublishedDocumentUrlSegment() + { + DocumentKey = document.Key, LanguageId = language.Id, UrlSegment = publishedUrlSegment, IsDraft = false + }; + } + + var draftUrlSegment = document.GetUrlSegment(_shortStringHelper, _urlSegmentProviderCollection, culture, false); + + if(draftUrlSegment.IsNullOrWhiteSpace()) + { + _logger.LogWarning("No draft url segment found for document {DocumentKey} in culture {Culture}", document.Key, culture ?? "{null}"); + } + else + { + yield return new PublishedDocumentUrlSegment() + { + DocumentKey = document.Key, LanguageId = language.Id, UrlSegment = draftUrlSegment, IsDraft = true + }; + } + } + + public async Task DeleteUrlsFromCacheAsync(IEnumerable documentKeys) + { + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + + IEnumerable languages = await _languageService.GetAllAsync(); + + foreach (ILanguage language in languages) + { + foreach (Guid documentKey in documentKeys) + { + RemoveFromCache(_coreScopeProvider.Context!, documentKey, language.IsoCode); + } + } + + scope.Complete(); + } + + public Guid? GetDocumentKeyByRoute(string route, string? culture, int? documentStartNodeId, bool isDraft) + { + var urlSegments = route.Split(Constants.CharArrays.ForwardSlash, StringSplitOptions.RemoveEmptyEntries); + + // We need to translate legacy int ids to guid keys. + Guid? runnerKey = GetStartNodeKey(documentStartNodeId); + var hideTopLevelNodeFromPath = _globalSettings.HideTopLevelNodeFromPath; + + culture ??= _languageService.GetDefaultIsoCodeAsync().GetAwaiter().GetResult(); + + if (!_globalSettings.ForceCombineUrlPathLeftToRight + && CultureInfo.GetCultureInfo(culture).TextInfo.IsRightToLeft) + { + urlSegments = urlSegments.Reverse().ToArray(); + } + + // If a domain is assigned to this route, we need to follow the url segments + if (runnerKey.HasValue) + { + // If there is no url segments it means the domain root has been requested + if (urlSegments.Length == 0) + { + return runnerKey.Value; + } + + // Otherwise we have to find the child with that segment anc follow that + foreach (var urlSegment in urlSegments) + { + //Get the children of the runnerKey and find the child (if any) with the correct url segment + var childKeys = GetChildKeys(runnerKey.Value); + + runnerKey = GetChildWithUrlSegment(childKeys, urlSegment, culture, isDraft); + + if (runnerKey is null) + { + break; + } + } + + return runnerKey; + } + // If there is no parts, it means it is a root (and no assigned domain) + if(urlSegments.Length == 0) + { + // // if we do not hide the top level and no domain was found, it maens there is no content. + // // TODO we can remove this to keep consistency with the old routing, but it seems incorrect to allow that. + // if (hideTopLevelNodeFromPath is false) + // { + // return null; + // } + + return GetTopMostRootKey(); + } + + // Otherwise we have to find the root items (or child of the first root when hideTopLevelNodeFromPath is true) and follow the url segments in them to get to correct document key + for (var index = 0; index < urlSegments.Length; index++) + { + var urlSegment = urlSegments[index]; + IEnumerable runnerKeys; + if (index == 0) + { + runnerKeys = GetKeysInRoot(hideTopLevelNodeFromPath); + } + else + { + if (runnerKey is null) + { + break; + } + + runnerKeys = GetChildKeys(runnerKey.Value); + } + + runnerKey = GetChildWithUrlSegment(runnerKeys, urlSegment, culture, isDraft); + } + + return runnerKey; + } + + public string GetLegacyRouteFormat(Guid docuemntKey, string? culture, bool isDraft) + { + var documentIdAttempt = _idKeyMap.GetIdForKey(docuemntKey, UmbracoObjectTypes.Document); + + if(documentIdAttempt.Success is false) + { + return "#"; + } + + if (_documentNavigationQueryService.TryGetAncestorsOrSelfKeys(docuemntKey, + out IEnumerable ancestorsOrSelfKeys) is false) + { + return "#"; + } + + var cultureOrDefault = culture ?? _languageService.GetDefaultIsoCodeAsync().GetAwaiter().GetResult(); + + Guid[] ancestorsOrSelfKeysArray = ancestorsOrSelfKeys as Guid[] ?? ancestorsOrSelfKeys.ToArray(); + IDictionary ancestorOrSelfKeyToDomains = ancestorsOrSelfKeysArray.ToDictionary(x => x, ancestorKey => + { + IEnumerable domains = _domainService.GetAssignedDomainsAsync(ancestorKey, false).GetAwaiter().GetResult(); + return domains.FirstOrDefault(x=>x.LanguageIsoCode == cultureOrDefault); + }); + + var urlSegments = new List(); + + IDomain? foundDomain = null; + + foreach (Guid ancestorOrSelfKey in ancestorsOrSelfKeysArray) + { + if (ancestorOrSelfKeyToDomains.TryGetValue(ancestorOrSelfKey, out IDomain? domain)) + { + if (domain is not null) + { + foundDomain = domain; + break; + } + } + + if (_cache.TryGetValue(CreateCacheKey(ancestorOrSelfKey, cultureOrDefault, isDraft), out PublishedDocumentUrlSegment? publishedDocumentUrlSegment)) + { + urlSegments.Add(publishedDocumentUrlSegment.UrlSegment); + } + + if (foundDomain is not null) + { + break; + } + } + + if (foundDomain is not null) + { + //we found a domain, and not to construct the route in the funny legacy way + return foundDomain.RootContentId + "/" + string.Join("/", urlSegments); + } + + var isRootFirstItem = GetTopMostRootKey() == ancestorsOrSelfKeysArray.Last(); + return GetFullUrl(isRootFirstItem, urlSegments, null); + } + + + public async Task> ListUrlsAsync(Guid contentKey) + { + var result = new List(); + + var documentIdAttempt = _idKeyMap.GetIdForKey(contentKey, UmbracoObjectTypes.Document); + + if(documentIdAttempt.Success is false) + { + return result; + } + + IEnumerable ancestorsOrSelfKeys = contentKey.Yield() + .Concat(_contentService.GetAncestors(documentIdAttempt.Result).Select(x => x.Key).Reverse()); + + IEnumerable languages = await _languageService.GetAllAsync(); + IEnumerable cultures = languages.Select(x=>x.IsoCode); + + + Guid[] ancestorsOrSelfKeysArray = ancestorsOrSelfKeys as Guid[] ?? ancestorsOrSelfKeys.ToArray(); + Dictionary>> ancestorOrSelfKeyToDomains = ancestorsOrSelfKeysArray.ToDictionary(x => x, async ancestorKey => + { + IEnumerable domains = await _domainService.GetAssignedDomainsAsync(ancestorKey, false); + return domains.ToDictionary(x => x.LanguageIsoCode!); + }); + + var urlSegments = new List(); + foreach (var culture in cultures) + { + IDomain? foundDomain = null; + + foreach (Guid ancestorOrSelfKey in ancestorsOrSelfKeysArray) + { + if (ancestorOrSelfKeyToDomains.TryGetValue(ancestorOrSelfKey, out Task>? domainDictionaryTask)) + { + var domainDictionary = await domainDictionaryTask; + if (domainDictionary.TryGetValue(culture, out IDomain? domain)) + { + foundDomain = domain; + break; + } + } + + if (_cache.TryGetValue(CreateCacheKey(ancestorOrSelfKey, culture, false), out PublishedDocumentUrlSegment? publishedDocumentUrlSegment)) + { + urlSegments.Add(publishedDocumentUrlSegment.UrlSegment); + } + } + + var isRootFirstItem = GetTopMostRootKey() == ancestorsOrSelfKeysArray.Last(); + result.Add(new UrlInfo( + text: GetFullUrl(isRootFirstItem, urlSegments, foundDomain), + isUrl: true, + culture: culture + )); + + } + + return result; + } + + private string GetFullUrl(bool isRootFirstItem, List reversedUrlSegments, IDomain? foundDomain) + { + var urlSegments = new List(reversedUrlSegments); + urlSegments.Reverse(); + + if (foundDomain is not null) + { + return foundDomain.DomainName + string.Join('/', urlSegments); + } + + return '/' + string.Join('/', urlSegments.Skip(_globalSettings.HideTopLevelNodeFromPath && isRootFirstItem ? 1 : 0)); + } + + public async Task CreateOrUpdateUrlSegmentsWithDescendantsAsync(Guid key) + { + var id = _idKeyMap.GetIdForKey(key, UmbracoObjectTypes.Document).Result; + IContent item = _contentService.GetById(id)!; + IEnumerable descendants = _contentService.GetPagedDescendants(id, 0, int.MaxValue, out _); + + await CreateOrUpdateUrlSegmentsAsync(new List(descendants) + { + item + }); + } + + public async Task CreateOrUpdateUrlSegmentsAsync(Guid key) + { + IContent? content = _contentService.GetById(key); + + if (content is not null) + { + await CreateOrUpdateUrlSegmentsAsync(content.Yield()); + } + } + + + //TODO test cases: + // - Find the root, when a domain is set + // - Find a nested child, when a domain is set + + // - Find the root when no domain is set and hideTopLevelNodeFromPath is true + // - Find a nested child of item in the root top when no domain is set and hideTopLevelNodeFromPath is true + // - Find a nested child of item in the root bottom when no domain is set and hideTopLevelNodeFromPath is true + // - Find the root when no domain is set and hideTopLevelNodeFromPath is false + // - Find a nested child of item in the root top when no domain is set and hideTopLevelNodeFromPath is false + // - Find a nested child of item in the root bottom when no domain is set and hideTopLevelNodeFromPath is false + + // - All of the above when having Constants.Conventions.Content.UrlName set to a value + + private IEnumerable GetKeysInRoot(bool addFirstLevelChildren) + { + //TODO replace with something more performand - Should be possible with navigationservice.. + IEnumerable rootKeys = _contentService.GetRootContent().Select(x=>x.Key).ToArray(); + + foreach (Guid rootKey in rootKeys) + { + yield return rootKey; + } + + if (addFirstLevelChildren) + { + foreach (Guid rootKey in rootKeys) + { + IEnumerable childKeys = GetChildKeys(rootKey); + + foreach (Guid childKey in childKeys) + { + yield return childKey; + } + } + } + + } + + private Guid? GetChildWithUrlSegment(IEnumerable childKeys, string urlSegment, string culture, bool isDraft) + { + foreach (Guid childKey in childKeys) + { + var childUrlSegment = GetUrlSegment(childKey, culture, isDraft); + + if (string.Equals(childUrlSegment, urlSegment)) + { + return childKey; + } + } + + return null; + } + + /// + /// Gets the children based on the latest published version of the content. (No aware of things in this scope). + /// + /// The key of the document to get children from. + /// The keys of all the children of the document. + private IEnumerable GetChildKeys(Guid documentKey) + { + if(_documentNavigationQueryService.TryGetChildrenKeys(documentKey, out IEnumerable childrenKeys)) + { + return childrenKeys; + } + + return Enumerable.Empty(); + } + + /// + /// Gets the top most root key. + /// + /// The top most root key. + private Guid? GetTopMostRootKey() + { + if (_documentNavigationQueryService.TryGetRootKeys(out IEnumerable rootKeys)) + { + return rootKeys.FirstOrDefault(); + } + return null; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static string CreateCacheKey(Guid documentKey, string culture, bool isDraft) => $"{documentKey}|{culture}|{isDraft}"; + + private Guid? GetStartNodeKey(int? documentStartNodeId) + { + if (documentStartNodeId is null) + { + return null; + } + + Attempt attempt = _idKeyMap.GetKeyForId(documentStartNodeId.Value, UmbracoObjectTypes.Document); + return attempt.Success ? attempt.Result : null; + } + +} diff --git a/src/Umbraco.Core/Services/DocumentUrlServiceInitializer.cs b/src/Umbraco.Core/Services/DocumentUrlServiceInitializer.cs new file mode 100644 index 0000000000..4c99b9a8ae --- /dev/null +++ b/src/Umbraco.Core/Services/DocumentUrlServiceInitializer.cs @@ -0,0 +1,53 @@ +using Microsoft.Extensions.Hosting; + +namespace Umbraco.Cms.Core.Services; + +public class DocumentUrlServiceInitializer : IHostedLifecycleService +{ + private readonly IDocumentUrlService _documentUrlService; + private readonly IRuntimeState _runtimeState; + + public DocumentUrlServiceInitializer(IDocumentUrlService documentUrlService, IRuntimeState runtimeState) + { + _documentUrlService = documentUrlService; + _runtimeState = runtimeState; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + if (_runtimeState.Level == RuntimeLevel.Upgrade) + { + //Special case on the first upgrade, as the database is not ready yet. + return; + } + + await _documentUrlService.InitAsync( + _runtimeState.Level <= RuntimeLevel.Install, + cancellationToken); + } + + public Task StopAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public Task StartingAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public Task StartedAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public Task StoppingAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public Task StoppedAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } +} diff --git a/src/Umbraco.Core/Services/IDocumentUrlService.cs b/src/Umbraco.Core/Services/IDocumentUrlService.cs new file mode 100644 index 0000000000..91427fa5f0 --- /dev/null +++ b/src/Umbraco.Core/Services/IDocumentUrlService.cs @@ -0,0 +1,36 @@ +using Umbraco.Cms.Core.Media.EmbedProviders; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Routing; + +namespace Umbraco.Cms.Core.Services; + +public interface IDocumentUrlService +{ + + /// + /// Initializes the service and ensure the content in the database is correct with the current configuration. + /// + /// + /// + Task InitAsync(bool forceEmpty, CancellationToken cancellationToken); + + Task RebuildAllUrlsAsync(); + /// + /// Gets the Url from a document key, culture and segment. Preview urls are returned if isPreview is true. + /// + /// The key of the document. + /// The culture code. + /// Whether to get the url of the draft or published document. + /// The url of the document. + string? GetUrlSegment(Guid documentKey, string culture, bool isDraft); + + Task CreateOrUpdateUrlSegmentsAsync(IEnumerable documents); + + Task DeleteUrlsFromCacheAsync(IEnumerable documentKeys); + + Guid? GetDocumentKeyByRoute(string route, string? culture, int? documentStartNodeId, bool isDraft); + Task> ListUrlsAsync(Guid contentKey); + Task CreateOrUpdateUrlSegmentsWithDescendantsAsync(Guid key); + Task CreateOrUpdateUrlSegmentsAsync(Guid key); + string GetLegacyRouteFormat(Guid key, string? culture, bool isDraft); +} diff --git a/src/Umbraco.Core/Services/Navigation/INavigationQueryService.cs b/src/Umbraco.Core/Services/Navigation/INavigationQueryService.cs index 204ec657eb..ad4e7ae150 100644 --- a/src/Umbraco.Core/Services/Navigation/INavigationQueryService.cs +++ b/src/Umbraco.Core/Services/Navigation/INavigationQueryService.cs @@ -1,3 +1,5 @@ +using Umbraco.Extensions; + namespace Umbraco.Cms.Core.Services.Navigation; /// @@ -14,7 +16,32 @@ public interface INavigationQueryService bool TryGetDescendantsKeys(Guid parentKey, out IEnumerable descendantsKeys); + bool TryGetDescendantsKeysOrSelfKeys(Guid childKey, out IEnumerable descendantsOrSelfKeys) + { + if(TryGetDescendantsKeys(childKey, out var descendantsKeys)) + { + descendantsOrSelfKeys = childKey.Yield().Concat(descendantsKeys); + return true; + } + + descendantsOrSelfKeys = Array.Empty(); + return false; + } + + bool TryGetAncestorsKeys(Guid childKey, out IEnumerable ancestorsKeys); + bool TryGetAncestorsOrSelfKeys(Guid childKey, out IEnumerable ancestorsOrSelfKeys) + { + if(TryGetAncestorsKeys(childKey, out var ancestorsKeys)) + { + ancestorsOrSelfKeys = childKey.Yield().Concat(ancestorsKeys); + return true; + } + + ancestorsOrSelfKeys = Array.Empty(); + return false; + } + bool TryGetSiblingsKeys(Guid key, out IEnumerable siblingsKeys); } diff --git a/src/Umbraco.Core/Services/Navigation/IRecycleBinNavigationQueryService.cs b/src/Umbraco.Core/Services/Navigation/IRecycleBinNavigationQueryService.cs index 0a57f5346c..9741e86e1f 100644 --- a/src/Umbraco.Core/Services/Navigation/IRecycleBinNavigationQueryService.cs +++ b/src/Umbraco.Core/Services/Navigation/IRecycleBinNavigationQueryService.cs @@ -1,3 +1,5 @@ +using Umbraco.Extensions; + namespace Umbraco.Cms.Core.Services.Navigation; /// @@ -12,6 +14,19 @@ public interface IRecycleBinNavigationQueryService bool TryGetDescendantsKeysInBin(Guid parentKey, out IEnumerable descendantsKeys); + bool TryGetDescendantsKeysOrSelfKeysInBin(Guid childKey, out IEnumerable descendantsOrSelfKeys) + { + if(TryGetDescendantsKeysInBin(childKey, out var descendantsKeys)) + { + descendantsOrSelfKeys = childKey.Yield().Concat(descendantsKeys); + return true; + } + + descendantsOrSelfKeys = Array.Empty(); + return false; + } + + bool TryGetAncestorsKeysInBin(Guid childKey, out IEnumerable ancestorsKeys); bool TryGetSiblingsKeysInBin(Guid key, out IEnumerable siblingsKeys); diff --git a/src/Umbraco.Core/Strings/DefaultUrlSegmentProvider.cs b/src/Umbraco.Core/Strings/DefaultUrlSegmentProvider.cs index 36c0d6e85e..5e0ae176b7 100644 --- a/src/Umbraco.Core/Strings/DefaultUrlSegmentProvider.cs +++ b/src/Umbraco.Core/Strings/DefaultUrlSegmentProvider.cs @@ -12,21 +12,25 @@ public class DefaultUrlSegmentProvider : IUrlSegmentProvider public DefaultUrlSegmentProvider(IShortStringHelper shortStringHelper) => _shortStringHelper = shortStringHelper; + + public virtual string? GetUrlSegment(IContentBase content, bool published, string? culture = null) => + GetUrlSegmentSource(content, culture, published)?.ToUrlSegment(_shortStringHelper, culture); + /// /// Gets the URL segment for a specified content and culture. /// /// The content. /// The culture. /// The URL segment. - public string? GetUrlSegment(IContentBase content, string? culture = null) => - GetUrlSegmentSource(content, culture)?.ToUrlSegment(_shortStringHelper, culture); + public virtual string? GetUrlSegment(IContentBase content, string? culture = null) => + GetUrlSegmentSource(content, culture, true)?.ToUrlSegment(_shortStringHelper, culture); - private static string? GetUrlSegmentSource(IContentBase content, string? culture) + private static string? GetUrlSegmentSource(IContentBase content, string? culture, bool published) { string? source = null; if (content.HasProperty(Constants.Conventions.Content.UrlName)) { - source = (content.GetValue(Constants.Conventions.Content.UrlName, culture) ?? string.Empty).Trim(); + source = (content.GetValue(Constants.Conventions.Content.UrlName, culture, published: published) ?? string.Empty).Trim(); } if (string.IsNullOrWhiteSpace(source)) diff --git a/src/Umbraco.Core/Strings/IUrlSegmentProvider.cs b/src/Umbraco.Core/Strings/IUrlSegmentProvider.cs index c7050050e1..6e9f1db326 100644 --- a/src/Umbraco.Core/Strings/IUrlSegmentProvider.cs +++ b/src/Umbraco.Core/Strings/IUrlSegmentProvider.cs @@ -20,6 +20,7 @@ public interface IUrlSegmentProvider /// URL per culture. /// string? GetUrlSegment(IContentBase content, string? culture = null); + string? GetUrlSegment(IContentBase content, bool published, string? culture = null) => GetUrlSegment(content, culture); // TODO: For the 301 tracking, we need to add another extended interface to this so that // the RedirectTrackingEventHandler can ask the IUrlSegmentProvider if the URL is changing. diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs index 757e103727..37e3c6063c 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs @@ -33,6 +33,7 @@ public static partial class UmbracoBuilderExtensions builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddUnique(); + builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddMultipleUnique(); builder.Services.AddUnique(); diff --git a/src/Umbraco.Infrastructure/Examine/ContentValueSetBuilder.cs b/src/Umbraco.Infrastructure/Examine/ContentValueSetBuilder.cs index b791babe61..a6aaedca4c 100644 --- a/src/Umbraco.Infrastructure/Examine/ContentValueSetBuilder.cs +++ b/src/Umbraco.Infrastructure/Examine/ContentValueSetBuilder.cs @@ -1,5 +1,7 @@ using Examine; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.PropertyEditors; @@ -27,7 +29,10 @@ public class ContentValueSetBuilder : BaseValueSetBuilder, IContentVal private readonly ILocalizationService _localizationService; private readonly IContentTypeService _contentTypeService; private readonly ILogger _logger; + private readonly IDocumentUrlService _documentUrlService; + private readonly ILanguageService _languageService; + [Obsolete("Use the non-obsolete constructor. This will be removed in Umbraco 16.")] public ContentValueSetBuilder( PropertyEditorCollection propertyEditors, UrlSegmentProviderCollection urlSegmentProviders, @@ -38,6 +43,36 @@ public class ContentValueSetBuilder : BaseValueSetBuilder, IContentVal ILocalizationService localizationService, IContentTypeService contentTypeService, ILogger logger) + : this( + propertyEditors, + urlSegmentProviders, + userService, + shortStringHelper, + scopeProvider, + publishedValuesOnly, + localizationService, + contentTypeService, + logger, + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService() + ) + { + + } + + [Obsolete("Use the non-obsolete constructor. This will be removed in Umbraco 16.")] + public ContentValueSetBuilder( + PropertyEditorCollection propertyEditors, + UrlSegmentProviderCollection urlSegmentProviders, + IUserService userService, + IShortStringHelper shortStringHelper, + ICoreScopeProvider scopeProvider, + bool publishedValuesOnly, + ILocalizationService localizationService, + IContentTypeService contentTypeService, + ILogger logger, + IDocumentUrlService documentUrlService, + ILanguageService languageService) : base(propertyEditors, publishedValuesOnly) { _urlSegmentProviders = urlSegmentProviders; @@ -47,8 +82,9 @@ public class ContentValueSetBuilder : BaseValueSetBuilder, IContentVal _localizationService = localizationService; _contentTypeService = contentTypeService; _logger = logger; + _documentUrlService = documentUrlService; + _languageService = languageService; } - /// public override IEnumerable GetValueSets(params IContent[] content) { @@ -73,6 +109,7 @@ public class ContentValueSetBuilder : BaseValueSetBuilder, IContentVal { IDictionary contentTypeDictionary = _contentTypeService.GetAll().ToDictionary(x => x.Key); + var defaultCulture = _languageService.GetDefaultIsoCodeAsync().GetAwaiter().GetResult(); // TODO: There is a lot of boxing going on here and ultimately all values will be boxed by Lucene anyways // but I wonder if there's a way to reduce the boxing that we have to do or if it will matter in the end since // Lucene will do it no matter what? One idea was to create a `FieldValue` struct which would contain `object`, `object[]`, `ValueType` and `ValueType[]` @@ -81,7 +118,7 @@ public class ContentValueSetBuilder : BaseValueSetBuilder, IContentVal { var isVariant = c.ContentType.VariesByCulture(); - var urlValue = c.GetUrlSegment(_shortStringHelper, _urlSegmentProviders); // Always add invariant urlName + var urlValue = _documentUrlService.GetUrlSegment(c.Key, defaultCulture, false); // Always add invariant urlName var values = new Dictionary> { { "icon", c.ContentType.Icon?.Yield() ?? Enumerable.Empty() }, diff --git a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs index b118b7f84c..5e634d9684 100644 --- a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs +++ b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs @@ -71,6 +71,7 @@ public class DatabaseSchemaCreator typeof(UserStartNodeDto), typeof(ContentNuDto), typeof(DocumentVersionDto), + typeof(DocumentUrlDto), typeof(KeyValueDto), typeof(UserLoginDto), typeof(ConsentDto), diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs index 0dda6bf003..d318b5f7e1 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs @@ -99,5 +99,6 @@ public class UmbracoPlan : MigrationPlan // To 15.0.0 To("{7F4F31D8-DD71-4F0D-93FC-2690A924D84B}"); To("{1A8835EF-F8AB-4472-B4D8-D75B7C164022}"); + } } diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPremigrationPlan.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPremigrationPlan.cs index a8131a0da4..42a4db8677 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPremigrationPlan.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPremigrationPlan.cs @@ -57,5 +57,8 @@ public class UmbracoPremigrationPlan : MigrationPlan To("{7BCB5352-B2ED-4D4B-B27D-ECDED930B50A}"); To("{3E69BF9B-BEAB-41B1-BB11-15383CCA1C7F}"); To("{F12C609B-86B9-4386-AFA4-78E02857247C}"); + + // To 15.0.0 + To("{B9133686-B758-404D-AF12-708AA80C7E44}"); } } diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_0_0/AddDocumentUrl.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_0_0/AddDocumentUrl.cs new file mode 100644 index 0000000000..84c158eb33 --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_0_0/AddDocumentUrl.cs @@ -0,0 +1,22 @@ +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_15_0_0; + +[Obsolete("Remove in Umbraco 18.")] +public class AddDocumentUrl : MigrationBase +{ + private readonly IDocumentUrlService _documentUrlService; + + public AddDocumentUrl(IMigrationContext context, IDocumentUrlService documentUrlService) + : base(context) + { + _documentUrlService = documentUrlService; + } + + protected override void Migrate() + { + Create.Table().Do(); + _documentUrlService.InitAsync(false, CancellationToken.None); + } +} diff --git a/src/Umbraco.Infrastructure/Persistence/DatabaseAnnotations/IndexTypes.cs b/src/Umbraco.Infrastructure/Persistence/DatabaseAnnotations/IndexTypes.cs index 46697b9c97..3c1d483d58 100644 --- a/src/Umbraco.Infrastructure/Persistence/DatabaseAnnotations/IndexTypes.cs +++ b/src/Umbraco.Infrastructure/Persistence/DatabaseAnnotations/IndexTypes.cs @@ -8,4 +8,5 @@ public enum IndexTypes Clustered, NonClustered, UniqueNonClustered, + UniqueClustered, } diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/DocumentUrlDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/DocumentUrlDto.cs new file mode 100644 index 0000000000..4a006f9307 --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/DocumentUrlDto.cs @@ -0,0 +1,41 @@ + +using NPoco; +using Umbraco.Cms.Core; +using Umbraco.Cms.Infrastructure.Persistence.DatabaseAnnotations; + +namespace Umbraco.Cms.Infrastructure.Persistence.Dtos; + +[TableName(TableName)] +[PrimaryKey("id", AutoIncrement = true)] +[ExplicitColumns] +public class DocumentUrlDto +{ + public const string TableName = Constants.DatabaseSchema.Tables.DocumentUrl; + + [Column("id")] + [PrimaryKeyColumn(Clustered = false, AutoIncrement = true)] + public int NodeId { get; set; } + + [Index(IndexTypes.UniqueClustered, ForColumns = "uniqueId, languageId, isDraft", Name = "IX_" + TableName)] + [Column("uniqueId")] + [ForeignKey(typeof(NodeDto), Column = "uniqueId")] + public Guid UniqueId { get; set; } + + [Column("isDraft")] + public bool IsDraft { get; set; } + + [Column("languageId")] + [ForeignKey(typeof(LanguageDto))] + public int LanguageId { get; set; } + + // + // [Column("segment")] + // [NullSetting(NullSetting = NullSettings.Null)] + // [Length(PropertyDataDto.SegmentLength)] + // public string Segment { get; set; } = string.Empty; + + [Column("urlSegment")] + [NullSetting(NullSetting = NullSettings.NotNull)] + public string UrlSegment { get; set; } = string.Empty; + +} diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentRepository.cs index ce9c659720..7579d66d82 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentRepository.cs @@ -742,7 +742,10 @@ public class DocumentRepository : ContentRepositoryBase _scopeAccessor = scopeAccessor; + + private IUmbracoDatabase Database + { + get + { + if (_scopeAccessor.AmbientScope is null) + { + throw new NotSupportedException("Need to be executed in a scope"); + } + + return _scopeAccessor.AmbientScope.Database; + } + } + + public void Save(IEnumerable publishedDocumentUrlSegments) + { + //TODO avoid this is called as first thing on first restart after install + IEnumerable documentKeys = publishedDocumentUrlSegments.Select(x => x.DocumentKey).Distinct(); + + Dictionary<(Guid UniqueId, int LanguageId, bool isDraft), DocumentUrlDto> dtoDictionary = publishedDocumentUrlSegments.Select(BuildDto).ToDictionary(x=> (x.UniqueId, x.LanguageId, x.IsDraft)); + + var toUpdate = new List(); + var toDelete = new List(); + var toInsert = dtoDictionary.Values.ToDictionary(x => (x.UniqueId, x.LanguageId, x.IsDraft)); + + foreach (IEnumerable group in documentKeys.InGroupsOf(Constants.Sql.MaxParameterCount)) + { + Sql sql = Database.SqlContext.Sql() + .Select() + .From() + .Where(x => group.Contains(x.UniqueId)) + .ForUpdate(); + + List existingUrlsInBatch = Database.Fetch(sql); + + foreach (DocumentUrlDto existing in existingUrlsInBatch) + { + + if (dtoDictionary.TryGetValue((existing.UniqueId, existing.LanguageId, existing.IsDraft), out DocumentUrlDto? found)) + { + found.NodeId = existing.NodeId; + + // Only update if the url segment is different + if (found.UrlSegment != existing.UrlSegment) + { + toUpdate.Add(found); + } + // if we found it, we know we should not insert it as a new + toInsert.Remove((found.UniqueId, found.LanguageId, found.IsDraft)); + } + else + { + toDelete.Add(existing.NodeId); + } + } + } + + // do the deletes, updates and inserts + if (toDelete.Count > 0) + { + Database.DeleteMany().Where(x => toDelete.Contains(x.NodeId)).Execute(); + } + + if (toUpdate.Any()) + { + foreach (DocumentUrlDto updated in toUpdate) + { + Database.Update(updated); + } + } + + Database.InsertBulk(toInsert.Values); + } + + public IEnumerable GetAll() + { + List? dtos = Database.Fetch(Database.SqlContext.Sql().Select().From()); + + return dtos.Select(BuildModel); + } + + public void DeleteByDocumentKey(IEnumerable documentKeys) + { + foreach (IEnumerable group in documentKeys.InGroupsOf(Constants.Sql.MaxParameterCount)) + { + Database.Execute(Database.SqlContext.Sql().Delete().WhereIn(x => x.UniqueId, group)); + } + } + + private PublishedDocumentUrlSegment BuildModel(DocumentUrlDto dto) => + new() + { + UrlSegment = dto.UrlSegment, + DocumentKey = dto.UniqueId, + LanguageId = dto.LanguageId, + IsDraft = dto.IsDraft + }; + + private DocumentUrlDto BuildDto(PublishedDocumentUrlSegment model) + { + return new DocumentUrlDto() + { + UrlSegment = model.UrlSegment, + UniqueId = model.DocumentKey, + LanguageId = model.LanguageId, + IsDraft = model.IsDraft, + }; + } +} diff --git a/src/Umbraco.Infrastructure/Persistence/SqlSyntax/SqlSyntaxProviderBase.cs b/src/Umbraco.Infrastructure/Persistence/SqlSyntax/SqlSyntaxProviderBase.cs index 0a3cda3b6d..fe33fbe7b6 100644 --- a/src/Umbraco.Infrastructure/Persistence/SqlSyntax/SqlSyntaxProviderBase.cs +++ b/src/Umbraco.Infrastructure/Persistence/SqlSyntax/SqlSyntaxProviderBase.cs @@ -138,20 +138,23 @@ public abstract class SqlSyntaxProviderBase : ISqlSyntaxProvider public virtual string GetIndexType(IndexTypes indexTypes) { - string indexType; + var indexType = string.Empty; - if (indexTypes == IndexTypes.Clustered) + if (indexTypes == IndexTypes.UniqueClustered || indexTypes == IndexTypes.UniqueNonClustered) { - indexType = "CLUSTERED"; + indexType += " UNIQUE"; + } + + if (indexTypes == IndexTypes.UniqueClustered || indexTypes == IndexTypes.Clustered) + { + indexType += " CLUSTERED"; } else { - indexType = indexTypes == IndexTypes.NonClustered - ? "NONCLUSTERED" - : "UNIQUE NONCLUSTERED"; + indexType += " NONCLUSTERED"; } - return indexType; + return indexType.Trim(); } public virtual string GetSpecialDbType(SpecialDbType dbType) diff --git a/src/Umbraco.PublishedCache.HybridCache/Services/DomainCacheService.cs b/src/Umbraco.PublishedCache.HybridCache/Services/DomainCacheService.cs index b4cb3019af..9efc749d6b 100644 --- a/src/Umbraco.PublishedCache.HybridCache/Services/DomainCacheService.cs +++ b/src/Umbraco.PublishedCache.HybridCache/Services/DomainCacheService.cs @@ -37,7 +37,7 @@ public class DomainCacheService : IDomainCacheService { // probably this could be optimized with an index // but then we'd need a custom DomainStore of some sort - IEnumerable list = _domains.Select(x => x.Value).Where(x => x.ContentId == documentId); + IEnumerable list = _domains.Values.Where(x => x.ContentId == documentId); if (includeWildcards == false) { list = list.Where(x => x.IsWildcard == false); diff --git a/src/Umbraco.Web.UI.Client b/src/Umbraco.Web.UI.Client index e2782af371..4729d3baa7 160000 --- a/src/Umbraco.Web.UI.Client +++ b/src/Umbraco.Web.UI.Client @@ -1 +1 @@ -Subproject commit e2782af3719ea1715e3f995fc9b48e04ce63774f +Subproject commit 4729d3baa7611ed63380abcfc184c1bb5a48b3bb diff --git a/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTest.cs b/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTest.cs index 43ade570e9..ec4d0e2600 100644 --- a/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTest.cs +++ b/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTest.cs @@ -21,7 +21,7 @@ using Umbraco.Cms.Persistence.SqlServer; using Umbraco.Cms.Tests.Common.Builders; using Umbraco.Cms.Tests.Integration.DependencyInjection; using Umbraco.Cms.Tests.Integration.Extensions; -using Umbraco.Cms.Tests.Integration.TestServerTest; + using Constants = Umbraco.Cms.Core.Constants; namespace Umbraco.Cms.Tests.Integration.Testing; @@ -162,6 +162,7 @@ public abstract class UmbracoIntegrationTest : UmbracoIntegrationTestBase .AddExamine() .AddUmbracoSqlServerSupport() .AddUmbracoSqliteSupport() + .AddUmbracoHybridCache() .AddTestServices(TestHelper); if (TestOptions.Mapper) @@ -171,6 +172,7 @@ public abstract class UmbracoIntegrationTest : UmbracoIntegrationTestBase .AddCoreMappingProfiles(); } + services.RemoveAll(x=>x.ImplementationType == typeof(DocumentUrlServiceInitializer)); services.AddSignalR(); services.AddMvc(); diff --git a/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTestWithContent.cs b/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTestWithContent.cs index 74122d2514..2ff15c3539 100644 --- a/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTestWithContent.cs +++ b/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTestWithContent.cs @@ -11,6 +11,12 @@ namespace Umbraco.Cms.Tests.Integration.Testing; public abstract class UmbracoIntegrationTestWithContent : UmbracoIntegrationTest { + protected const string TextpageKey = "B58B3AD4-62C2-4E27-B1BE-837BD7C533E0"; + protected const string SubPageKey = "07EABF4A-5C62-4662-9F2A-15BBB488BCA5"; + protected const string SubPage2Key = "0EED78FC-A6A8-4587-AB18-D3AFE212B1C4"; + protected const string SubPage3Key = "29BBB8CF-E69B-4A21-9363-02ED5B6637C4"; + protected const string TrashedKey = "EAE9EE57-FFE4-4841-8586-1B636C43A3D4"; + protected IContentTypeService ContentTypeService => GetRequiredService(); protected IFileService FileService => GetRequiredService(); @@ -44,26 +50,30 @@ public abstract class UmbracoIntegrationTestWithContent : UmbracoIntegrationTest ContentTypeService.Save(ContentType); // Create and Save Content "Homepage" based on "umbTextpage" -> 1053 - Textpage = ContentBuilder.CreateSimpleContent(ContentType); - Textpage.Key = new Guid("B58B3AD4-62C2-4E27-B1BE-837BD7C533E0"); + Textpage = ContentBuilder.CreateSimpleContent(ContentType, "Textpage"); + Textpage.Key = new Guid(TextpageKey); ContentService.Save(Textpage, -1); // Create and Save Content "Text Page 1" based on "umbTextpage" -> 1054 Subpage = ContentBuilder.CreateSimpleContent(ContentType, "Text Page 1", Textpage.Id); + Subpage.Key = new Guid(SubPageKey); var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.Now.AddMinutes(-5), null); ContentService.Save(Subpage, -1, contentSchedule); // Create and Save Content "Text Page 1" based on "umbTextpage" -> 1055 Subpage2 = ContentBuilder.CreateSimpleContent(ContentType, "Text Page 2", Textpage.Id); + Subpage2.Key = new Guid(SubPage2Key); ContentService.Save(Subpage2, -1); Subpage3 = ContentBuilder.CreateSimpleContent(ContentType, "Text Page 3", Textpage.Id); + Subpage3.Key = new Guid(SubPage3Key); ContentService.Save(Subpage3, -1); // Create and Save Content "Text Page Deleted" based on "umbTextpage" -> 1056 Trashed = ContentBuilder.CreateSimpleContent(ContentType, "Text Page Deleted", -20); Trashed.Trashed = true; + Trashed.Key = new Guid(TrashedKey); ContentService.Save(Trashed, -1); } } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentServiceTests.cs index 646fbbd51c..8e4b836fee 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentServiceTests.cs @@ -1148,7 +1148,7 @@ public class ContentServiceTests : UmbracoIntegrationTestWithContent Assert.AreEqual("foo", entity.Name); var e = ContentService.GetById(entity.Id); - Assert.AreEqual("Home", e.Name); + Assert.AreEqual("Textpage", e.Name); savingWasCalled = true; }; @@ -1165,7 +1165,7 @@ public class ContentServiceTests : UmbracoIntegrationTestWithContent try { var content = ContentService.GetById(Textpage.Id); - Assert.AreEqual("Home", content.Name); + Assert.AreEqual("Textpage", content.Name); content.Name = "foo"; ContentService.Save(content); @@ -2186,7 +2186,7 @@ public class ContentServiceTests : UmbracoIntegrationTestWithContent { // Arrange var temp = ContentService.GetById(Textpage.Id); - Assert.AreEqual("Home", temp.Name); + Assert.AreEqual("Textpage", temp.Name); Assert.AreEqual(3, ContentService.CountChildren(temp.Id)); // Act @@ -2211,7 +2211,7 @@ public class ContentServiceTests : UmbracoIntegrationTestWithContent { // Arrange var temp = ContentService.GetById(Textpage.Id); - Assert.AreEqual("Home", temp.Name); + Assert.AreEqual("Textpage", temp.Name); Assert.AreEqual(3, ContentService.CountChildren(temp.Id)); // Act diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTest.cs new file mode 100644 index 0000000000..d4b02a8755 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTest.cs @@ -0,0 +1,190 @@ +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Handlers; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Sync; +using Umbraco.Cms.Infrastructure.DependencyInjection; +using Umbraco.Cms.Infrastructure.Examine; +using Umbraco.Cms.Infrastructure.Examine.DependencyInjection; +using Umbraco.Cms.Infrastructure.HostedServices; +using Umbraco.Cms.Infrastructure.Search; +using Umbraco.Cms.Tests.Common.Attributes; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; +using Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Scoping; +using Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest, Logger = UmbracoTestOptions.Logger.Mock)] +public class DocumentUrlServiceTest : UmbracoIntegrationTestWithContent +{ + protected IDocumentUrlService DocumentUrlService => GetRequiredService(); + protected ILanguageService LanguageService => GetRequiredService(); + + protected override void CustomTestSetup(IUmbracoBuilder builder) + { + builder.Services.AddUnique(); + builder.AddNotificationHandler(); + + builder.Services.AddHostedService(); + + } + + + + // + // [Test] + // [LongRunning] + // public async Task InitAsync() + // { + // // ContentService.PublishBranch(Textpage, true, []); + // // + // // for (int i = 3; i < 10; i++) + // // { + // // var unusedSubPage = ContentBuilder.CreateSimpleContent(ContentType, "Text Page " + i, Textpage.Id); + // // unusedSubPage.Key = Guid.NewGuid(); + // // ContentService.Save(unusedSubPage); + // // ContentService.Publish(unusedSubPage, new string[0]); + // // } + // // + // // await DocumentUrlService.InitAsync(CancellationToken.None); + // + // } + + [Test] + public async Task Trashed_documents_do_not_have_a_url_segment() + { + var isoCode = (await LanguageService.GetDefaultLanguageAsync()).IsoCode; + + var actual = DocumentUrlService.GetUrlSegment(Trashed.Key, isoCode, true); + + Assert.IsNull(actual); + } + + //TODO test with the urlsegment property value! + + [Test] + public async Task Deleted_documents_do_not_have_a_url_segment__Parent_deleted() + { + ContentService.PublishBranch(Textpage, true, new[] { "*" }); + + ContentService.Delete(Textpage); + + var isoCode = (await LanguageService.GetDefaultLanguageAsync()).IsoCode; + + var actual = DocumentUrlService.GetUrlSegment(Subpage2.Key, isoCode, false); + + Assert.IsNull(actual); + } + + [Test] + public async Task Deleted_documents_do_not_have_a_url_segment() + { + ContentService.PublishBranch(Textpage, true, new[] { "*" }); + + ContentService.Delete(Subpage2); + + var isoCode = (await LanguageService.GetDefaultLanguageAsync()).IsoCode; + + var actual = DocumentUrlService.GetUrlSegment(Subpage2.Key, isoCode, false); + + Assert.IsNull(actual); + } + + [Test] + [TestCase("/", "en-US", true, ExpectedResult = TextpageKey)] + [TestCase("/text-page-1", "en-US", true, ExpectedResult = SubPageKey)] + [TestCase("/text-page-2", "en-US", true, ExpectedResult = SubPage2Key)] + [TestCase("/text-page-3", "en-US", true, ExpectedResult = SubPage3Key)] + [TestCase("/", "en-US", false, ExpectedResult = TextpageKey)] + [TestCase("/text-page-1", "en-US", false, ExpectedResult = SubPageKey)] + [TestCase("/text-page-2", "en-US", false, ExpectedResult = SubPage2Key)] + [TestCase("/text-page-3", "en-US", false, ExpectedResult = SubPage3Key)] + public string? Expected_Routes(string route, string isoCode, bool loadDraft) + { + if (loadDraft is false) + { + ContentService.PublishBranch(Textpage, true, new[] { "*" }); + } + + return DocumentUrlService.GetDocumentKeyByRoute(route, isoCode, null, loadDraft)?.ToString()?.ToUpper(); + } + + [Test] + public void No_Published_Route_when_not_published() + { + Assert.IsNull(DocumentUrlService.GetDocumentKeyByRoute("/text-page-1", "en-US", null, false)); + } + + + [Test] + [TestCase("/text-page-1/sub-page-1", "en-US", true, ExpectedResult = "DF49F477-12F2-4E33-8563-91A7CC1DCDBB")] + [TestCase("/text-page-1/sub-page-1", "en-US", false, ExpectedResult = "DF49F477-12F2-4E33-8563-91A7CC1DCDBB")] + public string? Expected_Routes_with_subpages(string route, string isoCode, bool loadDraft) + { + // Create a subpage + var subsubpage = ContentBuilder.CreateSimpleContent(ContentType, "Sub Page 1", Subpage.Id); + subsubpage.Key = new Guid("DF49F477-12F2-4E33-8563-91A7CC1DCDBB"); + var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.Now.AddMinutes(-5), null); + ContentService.Save(subsubpage, -1, contentSchedule); + + if (loadDraft is false) + { + ContentService.PublishBranch(Textpage, true, new[] { "*" }); + } + + return DocumentUrlService.GetDocumentKeyByRoute(route, isoCode, null, loadDraft)?.ToString()?.ToUpper(); + } + + [Test] + [TestCase("/second-root", "en-US", true, ExpectedResult = "8E21BCD4-02CA-483D-84B0-1FC92702E198")] + [TestCase("/second-root", "en-US", false, ExpectedResult = "8E21BCD4-02CA-483D-84B0-1FC92702E198")] + public string? Second_root_cannot_hide_url(string route, string isoCode, bool loadDraft) + { + // Create a second root + var secondRoot = ContentBuilder.CreateSimpleContent(ContentType, "Second Root", null); + secondRoot.Key = new Guid("8E21BCD4-02CA-483D-84B0-1FC92702E198"); + var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.Now.AddMinutes(-5), null); + ContentService.Save(secondRoot, -1, contentSchedule); + + if (loadDraft is false) + { + ContentService.PublishBranch(Textpage, true, new[] { "*" }); + ContentService.PublishBranch(secondRoot, true, new[] { "*" }); + } + + return DocumentUrlService.GetDocumentKeyByRoute(route, isoCode, null, loadDraft)?.ToString()?.ToUpper(); + } + + [Test] + [TestCase("/child-of-second-root", "en-US", true, ExpectedResult = "FF6654FB-BC68-4A65-8C6C-135567F50BD6")] + [TestCase("/child-of-second-root", "en-US", false, ExpectedResult = "FF6654FB-BC68-4A65-8C6C-135567F50BD6")] + public string? Child_of_second_root_do_not_have_parents_url_as_prefix(string route, string isoCode, bool loadDraft) + { + // Create a second root + var secondRoot = ContentBuilder.CreateSimpleContent(ContentType, "Second Root", null); + var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.Now.AddMinutes(-5), null); + ContentService.Save(secondRoot, -1, contentSchedule); + + // Create a child of second root + var childOfSecondRoot = ContentBuilder.CreateSimpleContent(ContentType, "Child of Second Root", secondRoot); + childOfSecondRoot.Key = new Guid("FF6654FB-BC68-4A65-8C6C-135567F50BD6"); + ContentService.Save(childOfSecondRoot, -1, contentSchedule); + + // Publish both the main root and the second root with descendants + if (loadDraft is false) + { + ContentService.PublishBranch(Textpage, true, new[] { "*" }); + ContentService.PublishBranch(secondRoot, true, new[] { "*" }); + } + + return DocumentUrlService.GetDocumentKeyByRoute(route, isoCode, null, loadDraft)?.ToString()?.ToUpper(); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTest_hidetoplevel_false.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTest_hidetoplevel_false.cs new file mode 100644 index 0000000000..c38bd544bc --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTest_hidetoplevel_false.cs @@ -0,0 +1,117 @@ +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Handlers; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Sync; +using Umbraco.Cms.Tests.Common.Attributes; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; +using Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Scoping; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest, Logger = UmbracoTestOptions.Logger.Console)] +public class DocumentUrlServiceTest_HideTopLevel_False : UmbracoIntegrationTestWithContent +{ + protected IDocumentUrlService DocumentUrlService => GetRequiredService(); + protected ILanguageService LanguageService => GetRequiredService(); + + protected override void CustomTestSetup(IUmbracoBuilder builder) + { + builder.Services.Configure(x => x.HideTopLevelNodeFromPath = false); + + builder.Services.AddUnique(); + builder.AddNotificationHandler(); + + builder.Services.AddHostedService(); + } + [Test] + [TestCase("/textpage/", "en-US", true, ExpectedResult = TextpageKey)] + [TestCase("/textpage/text-page-1", "en-US", true, ExpectedResult = SubPageKey)] + [TestCase("/textpage/text-page-2", "en-US", true, ExpectedResult = SubPage2Key)] + [TestCase("/textpage/text-page-3", "en-US", true, ExpectedResult = SubPage3Key)] + [TestCase("/textpage/", "en-US", false, ExpectedResult = TextpageKey)] + [TestCase("/textpage/text-page-1", "en-US", false, ExpectedResult = SubPageKey)] + [TestCase("/textpage/text-page-2", "en-US", false, ExpectedResult = SubPage2Key)] + [TestCase("/textpage/text-page-3", "en-US", false, ExpectedResult = SubPage3Key)] + public string? Expected_Routes(string route, string isoCode, bool loadDraft) + { + if (loadDraft is false) + { + ContentService.PublishBranch(Textpage, true, new[] { "*" }); + } + + + return DocumentUrlService.GetDocumentKeyByRoute(route, isoCode, null, loadDraft)?.ToString()?.ToUpper(); + } + + [Test] + [TestCase("/textpage/text-page-1/sub-page-1", "en-US", true, ExpectedResult = "DF49F477-12F2-4E33-8563-91A7CC1DCDBB")] + [TestCase("/textpage/text-page-1/sub-page-1", "en-US", false, ExpectedResult = "DF49F477-12F2-4E33-8563-91A7CC1DCDBB")] + public string? Expected_Routes_with_subpages(string route, string isoCode, bool loadDraft) + { + // Create a subpage + var subsubpage = ContentBuilder.CreateSimpleContent(ContentType, "Sub Page 1", Subpage.Id); + subsubpage.Key = new Guid("DF49F477-12F2-4E33-8563-91A7CC1DCDBB"); + var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.Now.AddMinutes(-5), null); + ContentService.Save(subsubpage, -1, contentSchedule); + + if (loadDraft is false) + { + ContentService.PublishBranch(Textpage, true, new[] { "*" }); + } + + return DocumentUrlService.GetDocumentKeyByRoute(route, isoCode, null, loadDraft)?.ToString()?.ToUpper(); + } + + [Test] + [TestCase("/second-root", "en-US", true, ExpectedResult = "8E21BCD4-02CA-483D-84B0-1FC92702E198")] + [TestCase("/second-root", "en-US", false, ExpectedResult = "8E21BCD4-02CA-483D-84B0-1FC92702E198")] + public string? Second_root_cannot_hide_url(string route, string isoCode, bool loadDraft) + { + // Create a second root + var secondRoot = ContentBuilder.CreateSimpleContent(ContentType, "Second Root", null); + secondRoot.Key = new Guid("8E21BCD4-02CA-483D-84B0-1FC92702E198"); + var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.Now.AddMinutes(-5), null); + ContentService.Save(secondRoot, -1, contentSchedule); + + if (loadDraft is false) + { + ContentService.PublishBranch(Textpage, true, new[] { "*" }); + ContentService.PublishBranch(secondRoot, true, new[] { "*" }); + } + + return DocumentUrlService.GetDocumentKeyByRoute(route, isoCode, null, loadDraft)?.ToString()?.ToUpper(); + } + + [Test] + [TestCase("/second-root/child-of-second-root", "en-US", true, ExpectedResult = "FF6654FB-BC68-4A65-8C6C-135567F50BD6")] + [TestCase("/second-root/child-of-second-root", "en-US", false, ExpectedResult = "FF6654FB-BC68-4A65-8C6C-135567F50BD6")] + public string? Child_of_second_root_do_not_have_parents_url_as_prefix(string route, string isoCode, bool loadDraft) + { + // Create a second root + var secondRoot = ContentBuilder.CreateSimpleContent(ContentType, "Second Root", null); + var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.Now.AddMinutes(-5), null); + ContentService.Save(secondRoot, -1, contentSchedule); + + // Create a child of second root + var childOfSecondRoot = ContentBuilder.CreateSimpleContent(ContentType, "Child of Second Root", secondRoot); + childOfSecondRoot.Key = new Guid("FF6654FB-BC68-4A65-8C6C-135567F50BD6"); + ContentService.Save(childOfSecondRoot, -1, contentSchedule); + + // Publish both the main root and the second root with descendants + if (loadDraft is false) + { + ContentService.PublishBranch(Textpage, true, new[] { "*" }); + ContentService.PublishBranch(secondRoot, true, new[] { "*" }); + } + + return DocumentUrlService.GetDocumentKeyByRoute(route, isoCode, null, loadDraft)?.ToString()?.ToUpper(); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/BackOfficeExamineSearcherTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/BackOfficeExamineSearcherTests.cs index 2bc60dcd59..fd852afa86 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/BackOfficeExamineSearcherTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/BackOfficeExamineSearcherTests.cs @@ -19,6 +19,7 @@ using Umbraco.Cms.Infrastructure.Search; using Umbraco.Cms.Tests.Common.Builders; using Umbraco.Cms.Tests.Common.Builders.Extensions; using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Scoping; using Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; using Umbraco.Cms.Web.Common.Security; @@ -64,11 +65,8 @@ public class BackOfficeExamineSearcherTests : ExamineBaseTest protected override void CustomTestSetup(IUmbracoBuilder builder) { + base.CustomTestSetup(builder); builder.Services.AddUnique(); - builder.Services.AddUnique(); - builder - .AddNotificationHandler(); builder.AddNotificationHandler(); builder.AddExamineIndexes(); builder.Services.AddHostedService(); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/ExamineBaseTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/ExamineBaseTest.cs index fa14bcf18f..66e9ad85fc 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/ExamineBaseTest.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/ExamineBaseTest.cs @@ -5,15 +5,19 @@ using Microsoft.Extensions.DependencyInjection; using Moq; using NUnit.Framework; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Hosting; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Persistence.Querying; using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Sync; using Umbraco.Cms.Infrastructure.Examine; using Umbraco.Cms.Infrastructure.Persistence; using Umbraco.Cms.Tests.Integration.Testing; +using Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; namespace Umbraco.Cms.Tests.Integration.Umbraco.Examine.Lucene.UmbracoExamine; @@ -33,6 +37,16 @@ public abstract class ExamineBaseTest : UmbracoIntegrationTest protected override void ConfigureTestServices(IServiceCollection services) => services.AddSingleton(); + protected override void CustomTestSetup(IUmbracoBuilder builder) + { + base.CustomTestSetup(builder); + builder.Services.AddUnique(); + builder + .AddNotificationHandler(); + builder.Services.AddHostedService(); + + } /// /// Used to create and manage a testable index /// diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/ExamineExternalIndexTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/ExamineExternalIndexTests.cs index a4258755e8..24c04b643d 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/ExamineExternalIndexTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/ExamineExternalIndexTests.cs @@ -48,6 +48,7 @@ public class ExamineExternalIndexTests : ExamineBaseTest Services.DisposeIfDisposable(); } + private IExamineExternalIndexSearcherTest ExamineExternalIndexSearcher => GetRequiredService(); @@ -66,11 +67,8 @@ public class ExamineExternalIndexTests : ExamineBaseTest protected override void CustomTestSetup(IUmbracoBuilder builder) { + base.CustomTestSetup(builder); builder.Services.AddUnique(); - builder.Services.AddUnique(); - builder - .AddNotificationHandler(); builder.AddNotificationHandler(); builder.AddExamineIndexes(); builder.Services.AddHostedService(); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/UrlAndDomains/DomainAndUrlsTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/UrlAndDomains/DomainAndUrlsTests.cs index 679daeca75..2b25fde0a3 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/UrlAndDomains/DomainAndUrlsTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/UrlAndDomains/DomainAndUrlsTests.cs @@ -1,17 +1,21 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using NUnit.Framework; +using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Packaging; using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Core.Sync; using Umbraco.Cms.Core.Web; using Umbraco.Cms.Tests.Common; using Umbraco.Cms.Tests.Common.Testing; using Umbraco.Cms.Tests.Integration.Testing; +using Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Scoping; namespace Umbraco.Cms.Tests.Integration.Umbraco.Web.BackOffice.UrlAndDomains; @@ -69,6 +73,12 @@ public class DomainAndUrlsTests : UmbracoIntegrationTest builder.Services.AddUnique(_variationContextAccessor); builder.AddUmbracoHybridCache(); builder.AddNuCache(); + + // Ensure cache refreshers runs + builder.Services.AddUnique(); + builder.AddNotificationHandler(); + builder.AddNotificationHandler(); + } private readonly TestVariationContextAccessor _variationContextAccessor = new(); diff --git a/tests/Umbraco.Tests.UnitTests/TestHelpers/PublishedSnapshotServiceTestBase.cs b/tests/Umbraco.Tests.UnitTests/TestHelpers/PublishedSnapshotServiceTestBase.cs index 54b6abea9a..b4d315a699 100644 --- a/tests/Umbraco.Tests.UnitTests/TestHelpers/PublishedSnapshotServiceTestBase.cs +++ b/tests/Umbraco.Tests.UnitTests/TestHelpers/PublishedSnapshotServiceTestBase.cs @@ -117,20 +117,25 @@ public class PublishedSnapshotServiceTestBase protected static PublishedRouter CreatePublishedRouter( IUmbracoContextAccessor umbracoContextAccessor, IEnumerable contentFinders = null, - IPublishedUrlProvider publishedUrlProvider = null) => new( - Mock.Of>(x => x.CurrentValue == new WebRoutingSettings()), - new ContentFinderCollection(() => contentFinders ?? Enumerable.Empty()), - new TestLastChanceFinder(), - new TestVariationContextAccessor(), - Mock.Of(), - Mock.Of>(), - publishedUrlProvider ?? Mock.Of(), - Mock.Of(), - Mock.Of(), - Mock.Of(), - Mock.Of(), - umbracoContextAccessor, - Mock.Of()); + IPublishedUrlProvider publishedUrlProvider = null, + IDomainCache domainCache = null) + { + return new( + Mock.Of>(x => x.CurrentValue == new WebRoutingSettings()), + new ContentFinderCollection(() => contentFinders ?? Enumerable.Empty()), + new TestLastChanceFinder(), + new TestVariationContextAccessor(), + Mock.Of(), + Mock.Of>(), + publishedUrlProvider ?? Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + umbracoContextAccessor, + Mock.Of(), + domainCache ?? Mock.Of()); + } protected IUmbracoContextAccessor GetUmbracoContextAccessor(string urlAsString) { diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/PublishedContentCacheTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/PublishedContentCacheTests.cs index 9ccb285e53..b3f0abe5e1 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/PublishedContentCacheTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/PublishedContentCacheTests.cs @@ -5,6 +5,7 @@ using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.DeliveryApi; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Services; namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.DeliveryApi; @@ -16,6 +17,8 @@ public class PublishedContentCacheTests : DeliveryApiTests private readonly Guid _contentTwoId = Guid.Parse("4EF11E1E-FB50-4627-8A86-E10ED6F4DCE4"); private IPublishedSnapshotAccessor _publishedSnapshotAccessor = null!; + private IPublishedContentCache _contentCacheMock; + private IDocumentUrlService _documentUrlService; [SetUp] public void Setup() @@ -30,6 +33,14 @@ public class PublishedContentCacheTests : DeliveryApiTests var contentTwoMock = new Mock(); ConfigurePublishedContentMock(contentTwoMock, _contentTwoId, "Content Two", "content-two", contentTypeTwoMock.Object, Array.Empty()); + var documentUrlService = new Mock(); + documentUrlService + .Setup(x => x.GetDocumentKeyByRoute("content-one", It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(_contentOneId); + documentUrlService + .Setup(x => x.GetDocumentKeyByRoute("content-two", It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(_contentTwoId); + var contentCacheMock = new Mock(); contentCacheMock .Setup(m => m.GetByRoute(It.IsAny(), "content-one", null, null)) @@ -52,12 +63,14 @@ public class PublishedContentCacheTests : DeliveryApiTests publishedSnapshotAccessorMock.Setup(m => m.TryGetPublishedSnapshot(out publishedSnapshot)).Returns(true); _publishedSnapshotAccessor = publishedSnapshotAccessorMock.Object; + _contentCacheMock = contentCacheMock.Object; + _documentUrlService = documentUrlService.Object; } [Test] public void PublishedContentCache_CanGetById() { - var publishedContentCache = new ApiPublishedContentCache(_publishedSnapshotAccessor, CreateRequestPreviewService(), CreateDeliveryApiSettings()); + var publishedContentCache = new ApiPublishedContentCache(CreateRequestPreviewService(), CreateRequestCultureService(), CreateDeliveryApiSettings(), _documentUrlService, _contentCacheMock); var content = publishedContentCache.GetById(_contentOneId); Assert.IsNotNull(content); Assert.AreEqual(_contentOneId, content.Key); @@ -68,7 +81,7 @@ public class PublishedContentCacheTests : DeliveryApiTests [Test] public void PublishedContentCache_CanGetByRoute() { - var publishedContentCache = new ApiPublishedContentCache(_publishedSnapshotAccessor, CreateRequestPreviewService(), CreateDeliveryApiSettings()); + var publishedContentCache = new ApiPublishedContentCache(CreateRequestPreviewService(), CreateRequestCultureService(), CreateDeliveryApiSettings(), _documentUrlService, _contentCacheMock); var content = publishedContentCache.GetByRoute("content-two"); Assert.IsNotNull(content); Assert.AreEqual(_contentTwoId, content.Key); @@ -79,7 +92,7 @@ public class PublishedContentCacheTests : DeliveryApiTests [Test] public void PublishedContentCache_CanGetByIds() { - var publishedContentCache = new ApiPublishedContentCache(_publishedSnapshotAccessor, CreateRequestPreviewService(), CreateDeliveryApiSettings()); + var publishedContentCache = new ApiPublishedContentCache(CreateRequestPreviewService(), CreateRequestCultureService(), CreateDeliveryApiSettings(), _documentUrlService, _contentCacheMock); var content = publishedContentCache.GetByIds(new[] { _contentOneId, _contentTwoId }).ToArray(); Assert.AreEqual(2, content.Length); Assert.AreEqual(_contentOneId, content.First().Key); @@ -91,7 +104,7 @@ public class PublishedContentCacheTests : DeliveryApiTests public void PublishedContentCache_GetById_SupportsDenyList(bool denied) { var denyList = denied ? new[] { "theOtherContentType" } : null; - var publishedContentCache = new ApiPublishedContentCache(_publishedSnapshotAccessor, CreateRequestPreviewService(), CreateDeliveryApiSettings(denyList)); + var publishedContentCache = new ApiPublishedContentCache(CreateRequestPreviewService(), CreateRequestCultureService(), CreateDeliveryApiSettings(denyList), _documentUrlService, _contentCacheMock); var content = publishedContentCache.GetById(_contentTwoId); if (denied) @@ -109,7 +122,7 @@ public class PublishedContentCacheTests : DeliveryApiTests public void PublishedContentCache_GetByRoute_SupportsDenyList(bool denied) { var denyList = denied ? new[] { "theContentType" } : null; - var publishedContentCache = new ApiPublishedContentCache(_publishedSnapshotAccessor, CreateRequestPreviewService(), CreateDeliveryApiSettings(denyList)); + var publishedContentCache = new ApiPublishedContentCache(CreateRequestPreviewService(), CreateRequestCultureService(), CreateDeliveryApiSettings(denyList), _documentUrlService, _contentCacheMock); var content = publishedContentCache.GetByRoute("content-one"); if (denied) @@ -127,7 +140,7 @@ public class PublishedContentCacheTests : DeliveryApiTests public void PublishedContentCache_GetByIds_SupportsDenyList(string deniedContentType) { var denyList = new[] { deniedContentType }; - var publishedContentCache = new ApiPublishedContentCache(_publishedSnapshotAccessor, CreateRequestPreviewService(), CreateDeliveryApiSettings(denyList)); + var publishedContentCache = new ApiPublishedContentCache(CreateRequestPreviewService(), CreateRequestCultureService(), CreateDeliveryApiSettings(denyList), _documentUrlService, _contentCacheMock); var content = publishedContentCache.GetByIds(new[] { _contentOneId, _contentTwoId }).ToArray(); Assert.AreEqual(1, content.Length); @@ -145,7 +158,7 @@ public class PublishedContentCacheTests : DeliveryApiTests public void PublishedContentCache_GetById_CanRetrieveContentTypesOutsideTheDenyList() { var denyList = new[] { "theContentType" }; - var publishedContentCache = new ApiPublishedContentCache(_publishedSnapshotAccessor, CreateRequestPreviewService(), CreateDeliveryApiSettings(denyList)); + var publishedContentCache = new ApiPublishedContentCache(CreateRequestPreviewService(), CreateRequestCultureService(), CreateDeliveryApiSettings(denyList), _documentUrlService, _contentCacheMock); var content = publishedContentCache.GetById(_contentTwoId); Assert.IsNotNull(content); Assert.AreEqual(_contentTwoId, content.Key); @@ -157,7 +170,7 @@ public class PublishedContentCacheTests : DeliveryApiTests public void PublishedContentCache_GetByRoute_CanRetrieveContentTypesOutsideTheDenyList() { var denyList = new[] { "theOtherContentType" }; - var publishedContentCache = new ApiPublishedContentCache(_publishedSnapshotAccessor, CreateRequestPreviewService(), CreateDeliveryApiSettings(denyList)); + var publishedContentCache = new ApiPublishedContentCache(CreateRequestPreviewService(), CreateRequestCultureService(), CreateDeliveryApiSettings(denyList), _documentUrlService, _contentCacheMock); var content = publishedContentCache.GetByRoute("content-one"); Assert.IsNotNull(content); Assert.AreEqual(_contentOneId, content.Key); @@ -169,7 +182,7 @@ public class PublishedContentCacheTests : DeliveryApiTests public void PublishedContentCache_GetByIds_CanDenyAllRequestedContent() { var denyList = new[] { "theContentType", "theOtherContentType" }; - var publishedContentCache = new ApiPublishedContentCache(_publishedSnapshotAccessor, CreateRequestPreviewService(), CreateDeliveryApiSettings(denyList)); + var publishedContentCache = new ApiPublishedContentCache(CreateRequestPreviewService(), CreateRequestCultureService(), CreateDeliveryApiSettings(denyList), _documentUrlService, _contentCacheMock); var content = publishedContentCache.GetByIds(new[] { _contentOneId, _contentTwoId }).ToArray(); Assert.IsEmpty(content); } @@ -178,11 +191,18 @@ public class PublishedContentCacheTests : DeliveryApiTests public void PublishedContentCache_DenyListIsCaseInsensitive() { var denyList = new[] { "THEcontentTYPE" }; - var publishedContentCache = new ApiPublishedContentCache(_publishedSnapshotAccessor, CreateRequestPreviewService(), CreateDeliveryApiSettings(denyList)); + var publishedContentCache = new ApiPublishedContentCache(CreateRequestPreviewService(), CreateRequestCultureService(), CreateDeliveryApiSettings(denyList), _documentUrlService, _contentCacheMock); var content = publishedContentCache.GetByRoute("content-one"); Assert.IsNull(content); } + private IRequestCultureService CreateRequestCultureService() + { + var mock = new Mock(); + + return mock.Object; + } + private IRequestPreviewService CreateRequestPreviewService(bool isPreview = false) { var previewServiceMock = new Mock(); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/DomainsAndCulturesTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/DomainsAndCulturesTests.cs index 3945e2346d..9f0e2cbbf4 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/DomainsAndCulturesTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/DomainsAndCulturesTests.cs @@ -305,7 +305,7 @@ public class DomainsAndCulturesTests : UrlRoutingTestBase GlobalSettings.HideTopLevelNodeFromPath = false; var umbracoContextAccessor = GetUmbracoContextAccessor(inputUrl); - var publishedRouter = CreatePublishedRouter(umbracoContextAccessor); + var publishedRouter = CreatePublishedRouter(umbracoContextAccessor, domainCache: umbracoContextAccessor.GetRequiredUmbracoContext().PublishedSnapshot.Domains); var umbracoContext = umbracoContextAccessor.GetRequiredUmbracoContext(); var frequest = await publishedRouter.CreateRequestAsync(umbracoContext.CleanedUmbracoUrl); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/PublishedRouterTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/PublishedRouterTests.cs index da5c641b2a..b56e55ff67 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/PublishedRouterTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/PublishedRouterTests.cs @@ -10,6 +10,7 @@ using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Logging; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Web; @@ -35,7 +36,9 @@ public class PublishedRouterTests Mock.Of(), Mock.Of(), umbracoContextAccessor, - Mock.Of()); + Mock.Of(), + Mock.Of() + ); private IUmbracoContextAccessor GetUmbracoContextAccessor() {