diff --git a/framework/Volo.Abp.slnx b/framework/Volo.Abp.slnx index 26d462fb4f..fa76c5930d 100644 --- a/framework/Volo.Abp.slnx +++ b/framework/Volo.Abp.slnx @@ -180,6 +180,7 @@ + diff --git a/framework/src/Volo.Abp.AspNetCore.Components.Web.Theming/Bundling/AbpScripts.razor b/framework/src/Volo.Abp.AspNetCore.Components.Web.Theming/Bundling/AbpScripts.razor index 436a22fc0e..a607815421 100644 --- a/framework/src/Volo.Abp.AspNetCore.Components.Web.Theming/Bundling/AbpScripts.razor +++ b/framework/src/Volo.Abp.AspNetCore.Components.Web.Theming/Bundling/AbpScripts.razor @@ -1,16 +1,14 @@ +@using Microsoft.AspNetCore.Components @implements IDisposable @inject IComponentBundleManager BundleManager @inject PersistentComponentState ApplicationState +@inject NavigationManager NavigationManager +@inject IComponentBundleUrlBuilder BundleUrlBuilder @if (ScriptFiles != null) { foreach (var file in ScriptFiles) { - var src = file; - if (!AppBasePath.IsNullOrWhiteSpace()) - { - src = AppBasePath.EnsureEndsWith('/') + file.RemovePreFix("/"); - } - + } } @@ -39,7 +37,8 @@ // We are in prerendering mode if (!BundleName.IsNullOrWhiteSpace()) { - ScriptFiles = (await BundleManager.GetScriptBundleFilesAsync(BundleName!)).ToList(); + var rawFiles = await BundleManager.GetScriptBundleFilesAsync(BundleName!); + ScriptFiles = await ResolveAsync(rawFiles); } } else @@ -51,6 +50,16 @@ } } + private async Task> ResolveAsync(IReadOnlyList files) + { + var resolved = new List(files.Count); + foreach (var file in files) + { + resolved.Add(await BundleUrlBuilder.BuildAsync(file, AppBasePath, NavigationManager.BaseUri)); + } + return resolved; + } + private Task Callback() { ApplicationState.PersistAsJson(PrerenderedKey, PrerenderedKey); diff --git a/framework/src/Volo.Abp.AspNetCore.Components.Web.Theming/Bundling/AbpStyles.razor b/framework/src/Volo.Abp.AspNetCore.Components.Web.Theming/Bundling/AbpStyles.razor index 1c2ede488f..2ebdf1aecf 100644 --- a/framework/src/Volo.Abp.AspNetCore.Components.Web.Theming/Bundling/AbpStyles.razor +++ b/framework/src/Volo.Abp.AspNetCore.Components.Web.Theming/Bundling/AbpStyles.razor @@ -1,16 +1,14 @@ +@using Microsoft.AspNetCore.Components @implements IDisposable @inject IComponentBundleManager BundleManager @inject PersistentComponentState ApplicationState +@inject NavigationManager NavigationManager +@inject IComponentBundleUrlBuilder BundleUrlBuilder @if (StyleFiles != null) { foreach (var file in StyleFiles) { - var href = file; - if (!AppBasePath.IsNullOrWhiteSpace()) - { - href = AppBasePath.EnsureEndsWith('/') + file.RemovePreFix("/"); - } - + } } @@ -39,7 +37,8 @@ // We are in prerendering mode if (!BundleName.IsNullOrWhiteSpace()) { - StyleFiles = (await BundleManager.GetStyleBundleFilesAsync(BundleName!)).ToList(); + var rawFiles = await BundleManager.GetStyleBundleFilesAsync(BundleName!); + StyleFiles = await ResolveAsync(rawFiles); } } else @@ -65,6 +64,16 @@ } } + private async Task> ResolveAsync(IReadOnlyList files) + { + var resolved = new List(files.Count); + foreach (var file in files) + { + resolved.Add(await BundleUrlBuilder.BuildAsync(file, AppBasePath, NavigationManager.BaseUri)); + } + return resolved; + } + private Task Callback() { ApplicationState.PersistAsJson(PrerenderedKey, StyleFiles); diff --git a/framework/src/Volo.Abp.AspNetCore.Components.Web.Theming/Bundling/ComponentBundleUrlBuilder.cs b/framework/src/Volo.Abp.AspNetCore.Components.Web.Theming/Bundling/ComponentBundleUrlBuilder.cs new file mode 100644 index 0000000000..7d27f1711c --- /dev/null +++ b/framework/src/Volo.Abp.AspNetCore.Components.Web.Theming/Bundling/ComponentBundleUrlBuilder.cs @@ -0,0 +1,57 @@ +using System; +using System.Threading.Tasks; +using JetBrains.Annotations; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.AspNetCore.Components.Web.Theming.Bundling; + +public class ComponentBundleUrlBuilder : IComponentBundleUrlBuilder, ITransientDependency +{ + public virtual Task BuildAsync( + [NotNull] string fileName, + [CanBeNull] string? appBasePath, + [CanBeNull] string? navigationBaseUri) + { + Check.NotNull(fileName, nameof(fileName)); + + if (IsExternalUrl(fileName)) + { + return Task.FromResult(fileName); + } + + var pathBase = !string.IsNullOrWhiteSpace(appBasePath) + ? appBasePath + : ExtractPathBaseFromNavigationBaseUri(navigationBaseUri); + + if (string.IsNullOrWhiteSpace(pathBase)) + { + return Task.FromResult(fileName); + } + + var normalized = pathBase.EnsureEndsWith('/'); + if (normalized == "/") + { + return Task.FromResult(fileName); + } + + return Task.FromResult(normalized + fileName.RemovePreFix("/")); + } + + protected virtual bool IsExternalUrl([NotNull] string fileName) + { + return fileName.StartsWith("//", StringComparison.Ordinal) || + (fileName.Contains(':') && Uri.TryCreate(fileName, UriKind.Absolute, out _)); + } + + protected virtual string? ExtractPathBaseFromNavigationBaseUri([CanBeNull] string? navigationBaseUri) + { + if (string.IsNullOrWhiteSpace(navigationBaseUri)) + { + return null; + } + + return Uri.TryCreate(navigationBaseUri, UriKind.Absolute, out var uri) + ? uri.AbsolutePath + : null; + } +} diff --git a/framework/src/Volo.Abp.AspNetCore.Components.Web.Theming/Bundling/IComponentBundleUrlBuilder.cs b/framework/src/Volo.Abp.AspNetCore.Components.Web.Theming/Bundling/IComponentBundleUrlBuilder.cs new file mode 100644 index 0000000000..6db6915781 --- /dev/null +++ b/framework/src/Volo.Abp.AspNetCore.Components.Web.Theming/Bundling/IComponentBundleUrlBuilder.cs @@ -0,0 +1,8 @@ +using System.Threading.Tasks; + +namespace Volo.Abp.AspNetCore.Components.Web.Theming.Bundling; + +public interface IComponentBundleUrlBuilder +{ + Task BuildAsync(string fileName, string? appBasePath, string? navigationBaseUri); +} diff --git a/framework/test/Volo.Abp.AspNetCore.Components.Web.Theming.Tests/Volo.Abp.AspNetCore.Components.Web.Theming.Tests.abppkg b/framework/test/Volo.Abp.AspNetCore.Components.Web.Theming.Tests/Volo.Abp.AspNetCore.Components.Web.Theming.Tests.abppkg new file mode 100644 index 0000000000..64c1552e37 --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Components.Web.Theming.Tests/Volo.Abp.AspNetCore.Components.Web.Theming.Tests.abppkg @@ -0,0 +1,3 @@ +{ + "role": "lib.test" +} diff --git a/framework/test/Volo.Abp.AspNetCore.Components.Web.Theming.Tests/Volo.Abp.AspNetCore.Components.Web.Theming.Tests.csproj b/framework/test/Volo.Abp.AspNetCore.Components.Web.Theming.Tests/Volo.Abp.AspNetCore.Components.Web.Theming.Tests.csproj new file mode 100644 index 0000000000..4e317e99ce --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Components.Web.Theming.Tests/Volo.Abp.AspNetCore.Components.Web.Theming.Tests.csproj @@ -0,0 +1,17 @@ + + + + + + net10.0 + + enable + + + + + + + + + diff --git a/framework/test/Volo.Abp.AspNetCore.Components.Web.Theming.Tests/Volo/Abp/AspNetCore/Components/Web/Theming/Bundling/ComponentBundleUrlBuilder_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Components.Web.Theming.Tests/Volo/Abp/AspNetCore/Components/Web/Theming/Bundling/ComponentBundleUrlBuilder_Tests.cs new file mode 100644 index 0000000000..a47250aaa4 --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Components.Web.Theming.Tests/Volo/Abp/AspNetCore/Components/Web/Theming/Bundling/ComponentBundleUrlBuilder_Tests.cs @@ -0,0 +1,105 @@ +using System.Threading.Tasks; +using Shouldly; +using Xunit; + +namespace Volo.Abp.AspNetCore.Components.Web.Theming.Bundling; + +public class ComponentBundleUrlBuilder_Tests +{ + private readonly IComponentBundleUrlBuilder _builder = new ComponentBundleUrlBuilder(); + + [Fact] + public async Task Should_Return_FileName_When_No_PathBase_Available() + { + (await _builder.BuildAsync("/__bundles/Global.css", appBasePath: null, navigationBaseUri: null)) + .ShouldBe("/__bundles/Global.css"); + } + + [Fact] + public async Task Should_Return_FileName_When_NavigationBaseUri_Has_Root_PathBase() + { + (await _builder.BuildAsync("/__bundles/Global.css", appBasePath: null, navigationBaseUri: "https://localhost/")) + .ShouldBe("/__bundles/Global.css"); + } + + [Fact] + public async Task Should_Use_Explicit_AppBasePath_Over_NavigationBaseUri() + { + (await _builder.BuildAsync("/__bundles/Global.css", appBasePath: "/explicit", navigationBaseUri: "https://localhost/from-nav/")) + .ShouldBe("/explicit/__bundles/Global.css"); + } + + [Fact] + public async Task Should_Resolve_PathBase_From_NavigationBaseUri() + { + (await _builder.BuildAsync("/__bundles/Global.css", appBasePath: null, navigationBaseUri: "https://localhost/foo/")) + .ShouldBe("/foo/__bundles/Global.css"); + } + + [Fact] + public async Task Should_Resolve_PathBase_From_NavigationBaseUri_When_AppBasePath_Empty() + { + (await _builder.BuildAsync("/__bundles/Global.css", appBasePath: "", navigationBaseUri: "https://localhost/foo/")) + .ShouldBe("/foo/__bundles/Global.css"); + } + + [Fact] + public async Task Should_Handle_Nested_PathBase() + { + (await _builder.BuildAsync("/__bundles/Global.css", appBasePath: null, navigationBaseUri: "https://localhost/foo/bar/")) + .ShouldBe("/foo/bar/__bundles/Global.css"); + } + + [Fact] + public async Task Should_Normalize_AppBasePath_Without_Trailing_Slash() + { + (await _builder.BuildAsync("/__bundles/Global.css", appBasePath: "/foo", navigationBaseUri: null)) + .ShouldBe("/foo/__bundles/Global.css"); + } + + [Fact] + public async Task Should_Not_Duplicate_Leading_Slash() + { + (await _builder.BuildAsync("/__bundles/Global.css", appBasePath: "/foo/", navigationBaseUri: null)) + .ShouldBe("/foo/__bundles/Global.css"); + } + + [Fact] + public async Task Should_Handle_FileName_Without_Leading_Slash() + { + (await _builder.BuildAsync("__bundles/Global.css", appBasePath: "/foo", navigationBaseUri: null)) + .ShouldBe("/foo/__bundles/Global.css"); + } + + [Fact] + public async Task Should_Return_FileName_When_NavigationBaseUri_Is_Invalid() + { + (await _builder.BuildAsync("/__bundles/Global.css", appBasePath: null, navigationBaseUri: "not-a-uri")) + .ShouldBe("/__bundles/Global.css"); + } + + [Fact] + public async Task Should_Throw_When_FileName_Is_Null() + { + await Should.ThrowAsync(async () => + await _builder.BuildAsync(null!, appBasePath: "/foo", navigationBaseUri: null)); + } + + [Theory] + [InlineData("https://cdn.example.com/foo.css")] + [InlineData("http://cdn.example.com/foo.css")] + [InlineData("//cdn.example.com/foo.css")] + [InlineData("data:text/css;base64,Zm9vIA==")] + public async Task Should_Not_Prefix_External_Urls(string externalUrl) + { + (await _builder.BuildAsync(externalUrl, appBasePath: "/foo", navigationBaseUri: "https://localhost/foo/")) + .ShouldBe(externalUrl); + } + + [Fact] + public async Task Should_Treat_Whitespace_AppBasePath_As_Not_Provided() + { + (await _builder.BuildAsync("/__bundles/Global.css", appBasePath: " ", navigationBaseUri: "https://localhost/foo/")) + .ShouldBe("/foo/__bundles/Global.css"); + } +}