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
8 changes: 8 additions & 0 deletions docs/en/framework/ui/blazor/branding.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,12 @@ The result will be like shown below:
* `LogoUrl`: A URL to show the application logo.
* `LogoReverseUrl`: A URL to show the application logo on a reverse color theme (dark, for example).

ABP's built-in Blazor themes resolve these URLs for the current application. `logo.png`, `/logo.png` and `~/logo.png` all mean the same application relative URL and include the base path of the application, so they keep working when it is deployed to a non-root path, like an IIS virtual directory. A URL that already contains the path gets it twice. External URLs are used as they are. `LogoReverseUrl` is used by the themes that have a dark style, like LeptonX, and falls back to `LogoUrl`.

> **Note**: The `<base href>` of the host page has to match the path the application is served from, like `<base href="/myapp/" />`.
A logo that the project defines in its own CSS is not resolved. The LeptonX Lite startup templates set the logo that way, so remove that declaration to deploy them to a non-root path.

To resolve a branding URL in a custom theme or component, use `NavigationManager.ResolveBrandingUrl(...)` with `@using Volo.Abp.AspNetCore.Components.Web.Theming.Branding`, or `ResolveBrandingCssUrl(...)` when it is rendered into `url('...')` in CSS.

> **Tip**: `IBrandingProvider` is used in every page refresh. For a multi-tenant application, you can return a tenant specific application name to customize it per tenant.
8 changes: 5 additions & 3 deletions docs/en/framework/ui/mvc-razor-pages/branding.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,11 @@ The result will be like shown below:
* `LogoUrl`: A URL to show the application logo.
* `LogoReverseUrl`: A URL to show the application logo on a reverse color theme (dark, for example).

ABP's built-in MVC themes resolve the branding URLs for the current request. `/logo.png`, `logo.png` and `~/logo.png` are treated as application relative URLs and include the `PathBase` of the request, so they keep working when the application is deployed to a non-root path, like an IIS virtual directory. Absolute HTTP(S) URLs, like `https://cdn.example.com/logo.png`, and protocol relative URLs, like `//cdn.example.com/logo.png`, are returned unchanged. `null` and white space values are treated as not set.
ABP's built-in MVC themes resolve these URLs for the current request. `logo.png`, `/logo.png` and `~/logo.png` all mean the same application relative URL and include the `PathBase` of the request, so they keep working when the application is deployed to a non-root path, like an IIS virtual directory. A URL that already contains the path gets it twice. External URLs are used as they are. `LogoReverseUrl` is used by the themes that have a dark style, like LeptonX, and falls back to `LogoUrl`.

If you render a branding URL in a custom MVC theme or view, resolve it with `Url.ResolveBrandingUrl(...)`.
A logo that the project defines in its own CSS is not resolved. The LeptonX Lite startup templates set the logo that way, so remove that declaration to deploy them to a non-root path.

To resolve a branding URL in a custom theme or view, use `Url.ResolveBrandingUrl(...)` with `@using Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared.Branding`, or `Url.ResolveBrandingCssUrl(...)` when it is rendered into `url('...')` in CSS.

> **Tip**: `IBrandingProvider` is used in every page refresh. For a multi-tenant application, you can return a tenant specific application name to customize it per tenant.

Expand Down Expand Up @@ -80,7 +82,7 @@ Both properties return `null` by default and follow the same URL rules as `LogoU

The active theme decides whether and where to use the compact logo. The LeptonX MVC theme enables its compact branding when `LogoIconUrl` is not empty: it uses the compact logo instead of the full logo in its branding areas and shows `AppName` next to it where there is room for both. Dark and dim styles use `LogoIconReverseUrl` and fall back to `LogoIconUrl` when it is not set. Themes that don't support `IBrandingLogoProvider` ignore these properties.

> This URL resolution and the compact logo apply to the ASP.NET Core MVC / Razor Pages themes. The Blazor themes handle branding on their own.
> The compact logo applies to the ASP.NET Core MVC / Razor Pages themes. The Blazor themes resolve the branding URLs by the same rules, see [Blazor UI: Branding](../blazor/branding.md).

## Overriding the Branding Area

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using System;
using Microsoft.AspNetCore.Components;
using Volo.Abp.Ui.Branding;

namespace Volo.Abp.AspNetCore.Components.Web.Theming.Branding;

public static class NavigationManagerBrandingExtensions
{
/// <summary>
/// Resolves a branding url of <see cref="IBrandingProvider"/>: "logo.svg", "/logo.svg" and
/// "~/logo.svg" all keep working under a non-root base path.
/// </summary>
public static string? ResolveBrandingUrl(this NavigationManager navigationManager, string? url)
{
Check.NotNull(navigationManager, nameof(navigationManager));

if (url.IsNullOrWhiteSpace())
{
return null;
}

var brandingUrl = url!.Trim();

if (BrandingUrlHelper.IsExternalUrl(brandingUrl))
{
return brandingUrl;
}

var applicationRelativeUrl = BrandingUrlHelper.RemoveApplicationRelativePrefix(brandingUrl);
var baseUri = new Uri(navigationManager.BaseUri);

// "/http://host/logo.svg" would silently lose its host when only the path is taken.
if (BrandingUrlHelper.IsExternalUrl(applicationRelativeUrl) ||
!Uri.TryCreate(baseUri, applicationRelativeUrl, out var absoluteUrl) ||
absoluteUrl.GetLeftPart(UriPartial.Authority) != baseUri.GetLeftPart(UriPartial.Authority))
{
return brandingUrl;
}

return absoluteUrl.PathAndQuery + absoluteUrl.Fragment;
}

/// <summary>
/// Same as <see cref="ResolveBrandingUrl"/>, escaped for url('...') in css.
/// </summary>
public static string? ResolveBrandingCssUrl(this NavigationManager navigationManager, string? url)
{
var resolvedUrl = navigationManager.ResolveBrandingUrl(url);

return resolvedUrl == null ? null : BrandingUrlHelper.EscapeCssValue(resolvedUrl);
}
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
using System;
using Microsoft.AspNetCore.Mvc;
using Volo.Abp.Ui.Branding;

namespace Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared.Branding;

public static class UrlHelperBrandingExtensions
{
/// <summary>
/// Resolves a branding url of <see cref="Volo.Abp.Ui.Branding.IBrandingProvider"/> for the current request.
/// "logo.svg", "/logo.svg" and "~/logo.svg" all mean the same application relative url and keep working
/// under a non-root <see cref="Microsoft.AspNetCore.Http.HttpRequest.PathBase"/>.
/// External urls ("http://", "https://" and "//host/") are returned as they are.
/// Returns null when <paramref name="url"/> is null or white space.
/// Resolves a branding url of <see cref="IBrandingProvider"/>: "logo.svg", "/logo.svg" and
/// "~/logo.svg" all keep working under a non-root <see cref="Microsoft.AspNetCore.Http.HttpRequest.PathBase"/>.
/// </summary>
public static string? ResolveBrandingUrl(this IUrlHelper urlHelper, string? url)
{
Expand All @@ -19,22 +17,31 @@ public static class UrlHelperBrandingExtensions
return null;
}

if (IsExternalUrl(url!))
var brandingUrl = url!.Trim();

if (BrandingUrlHelper.IsExternalUrl(brandingUrl))
{
return url;
return brandingUrl;
}

var applicationRelativeUrl = url!.StartsWith("~/", StringComparison.Ordinal)
? url
: "~/" + url.TrimStart('/');
var relativeUrl = BrandingUrlHelper.RemoveApplicationRelativePrefix(brandingUrl);

// "/http://host/logo.svg" would become a local path that does not exist.
if (BrandingUrlHelper.IsExternalUrl(relativeUrl))
{
return brandingUrl;
}

return urlHelper.Content(applicationRelativeUrl);
return urlHelper.Content("~/" + relativeUrl);
}

private static bool IsExternalUrl(string url)
/// <summary>
/// Same as <see cref="ResolveBrandingUrl"/>, escaped for url('...') in css.
/// </summary>
public static string? ResolveBrandingCssUrl(this IUrlHelper urlHelper, string? url)
{
return url.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|| url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)
|| url.StartsWith("//", StringComparison.Ordinal);
var resolvedUrl = urlHelper.ResolveBrandingUrl(url);

return resolvedUrl == null ? null : BrandingUrlHelper.EscapeCssValue(resolvedUrl);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using System;

namespace Volo.Abp.Ui.Branding;

public static class BrandingUrlHelper
{
// "//host/" and any url with a scheme are used as they are, the others are application relative.
public static bool IsExternalUrl(string? url)
{
if (url.IsNullOrWhiteSpace())
{
return false;
}

var brandingUrl = url!.Trim();

return brandingUrl.StartsWith("//", StringComparison.Ordinal) || HasScheme(brandingUrl);
}

public static string RemoveApplicationRelativePrefix(string url)
{
return url.StartsWith("~/", StringComparison.Ordinal)
? url.Substring(2)
: url.TrimStart('/');
}

// Rendered into url('...') inside a style element and must not be able to end either of them.
public static string EscapeCssValue(string url)
{
return url
.Replace("\\", "\\\\")
.Replace("'", "\\'")
.Replace("<", "%3C")
.Replace("\r", string.Empty)
.Replace("\n", string.Empty)
.Replace("\f", string.Empty);
}

// A broken value like "http://[" also has a scheme, so it is matched here instead of by Uri.TryCreate.
private static bool HasScheme(string url)
{
var schemeLength = url.IndexOf(':');
if (schemeLength < 1 || !IsLetter(url[0]))
{
return false;
}

for (var i = 1; i < schemeLength; i++)
{
var character = url[i];
if (!IsLetter(character) && !IsDigit(character) && character != '+' && character != '-' && character != '.')
{
return false;
}
}

return true;
}

private static bool IsLetter(char character)
{
return (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z');
}

private static bool IsDigit(char character)
{
return character >= '0' && character <= '9';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
using Microsoft.AspNetCore.Components;
using Shouldly;
using Xunit;

namespace Volo.Abp.AspNetCore.Components.Web.Theming.Branding;

public class NavigationManagerBrandingExtensions_Tests
{
[Theory]
[InlineData("logo.svg")]
[InlineData("/logo.svg")]
[InlineData("~/logo.svg")]
public void Should_Treat_All_Local_Formats_As_Application_Relative(string url)
{
CreateNavigationManager("https://localhost/").ResolveBrandingUrl(url)
.ShouldBe("/logo.svg");

CreateNavigationManager("https://localhost/myapp/").ResolveBrandingUrl(url)
.ShouldBe("/myapp/logo.svg");
}

// Keep in sync with the same test of the mvc themes.
[Theory]
[InlineData("logo.svg", "/myapp/logo.svg")]
[InlineData("/logo.svg", "/myapp/logo.svg")]
[InlineData("~/logo.svg", "/myapp/logo.svg")]
[InlineData("images/logo.svg?v=42", "/myapp/images/logo.svg?v=42")]
[InlineData("images/logo.svg#brand", "/myapp/images/logo.svg#brand")]
[InlineData("https://cdn.example.com/logo.svg", "https://cdn.example.com/logo.svg")]
[InlineData("//cdn.example.com/logo.svg", "//cdn.example.com/logo.svg")]
[InlineData("data:image/svg+xml;base64,PHN2Zy8+", "data:image/svg+xml;base64,PHN2Zy8+")]
[InlineData("http://[", "http://[")]
[InlineData("https://", "https://")]
[InlineData("http://a b", "http://a b")]
[InlineData(" ~/logo.svg ", "/myapp/logo.svg")]
[InlineData(" https://cdn.example.com/logo.svg ", "https://cdn.example.com/logo.svg")]
[InlineData("/http://cdn.example.com/logo.svg", "/http://cdn.example.com/logo.svg")]
[InlineData("~/http://cdn.example.com/logo.svg", "~/http://cdn.example.com/logo.svg")]
public void Should_Resolve_The_Same_As_The_Mvc_Themes(string url, string expected)
{
CreateNavigationManager("https://localhost/myapp/").ResolveBrandingUrl(url)
.ShouldBe(expected);
}

[Theory]
[InlineData("images/../logo.svg", "/myapp/logo.svg")]
[InlineData("images/../../logo.svg", "/logo.svg")]
public void Should_Resolve_A_Dot_Segment(string url, string expected)
{
CreateNavigationManager("https://localhost/myapp/").ResolveBrandingUrl(url).ShouldBe(expected);
}

[Fact]
public void Should_Not_Resolve_A_Url_That_Points_To_Another_Host()
{
CreateNavigationManager("https://localhost/myapp/").ResolveBrandingUrl("/http://cdn.example.com/logo.svg")
.ShouldBe("/http://cdn.example.com/logo.svg");
}

[Fact]
public void Should_Escape_A_Form_Feed_In_Css()
{
CreateNavigationManager("https://localhost/myapp/").ResolveBrandingCssUrl("data:image/svg+xml,\u000C<svg/>")
.ShouldBe("data:image/svg+xml,%3Csvg/>");
}

[Fact]
public void Should_Keep_Query_And_Fragment()
{
CreateNavigationManager("https://localhost/myapp/").ResolveBrandingUrl("~/images/logo.svg?v=42")
.ShouldBe("/myapp/images/logo.svg?v=42");

CreateNavigationManager("https://localhost/myapp/").ResolveBrandingUrl("images/logo.svg#brand")
.ShouldBe("/myapp/images/logo.svg#brand");
}

[Theory]
[InlineData("http://cdn.example.com/logo.svg")]
[InlineData("https://cdn.example.com/logo.svg")]
[InlineData("//cdn.example.com/logo.svg")]
[InlineData("data:image/svg+xml;base64,PHN2Zy8+")]
[InlineData("http://[")]
[InlineData("https://")]
[InlineData("http://a b")]
public void Should_Return_External_Urls_As_They_Are(string url)
{
CreateNavigationManager("https://localhost/myapp/").ResolveBrandingUrl(url)
.ShouldBe(url);
}

[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Should_Return_Null_When_Url_Is_Empty(string? url)
{
CreateNavigationManager("https://localhost/myapp/").ResolveBrandingUrl(url)
.ShouldBeNull();
}

[Fact]
public void Should_Escape_Css_Breaking_Characters()
{
CreateNavigationManager("https://localhost/myapp/")
.ResolveBrandingCssUrl("data:image/svg+xml,<svg/>')</style><script>alert(1)</script>")
.ShouldBe("data:image/svg+xml,%3Csvg/>\\')%3C/style>%3Cscript>alert(1)%3C/script>");
}

[Fact]
public void Should_Escape_Backslashes_And_Drop_Line_Breaks()
{
CreateNavigationManager("https://localhost/myapp/")
.ResolveBrandingCssUrl("data:image/svg+xml,a\\b\r\nc")
.ShouldBe("data:image/svg+xml,a\\\\bc");
}

[Fact]
public void Should_Resolve_And_Escape_Application_Relative_Urls()
{
CreateNavigationManager("https://localhost/myapp/")
.ResolveBrandingCssUrl("~/images/logo.svg")
.ShouldBe("/myapp/images/logo.svg");
}

[Fact]
public void Should_Return_Null_From_Css_Overload_When_Url_Is_Empty()
{
CreateNavigationManager("https://localhost/myapp/").ResolveBrandingCssUrl(" ").ShouldBeNull();
}

private static NavigationManager CreateNavigationManager(string baseUri)
{
return new TestNavigationManager(baseUri);
}

private class TestNavigationManager : NavigationManager
{
public TestNavigationManager(string baseUri)
{
Initialize(baseUri, baseUri);
}
}
}
Loading
Loading