From bd435f68832c21af309ced19106b2970737530c8 Mon Sep 17 00:00:00 2001 From: maliming Date: Wed, 9 Sep 2026 14:05:13 +0800 Subject: [PATCH] Resolve branding urls for the Blazor themes - Add NavigationManager.ResolveBrandingUrl and ResolveBrandingCssUrl - Move the url rules to Volo.Abp.Ui.Branding.BrandingUrlHelper, shared with the mvc themes --- docs/en/framework/ui/blazor/branding.md | 8 + .../framework/ui/mvc-razor-pages/branding.md | 8 +- .../NavigationManagerBrandingExtensions.cs | 52 +++++++ .../Branding/UrlHelperBrandingExtensions.cs | 37 +++-- .../Volo/Abp/Ui/Branding/BrandingUrlHelper.cs | 69 +++++++++ ...vigationManagerBrandingExtensions_Tests.cs | 143 ++++++++++++++++++ .../UrlHelperBrandingExtensions_Tests.cs | 110 ++++++++++++++ .../Ui/Branding/BrandingUrlHelper_Tests.cs | 49 ++++++ .../Themes/Basic/Branding.razor | 11 +- 9 files changed, 467 insertions(+), 20 deletions(-) create mode 100644 framework/src/Volo.Abp.AspNetCore.Components.Web.Theming/Branding/NavigationManagerBrandingExtensions.cs create mode 100644 framework/src/Volo.Abp.UI/Volo/Abp/Ui/Branding/BrandingUrlHelper.cs create mode 100644 framework/test/Volo.Abp.AspNetCore.Components.Web.Theming.Tests/Volo/Abp/AspNetCore/Components/Web/Theming/Branding/NavigationManagerBrandingExtensions_Tests.cs create mode 100644 framework/test/Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared.Tests/Volo/Abp/AspNetCore/Mvc/UI/Theme/Shared/Branding/UrlHelperBrandingExtensions_Tests.cs create mode 100644 framework/test/Volo.Abp.UI.Navigation.Tests/Volo/Abp/Ui/Branding/BrandingUrlHelper_Tests.cs diff --git a/docs/en/framework/ui/blazor/branding.md b/docs/en/framework/ui/blazor/branding.md index d6e7e4d358..a30ec40b78 100644 --- a/docs/en/framework/ui/blazor/branding.md +++ b/docs/en/framework/ui/blazor/branding.md @@ -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 `` of the host page has to match the path the application is served from, like ``. + +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. diff --git a/docs/en/framework/ui/mvc-razor-pages/branding.md b/docs/en/framework/ui/mvc-razor-pages/branding.md index 9c9f5c6a37..7543b82f74 100644 --- a/docs/en/framework/ui/mvc-razor-pages/branding.md +++ b/docs/en/framework/ui/mvc-razor-pages/branding.md @@ -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. @@ -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 diff --git a/framework/src/Volo.Abp.AspNetCore.Components.Web.Theming/Branding/NavigationManagerBrandingExtensions.cs b/framework/src/Volo.Abp.AspNetCore.Components.Web.Theming/Branding/NavigationManagerBrandingExtensions.cs new file mode 100644 index 0000000000..51d4f04419 --- /dev/null +++ b/framework/src/Volo.Abp.AspNetCore.Components.Web.Theming/Branding/NavigationManagerBrandingExtensions.cs @@ -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 +{ + /// + /// Resolves a branding url of : "logo.svg", "/logo.svg" and + /// "~/logo.svg" all keep working under a non-root base path. + /// + 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; + } + + /// + /// Same as , escaped for url('...') in css. + /// + public static string? ResolveBrandingCssUrl(this NavigationManager navigationManager, string? url) + { + var resolvedUrl = navigationManager.ResolveBrandingUrl(url); + + return resolvedUrl == null ? null : BrandingUrlHelper.EscapeCssValue(resolvedUrl); + } +} diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared/Branding/UrlHelperBrandingExtensions.cs b/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared/Branding/UrlHelperBrandingExtensions.cs index f652dac543..e817aadcbc 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared/Branding/UrlHelperBrandingExtensions.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared/Branding/UrlHelperBrandingExtensions.cs @@ -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 { /// - /// Resolves a branding url of 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 . - /// External urls ("http://", "https://" and "//host/") are returned as they are. - /// Returns null when is null or white space. + /// Resolves a branding url of : "logo.svg", "/logo.svg" and + /// "~/logo.svg" all keep working under a non-root . /// public static string? ResolveBrandingUrl(this IUrlHelper urlHelper, string? url) { @@ -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) + /// + /// Same as , escaped for url('...') in css. + /// + 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); } } diff --git a/framework/src/Volo.Abp.UI/Volo/Abp/Ui/Branding/BrandingUrlHelper.cs b/framework/src/Volo.Abp.UI/Volo/Abp/Ui/Branding/BrandingUrlHelper.cs new file mode 100644 index 0000000000..16807175c8 --- /dev/null +++ b/framework/src/Volo.Abp.UI/Volo/Abp/Ui/Branding/BrandingUrlHelper.cs @@ -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'; + } +} diff --git a/framework/test/Volo.Abp.AspNetCore.Components.Web.Theming.Tests/Volo/Abp/AspNetCore/Components/Web/Theming/Branding/NavigationManagerBrandingExtensions_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Components.Web.Theming.Tests/Volo/Abp/AspNetCore/Components/Web/Theming/Branding/NavigationManagerBrandingExtensions_Tests.cs new file mode 100644 index 0000000000..eba70ffc2c --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Components.Web.Theming.Tests/Volo/Abp/AspNetCore/Components/Web/Theming/Branding/NavigationManagerBrandingExtensions_Tests.cs @@ -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") + .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,')") + .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); + } + } +} diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared.Tests/Volo/Abp/AspNetCore/Mvc/UI/Theme/Shared/Branding/UrlHelperBrandingExtensions_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared.Tests/Volo/Abp/AspNetCore/Mvc/UI/Theme/Shared/Branding/UrlHelperBrandingExtensions_Tests.cs new file mode 100644 index 0000000000..72842ee52d --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared.Tests/Volo/Abp/AspNetCore/Mvc/UI/Theme/Shared/Branding/UrlHelperBrandingExtensions_Tests.cs @@ -0,0 +1,110 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Routing; +using Shouldly; +using Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared.Branding; +using Xunit; + +namespace Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared.Branding; + +public class UrlHelperBrandingExtensions_Tests +{ + private readonly IUrlHelper _urlHelper; + + public UrlHelperBrandingExtensions_Tests() + { + var httpContext = new DefaultHttpContext { + Request = { PathBase = "/myapp" } + }; + + _urlHelper = new UrlHelper(new ActionContext(httpContext, new RouteData(), new ActionDescriptor())); + } + + [Theory] + [InlineData("logo.svg")] + [InlineData("/logo.svg")] + [InlineData("~/logo.svg")] + public void Should_Treat_All_Local_Formats_As_Application_Relative(string url) + { + _urlHelper.ResolveBrandingUrl(url).ShouldBe("/myapp/logo.svg"); + } + + // Keep in sync with the same test of the blazor 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_Blazor_Themes(string url, string expected) + { + _urlHelper.ResolveBrandingUrl(url).ShouldBe(expected); + } + + [Fact] + public void Should_Keep_A_Dot_Segment_Of_An_Application_Relative_Url() + { + _urlHelper.ResolveBrandingUrl("images/../logo.svg").ShouldBe("/myapp/images/../logo.svg"); + } + + [Fact] + public void Should_Escape_A_Form_Feed_In_Css() + { + _urlHelper.ResolveBrandingCssUrl("data:image/svg+xml,\u000C") + .ShouldBe("data:image/svg+xml,%3Csvg/>"); + } + + [Fact] + public void Should_Keep_Query_And_Fragment() + { + _urlHelper.ResolveBrandingUrl("~/images/logo.svg?v=42").ShouldBe("/myapp/images/logo.svg?v=42"); + _urlHelper.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) + { + _urlHelper.ResolveBrandingUrl(url).ShouldBe(url); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Should_Return_Null_When_Url_Is_Empty(string? url) + { + _urlHelper.ResolveBrandingUrl(url).ShouldBeNull(); + } + + [Fact] + public void Should_Escape_Css_Breaking_Characters() + { + _urlHelper.ResolveBrandingCssUrl("data:image/svg+xml,')") + .ShouldBe("data:image/svg+xml,%3Csvg/>\\')%3C/style>"); + } + + [Fact] + public void Should_Resolve_And_Escape_Application_Relative_Urls() + { + _urlHelper.ResolveBrandingCssUrl("~/images/logo.svg").ShouldBe("/myapp/images/logo.svg"); + } +} diff --git a/framework/test/Volo.Abp.UI.Navigation.Tests/Volo/Abp/Ui/Branding/BrandingUrlHelper_Tests.cs b/framework/test/Volo.Abp.UI.Navigation.Tests/Volo/Abp/Ui/Branding/BrandingUrlHelper_Tests.cs new file mode 100644 index 0000000000..4524c4eeda --- /dev/null +++ b/framework/test/Volo.Abp.UI.Navigation.Tests/Volo/Abp/Ui/Branding/BrandingUrlHelper_Tests.cs @@ -0,0 +1,49 @@ +using Shouldly; +using Xunit; + +namespace Volo.Abp.Ui.Branding; + +public class BrandingUrlHelper_Tests +{ + [Theory] + [InlineData("//cdn.example.com/logo.svg")] + [InlineData("https://cdn.example.com/logo.svg")] + [InlineData("data:image/svg+xml;base64,PHN2Zy8+")] + [InlineData("blob:1234")] + [InlineData(" https://cdn.example.com/logo.svg ")] + [InlineData("http://[")] + public void Should_Detect_External_Urls(string url) + { + BrandingUrlHelper.IsExternalUrl(url).ShouldBeTrue(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("logo.svg")] + [InlineData("/logo.svg")] + [InlineData("~/logo.svg")] + [InlineData("/images/a:b.svg")] + [InlineData("1st:logo.svg")] + public void Should_Not_Detect_Other_Urls_As_External(string? url) + { + BrandingUrlHelper.IsExternalUrl(url).ShouldBeFalse(); + } + + [Theory] + [InlineData("~/images/logo.svg", "images/logo.svg")] + [InlineData("/images/logo.svg", "images/logo.svg")] + [InlineData("images/logo.svg", "images/logo.svg")] + public void Should_Remove_The_Application_Relative_Prefix(string url, string expected) + { + BrandingUrlHelper.RemoveApplicationRelativePrefix(url).ShouldBe(expected); + } + + [Fact] + public void Should_Escape_The_Characters_That_End_A_Css_Url_Or_A_Style_Element() + { + BrandingUrlHelper.EscapeCssValue("logo.svg\\')\r\n\f") + .ShouldBe("logo.svg\\\\\\')%3C/style>"); + } +} diff --git a/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.Web.BasicTheme/Themes/Basic/Branding.razor b/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.Web.BasicTheme/Themes/Basic/Branding.razor index a0c91635a9..9ec1bc32c5 100644 --- a/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.Web.BasicTheme/Themes/Basic/Branding.razor +++ b/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.Web.BasicTheme/Themes/Basic/Branding.razor @@ -1,9 +1,16 @@ +@using Volo.Abp.AspNetCore.Components.Web.Theming.Branding @using Volo.Abp.Ui.Branding @inject IBrandingProvider BrandingProvider +@inject NavigationManager NavigationManager + +@{ + var logoUrl = NavigationManager.ResolveBrandingUrl(BrandingProvider.LogoUrl); +} + - @if (!BrandingProvider.LogoUrl.IsNullOrWhiteSpace()) + @if (!logoUrl.IsNullOrWhiteSpace()) { - @BrandingProvider.AppName + @BrandingProvider.AppName } @BrandingProvider.AppName