Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions src/Umbraco.Core/Templates/HtmlLocalLinkParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ namespace Umbraco.Cms.Core.Templates;
/// </summary>
public sealed class HtmlLocalLinkParser
{
// needs to support media and document links, order of attributes should not matter nor should other attributes mess with things
// <a type="media" href="/{localLink:7e21a725-b905-4c5f-86dc-8c41ec116e39}" title="media">media</a>
// <a type="document" href="/{localLink:eed5fc6b-96fd-45a5-a0f1-b1adfb483c2f}" title="other page">other page</a>
internal static readonly Regex LocalLinkTagPattern = new(
@"<a\s+(?:(?:(?:type=['""](?<type>document|media)['""].*?(?<locallink>href=[""']/{localLink:(?<guid>[a-fA-F0-9-]+)})[""'])|((?<locallink>href=[""']/{localLink:(?<guid>[a-fA-F0-9-]+)})[""'].*?type=(['""])(?<type>document|media)(?:['""])))|(?:(?:type=['""](?<type>document|media)['""])|(?:(?<locallink>href=[""']/{localLink:[a-fA-F0-9-]+})[""'])))[^>]*>",
RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace);

internal static readonly Regex LocalLinkPattern = new(
@"href=""[/]?(?:\{|\%7B)localLink:([a-zA-Z0-9-://]+)(?:\}|\%7D)",
RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace);
Expand Down Expand Up @@ -105,6 +112,32 @@ public string EnsureInternalLinks(string text)
}

private IEnumerable<(int? intId, GuidUdi? udi, string tagValue)> FindLocalLinkIds(string text)
{
MatchCollection localLinkTagMatches = LocalLinkTagPattern.Matches(text);
foreach (Match linkTag in localLinkTagMatches)
{
if (linkTag.Groups.Count < 1)
{
continue;
}

if (Guid.TryParse(linkTag.Groups["guid"].Value, out Guid guid) is false)
{
continue;
}

yield return (null, new GuidUdi(linkTag.Groups["type"].Value, guid), linkTag.Groups["locallink"].Value);
}

// also return legacy results for values that have not been migrated
foreach ((int? intId, GuidUdi? udi, string tagValue) legacyResult in FindLegacyLocalLinkIds(text))
{
yield return legacyResult;
}
}

// todo remove at some point?
private IEnumerable<(int? intId, GuidUdi? udi, string tagValue)> FindLegacyLocalLinkIds(string text)
{
// Parse internal links
MatchCollection tags = LocalLinkPattern.Matches(text);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,15 @@ private void ReplaceLocalLinks(IPublishedSnapshot publishedSnapshot, Dictionary<
return;
}

if (attributes.ContainsKey("type") is false || attributes["type"] is not string type)
{
type = "unknown";
}

ReplaceLocalLinks(
publishedSnapshot,
href,
type,
route =>
{
attributes["route"] = route;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,9 @@ private void ReplaceLocalLinks(HtmlDocument doc, IPublishedSnapshot publishedSna
foreach (HtmlNode link in links)
{
ReplaceLocalLinks(
publishedSnapshot,
publishedSnapshot,
link.GetAttributeValue("href", string.Empty),
link.GetAttributeValue("type", "unknown"),
route =>
{
link.SetAttributeValue("href", route.Path);
Expand Down
83 changes: 74 additions & 9 deletions src/Umbraco.Infrastructure/DeliveryApi/ApiRichTextParserBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Umbraco.Cms.Core.Models.DeliveryApi;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Templates;

namespace Umbraco.Cms.Infrastructure.DeliveryApi;

Expand All @@ -18,20 +19,35 @@ protected ApiRichTextParserBase(IApiContentRouteBuilder apiContentRouteBuilder,
_apiMediaUrlProvider = apiMediaUrlProvider;
}

protected void ReplaceLocalLinks(IPublishedSnapshot publishedSnapshot, string href, Action<IApiContentRoute> handleContentRoute, Action<string> handleMediaUrl, Action handleInvalidLink)
protected void ReplaceLocalLinks(IPublishedSnapshot publishedSnapshot, string href, string type, Action<IApiContentRoute> handleContentRoute, Action<string> handleMediaUrl, Action handleInvalidLink)
{
ReplaceStatus replaceAttempt = ReplaceLocalLink(publishedSnapshot, href, type, handleContentRoute, handleMediaUrl);
if (replaceAttempt == ReplaceStatus.Success)
{
return;
}

if (replaceAttempt == ReplaceStatus.InvalidEntityType || ReplaceLegacyLocalLink(publishedSnapshot, href, handleContentRoute, handleMediaUrl) == ReplaceStatus.InvalidEntityType)
{
handleInvalidLink();
}
}

private ReplaceStatus ReplaceLocalLink(IPublishedSnapshot publishedSnapshot, string href, string type, Action<IApiContentRoute> handleContentRoute, Action<string> handleMediaUrl)
{
Match match = LocalLinkRegex().Match(href);
if (match.Success is false)
{
return;
return ReplaceStatus.NoMatch;
}

if (UdiParser.TryParse(match.Groups["udi"].Value, out Udi? udi) is false)
if (Guid.TryParse(match.Groups["guid"].Value, out Guid guid) is false)
{
return;
return ReplaceStatus.NoMatch;
}

bool handled = false;
var udi = new GuidUdi(type, guid);

switch (udi.EntityType)
{
case Constants.UdiEntityType.Document:
Expand All @@ -41,26 +57,65 @@ protected void ReplaceLocalLinks(IPublishedSnapshot publishedSnapshot, string hr
: null;
if (route != null)
{
handled = true;
handleContentRoute(route);
return ReplaceStatus.Success;
}

break;
case Constants.UdiEntityType.Media:
IPublishedContent? media = publishedSnapshot.Media?.GetById(udi);
if (media != null)
{
handled = true;
handleMediaUrl(_apiMediaUrlProvider.GetUrl(media));
return ReplaceStatus.Success;
}

break;
}

if(handled is false)
return ReplaceStatus.InvalidEntityType;
}

private ReplaceStatus ReplaceLegacyLocalLink(IPublishedSnapshot publishedSnapshot, string href, Action<IApiContentRoute> handleContentRoute, Action<string> handleMediaUrl)
{
Match match = LegacyLocalLinkRegex().Match(href);
if (match.Success is false)
{
handleInvalidLink();
return ReplaceStatus.NoMatch;
}

if (UdiParser.TryParse(match.Groups["udi"].Value, out Udi? udi) is false)
{
return ReplaceStatus.NoMatch;
}


switch (udi.EntityType)
{
case Constants.UdiEntityType.Document:
IPublishedContent? content = publishedSnapshot.Content?.GetById(udi);
IApiContentRoute? route = content != null
? _apiContentRouteBuilder.Build(content)
: null;
if (route != null)
{
handleContentRoute(route);
return ReplaceStatus.Success;
}

break;
case Constants.UdiEntityType.Media:
IPublishedContent? media = publishedSnapshot.Media?.GetById(udi);
if (media != null)
{
handleMediaUrl(_apiMediaUrlProvider.GetUrl(media));
return ReplaceStatus.Success;
}

break;
}

return ReplaceStatus.InvalidEntityType;
}

protected void ReplaceLocalImages(IPublishedSnapshot publishedSnapshot, string udi, Action<string> handleMediaUrl)
Expand All @@ -80,5 +135,15 @@ protected void ReplaceLocalImages(IPublishedSnapshot publishedSnapshot, string u
}

[GeneratedRegex("{localLink:(?<udi>umb:.+)}")]
private static partial Regex LegacyLocalLinkRegex();

[GeneratedRegex("{localLink:(?<guid>.+)}")]
private static partial Regex LocalLinkRegex();

private enum ReplaceStatus
{
NoMatch,
Success,
InvalidEntityType
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
using Umbraco.Cms.Core.Templates;
using Umbraco.Cms.Tests.Common;
using Umbraco.Cms.Tests.UnitTests.TestHelpers.Objects;
using Umbraco.Extensions;

namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Templates;

Expand All @@ -21,6 +22,32 @@ public class HtmlLocalLinkParserTests
{
[Test]
public void Returns_Udis_From_LocalLinks()
{
var input = @"<p>
<div>
<img src='/media/12312.jpg' data-udi='umb://media/D4B18427A1544721B09AC7692F35C264' />
<a type=""document"" href=""/{localLink:eed5fc6b-96fd-45a5-a0f1-b1adfb483c2f}"" title=""other page"">other page</a>
</div>
</p><p><img src='/media/234234.jpg' data-udi=""umb://media-type/B726D735E4C446D58F703F3FBCFC97A5"" />
<a type=""media"" href=""/{localLink:7e21a725-b905-4c5f-86dc-8c41ec116e39}"" title=""media"">media</a>
</p>";

var umbracoContextAccessor = new TestUmbracoContextAccessor();
var parser = new HtmlLocalLinkParser(umbracoContextAccessor, Mock.Of<IPublishedUrlProvider>());

var result = parser.FindUdisFromLocalLinks(input).ToList();

Assert.Multiple(() =>
{
Assert.AreEqual(2, result.Count);
Assert.Contains(UdiParser.Parse("umb://document/eed5fc6b-96fd-45a5-a0f1-b1adfb483c2f"), result);
Assert.Contains(UdiParser.Parse("umb://media/7e21a725-b905-4c5f-86dc-8c41ec116e39"), result);
});
}

// todo remove at some point and the implementation.
[Test]
public void Returns_Udis_From_Legacy_LocalLinks()
{
var input = @"<p>
<div>
Expand All @@ -36,12 +63,59 @@ public void Returns_Udis_From_LocalLinks()

var result = parser.FindUdisFromLocalLinks(input).ToList();

Assert.AreEqual(2, result.Count);
Assert.AreEqual(UdiParser.Parse("umb://document/C093961595094900AAF9170DDE6AD442"), result[0]);
Assert.AreEqual(UdiParser.Parse("umb://document-type/2D692FCB070B4CDA92FB6883FDBFD6E2"), result[1]);
Assert.Multiple(() =>
{
Assert.AreEqual(2, result.Count);
Assert.Contains(UdiParser.Parse("umb://document/C093961595094900AAF9170DDE6AD442"), result);
Assert.Contains(UdiParser.Parse("umb://document-type/2D692FCB070B4CDA92FB6883FDBFD6E2"), result);
});
}

// todo remove at some point and the implementation.
[Test]
public void Returns_Udis_From_Legacy_And_Current_LocalLinks()
{
var input = @"<p>
<div>
<img src='/media/12312.jpg' data-udi='umb://media/D4B18427A1544721B09AC7692F35C264' />
<a href=""{locallink:umb://document/C093961595094900AAF9170DDE6AD442}"">hello</a>
</div>
</p><p><img src='/media/234234.jpg' data-udi=""umb://media-type/B726D735E4C446D58F703F3FBCFC97A5"" />
<a href=""{locallink:umb://document-type/2D692FCB070B4CDA92FB6883FDBFD6E2}"">hello</a>
</p>
<p>
<div>
<img src='/media/12312.jpg' data-udi='umb://media/D4B18427A1544721B09AC7692F35C264' />
<a type=""document"" href=""/{localLink:eed5fc6b-96fd-45a5-a0f1-b1adfb483c2f}"" title=""other page"">other page</a>
</div>
</p><p><img src='/media/234234.jpg' data-udi=""umb://media-type/B726D735E4C446D58F703F3FBCFC97A5"" />
<a type=""media"" href=""/{localLink:7e21a725-b905-4c5f-86dc-8c41ec116e39}"" title=""media"">media</a>
</p>";

var umbracoContextAccessor = new TestUmbracoContextAccessor();
var parser = new HtmlLocalLinkParser(umbracoContextAccessor, Mock.Of<IPublishedUrlProvider>());

var result = parser.FindUdisFromLocalLinks(input).ToList();

Assert.Multiple(() =>
{
Assert.AreEqual(4, result.Count);
Assert.Contains(UdiParser.Parse("umb://document/eed5fc6b-96fd-45a5-a0f1-b1adfb483c2f"), result);
Assert.Contains(UdiParser.Parse("umb://media/7e21a725-b905-4c5f-86dc-8c41ec116e39"), result);
Assert.Contains(UdiParser.Parse("umb://document/C093961595094900AAF9170DDE6AD442"), result);
Assert.Contains(UdiParser.Parse("umb://document-type/2D692FCB070B4CDA92FB6883FDBFD6E2"), result);
});
}

[TestCase("", "")]
// current
[TestCase(
"<a type=\"document\" href=\"/{localLink:9931BDE0-AAC3-4BAB-B838-909A7B47570E}\" title=\"world\">world</a>",
"<a type=\"document\" href=\"/my-test-url\" title=\"world\">world</a>")]
[TestCase(
"<a type=\"media\" href=\"/{localLink:9931BDE0-AAC3-4BAB-B838-909A7B47570E}\" title=\"world\">world</a>",
"<a type=\"media\" href=\"/media/1001/my-image.jpg\" title=\"world\">world</a>")]
// legacy
[TestCase(
"hello href=\"{localLink:1234}\" world ",
"hello href=\"/my-test-url\" world ")]
Expand Down
Loading