diff --git a/src/Aspire.Dashboard/Components/Controls/SummaryDetailsView.razor.cs b/src/Aspire.Dashboard/Components/Controls/SummaryDetailsView.razor.cs index e454b441acf..c0af7c0f417 100644 --- a/src/Aspire.Dashboard/Components/Controls/SummaryDetailsView.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/SummaryDetailsView.razor.cs @@ -4,7 +4,6 @@ using System.Globalization; using Aspire.Dashboard.Components.Resize; using Aspire.Dashboard.Model; -using Aspire.Dashboard.Utils; using Microsoft.AspNetCore.Components; using Microsoft.FluentUI.AspNetCore.Components; using Microsoft.JSInterop; @@ -86,7 +85,7 @@ protected override async Task OnParametersSetAsync() { if (RememberOrientation) { - var orientationResult = await LocalStore.SafeGetAsync(GetOrientationStorageKey()); + var orientationResult = await LocalStore.GetUnprotectedAsync(GetOrientationStorageKey()); if (orientationResult.Success) { Orientation = orientationResult.Value; @@ -95,10 +94,11 @@ protected override async Task OnParametersSetAsync() if (RememberSize) { - var panel1FractionResult = await LocalStore.SafeGetAsync(GetSizeStorageKey()); + var panel1FractionResult = await LocalStore.GetUnprotectedAsync(GetSizeStorageKey()); if (panel1FractionResult.Success) { - SetPanelSizes(panel1FractionResult.Value); + var fraction = Math.Clamp(panel1FractionResult.Value, 0, 1); + SetPanelSizes(fraction); } } } @@ -128,16 +128,16 @@ private async Task HandleToggleOrientation() if (RememberOrientation) { - await LocalStore.SetAsync(GetOrientationStorageKey(), Orientation); + await LocalStore.SetUnprotectedAsync(GetOrientationStorageKey(), Orientation); } if (RememberSize) { - var panel1FractionResult = await LocalStore.SafeGetAsync(GetSizeStorageKey()); + var panel1FractionResult = await LocalStore.GetUnprotectedAsync(GetSizeStorageKey()); if (panel1FractionResult.Success) { - SetPanelSizes(panel1FractionResult.Value); - + var fraction = Math.Clamp(panel1FractionResult.Value, 0, 1); + SetPanelSizes(fraction); } else { @@ -170,7 +170,7 @@ private async Task HandleSplitterResize(SplitterResizedEventArgs args) private async Task SaveSizeToStorage(float panel1Fraction) { - await LocalStore.SetAsync(GetSizeStorageKey(), panel1Fraction); + await LocalStore.SetUnprotectedAsync(GetSizeStorageKey(), panel1Fraction); } private void ResetPanelSizes() @@ -293,13 +293,13 @@ static void GetPanelSizes( private string GetSizeStorageKey() { var viewKey = ViewKey ?? NavigationManager.ToBaseRelativePath(NavigationManager.Uri); - return $"SplitterSize_{Orientation}_{viewKey}"; + return $"Aspire_SplitterSize_{Orientation}_{viewKey}"; } private string GetOrientationStorageKey() { var viewKey = ViewKey ?? NavigationManager.ToBaseRelativePath(NavigationManager.Uri); - return $"SplitterOrientation_{viewKey}"; + return $"Aspire_SplitterOrientation_{viewKey}"; } public void Dispose() diff --git a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs index 1ffcd84290f..7cf8210919e 100644 --- a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs @@ -57,7 +57,7 @@ public sealed partial class ConsoleLogs : ComponentBase, IAsyncDisposable, IPage public ConsoleLogsViewModel PageViewModel { get; set; } = null!; public string BasePath => DashboardUrls.ConsoleLogBasePath; - public string SessionStorageKey => "ConsoleLogs_PageState"; + public string SessionStorageKey => "Aspire_ConsoleLogs_PageState"; protected override async Task OnInitializedAsync() { diff --git a/src/Aspire.Dashboard/Components/Pages/Metrics.razor.cs b/src/Aspire.Dashboard/Components/Pages/Metrics.razor.cs index e971771ac32..d641b44f9d1 100644 --- a/src/Aspire.Dashboard/Components/Pages/Metrics.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/Metrics.razor.cs @@ -27,7 +27,7 @@ public partial class Metrics : IDisposable, IPageWithSessionAndUrlState DashboardUrls.MetricsBasePath; - public string SessionStorageKey => "Metrics_PageState"; + public string SessionStorageKey => "Aspire_Metrics_PageState"; public MetricsViewModel PageViewModel { get; set; } = null!; [Parameter] diff --git a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs index ec1db4e147c..1b8abe3c087 100644 --- a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs @@ -44,7 +44,7 @@ public partial class StructuredLogs : IPageWithSessionAndUrlState DashboardUrls.StructuredLogsBasePath; - public string SessionStorageKey => "StructuredLogs_PageState"; + public string SessionStorageKey => "Aspire_StructuredLogs_PageState"; public StructuredLogsPageViewModel PageViewModel { get; set; } = null!; [Inject] diff --git a/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs b/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs index 8a1fd0064ac..49fb9a4ddef 100644 --- a/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs @@ -40,7 +40,7 @@ public partial class Traces : IPageWithSessionAndUrlState "Traces_PageState"; + public string SessionStorageKey => "Aspire_Traces_PageState"; public string BasePath => DashboardUrls.TracesBasePath; public TracesPageViewModel PageViewModel { get; set; } = null!; diff --git a/src/Aspire.Dashboard/Model/BrowserStorage/ILocalStorage.cs b/src/Aspire.Dashboard/Model/BrowserStorage/ILocalStorage.cs index 4b4b4a3691b..f81e409cb46 100644 --- a/src/Aspire.Dashboard/Model/BrowserStorage/ILocalStorage.cs +++ b/src/Aspire.Dashboard/Model/BrowserStorage/ILocalStorage.cs @@ -1,8 +1,17 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. namespace Aspire.Dashboard.Model.BrowserStorage; public interface ILocalStorage : IBrowserStorage { + /// + /// Get unprotected data from local storage. This must only be used with non-sensitive data. + /// + Task> GetUnprotectedAsync(string key); + + /// + /// Set unprotected data to local storage. This must only be used with non-sensitive data. + /// + Task SetUnprotectedAsync(string key, T value); } diff --git a/src/Aspire.Dashboard/Model/BrowserStorage/LocalBrowserStorage.cs b/src/Aspire.Dashboard/Model/BrowserStorage/LocalBrowserStorage.cs index 5b38dc3aa18..589fe19db77 100644 --- a/src/Aspire.Dashboard/Model/BrowserStorage/LocalBrowserStorage.cs +++ b/src/Aspire.Dashboard/Model/BrowserStorage/LocalBrowserStorage.cs @@ -1,13 +1,60 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Text.Json; using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage; +using Microsoft.JSInterop; namespace Aspire.Dashboard.Model.BrowserStorage; public class LocalBrowserStorage : BrowserStorageBase, ILocalStorage { - public LocalBrowserStorage(ProtectedLocalStorage protectedLocalStorage) : base(protectedLocalStorage) + private static readonly JsonSerializerOptions s_options = new() { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + private readonly IJSRuntime _jsRuntime; + private readonly ILogger _logger; + + public LocalBrowserStorage(IJSRuntime jsRuntime, ProtectedLocalStorage protectedLocalStorage, ILogger logger) : base(protectedLocalStorage) + { + _jsRuntime = jsRuntime; + _logger = logger; } + + public async Task> GetUnprotectedAsync(string key) + { + var json = await GetJsonAsync(key).ConfigureAwait(false); + + if (json == null) + { + return new StorageResult(false, default); + } + + try + { + return new StorageResult(true, JsonSerializer.Deserialize(json, s_options)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, $"Error when reading '{key}' as {typeof(T).Name} from local browser storage."); + + return new StorageResult(false, default); + } + } + + public async Task SetUnprotectedAsync(string key, T value) + { + var json = JsonSerializer.Serialize(value, s_options); + + await SetJsonAsync(key, json).ConfigureAwait(false); + } + + private ValueTask SetJsonAsync(string key, string json) + => _jsRuntime.InvokeVoidAsync("localStorage.setItem", key, json); + + private ValueTask GetJsonAsync(string key) + => _jsRuntime.InvokeAsync("localStorage.getItem", key); } diff --git a/src/Aspire.Dashboard/Model/BrowserStorage/SessionBrowserStorage.cs b/src/Aspire.Dashboard/Model/BrowserStorage/SessionBrowserStorage.cs index 94b9491ac99..f928cf1ba0d 100644 --- a/src/Aspire.Dashboard/Model/BrowserStorage/SessionBrowserStorage.cs +++ b/src/Aspire.Dashboard/Model/BrowserStorage/SessionBrowserStorage.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage; diff --git a/src/Aspire.Dashboard/Utils/IBrowserStorageExtensions.cs b/src/Aspire.Dashboard/Utils/IBrowserStorageExtensions.cs deleted file mode 100644 index 2093c0f8017..00000000000 --- a/src/Aspire.Dashboard/Utils/IBrowserStorageExtensions.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Diagnostics; -using System.Security.Cryptography; - -namespace Aspire.Dashboard.Utils; - -internal static class IBrowserStorageExtensions -{ - /// - /// Retrieves the value associated with the specified key. - /// If there is a CryptographicException, return default instead of throwing. A CryptographicException can occur - /// because the local data protection key for the dashboard has changed, and previously stored data can no longer be - /// successfully decrypted. - /// - public static async Task> SafeGetAsync(this IBrowserStorage value, string key) - { - try - { - return await value.GetAsync(key).ConfigureAwait(false); - } - catch (CryptographicException ex) - { - Debug.WriteLine($"Failed to decrypt data for key '{key}': {ex.Message}"); - return default; - } - } -} diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/TestLocalStorage.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/TestLocalStorage.cs index e13c91d70dc..1f9375ccdef 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/TestLocalStorage.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/TestLocalStorage.cs @@ -9,11 +9,21 @@ public sealed class TestLocalStorage : ILocalStorage { public Task> GetAsync(string key) { - return Task.FromResult>(new StorageResult(Success: false, Value: default)); + return Task.FromResult(new StorageResult(Success: false, Value: default)); + } + + public Task> GetUnprotectedAsync(string key) + { + return Task.FromResult(new StorageResult(Success: false, Value: default)); } public Task SetAsync(string key, T value) { return Task.CompletedTask; } + + public Task SetUnprotectedAsync(string key, T value) + { + return Task.CompletedTask; + } } diff --git a/tests/Aspire.Dashboard.Tests/LocalBrowserStorageTests.cs b/tests/Aspire.Dashboard.Tests/LocalBrowserStorageTests.cs new file mode 100644 index 00000000000..0740cc6e200 --- /dev/null +++ b/tests/Aspire.Dashboard.Tests/LocalBrowserStorageTests.cs @@ -0,0 +1,168 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using Aspire.Dashboard.Model.BrowserStorage; +using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.JSInterop; +using Xunit; + +namespace Aspire.Dashboard.Tests; + +public class LocalBrowserStorageTests +{ + [Theory] + [InlineData(123, "123")] + [InlineData("Hello world", @"""Hello world""")] + [InlineData(null, "null")] + public async Task SetUnprotectedAsync_JSInvokedWithJson(object? value, string result) + { + // Arrange + string? identifier = null; + object?[]? args = null; + + var testJsonRuntime = new TestJSRuntime(); + testJsonRuntime.OnInvoke = r => + { + (identifier, args) = r; + return default; + }; + var localStorage = CreateBrowserLocalStorage(testJsonRuntime); + + // Act + await localStorage.SetUnprotectedAsync("MyKey", value); + + // Assert + Assert.Equal("localStorage.setItem", identifier); + Assert.NotNull(args); + Assert.Equal("MyKey", args[0]); + Assert.Equal(result, args[1]); + } + + [Fact] + public async Task GetUnprotectedAsync_HasValue_Success() + { + // Arrange + string? identifier = null; + object?[]? args = null; + + var testJsonRuntime = new TestJSRuntime(); + testJsonRuntime.OnInvoke = r => + { + (identifier, args) = r; + return "123"; + }; + var localStorage = CreateBrowserLocalStorage(testJsonRuntime); + + // Act + var result = await localStorage.GetUnprotectedAsync("MyKey"); + + // Assert + Assert.True(result.Success); + Assert.Equal(123, result.Value); + Assert.Equal("localStorage.getItem", identifier); + Assert.NotNull(args); + Assert.Equal("MyKey", args[0]); + } + + [Fact] + public async Task GetUnprotectedAsync_NoValue_Failure() + { + // Arrange + string? identifier = null; + object?[]? args = null; + + var testJsonRuntime = new TestJSRuntime(); + testJsonRuntime.OnInvoke = r => + { + (identifier, args) = r; + return default; + }; + var localStorage = CreateBrowserLocalStorage(testJsonRuntime); + + // Act + var result = await localStorage.GetUnprotectedAsync("MyKey"); + + // Assert + Assert.False(result.Success); + Assert.Equal("localStorage.getItem", identifier); + Assert.NotNull(args); + Assert.Equal("MyKey", args[0]); + } + + [Fact] + public async Task GetUnprotectedAsync_InvalidValue_Failure() + { + // Arrange + string? identifier = null; + object?[]? args = null; + + var testJsonRuntime = new TestJSRuntime(); + testJsonRuntime.OnInvoke = r => + { + (identifier, args) = r; + return "One"; + }; + var localStorage = CreateBrowserLocalStorage(testJsonRuntime); + + // Act + var result = await localStorage.GetUnprotectedAsync("MyKey"); + + // Assert + Assert.False(result.Success); + Assert.Equal("localStorage.getItem", identifier); + Assert.NotNull(args); + Assert.Equal("MyKey", args[0]); + } + + private static LocalBrowserStorage CreateBrowserLocalStorage(TestJSRuntime testJsonRuntime) + { + return new LocalBrowserStorage( + testJsonRuntime, + new ProtectedLocalStorage(testJsonRuntime, new TestDataProtector()), + NullLogger.Instance); + } + + private sealed class TestJSRuntime : IJSRuntime + { + public Func<(string Identifier, object?[]? Args), object?>? OnInvoke { get; set; } + + public ValueTask InvokeAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicFields | DynamicallyAccessedMemberTypes.PublicProperties)] TValue>(string identifier, object?[]? args) + { + if (OnInvoke?.Invoke((identifier, args)) is TValue result) + { + return ValueTask.FromResult(result); + } + return default; + } + + public ValueTask InvokeAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicFields | DynamicallyAccessedMemberTypes.PublicProperties)] TValue>(string identifier, CancellationToken cancellationToken, object?[]? args) + { + if (OnInvoke?.Invoke((identifier, args)) is TValue result) + { + return ValueTask.FromResult(result); + } + return default; + } + } + + private sealed class TestDataProtector : IDataProtector + { + public IDataProtector CreateProtector(string purpose) + { + throw new NotImplementedException(); + } + + public byte[] Protect(byte[] plaintext) + { + throw new NotImplementedException(); + } + + public byte[] Unprotect(byte[] protectedData) + { + throw new NotImplementedException(); + } + } +}