File: TagHelpers\UrlResolutionTagHelper.cs
Web Access
Project: src\src\Mvc\Mvc.Razor\src\Microsoft.AspNetCore.Mvc.Razor.csproj (Microsoft.AspNetCore.Mvc.Razor)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Buffers;
using System.Diagnostics.CodeAnalysis;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
 
namespace Microsoft.AspNetCore.Mvc.Razor.TagHelpers;
 
/// <summary>
/// <see cref="ITagHelper"/> implementation targeting elements containing attributes with URL expected values.
/// </summary>
/// <remarks>Resolves URLs starting with '~/' (relative to the application's 'webroot' setting) that are not
/// targeted by other <see cref="ITagHelper"/>s. Runs prior to other <see cref="ITagHelper"/>s to ensure
/// application-relative URLs are resolved.</remarks>
[HtmlTargetElement("*", Attributes = "[itemid^='~/']")]
[HtmlTargetElement("a", Attributes = "[href^='~/']")]
[HtmlTargetElement("applet", Attributes = "[archive^='~/']")]
[HtmlTargetElement("area", Attributes = "[href^='~/']", TagStructure = TagStructure.WithoutEndTag)]
[HtmlTargetElement("audio", Attributes = "[src^='~/']")]
[HtmlTargetElement("base", Attributes = "[href^='~/']", TagStructure = TagStructure.WithoutEndTag)]
[HtmlTargetElement("blockquote", Attributes = "[cite^='~/']")]
[HtmlTargetElement("button", Attributes = "[formaction^='~/']")]
[HtmlTargetElement("del", Attributes = "[cite^='~/']")]
[HtmlTargetElement("embed", Attributes = "[src^='~/']", TagStructure = TagStructure.WithoutEndTag)]
[HtmlTargetElement("form", Attributes = "[action^='~/']")]
[HtmlTargetElement("html", Attributes = "[manifest^='~/']")]
[HtmlTargetElement("iframe", Attributes = "[src^='~/']")]
[HtmlTargetElement("img", Attributes = "[src^='~/']", TagStructure = TagStructure.WithoutEndTag)]
[HtmlTargetElement("img", Attributes = "[srcset^='~/']", TagStructure = TagStructure.WithoutEndTag)]
[HtmlTargetElement("input", Attributes = "[src^='~/']", TagStructure = TagStructure.WithoutEndTag)]
[HtmlTargetElement("input", Attributes = "[formaction^='~/']", TagStructure = TagStructure.WithoutEndTag)]
[HtmlTargetElement("ins", Attributes = "[cite^='~/']")]
[HtmlTargetElement("link", Attributes = "[href^='~/']", TagStructure = TagStructure.WithoutEndTag)]
[HtmlTargetElement("menuitem", Attributes = "[icon^='~/']")]
[HtmlTargetElement("object", Attributes = "[archive^='~/']")]
[HtmlTargetElement("object", Attributes = "[data^='~/']")]
[HtmlTargetElement("q", Attributes = "[cite^='~/']")]
[HtmlTargetElement("script", Attributes = "[src^='~/']")]
[HtmlTargetElement("source", Attributes = "[src^='~/']", TagStructure = TagStructure.WithoutEndTag)]
[HtmlTargetElement("source", Attributes = "[srcset^='~/']", TagStructure = TagStructure.WithoutEndTag)]
[HtmlTargetElement("track", Attributes = "[src^='~/']", TagStructure = TagStructure.WithoutEndTag)]
[HtmlTargetElement("video", Attributes = "[src^='~/']")]
[HtmlTargetElement("video", Attributes = "[poster^='~/']")]
public class UrlResolutionTagHelper : TagHelper
{
    // Valid whitespace characters defined by the HTML5 spec.
    private static readonly SearchValues<char> ValidAttributeWhitespaceChars = SearchValues.Create("\t\n\u000C\r ");
 
    private static readonly Dictionary<string, string[]> ElementAttributeLookups =
        new(StringComparer.OrdinalIgnoreCase)
        {
            { "a", new[] { "href" } },
            { "applet", new[] { "archive" } },
            { "area", new[] { "href" } },
            { "audio", new[] { "src" } },
            { "base", new[] { "href" } },
            { "blockquote", new[] { "cite" } },
            { "button", new[] { "formaction" } },
            { "del", new[] { "cite" } },
            { "embed", new[] { "src" } },
            { "form", new[] { "action" } },
            { "html", new[] { "manifest" } },
            { "iframe", new[] { "src" } },
            { "img", new[] { "src", "srcset" } },
            { "input", new[] { "src", "formaction" } },
            { "ins", new[] { "cite" } },
            { "link", new[] { "href" } },
            { "menuitem", new[] { "icon" } },
            { "object", new[] { "archive", "data" } },
            { "q", new[] { "cite" } },
            { "script", new[] { "src" } },
            { "source", new[] { "src", "srcset" } },
            { "track", new[] { "src" } },
            { "video", new[] { "poster", "src" } },
        };
 
    /// <summary>
    /// Creates a new <see cref="UrlResolutionTagHelper"/>.
    /// </summary>
    /// <param name="urlHelperFactory">The <see cref="IUrlHelperFactory"/>.</param>
    /// <param name="htmlEncoder">The <see cref="HtmlEncoder"/>.</param>
    public UrlResolutionTagHelper(IUrlHelperFactory urlHelperFactory, HtmlEncoder htmlEncoder)
    {
        UrlHelperFactory = urlHelperFactory;
        HtmlEncoder = htmlEncoder;
    }
 
    /// <inheritdoc />
    public override int Order => -1000 - 999;
 
    /// <summary>
    /// The <see cref="IUrlHelperFactory"/>.
    /// </summary>
    protected IUrlHelperFactory UrlHelperFactory { get; }
 
    /// <summary>
    /// The <see cref="HtmlEncoder"/>.
    /// </summary>
    protected HtmlEncoder HtmlEncoder { get; }
 
    /// <summary>
    /// The <see cref="ViewContext"/>.
    /// </summary>
    [HtmlAttributeNotBound]
    [ViewContext]
    public ViewContext ViewContext { get; set; } = default!;
 
    /// <inheritdoc />
    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        ArgumentNullException.ThrowIfNull(context);
        ArgumentNullException.ThrowIfNull(output);
 
        if (output.TagName == null)
        {
            return;
        }
 
        if (ElementAttributeLookups.TryGetValue(output.TagName, out var attributeNames))
        {
            for (var i = 0; i < attributeNames.Length; i++)
            {
                ProcessUrlAttribute(attributeNames[i], output);
            }
        }
 
        // itemid can be present on any HTML element.
        ProcessUrlAttribute("itemid", output);
    }
 
    /// <summary>
    /// Resolves and updates URL values starting with '~/' (relative to the application's 'webroot' setting) for
    /// <paramref name="output"/>'s <see cref="TagHelperOutput.Attributes"/> whose
    /// <see cref="TagHelperAttribute.Name"/> is <paramref name="attributeName"/>.
    /// </summary>
    /// <param name="attributeName">The attribute name used to lookup values to resolve.</param>
    /// <param name="output">The <see cref="TagHelperOutput"/>.</param>
    protected void ProcessUrlAttribute(string attributeName, TagHelperOutput output)
    {
        ArgumentNullException.ThrowIfNull(attributeName);
        ArgumentNullException.ThrowIfNull(output);
 
        var attributes = output.Attributes;
        // Read interface .Count once rather than per iteration
        var attributesCount = attributes.Count;
        for (var i = 0; i < attributesCount; i++)
        {
            var attribute = attributes[i];
            if (!string.Equals(attribute.Name, attributeName, StringComparison.OrdinalIgnoreCase))
            {
                continue;
            }
 
            if (attribute.Value is string stringValue)
            {
                if (TryResolveUrl(stringValue, resolvedUrl: out string? resolvedUrl))
                {
                    attributes[i] = new TagHelperAttribute(
                        attribute.Name,
                        resolvedUrl,
                        attribute.ValueStyle);
                }
            }
            else
            {
                if (attribute.Value is IHtmlContent htmlContent)
                {
                    var htmlString = htmlContent as HtmlString;
                    if (htmlString != null)
                    {
                        // No need for a StringWriter in this case.
                        stringValue = htmlString.ToString();
                    }
                    else
                    {
                        using (var writer = new StringWriter())
                        {
                            htmlContent.WriteTo(writer, HtmlEncoder);
                            stringValue = writer.ToString();
                        }
                    }
 
                    if (TryResolveUrl(stringValue, resolvedUrl: out IHtmlContent? resolvedUrl))
                    {
                        attributes[i] = new TagHelperAttribute(
                            attribute.Name,
                            resolvedUrl,
                            attribute.ValueStyle);
                    }
                    else if (htmlString == null)
                    {
                        // Not a ~/ URL. Just avoid re-encoding the attribute value later.
                        attributes[i] = new TagHelperAttribute(
                            attribute.Name,
                            new HtmlString(stringValue),
                            attribute.ValueStyle);
                    }
                }
            }
        }
    }
 
    /// <summary>
    /// Tries to resolve the given <paramref name="url"/> value relative to the application's 'webroot' setting.
    /// </summary>
    /// <param name="url">The URL to resolve.</param>
    /// <param name="resolvedUrl">Absolute URL beginning with the application's virtual root. <c>null</c> if
    /// <paramref name="url"/> could not be resolved.</param>
    /// <returns><c>true</c> if the <paramref name="url"/> could be resolved; <c>false</c> otherwise.</returns>
    protected bool TryResolveUrl([StringSyntax(StringSyntaxAttribute.Uri, UriKind.Relative)] string url, out string? resolvedUrl)
    {
        resolvedUrl = null;
        if (!TryCreateTrimmedString(url, out var trimmedUrl))
        {
            return false;
        }
 
        trimmedUrl = GetVersionedResourceUrl(trimmedUrl);
 
        var urlHelper = UrlHelperFactory.GetUrlHelper(ViewContext);
        resolvedUrl = urlHelper.Content(trimmedUrl);
 
        return true;
    }
 
    /// <summary>
    /// Tries to resolve the given <paramref name="url"/> value relative to the application's 'webroot' setting.
    /// </summary>
    /// <param name="url">The URL to resolve.</param>
    /// <param name="resolvedUrl">
    /// Absolute URL beginning with the application's virtual root. <c>null</c> if <paramref name="url"/> could
    /// not be resolved.
    /// </param>
    /// <returns><c>true</c> if the <paramref name="url"/> could be resolved; <c>false</c> otherwise.</returns>
    protected bool TryResolveUrl([StringSyntax(StringSyntaxAttribute.Uri, UriKind.Relative)] string url, [NotNullWhen(true)] out IHtmlContent? resolvedUrl)
    {
        resolvedUrl = null;
        if (!TryCreateTrimmedString(url, out var trimmedUrl))
        {
            return false;
        }
 
        trimmedUrl = GetVersionedResourceUrl(trimmedUrl);
 
        var urlHelper = UrlHelperFactory.GetUrlHelper(ViewContext);
        var appRelativeUrl = urlHelper.Content(trimmedUrl);
        var postTildeSlashUrlValue = trimmedUrl.Substring(2);
 
        if (!appRelativeUrl.EndsWith(postTildeSlashUrlValue, StringComparison.Ordinal))
        {
            throw new InvalidOperationException(
                Resources.FormatCouldNotResolveApplicationRelativeUrl_TagHelper(
                    url,
                    nameof(IUrlHelper),
                    nameof(IUrlHelper.Content),
                    "removeTagHelper",
                    typeof(UrlResolutionTagHelper).FullName,
                    typeof(UrlResolutionTagHelper).Assembly.GetName().Name));
        }
 
        resolvedUrl = new EncodeFirstSegmentContent(
            appRelativeUrl,
            appRelativeUrl.Length - postTildeSlashUrlValue.Length,
            postTildeSlashUrlValue);
 
        return true;
    }
 
    private static bool TryCreateTrimmedString(string input, [NotNullWhen(true)] out string? trimmed)
    {
        trimmed = null;
        if (input == null)
        {
            return false;
        }
 
        var url = input.AsSpan();
        var start = url.IndexOfAnyExcept(ValidAttributeWhitespaceChars);
        if (start < 0)
        {
            return false;
        }
 
        // Url without leading whitespace.
        url = url.Slice(start);
 
        // Before doing more work, ensure that the URL we're looking at is app-relative.
        if (!url.StartsWith("~/"))
        {
            return false;
        }
 
        var remainingLength = url.LastIndexOfAnyExcept(ValidAttributeWhitespaceChars) + 1;
 
        // Substring returns same string if start == 0 && len == Length
        trimmed = input.Substring(start, remainingLength);
        return true;
    }
 
    private string GetVersionedResourceUrl(string value)
    {
        var assetCollection = GetAssetCollection();
        if (assetCollection != null)
        {
            var (key, remainder) = ExtractKeyAndRest(value);
 
            var src = assetCollection[key];
            if (!string.Equals(src, key, StringComparison.Ordinal))
            {
                return $"~/{src}{value[remainder..]}";
            }
        }
 
        return value;
 
        static (string key, int rest) ExtractKeyAndRest(string value)
        {
            var lastNonWhitespaceChar = value.AsSpan().TrimEnd().LastIndexOfAnyExcept(ValidAttributeWhitespaceChars);
            var keyEnd = lastNonWhitespaceChar > -1 ? lastNonWhitespaceChar + 1 : value.Length;
            var key = value.AsSpan();
            if (key.StartsWith("~/", StringComparison.Ordinal))
            {
                key = value.AsSpan()[2..keyEnd].Trim();
            }
 
            return (key.ToString(), keyEnd);
        }
    }
 
    private ResourceAssetCollection? GetAssetCollection()
    {
        return ViewContext.HttpContext.GetEndpoint()?.Metadata.GetMetadata<ResourceAssetCollection>();
    }
 
    private sealed class EncodeFirstSegmentContent : IHtmlContent
    {
        private readonly string _firstSegment;
        private readonly int _firstSegmentLength;
        private readonly string _secondSegment;
 
        public EncodeFirstSegmentContent(string firstSegment, int firstSegmentLength, string secondSegment)
        {
            _firstSegment = firstSegment;
            _firstSegmentLength = firstSegmentLength;
            _secondSegment = secondSegment;
        }
 
        public void WriteTo(TextWriter writer, HtmlEncoder encoder)
        {
            encoder.Encode(writer, _firstSegment, 0, _firstSegmentLength);
            writer.Write(_secondSegment);
        }
    }
}