I’m working on an Umbraco 16 project where a requirement is to customise the URLs of certain pages. For example, prepending the date a blog post was created to its URL segment.
I’ve followed the approach in the Umbraco documentation for creating a UrlProvider to generate the custom URL, and added a ContentFinder for detecting the custom URL format and serving the appropriate content item.
In testing, we’ve found that items with custom URLs aren’t handled gracefully by Umbraco’s automatic redirect generation.
Steps to reproduce
- Create and publish a blog post
- Rename the blog post and publish it
- Rename the blog post back to its original name and publish it
- Attempt to view the blog post
Expected outcome
Navigating to either URL loads the blog post.
Actual outcome
Navigating to either URL results in a ERR_TOO_MANY_REDIRECTS infinite redirect loop
Umbraco’s Redirect URL Management tab shows a clear redirect loop:
- Original URL => Altered URL
- Altered URL => Original URL
Deleting the automatically-generated redirects (or disabling the feature entirely) works around the issue, but I don’t think either is desirable for a live environment.
Is there a way to make Umbraco’s automatic redirect feature aware of UrlProvider-customised URLs, or some other way to make sure that this use case is handled gracefully?
BlogPostPageUrlProvider.cs
using System.Globalization;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Configuration.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.Services.Navigation;
using Umbraco.Cms.Core.Web;
using Umbraco.Cms.Web.Common.PublishedModels;
namespace UmbracoSite.UrlProviders
{
public class BlogPostPageUrlProvider : NewDefaultUrlProvider
{
public BlogPostPageUrlProvider(
IOptionsMonitor<RequestHandlerSettings> requestSettings,
ILogger<DefaultUrlProvider> logger,
ISiteDomainMapper siteDomainMapper,
IUmbracoContextAccessor umbracoContextAccessor,
UriUtility uriUtility,
ILocalizationService localizationService,
IPublishedContentCache publishedContentCache,
IDomainCache domainCache,
IIdKeyMap idKeyMap,
IDocumentUrlService documentUrlService,
IDocumentNavigationQueryService navigationQueryService,
IPublishedContentStatusFilteringService publishedContentStatusFilteringService,
ILanguageService languageService)
: base(requestSettings, logger, siteDomainMapper, umbracoContextAccessor, uriUtility, localizationService, publishedContentCache, domainCache, idKeyMap, documentUrlService, navigationQueryService, publishedContentStatusFilteringService, languageService)
{
}
public override UrlInfo? GetUrl(IPublishedContent content, UrlMode mode, string? culture, Uri current)
{
if (content == null || content is not BlogPostPage)
{
return null;
}
var baseUrlInfo = base.GetUrl(content, mode, culture, current);
if (baseUrlInfo is null)
{
return null;
}
var publishDate = content.Value<DateTime?>("publishDate", culture);
if (publishDate is null)
{
return baseUrlInfo;
}
var currentUrl = baseUrlInfo.Text;
if (!Uri.TryCreate(currentUrl, UriKind.RelativeOrAbsolute, out var uri))
{
return baseUrlInfo;
}
var schemeHost = string.Empty;
var path = currentUrl;
if (uri.IsAbsoluteUri)
{
schemeHost = uri.GetLeftPart(UriPartial.Authority);
path = uri.AbsolutePath;
}
var segments = path.TrimEnd('/')
.Split('/', StringSplitOptions.RemoveEmptyEntries)
.ToList();
if (segments.Count == 0)
{
return baseUrlInfo;
}
segments.Insert(segments.Count - 1, publishDate.Value.ToString("yyyy/MM/dd", CultureInfo.InvariantCulture));
var newPath = "/" + string.Join("/", segments);
var finalUrl = (schemeHost + newPath).TrimEnd('/');
return new UrlInfo(finalUrl, true, culture);
}
}
}
BlogPostPageContentFinder.cs
using System.Text.RegularExpressions;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Routing;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Web;
using Umbraco.Cms.Web.Common.PublishedModels;
namespace UmbracoSite.ContentFinders
{
public class BlogPostPageContentFinder : IContentFinder
{
private static readonly Regex DatePathRegex = new Regex(@"/(?<year>\d{4})/(?<month>\d{2})/(?<day>\d{2})/");
private readonly IUmbracoContextAccessor _umbracoContextAccessor;
private readonly IDocumentUrlService _documentUrlService;
private readonly IPublishedContentCache _publishedContentCache;
public BlogPostPageContentFinder(IUmbracoContextAccessor umbracoContextAccessor, IDocumentUrlService documentUrlService, IPublishedContentCache publishedContentCache)
{
_umbracoContextAccessor = umbracoContextAccessor;
_documentUrlService = documentUrlService;
_publishedContentCache = publishedContentCache;
}
public Task<bool> TryFindContent(IPublishedRequestBuilder request)
{
if (!_umbracoContextAccessor.TryGetUmbracoContext(out var umbracoContext))
{
return Task.FromResult(false);
}
var requestedPath = request.Uri.GetAbsolutePathDecoded();
if (!DatePathRegex.IsMatch(requestedPath))
{
return Task.FromResult(false);
}
var strippedPath = DatePathRegex.Replace(requestedPath, "/");
if (request.HasDomain() && !string.IsNullOrWhiteSpace(request.Domain?.Uri.AbsolutePath))
{
strippedPath = strippedPath.TrimStart(request.Domain.Uri.AbsolutePath);
}
var documentKey = _documentUrlService.GetDocumentKeyByRoute(
strippedPath,
request.Culture,
request.Domain?.ContentId,
false);
if (!documentKey.HasValue)
{
return Task.FromResult(false);
}
var content = _publishedContentCache.GetById(documentKey.Value);
if (content == null || content is not BlogPostPage)
{
return Task.FromResult(false);
}
var contentDate = content.Value<DateTime?>("publishDate", request.Culture)?.Date;
var urlDateMatch = DatePathRegex.Match(requestedPath);
if (!urlDateMatch.Success)
{
return Task.FromResult(false);
}
var year = int.Parse(urlDateMatch.Groups["year"].Value);
var month = int.Parse(urlDateMatch.Groups["month"].Value);
var day = int.Parse(urlDateMatch.Groups["day"].Value);
var urlDate = new DateTime(year, month, day).Date;
if (contentDate.HasValue && contentDate.Value != urlDate)
{
return Task.FromResult(false);
}
request.SetPublishedContent(content);
return Task.FromResult(true);
}
}
}