Updates and support for re-use of CMS logic in Deploy (#14990)

* Adds additional parameter to IFileSource.GetFilesAsync allowing the caller to continue on a file not found exception.

* Moved redirect tracking and creation logic out of handler into a service, allowing for re-use in Deploy.

* Reverted breaking change in IFileSource by obsoleting old method and
This commit is contained in:
Andy Butland
2023-10-31 12:38:44 +01:00
committed by GitHub
parent 53b87cb78c
commit d08d141bcf
5 changed files with 179 additions and 164 deletions

View File

@@ -68,18 +68,24 @@ public interface IFileSource
/// <param name="fileTypes">A collection of file types which can store the files.</param>
void GetFiles(IEnumerable<StringUdi> udis, IFileTypeCollection fileTypes);
// TODO (V14): Remove obsolete method and default implementation for GetFilesAsync overloads.
/// <summary>
/// Gets files and store them using a file store.
/// </summary>
/// <param name="udis">The udis of the files to get.</param>
/// <param name="fileTypes">A collection of file types which can store the files.</param>
/// <param name="token">A cancellation token.</param>
[Obsolete("Please use the method overload taking all parameters. This method overload will be removed in Umbraco 14.")]
Task GetFilesAsync(IEnumerable<StringUdi> udis, IFileTypeCollection fileTypes, CancellationToken token);
///// <summary>
///// Gets the content of a file as a bytes array.
///// </summary>
///// <param name="Udi">A file entity identifier.</param>
///// <returns>A byte array containing the file content.</returns>
// byte[] GetFileBytes(StringUdi Udi);
/// <summary>
/// Gets files and store them using a file store.
/// </summary>
/// <param name="udis">The udis of the files to get.</param>
/// <param name="fileTypes">A collection of file types which can store the files.</param>
/// <param name="continueOnFileNotFound">A flag indicating whether to continue if a file isn't found or to stop and throw a FileNotFoundException.</param>
/// <param name="token">A cancellation token.</param>
Task GetFilesAsync(IEnumerable<StringUdi> udis, IFileTypeCollection fileTypes, bool continueOnFileNotFound, CancellationToken token)
=> GetFilesAsync(udis, fileTypes, token);
}

View File

@@ -0,0 +1,23 @@
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Core.Routing
{
/// <summary>
/// Determines and records redirects for a content item following an update that may change it's public URL.
/// </summary>
public interface IRedirectTracker
{
/// <summary>
/// Stores the existing routes for a content item before update.
/// </summary>
/// <param name="entity">The content entity updated.</param>
/// <param name="oldRoutes">The dictionary of routes for population.</param>
void StoreOldRoute(IContent entity, Dictionary<(int ContentId, string Culture), (Guid ContentKey, string OldRoute)> oldRoutes);
/// <summary>
/// Creates appropriate redirects for the content item following an update.
/// </summary>
/// <param name="oldRoutes">The populated dictionary of old routes;</param>
void CreateRedirects(IDictionary<(int ContentId, string Culture), (Guid ContentKey, string OldRoute)> oldRoutes);
}
}

View File

@@ -52,6 +52,7 @@ using Umbraco.Cms.Infrastructure.Migrations.PostMigrations;
using Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_8_0_0.DataTypes;
using Umbraco.Cms.Infrastructure.Persistence;
using Umbraco.Cms.Infrastructure.Persistence.Mappers;
using Umbraco.Cms.Infrastructure.Routing;
using Umbraco.Cms.Infrastructure.Runtime;
using Umbraco.Cms.Infrastructure.Runtime.RuntimeModeValidators;
using Umbraco.Cms.Infrastructure.Scoping;
@@ -216,6 +217,9 @@ public static partial class UmbracoBuilderExtensions
builder.Services.AddSingleton<ICronTabParser, NCronTabParser>();
builder.Services.AddTransient<INodeCountService, NodeCountService>();
builder.Services.AddSingleton<IRedirectTracker, RedirectTracker>();
builder.AddInstaller();
// Services required to run background jobs (with out the handler)

View File

@@ -0,0 +1,125 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core;
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;
using Umbraco.Extensions;
namespace Umbraco.Cms.Infrastructure.Routing
{
internal class RedirectTracker : IRedirectTracker
{
private readonly IUmbracoContextFactory _umbracoContextFactory;
private readonly IVariationContextAccessor _variationContextAccessor;
private readonly ILocalizationService _localizationService;
private readonly IRedirectUrlService _redirectUrlService;
private readonly ILogger<RedirectTracker> _logger;
public RedirectTracker(
IUmbracoContextFactory umbracoContextFactory,
IVariationContextAccessor variationContextAccessor,
ILocalizationService localizationService,
IRedirectUrlService redirectUrlService,
ILogger<RedirectTracker> logger)
{
_umbracoContextFactory = umbracoContextFactory;
_variationContextAccessor = variationContextAccessor;
_localizationService = localizationService;
_redirectUrlService = redirectUrlService;
_logger = logger;
}
/// <inheritdoc/>
public void StoreOldRoute(IContent entity, Dictionary<(int ContentId, string Culture), (Guid ContentKey, string OldRoute)> oldRoutes)
{
using UmbracoContextReference reference = _umbracoContextFactory.EnsureUmbracoContext();
IPublishedContentCache? contentCache = reference.UmbracoContext.Content;
IPublishedContent? entityContent = contentCache?.GetById(entity.Id);
if (entityContent is null)
{
return;
}
// Get the default affected cultures by going up the tree until we find the first culture variant entity (default to no cultures)
var defaultCultures = new Lazy<string[]>(() => entityContent.AncestorsOrSelf().FirstOrDefault(a => a.Cultures.Any())?.Cultures.Keys.ToArray() ?? Array.Empty<string>());
// Get all language ISO codes (in case we're dealing with invariant content with variant ancestors)
var languageIsoCodes = new Lazy<string[]>(() => _localizationService.GetAllLanguages().Select(x => x.IsoCode).ToArray());
foreach (IPublishedContent publishedContent in entityContent.DescendantsOrSelf(_variationContextAccessor))
{
// If this entity defines specific cultures, use those instead of the default ones
IEnumerable<string> cultures = publishedContent.Cultures.Any() ? publishedContent.Cultures.Keys : defaultCultures.Value;
foreach (var culture in cultures)
{
try
{
var route = contentCache?.GetRouteById(publishedContent.Id, culture);
if (IsValidRoute(route))
{
oldRoutes[(publishedContent.Id, culture)] = (publishedContent.Key, route);
}
else if (string.IsNullOrEmpty(culture))
{
// Retry using all languages, if this is invariant but has a variant ancestor.
foreach (string languageIsoCode in languageIsoCodes.Value)
{
route = contentCache?.GetRouteById(publishedContent.Id, languageIsoCode);
if (IsValidRoute(route))
{
oldRoutes[(publishedContent.Id, languageIsoCode)] = (publishedContent.Key, route);
}
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not register redirects because the old route couldn't be retrieved for content ID {ContentId} and culture '{Culture}'.", publishedContent.Id, culture);
}
}
}
}
/// <inheritdoc/>
public void CreateRedirects(IDictionary<(int ContentId, string Culture), (Guid ContentKey, string OldRoute)> oldRoutes)
{
if (!oldRoutes.Any())
{
return;
}
using UmbracoContextReference reference = _umbracoContextFactory.EnsureUmbracoContext();
IPublishedContentCache? contentCache = reference.UmbracoContext.Content;
if (contentCache == null)
{
_logger.LogWarning("Could not track redirects because there is no published content cache available on the current published snapshot.");
return;
}
foreach (((int contentId, string culture), (Guid contentKey, string oldRoute)) in oldRoutes)
{
try
{
var newRoute = contentCache.GetRouteById(contentId, culture);
if (!IsValidRoute(newRoute) || oldRoute == newRoute)
{
continue;
}
_redirectUrlService.Register(oldRoute, contentKey, culture);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not track redirects because the new route couldn't be retrieved for content ID {ContentId} and culture '{Culture}'.", contentId, culture);
}
}
}
private static bool IsValidRoute([NotNullWhen(true)] string? route) => route is not null && !route.StartsWith("err/");
}
}

View File

@@ -1,17 +1,11 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Services;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.Routing;
@@ -30,44 +24,17 @@ public sealed class RedirectTrackingHandler :
INotificationHandler<ContentMovedNotification>
{
private const string NotificationStateKey = "Umbraco.Cms.Core.Routing.RedirectTrackingHandler";
private readonly ILogger<RedirectTrackingHandler> _logger;
private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor;
private readonly IRedirectUrlService _redirectUrlService;
private readonly IVariationContextAccessor _variationContextAccessor;private readonly ILocalizationService _localizationService;
private readonly IOptionsMonitor<WebRoutingSettings> _webRoutingSettings;
private readonly IRedirectTracker _redirectTracker;
public RedirectTrackingHandler(
ILogger<RedirectTrackingHandler> logger,
IOptionsMonitor<WebRoutingSettings> webRoutingSettings,
IPublishedSnapshotAccessor publishedSnapshotAccessor,
IRedirectUrlService redirectUrlService,
IVariationContextAccessor variationContextAccessor,
ILocalizationService localizationService){
_logger = logger;
IRedirectTracker redirectTracker)
{
_webRoutingSettings = webRoutingSettings;
_publishedSnapshotAccessor = publishedSnapshotAccessor;
_redirectUrlService = redirectUrlService;
_variationContextAccessor = variationContextAccessor;
_localizationService = localizationService;
}
[Obsolete("Use ctor with all params")]
public RedirectTrackingHandler(
ILogger<RedirectTrackingHandler> logger,
IOptionsMonitor<WebRoutingSettings> webRoutingSettings,
IPublishedSnapshotAccessor publishedSnapshotAccessor,
IRedirectUrlService redirectUrlService,
IVariationContextAccessor variationContextAccessor)
:this(
logger,
webRoutingSettings,
publishedSnapshotAccessor,
redirectUrlService,
variationContextAccessor,
StaticServiceProvider.Instance.GetRequiredService<ILocalizationService>())
{
}
_redirectTracker = redirectTracker;
}
public void Handle(ContentMovedNotification notification) => CreateRedirectsForOldRoutes(notification);
@@ -79,150 +46,40 @@ public sealed class RedirectTrackingHandler :
public void Handle(ContentPublishingNotification notification) =>
StoreOldRoutes(notification.PublishedEntities, notification);
private static bool IsNotRoute(string? route) =>
// null if content not found
// err/- if collision or anomaly or ...
route == null || route.StartsWith("err/");
private void StoreOldRoutes(IEnumerable<IContent> entities, IStatefulNotification notification)
{
// don't let the notification handlers kick in if Redirect Tracking is turned off in the config
// Don't let the notification handlers kick in if redirect tracking is turned off in the config.
if (_webRoutingSettings.CurrentValue.DisableRedirectUrlTracking)
{
return;
}
OldRoutesDictionary oldRoutes = GetOldRoutes(notification);
Dictionary<(int ContentId, string Culture), (Guid ContentKey, string OldRoute)> oldRoutes = GetOldRoutes(notification);
foreach (IContent entity in entities)
{
StoreOldRoute(entity, oldRoutes);
_redirectTracker.StoreOldRoute(entity, oldRoutes);
}
}
private void CreateRedirectsForOldRoutes(IStatefulNotification notification)
{
// don't let the notification handlers kick in if Redirect Tracking is turned off in the config
// Don't let the notification handlers kick in if redirect tracking is turned off in the config.
if (_webRoutingSettings.CurrentValue.DisableRedirectUrlTracking)
{
return;
}
OldRoutesDictionary oldRoutes = GetOldRoutes(notification);
CreateRedirects(oldRoutes);
Dictionary<(int ContentId, string Culture), (Guid ContentKey, string OldRoute)> oldRoutes = GetOldRoutes(notification);
_redirectTracker.CreateRedirects(oldRoutes);
}
private OldRoutesDictionary GetOldRoutes(IStatefulNotification notification)
private Dictionary<(int ContentId, string Culture), (Guid ContentKey, string OldRoute)> GetOldRoutes(IStatefulNotification notification)
{
if (notification.State.ContainsKey(NotificationStateKey) == false)
{
notification.State[NotificationStateKey] = new OldRoutesDictionary();
notification.State[NotificationStateKey] = new Dictionary<(int ContentId, string Culture), (Guid ContentKey, string OldRoute)>();
}
return (OldRoutesDictionary?)notification.State[NotificationStateKey] ?? new OldRoutesDictionary();
}
private void StoreOldRoute(IContent entity, OldRoutesDictionary oldRoutes)
{
if (!_publishedSnapshotAccessor.TryGetPublishedSnapshot(out IPublishedSnapshot? publishedSnapshot))
{
return;
}
IPublishedContentCache? contentCache = publishedSnapshot?.Content;
IPublishedContent? entityContent = contentCache?.GetById(entity.Id);
if (entityContent is null)
{
return;
}
// get the default affected cultures by going up the tree until we find the first culture variant entity (default to no cultures)
var defaultCultures = entityContent.AncestorsOrSelf().FirstOrDefault(a => a.Cultures.Any())?.Cultures.Keys
.ToArray()
?? Array.Empty<string>();
foreach (IPublishedContent publishedContent in entityContent.DescendantsOrSelf(_variationContextAccessor))
{
// if this entity defines specific cultures, use those instead of the default ones
IEnumerable<string> cultures =
publishedContent.Cultures.Any() ? publishedContent.Cultures.Keys : defaultCultures;
foreach (var culture in cultures)
{
var route = contentCache?.GetRouteById(publishedContent.Id, culture);
if (!IsNotRoute(route))
{
oldRoutes[new ContentIdAndCulture(publishedContent.Id, culture)] = new ContentKeyAndOldRoute(publishedContent.Key, route!);
}
else if (string.IsNullOrEmpty(culture))
{
// Retry using all languages, if this is invariant but has a variant ancestor
var languages = _localizationService.GetAllLanguages();
foreach (var language in languages)
{
route = contentCache?.GetRouteById(publishedContent.Id, language.IsoCode);
if (!IsNotRoute(route))
{
oldRoutes[new ContentIdAndCulture(publishedContent.Id, language.IsoCode)] =
new ContentKeyAndOldRoute(publishedContent.Key, route!);
}
}
}}
}
}
private void CreateRedirects(OldRoutesDictionary oldRoutes)
{
if (!_publishedSnapshotAccessor.TryGetPublishedSnapshot(out IPublishedSnapshot? publishedSnapshot))
{
return;
}
IPublishedContentCache? contentCache = publishedSnapshot?.Content;
if (contentCache == null)
{
_logger.LogWarning("Could not track redirects because there is no current published snapshot available.");
return;
}
foreach (KeyValuePair<ContentIdAndCulture, ContentKeyAndOldRoute> oldRoute in oldRoutes)
{
var newRoute = contentCache.GetRouteById(oldRoute.Key.ContentId, oldRoute.Key.Culture);
if (IsNotRoute(newRoute) || oldRoute.Value.OldRoute == newRoute)
{
continue;
}
_redirectUrlService.Register(oldRoute.Value.OldRoute, oldRoute.Value.ContentKey, oldRoute.Key.Culture);
}
}
private class ContentIdAndCulture : Tuple<int, string>
{
public ContentIdAndCulture(int contentId, string culture)
: base(contentId, culture)
{
}
public int ContentId => Item1;
public string Culture => Item2;
}
private class ContentKeyAndOldRoute : Tuple<Guid, string>
{
public ContentKeyAndOldRoute(Guid contentKey, string oldRoute)
: base(contentKey, oldRoute)
{
}
public Guid ContentKey => Item1;
public string OldRoute => Item2;
}
private class OldRoutesDictionary : Dictionary<ContentIdAndCulture, ContentKeyAndOldRoute>
{
return (Dictionary<(int ContentId, string Culture), (Guid ContentKey, string OldRoute)>?)notification.State[NotificationStateKey]!;
}
}