Javiercn/js interop improvements part 1 (#11809)

* Adds exception sanitization to RemoteJSRuntime
* Adds default async timeouts of 1 minute for JS async calls to server-side blazor
This commit is contained in:
Javier Calvarro Nelson 2019-07-05 16:28:41 +02:00 committed by GitHub
parent b31cd35f92
commit 7d545d40aa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 281 additions and 10 deletions

View File

@ -27,6 +27,8 @@ namespace Microsoft.AspNetCore.Components.Server
{ {
public CircuitOptions() { } public CircuitOptions() { }
public System.TimeSpan DisconnectedCircuitRetentionPeriod { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } } public System.TimeSpan DisconnectedCircuitRetentionPeriod { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
public System.TimeSpan JSInteropDefaultCallTimeout { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
public bool JSInteropDetailedErrors { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
public int MaxRetainedDisconnectedCircuits { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } } public int MaxRetainedDisconnectedCircuits { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
} }
public sealed partial class ComponentEndpointConventionBuilder : Microsoft.AspNetCore.Builder.IEndpointConventionBuilder, Microsoft.AspNetCore.SignalR.IHubEndpointConventionBuilder public sealed partial class ComponentEndpointConventionBuilder : Microsoft.AspNetCore.Builder.IEndpointConventionBuilder, Microsoft.AspNetCore.SignalR.IHubEndpointConventionBuilder

View File

@ -2,7 +2,6 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System; using System;
using Microsoft.AspNetCore.DataProtection;
namespace Microsoft.AspNetCore.Components.Server namespace Microsoft.AspNetCore.Components.Server
{ {
@ -46,5 +45,24 @@ namespace Microsoft.AspNetCore.Components.Server
/// Defaults to <c>3 minutes</c>. /// Defaults to <c>3 minutes</c>.
/// </value> /// </value>
public TimeSpan DisconnectedCircuitRetentionPeriod { get; set; } = TimeSpan.FromMinutes(3); public TimeSpan DisconnectedCircuitRetentionPeriod { get; set; } = TimeSpan.FromMinutes(3);
/// <summary>
/// Gets or sets a value that determines whether or not to send detailed exception messages from .NET interop method invocation
/// exceptions to JavaScript.
/// </summary>
/// <remarks>
/// This value should only be turned on in development scenarios as turning it on in production might result in the leak of
/// sensitive information to untrusted parties.
/// </remarks>
/// <value>Defaults to <c>false</c>.</value>
public bool JSInteropDetailedErrors { get; set; }
/// <summary>
/// Gets or sets a value that indicates how long the server will wait before timing out an asynchronous JavaScript function invocation.
/// </summary>
/// <value>
/// Defaults to <c>1 minute</c>.
/// </value>
public TimeSpan JSInteropDefaultCallTimeout { get; set; } = TimeSpan.FromMinutes(1);
} }
} }

View File

@ -0,0 +1,24 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
namespace Microsoft.AspNetCore.Components.Server
{
internal class CircuitOptionsJSInteropDetailedErrorsConfiguration : IConfigureOptions<CircuitOptions>
{
public CircuitOptionsJSInteropDetailedErrorsConfiguration(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
public void Configure(CircuitOptions options)
{
options.JSInteropDetailedErrors = Configuration.GetValue<bool>(WebHostDefaults.DetailedErrorsKey);
}
}
}

View File

@ -3,19 +3,40 @@
using System; using System;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Options;
using Microsoft.JSInterop; using Microsoft.JSInterop;
namespace Microsoft.AspNetCore.Components.Server.Circuits namespace Microsoft.AspNetCore.Components.Server.Circuits
{ {
internal class RemoteJSRuntime : JSRuntimeBase internal class RemoteJSRuntime : JSRuntimeBase
{ {
private readonly CircuitOptions _options;
private CircuitClientProxy _clientProxy; private CircuitClientProxy _clientProxy;
public RemoteJSRuntime(IOptions<CircuitOptions> options)
{
_options = options.Value;
DefaultAsyncTimeout = _options.JSInteropDefaultCallTimeout;
}
internal void Initialize(CircuitClientProxy clientProxy) internal void Initialize(CircuitClientProxy clientProxy)
{ {
_clientProxy = clientProxy ?? throw new ArgumentNullException(nameof(clientProxy)); _clientProxy = clientProxy ?? throw new ArgumentNullException(nameof(clientProxy));
} }
protected override object OnDotNetInvocationException(Exception exception, string assemblyName, string methodIdentifier)
{
if (_options.JSInteropDetailedErrors)
{
return base.OnDotNetInvocationException(exception, assemblyName, methodIdentifier);
}
var message = $"There was an exception invoking '{methodIdentifier}' on assembly '{assemblyName}'. For more details turn on " +
$"detailed exceptions in '{typeof(CircuitOptions).Name}.{nameof(CircuitOptions.JSInteropDetailedErrors)}'";
return message;
}
protected override void BeginInvokeJS(long asyncHandle, string identifier, string argsJson) protected override void BeginInvokeJS(long asyncHandle, string identifier, string argsJson)
{ {
if (!_clientProxy.Connected) if (!_clientProxy.Connected)

View File

@ -78,9 +78,11 @@ namespace Microsoft.Extensions.DependencyInjection
services.AddScoped<IComponentContext, RemoteComponentContext>(); services.AddScoped<IComponentContext, RemoteComponentContext>();
services.AddScoped<AuthenticationStateProvider, FixedAuthenticationStateProvider>(); services.AddScoped<AuthenticationStateProvider, FixedAuthenticationStateProvider>();
services.TryAddEnumerable(ServiceDescriptor.Singleton<IConfigureOptions<CircuitOptions>, CircuitOptionsJSInteropDetailedErrorsConfiguration>());
if (configure != null) if (configure != null)
{ {
services.Configure<CircuitOptions>(configure); services.Configure(configure);
} }
return builder; return builder;

View File

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>netcoreapp3.0</TargetFramework> <TargetFramework>netcoreapp3.0</TargetFramework>

View File

@ -13,6 +13,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Moq; using Moq;
using Microsoft.Extensions.Options;
namespace Microsoft.AspNetCore.Components.Server.Circuits namespace Microsoft.AspNetCore.Components.Server.Circuits
{ {
@ -38,7 +39,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
serviceScope = serviceScope ?? Mock.Of<IServiceScope>(); serviceScope = serviceScope ?? Mock.Of<IServiceScope>();
clientProxy = clientProxy ?? new CircuitClientProxy(Mock.Of<IClientProxy>(), Guid.NewGuid().ToString()); clientProxy = clientProxy ?? new CircuitClientProxy(Mock.Of<IClientProxy>(), Guid.NewGuid().ToString());
var renderRegistry = new RendererRegistry(); var renderRegistry = new RendererRegistry();
var jsRuntime = new RemoteJSRuntime(); var jsRuntime = new RemoteJSRuntime(Options.Create(new CircuitOptions()));
var dispatcher = Rendering.Renderer.CreateDefaultDispatcher(); var dispatcher = Rendering.Renderer.CreateDefaultDispatcher();
if (remoteRenderer == null) if (remoteRenderer == null)

View File

@ -2,7 +2,9 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq;
using System.Reflection; using System.Reflection;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
@ -18,6 +20,8 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures
public AspNetEnvironment Environment { get; set; } = AspNetEnvironment.Production; public AspNetEnvironment Environment { get; set; } = AspNetEnvironment.Production;
public List<string> AdditionalArguments { get; set; } = new List<string>();
protected override IWebHost CreateWebHost() protected override IWebHost CreateWebHost()
{ {
if (BuildWebHostMethod == null) if (BuildWebHostMethod == null)
@ -34,7 +38,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures
"--urls", "http://127.0.0.1:0", "--urls", "http://127.0.0.1:0",
"--contentroot", sampleSitePath, "--contentroot", sampleSitePath,
"--environment", Environment.ToString(), "--environment", Environment.ToString(),
}); }.Concat(AdditionalArguments).ToArray());
} }
} }
} }

View File

@ -2,6 +2,7 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System; using System;
using System.Collections.Generic;
namespace Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures namespace Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures
{ {
@ -15,6 +16,8 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures
private AspNetSiteServerFixture.BuildWebHost _buildWebHostMethod; private AspNetSiteServerFixture.BuildWebHost _buildWebHostMethod;
private IDisposable _serverToDispose; private IDisposable _serverToDispose;
public List<string> AspNetFixtureAdditionalArguments { get; set; } = new List<string>();
public void UseAspNetHost(AspNetSiteServerFixture.BuildWebHost buildWebHostMethod) public void UseAspNetHost(AspNetSiteServerFixture.BuildWebHost buildWebHostMethod)
{ {
_buildWebHostMethod = buildWebHostMethod _buildWebHostMethod = buildWebHostMethod
@ -35,6 +38,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures
{ {
// Use specified ASP.NET host server // Use specified ASP.NET host server
var underlying = new AspNetSiteServerFixture(); var underlying = new AspNetSiteServerFixture();
underlying.AdditionalArguments.AddRange(AspNetFixtureAdditionalArguments);
underlying.BuildWebHostMethod = _buildWebHostMethod; underlying.BuildWebHostMethod = _buildWebHostMethod;
_serverToDispose = underlying; _serverToDispose = underlying;
return underlying.RootUri.AbsoluteUri; return underlying.RootUri.AbsoluteUri;
@ -45,6 +49,12 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures
{ {
_serverToDispose?.Dispose(); _serverToDispose?.Dispose();
} }
internal ToggleExecutionModeServerFixture<TClientProgram> WithAdditionalArguments(string [] additionalArguments)
{
AspNetFixtureAdditionalArguments.AddRange(additionalArguments);
return this;
}
} }
public enum ExecutionMode { Client, Server } public enum ExecutionMode { Client, Server }

View File

@ -7,7 +7,7 @@
<TargetFramework>netcoreapp3.0</TargetFramework> <TargetFramework>netcoreapp3.0</TargetFramework>
<TestGroupName>Components.E2ETests</TestGroupName> <TestGroupName>Components.E2ETests</TestGroupName>
<SkipTests>true</SkipTests> <SkipTests Condition="$(ContinuousIntegrationBuild) == 'true'" >true</SkipTests>
<!-- https://github.com/aspnet/AspNetCore/issues/6857 --> <!-- https://github.com/aspnet/AspNetCore/issues/6857 -->
<BuildHelixPayload>false</BuildHelixPayload> <BuildHelixPayload>false</BuildHelixPayload>

View File

@ -0,0 +1,70 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Collections.Generic;
using BasicTestApp;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
using Microsoft.AspNetCore.Components.Server;
using Microsoft.AspNetCore.E2ETesting;
using OpenQA.Selenium;
using OpenQA.Selenium.Support.UI;
using Xunit;
using Xunit.Abstractions;
namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests
{
public class ServerInteropTestDefaultExceptionsBehavior : BasicTestAppTestBase
{
public ServerInteropTestDefaultExceptionsBehavior(
BrowserFixture browserFixture,
ToggleExecutionModeServerFixture<Program> serverFixture,
ITestOutputHelper output)
: base(browserFixture, serverFixture.WithServerExecution(), output)
{
}
protected override void InitializeAsyncCore()
{
Navigate(ServerPathBase, noReload: true);
MountTestComponent<InteropComponent>();
}
[Fact]
public void DotNetExceptionDetailsAreNotLoggedByDefault()
{
// Arrange
var expectedValues = new Dictionary<string, string>
{
["AsyncThrowSyncException"] = GetExpectedMessage("AsyncThrowSyncException"),
["AsyncThrowAsyncException"] = GetExpectedMessage("AsyncThrowAsyncException"),
};
var actualValues = new Dictionary<string, string>();
// Act
var interopButton = Browser.FindElement(By.Id("btn-interop"));
interopButton.Click();
var wait = new WebDriverWait(Browser, TimeSpan.FromSeconds(10))
.Until(d => d.FindElement(By.Id("done-with-interop")));
foreach (var expectedValue in expectedValues)
{
var currentValue = Browser.FindElement(By.Id(expectedValue.Key));
actualValues.Add(expectedValue.Key, currentValue.Text);
}
// Assert
foreach (var expectedValue in expectedValues)
{
Assert.Equal(expectedValue.Value, actualValues[expectedValue.Key]);
}
string GetExpectedMessage(string method) =>
$"\"There was an exception invoking '{method}' on assembly 'BasicTestApp'. For more details turn on " +
$"detailed exceptions in '{typeof(CircuitOptions).Name}.{nameof(CircuitOptions.JSInteropDetailedErrors)}'\"";
}
}
}

View File

@ -0,0 +1,51 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System.Threading.Tasks;
using BasicTestApp;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
using Microsoft.AspNetCore.E2ETesting;
using OpenQA.Selenium;
using Xunit;
using Xunit.Abstractions;
namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests
{
public class ServerInteropTestJsInvocationsTimeoutsBehavior : BasicTestAppTestBase
{
public ServerInteropTestJsInvocationsTimeoutsBehavior(
BrowserFixture browserFixture,
ToggleExecutionModeServerFixture<Program> serverFixture,
ITestOutputHelper output)
: base(browserFixture, serverFixture.WithServerExecution(), output)
{
}
protected override void InitializeAsyncCore()
{
Navigate(ServerPathBase, noReload: true);
MountTestComponent<LongRunningInterop>();
}
[Fact]
public async Task LongRunningJavaScriptFunctionsResultInCancellationAndWorkingAppAfterFunctionCompletion()
{
// Act & Assert
var interopButton = Browser.FindElement(By.Id("btn-interop"));
interopButton.Click();
Browser.Exists(By.Id("done-with-interop"));
Browser.Exists(By.Id("task-was-cancelled"));
// wait 10 seconds, js method completes in 5 seconds, after this point it would have triggered a completion for sure.
await Task.Delay(10000);
var circuitFunctional = Browser.FindElement(By.Id("circuit-functional"));
circuitFunctional.Click();
Browser.Exists(By.Id("done-circuit-functional"));
}
}
}

View File

@ -2,7 +2,6 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using BasicTestApp; using BasicTestApp;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
using Microsoft.AspNetCore.Components.E2ETest.Tests; using Microsoft.AspNetCore.Components.E2ETest.Tests;
using Microsoft.AspNetCore.E2ETesting; using Microsoft.AspNetCore.E2ETesting;
@ -37,9 +36,12 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests
public class ServerInteropTest : InteropTest public class ServerInteropTest : InteropTest
{ {
public ServerInteropTest(BrowserFixture browserFixture, ToggleExecutionModeServerFixture<Program> serverFixture, ITestOutputHelper output) public ServerInteropTest(BrowserFixture browserFixture, ToggleExecutionModeServerFixture<Program> serverFixture, ITestOutputHelper output)
: base(browserFixture, serverFixture.WithServerExecution(), output) : base(browserFixture, serverFixture.WithServerExecution().WithAdditionalArguments(GetAdditionalArguments()), output)
{ {
} }
private static string[] GetAdditionalArguments() =>
new string[] { "--circuit-detailed-errors", "true" };
} }
public class ServerRoutingTest : RoutingTest public class ServerRoutingTest : RoutingTest

View File

@ -4,6 +4,7 @@
<select @bind=SelectedComponentTypeName> <select @bind=SelectedComponentTypeName>
<option value="none">Choose...</option> <option value="none">Choose...</option>
<option value="BasicTestApp.InteropComponent">Interop component</option> <option value="BasicTestApp.InteropComponent">Interop component</option>
<option value="BasicTestApp.LongRunningInterop">Long running interop</option>
<option value="BasicTestApp.AsyncEventHandlerComponent">Async event handlers</option> <option value="BasicTestApp.AsyncEventHandlerComponent">Async event handlers</option>
<option value="BasicTestApp.AddRemoveChildComponents">Add/remove child components</option> <option value="BasicTestApp.AddRemoveChildComponents">Add/remove child components</option>
<option value="BasicTestApp.CounterComponent">Counter</option> <option value="BasicTestApp.CounterComponent">Counter</option>

View File

@ -0,0 +1,53 @@
@using Microsoft.JSInterop
@using BasicTestApp.InteropTest
@using System.Runtime.InteropServices
@using System.Text.Json
@inject IJSRuntime JSRuntime
<button id="btn-interop" @onclick="@InvokeInteropAsync">Invoke interop!</button>
@if (DoneWithInterop)
{
<button id="circuit-functional" @onclick="@DoWork">Circuit still functional</button>
if (TaskWasCancelled)
{
<p id="task-was-cancelled">Task was cancelled.</p>
}
<p id="done-with-interop">Done with interop.</p>
}
@if (WorkDone)
{
<p id="done-circuit-functional">Circuit is functional after client notification completion.</p>
}
@code {
public bool TaskWasCancelled { get; set; }
public bool WorkDone { get; set; }
public bool DoneWithInterop { get; set; }
public async Task InvokeInteropAsync()
{
try
{
using var cancellationTokenSource = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(1));
await JSRuntime.InvokeAsync<object>(
"asyncFunctionTakesLongerThanDefaultTimeoutToResolve",
Array.Empty<object>(),
cancellationTokenSource.Token);
}
catch (TaskCanceledException)
{
TaskWasCancelled = true;
}
DoneWithInterop = true;
}
public void DoWork()
{
WorkDone = true;
}
}

View File

@ -210,6 +210,12 @@ function asyncFunctionThrowsAsyncException() {
}); });
} }
function asyncFunctionTakesLongerThanDefaultTimeoutToResolve() {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(undefined), 5000);
});
}
function collectInteropResults() { function collectInteropResults() {
let result = {}; let result = {};
let properties = Object.getOwnPropertyNames(results); let properties = Object.getOwnPropertyNames(results);

View File

@ -10,7 +10,6 @@
<Reference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" /> <Reference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" />
<Reference Include="Microsoft.Extensions.Logging.Console" /> <Reference Include="Microsoft.Extensions.Logging.Console" />
<Reference Include="Microsoft.AspNetCore.Components.Server" /> <Reference Include="Microsoft.AspNetCore.Components.Server" />
<Reference Include="System.Text.Json" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -25,7 +25,12 @@ namespace TestServer
{ {
options.AddPolicy("AllowAll", _ => { /* Controlled below */ }); options.AddPolicy("AllowAll", _ => { /* Controlled below */ });
}); });
services.AddServerSideBlazor(); services.AddServerSideBlazor()
.AddCircuitOptions(o =>
{
var detailedErrors = Configuration.GetValue<bool>("circuit-detailed-errors");
o.JSInteropDetailedErrors = detailedErrors;
});
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(); services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie();
services.AddAuthorization(options => services.AddAuthorization(options =>

View File

@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.JSInterop; using Microsoft.JSInterop;
using Microsoft.Net.Http.Headers; using Microsoft.Net.Http.Headers;
@ -484,6 +485,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
private static IHtmlHelper CreateHelper(HttpContext ctx = null, Action<IServiceCollection> configureServices = null) private static IHtmlHelper CreateHelper(HttpContext ctx = null, Action<IServiceCollection> configureServices = null)
{ {
var services = new ServiceCollection(); var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build());
services.AddLogging(); services.AddLogging();
services.AddDataProtection(); services.AddDataProtection();
services.AddSingleton(HtmlEncoder.Default); services.AddSingleton(HtmlEncoder.Default);