325 lines
13 KiB
C#
325 lines
13 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using Umbraco.Core;
|
|
using Umbraco.Core.Cache;
|
|
using Umbraco.Core.Configuration;
|
|
using Umbraco.Core.Events;
|
|
using Umbraco.Core.Models;
|
|
using Umbraco.Core.Models.PublishedContent;
|
|
using Umbraco.Core.Publishing;
|
|
using Umbraco.Core.Services;
|
|
using Umbraco.Core.Strings;
|
|
using Umbraco.Core.Sync;
|
|
using Umbraco.Web.Cache;
|
|
using Umbraco.Web.PublishedCache;
|
|
|
|
namespace Umbraco.Web.Routing
|
|
{
|
|
/// <summary>
|
|
/// Implements an Application Event Handler for managing redirect urls tracking.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>when content is renamed or moved, we want to create a permanent 301 redirect from it's old url</para>
|
|
/// <para>not managing domains because we don't know how to do it - changing domains => must create a higher level strategy using rewriting rules probably</para>
|
|
/// <para>recycle bin = moving to and from does nothing: to = the node is gone, where would we redirect? from = same</para>
|
|
/// </remarks>
|
|
public class RedirectTrackingEventHandler : ApplicationEventHandler
|
|
{
|
|
private const string ContextKey1 = "Umbraco.Web.Routing.RedirectTrackingEventHandler.1";
|
|
private const string ContextKey2 = "Umbraco.Web.Routing.RedirectTrackingEventHandler.2";
|
|
private const string ContextKey3 = "Umbraco.Web.Routing.RedirectTrackingEventHandler.3";
|
|
|
|
/// <inheritdoc />
|
|
protected override void ApplicationStarting(UmbracoApplicationBase umbracoApplication, ApplicationContext applicationContext)
|
|
{
|
|
if (UmbracoConfig.For.UmbracoSettings().WebRouting.DisableRedirectUrlTracking)
|
|
{
|
|
ContentFinderResolver.Current.RemoveType<ContentFinderByRedirectUrl>();
|
|
}
|
|
else
|
|
{
|
|
// if any of these dlls are loaded we don't want to run our finder
|
|
var dlls = new[]
|
|
{
|
|
"InfoCaster.Umbraco.UrlTracker",
|
|
"SEOChecker",
|
|
"Simple301",
|
|
"Terabyte.Umbraco.Modules.PermanentRedirect",
|
|
"CMUmbracoTools",
|
|
"PWUrlRedirect"
|
|
};
|
|
|
|
// assuming all assemblies have been loaded already
|
|
// check if any of them matches one of the above dlls
|
|
var found = AppDomain.CurrentDomain.GetAssemblies()
|
|
.Select(x => x.FullName.Split(',')[0])
|
|
.Any(x => dlls.Contains(x));
|
|
if (found)
|
|
ContentFinderResolver.Current.RemoveType<ContentFinderByRedirectUrl>();
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
protected override void ApplicationStarted(UmbracoApplicationBase umbracoApplication, ApplicationContext applicationContext)
|
|
{
|
|
// don't let the event handlers kick in if Redirect Tracking is turned off in the config
|
|
if (UmbracoConfig.For.UmbracoSettings().WebRouting.DisableRedirectUrlTracking) return;
|
|
|
|
// events are weird
|
|
// on 'published' we 'could' get the old or the new route depending on event handlers order
|
|
// so it is not reliable. getting the old route in 'publishing' to be sure and storing in http
|
|
// context. then for the same reason, we have to process these old items only when the cache
|
|
// is ready
|
|
// when moving, the moved node is also published, which is causing all sorts of troubles with
|
|
// descendants, so when moving, we lock events so that neither 'published' nor 'publishing'
|
|
// are processed more than once
|
|
//
|
|
// this is all verrrry weird but it seems to work
|
|
|
|
ContentService.Publishing += ContentService_Publishing;
|
|
ContentService.Published += ContentService_Published;
|
|
ContentService.Moving += ContentService_Moving;
|
|
ContentService.Moved += ContentService_Moved;
|
|
PageCacheRefresher.CacheUpdated += PageCacheRefresher_CacheUpdated;
|
|
|
|
// kill all redirects once a content is deleted
|
|
//ContentService.Deleted += ContentService_Deleted;
|
|
// BUT, doing it here would prevent content deletion due to FK
|
|
// so the rows are actually deleted by the ContentRepository (see GetDeleteClauses)
|
|
|
|
// rolled back items have to be published, so publishing will take care of that
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tracks a documents URLs during publishing in the current request
|
|
/// </summary>
|
|
private static Dictionary<int, Tuple<Guid, string>> OldRoutes
|
|
{
|
|
get
|
|
{
|
|
var oldRoutes = RequestCache.GetCacheItem<Dictionary<int, Tuple<Guid, string>>>(
|
|
ContextKey3,
|
|
() => new Dictionary<int, Tuple<Guid, string>>());
|
|
return oldRoutes;
|
|
}
|
|
}
|
|
|
|
private static bool LockedEvents
|
|
{
|
|
get
|
|
{
|
|
return Moving && RequestCache.GetCacheItem(ContextKey2) != null;
|
|
}
|
|
set
|
|
{
|
|
if (Moving && value)
|
|
{
|
|
//this forces true into the cache
|
|
RequestCache.GetCacheItem(ContextKey2, () => true);
|
|
}
|
|
else
|
|
{
|
|
RequestCache.ClearCacheItem(ContextKey2);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static bool Moving
|
|
{
|
|
get { return RequestCache.GetCacheItem(ContextKey1) != null; }
|
|
set
|
|
{
|
|
if (value)
|
|
{
|
|
//this forces true into the cache
|
|
RequestCache.GetCacheItem(ContextKey1, () => true);
|
|
}
|
|
else
|
|
{
|
|
RequestCache.ClearCacheItem(ContextKey1);
|
|
RequestCache.ClearCacheItem(ContextKey2);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Before the items are published, we need to get it's current URL before it changes
|
|
/// </summary>
|
|
/// <param name="sender"></param>
|
|
/// <param name="args"></param>
|
|
private static void ContentService_Publishing(IPublishingStrategy sender, PublishEventArgs<IContent> args)
|
|
{
|
|
if (LockedEvents) return;
|
|
|
|
var contentCache = GetPublishedCache();
|
|
if (contentCache == null) return;
|
|
|
|
// prepare entities - remove chances of duplicates
|
|
var entities = PrepareEntities(args.PublishedEntities);
|
|
|
|
foreach (var entity in entities)
|
|
{
|
|
// for each entity, we want to save the 'old route' of any impacted entity.
|
|
//
|
|
// previously, we'd save the routes of all descendants - super safe but has an
|
|
// impact on perfs - assuming that the descendant routes will NOT change if the
|
|
// entity's segment does not change (else... outside of the scope of the simple,
|
|
// built -in, tracker) then we can compare the entity's old and new segments
|
|
// and avoid processing the descendants
|
|
|
|
var process = true;
|
|
if (Moving == false) // always process descendants when moving
|
|
{
|
|
// SD: in 7.5.0 we re-lookup the entity that is published, which gets its
|
|
// current state in the DB, which we use to get the 'old' segment. In the
|
|
// future this will certainly cause some problems, to fix this we'd need to
|
|
// change the IUrlSegmentProvider to support being able to determine if a
|
|
// segment is going to change for an entity. See notes in IUrlSegmentProvider.
|
|
|
|
var oldEntity = ApplicationContext.Current.Services.ContentService.GetById(entity.Id);
|
|
if (oldEntity == null) continue;
|
|
var oldSegment = oldEntity.GetUrlSegment();
|
|
var newSegment = entity.GetUrlSegment();
|
|
process = oldSegment != newSegment;
|
|
}
|
|
|
|
// skip if no segment change
|
|
if (process == false) continue;
|
|
|
|
// else save routes for all descendants
|
|
var entityContent = contentCache.GetById(entity.Id);
|
|
if (entityContent == null) continue;
|
|
foreach (var x in entityContent.DescendantsOrSelf())
|
|
{
|
|
var route = contentCache.GetRouteById(x.Id);
|
|
if (IsNotRoute(route)) continue;
|
|
var wk = UnwrapToKey(x);
|
|
if (wk == null) continue;
|
|
|
|
OldRoutes[x.Id] = Tuple.Create(wk.Key, route);
|
|
}
|
|
}
|
|
|
|
LockedEvents = true; // we only want to see the "first batch"
|
|
}
|
|
|
|
private static IEnumerable<IContent> PrepareEntities(IEnumerable<IContent> eventEntities)
|
|
{
|
|
var entities = new List<IContent>();
|
|
foreach (var e in eventEntities.OrderBy(x => x.Level))
|
|
{
|
|
var pathIds = e.Path.Split(',').Select(int.Parse);
|
|
if (entities.Any(x => pathIds.Contains(x.Id))) continue;
|
|
entities.Add(e);
|
|
}
|
|
return entities;
|
|
}
|
|
|
|
private static IPublishedContentWithKey UnwrapToKey(IPublishedContent content)
|
|
{
|
|
if (content == null) return null;
|
|
var withKey = content as IPublishedContentWithKey;
|
|
if (withKey != null) return withKey;
|
|
|
|
var extended = content as PublishedContentExtended;
|
|
while (extended != null)
|
|
extended = (content = extended.Unwrap()) as PublishedContentExtended;
|
|
|
|
withKey = content as IPublishedContentWithKey;
|
|
return withKey;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Executed when the cache updates, which means we can know what the new URL is for a given document
|
|
/// </summary>
|
|
/// <param name="sender"></param>
|
|
/// <param name="cacheRefresherEventArgs"></param>
|
|
private void PageCacheRefresher_CacheUpdated(PageCacheRefresher sender, CacheRefresherEventArgs cacheRefresherEventArgs)
|
|
{
|
|
// only on master / single, not on slaves!
|
|
if (IsSlaveServer) return;
|
|
|
|
// simply getting OldRoutes will register it in the request cache,
|
|
// so whatever we do with it, try/finally it to ensure it's cleared
|
|
|
|
try
|
|
{
|
|
foreach (var oldRoute in OldRoutes)
|
|
{
|
|
// assuming we cannot have 'CacheUpdated' for only part of the infos else we'd need
|
|
// to set a flag in 'Published' to indicate which entities have been refreshed ok
|
|
CreateRedirect(oldRoute.Key, oldRoute.Value.Item1, oldRoute.Value.Item2);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
OldRoutes.Clear();
|
|
RequestCache.ClearCacheItem(ContextKey3);
|
|
}
|
|
}
|
|
|
|
private static void ContentService_Published(IPublishingStrategy sender, PublishEventArgs<IContent> e)
|
|
{
|
|
// look note in CacheUpdated
|
|
// we might want to set a flag on the entities we are seeing here
|
|
}
|
|
|
|
private static void ContentService_Moving(IContentService sender, MoveEventArgs<IContent> e)
|
|
{
|
|
Moving = true;
|
|
}
|
|
|
|
private static void ContentService_Moved(IContentService sender, MoveEventArgs<IContent> e)
|
|
{
|
|
Moving = false;
|
|
LockedEvents = false;
|
|
}
|
|
|
|
private static void CreateRedirect(int contentId, Guid contentKey, string oldRoute)
|
|
{
|
|
|
|
var contentCache = GetPublishedCache();
|
|
if (contentCache == null) return;
|
|
|
|
var newRoute = contentCache.GetRouteById(contentId);
|
|
if (IsNotRoute(newRoute) || oldRoute == newRoute) return;
|
|
var redirectUrlService = ApplicationContext.Current.Services.RedirectUrlService;
|
|
redirectUrlService.Register(oldRoute, contentKey);
|
|
}
|
|
|
|
private static bool IsNotRoute(string route)
|
|
{
|
|
// null if content not found
|
|
// err/- if collision or anomaly or ...
|
|
return route == null || route.StartsWith("err/");
|
|
}
|
|
|
|
// gets a value indicating whether server is 'slave'
|
|
private static bool IsSlaveServer
|
|
{
|
|
get
|
|
{
|
|
var serverRole = ApplicationContext.Current.GetCurrentServerRole();
|
|
return serverRole != ServerRole.Master && serverRole != ServerRole.Single;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the current request cache to persist the values between handlers
|
|
/// </summary>
|
|
private static ContextualPublishedContentCache GetPublishedCache()
|
|
{
|
|
return UmbracoContext.Current == null ? null : UmbracoContext.Current.ContentCache;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the current request cache to persist the values between handlers
|
|
/// </summary>
|
|
private static ICacheProvider RequestCache
|
|
{
|
|
get { return ApplicationContext.Current.ApplicationCache.RequestCache; }
|
|
}
|
|
}
|
|
}
|