Prevent synchronous writes when using Razor (#9395)
* Do not perform synchronous writes to the Response TextWriter after a Razor FlushAsync * Use ViewBuffer to perform async writes to the response when using ViewComponentResult Related to #6397 Fixes https://github.com/aspnet/AspNetCore/issues/4885
This commit is contained in:
parent
fb7e8a6895
commit
2be80522b2
|
|
@ -370,12 +370,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor
|
||||||
var encoder = HtmlEncoder;
|
var encoder = HtmlEncoder;
|
||||||
if (value is IHtmlContent htmlContent)
|
if (value is IHtmlContent htmlContent)
|
||||||
{
|
{
|
||||||
var bufferedWriter = writer as ViewBufferTextWriter;
|
if (writer is ViewBufferTextWriter bufferedWriter)
|
||||||
if (bufferedWriter == null || !bufferedWriter.IsBuffering)
|
|
||||||
{
|
|
||||||
htmlContent.WriteTo(writer, encoder);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
if (value is IHtmlContentContainer htmlContentContainer)
|
if (value is IHtmlContentContainer htmlContentContainer)
|
||||||
{
|
{
|
||||||
|
|
@ -389,6 +384,10 @@ namespace Microsoft.AspNetCore.Mvc.Razor
|
||||||
bufferedWriter.Buffer.AppendHtml(htmlContent);
|
bufferedWriter.Buffer.AppendHtml(htmlContent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
htmlContent.WriteTo(writer, encoder);
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -233,7 +233,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor
|
||||||
// (including the layout page we just rendered).
|
// (including the layout page we just rendered).
|
||||||
while (!string.IsNullOrEmpty(previousPage.Layout))
|
while (!string.IsNullOrEmpty(previousPage.Layout))
|
||||||
{
|
{
|
||||||
if (!bodyWriter.IsBuffering)
|
if (bodyWriter.Flushed)
|
||||||
{
|
{
|
||||||
// Once a call to RazorPage.FlushAsync is made, we can no longer render Layout pages - content has
|
// Once a call to RazorPage.FlushAsync is made, we can no longer render Layout pages - content has
|
||||||
// already been written to the client and the layout content would be appended rather than surround
|
// already been written to the client and the layout content would be appended rather than surround
|
||||||
|
|
@ -274,25 +274,22 @@ namespace Microsoft.AspNetCore.Mvc.Razor
|
||||||
layoutPage.EnsureRenderedBodyOrSections();
|
layoutPage.EnsureRenderedBodyOrSections();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bodyWriter.IsBuffering)
|
// We've got a bunch of content in the view buffer. How to best deal with it
|
||||||
|
// really depends on whether or not we're writing directly to the output or if we're writing to
|
||||||
|
// another buffer.
|
||||||
|
if (context.Writer is ViewBufferTextWriter viewBufferTextWriter)
|
||||||
{
|
{
|
||||||
// If IsBuffering - then we've got a bunch of content in the view buffer. How to best deal with it
|
// This means we're writing to another buffer. Use MoveTo to combine them.
|
||||||
// really depends on whether or not we're writing directly to the output or if we're writing to
|
bodyWriter.Buffer.MoveTo(viewBufferTextWriter.Buffer);
|
||||||
// another buffer.
|
}
|
||||||
var viewBufferTextWriter = context.Writer as ViewBufferTextWriter;
|
else
|
||||||
if (viewBufferTextWriter == null || !viewBufferTextWriter.IsBuffering)
|
{
|
||||||
|
// This means we're writing to a 'real' writer, probably to the actual output stream.
|
||||||
|
// We're using PagedBufferedTextWriter here to 'smooth' synchronous writes of IHtmlContent values.
|
||||||
|
using (var writer = _bufferScope.CreateWriter(context.Writer))
|
||||||
{
|
{
|
||||||
// This means we're writing to a 'real' writer, probably to the actual output stream.
|
await bodyWriter.Buffer.WriteToAsync(writer, _htmlEncoder);
|
||||||
// We're using PagedBufferedTextWriter here to 'smooth' synchronous writes of IHtmlContent values.
|
await writer.FlushAsync();
|
||||||
using (var writer = _bufferScope.CreateWriter(context.Writer))
|
|
||||||
{
|
|
||||||
await bodyWriter.Buffer.WriteToAsync(writer, _htmlEncoder);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// This means we're writing to another buffer. Use MoveTo to combine them.
|
|
||||||
bodyWriter.Buffer.MoveTo(viewBufferTextWriter.Buffer);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1204,7 +1204,9 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
|
||||||
}
|
}
|
||||||
public partial class ViewComponentResultExecutor : Microsoft.AspNetCore.Mvc.Infrastructure.IActionResultExecutor<Microsoft.AspNetCore.Mvc.ViewComponentResult>
|
public partial class ViewComponentResultExecutor : Microsoft.AspNetCore.Mvc.Infrastructure.IActionResultExecutor<Microsoft.AspNetCore.Mvc.ViewComponentResult>
|
||||||
{
|
{
|
||||||
|
[System.ObsoleteAttribute("This constructor is obsolete and will be removed in a future version.")]
|
||||||
public ViewComponentResultExecutor(Microsoft.Extensions.Options.IOptions<Microsoft.AspNetCore.Mvc.MvcViewOptions> mvcHelperOptions, Microsoft.Extensions.Logging.ILoggerFactory loggerFactory, System.Text.Encodings.Web.HtmlEncoder htmlEncoder, Microsoft.AspNetCore.Mvc.ModelBinding.IModelMetadataProvider modelMetadataProvider, Microsoft.AspNetCore.Mvc.ViewFeatures.ITempDataDictionaryFactory tempDataDictionaryFactory) { }
|
public ViewComponentResultExecutor(Microsoft.Extensions.Options.IOptions<Microsoft.AspNetCore.Mvc.MvcViewOptions> mvcHelperOptions, Microsoft.Extensions.Logging.ILoggerFactory loggerFactory, System.Text.Encodings.Web.HtmlEncoder htmlEncoder, Microsoft.AspNetCore.Mvc.ModelBinding.IModelMetadataProvider modelMetadataProvider, Microsoft.AspNetCore.Mvc.ViewFeatures.ITempDataDictionaryFactory tempDataDictionaryFactory) { }
|
||||||
|
public ViewComponentResultExecutor(Microsoft.Extensions.Options.IOptions<Microsoft.AspNetCore.Mvc.MvcViewOptions> mvcHelperOptions, Microsoft.Extensions.Logging.ILoggerFactory loggerFactory, System.Text.Encodings.Web.HtmlEncoder htmlEncoder, Microsoft.AspNetCore.Mvc.ModelBinding.IModelMetadataProvider modelMetadataProvider, Microsoft.AspNetCore.Mvc.ViewFeatures.ITempDataDictionaryFactory tempDataDictionaryFactory, Microsoft.AspNetCore.Mvc.Infrastructure.IHttpResponseStreamWriterFactory writerFactory) { }
|
||||||
[System.Diagnostics.DebuggerStepThroughAttribute]
|
[System.Diagnostics.DebuggerStepThroughAttribute]
|
||||||
public virtual System.Threading.Tasks.Task ExecuteAsync(Microsoft.AspNetCore.Mvc.ActionContext context, Microsoft.AspNetCore.Mvc.ViewComponentResult result) { throw null; }
|
public virtual System.Threading.Tasks.Task ExecuteAsync(Microsoft.AspNetCore.Mvc.ActionContext context, Microsoft.AspNetCore.Mvc.ViewComponentResult result) { throw null; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -86,25 +86,20 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Buffers
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override Encoding Encoding { get; }
|
public override Encoding Encoding { get; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public bool IsBuffering { get; private set; } = true;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the <see cref="ViewBuffer"/>.
|
/// Gets the <see cref="ViewBuffer"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public ViewBuffer Buffer { get; }
|
public ViewBuffer Buffer { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value that indiciates if <see cref="Flush"/> or <see cref="FlushAsync" /> was invoked.
|
||||||
|
/// </summary>
|
||||||
|
public bool Flushed { get; private set; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override void Write(char value)
|
public override void Write(char value)
|
||||||
{
|
{
|
||||||
if (IsBuffering)
|
Buffer.AppendHtml(value.ToString());
|
||||||
{
|
|
||||||
Buffer.AppendHtml(value.ToString());
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_inner.Write(value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|
@ -125,14 +120,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Buffers
|
||||||
throw new ArgumentOutOfRangeException(nameof(count));
|
throw new ArgumentOutOfRangeException(nameof(count));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IsBuffering)
|
Buffer.AppendHtml(new string(buffer, index, count));
|
||||||
{
|
|
||||||
Buffer.AppendHtml(new string(buffer, index, count));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_inner.Write(buffer, index, count);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|
@ -143,14 +131,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Buffers
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IsBuffering)
|
Buffer.AppendHtml(value);
|
||||||
{
|
|
||||||
Buffer.AppendHtml(value);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_inner.Write(value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|
@ -186,14 +167,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Buffers
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IsBuffering)
|
Buffer.AppendHtml(value);
|
||||||
{
|
|
||||||
Buffer.AppendHtml(value);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
value.WriteTo(_inner, _htmlEncoder);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -207,14 +181,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Buffers
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IsBuffering)
|
value.MoveTo(Buffer);
|
||||||
{
|
|
||||||
value.MoveTo(Buffer);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
value.WriteTo(_inner, _htmlEncoder);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|
@ -245,15 +212,8 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Buffers
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override Task WriteAsync(char value)
|
public override Task WriteAsync(char value)
|
||||||
{
|
{
|
||||||
if (IsBuffering)
|
Buffer.AppendHtml(value.ToString());
|
||||||
{
|
return Task.CompletedTask;
|
||||||
Buffer.AppendHtml(value.ToString());
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
return _inner.WriteAsync(value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|
@ -273,121 +233,64 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Buffers
|
||||||
throw new ArgumentOutOfRangeException(nameof(count));
|
throw new ArgumentOutOfRangeException(nameof(count));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IsBuffering)
|
Buffer.AppendHtml(new string(buffer, index, count));
|
||||||
{
|
return Task.CompletedTask;
|
||||||
Buffer.AppendHtml(new string(buffer, index, count));
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
return _inner.WriteAsync(buffer, index, count);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override Task WriteAsync(string value)
|
public override Task WriteAsync(string value)
|
||||||
{
|
{
|
||||||
if (IsBuffering)
|
Buffer.AppendHtml(value);
|
||||||
{
|
return Task.CompletedTask;
|
||||||
Buffer.AppendHtml(value);
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
return _inner.WriteAsync(value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override void WriteLine()
|
public override void WriteLine()
|
||||||
{
|
{
|
||||||
if (IsBuffering)
|
Buffer.AppendHtml(NewLine);
|
||||||
{
|
|
||||||
Buffer.AppendHtml(NewLine);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_inner.WriteLine();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override void WriteLine(string value)
|
public override void WriteLine(string value)
|
||||||
{
|
{
|
||||||
if (IsBuffering)
|
Buffer.AppendHtml(value);
|
||||||
{
|
Buffer.AppendHtml(NewLine);
|
||||||
Buffer.AppendHtml(value);
|
|
||||||
Buffer.AppendHtml(NewLine);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_inner.WriteLine(value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override Task WriteLineAsync(char value)
|
public override Task WriteLineAsync(char value)
|
||||||
{
|
{
|
||||||
if (IsBuffering)
|
Buffer.AppendHtml(value.ToString());
|
||||||
{
|
Buffer.AppendHtml(NewLine);
|
||||||
Buffer.AppendHtml(value.ToString());
|
return Task.CompletedTask;
|
||||||
Buffer.AppendHtml(NewLine);
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
return _inner.WriteLineAsync(value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override Task WriteLineAsync(char[] value, int start, int offset)
|
public override Task WriteLineAsync(char[] value, int start, int offset)
|
||||||
{
|
{
|
||||||
if (IsBuffering)
|
Buffer.AppendHtml(new string(value, start, offset));
|
||||||
{
|
Buffer.AppendHtml(NewLine);
|
||||||
Buffer.AppendHtml(new string(value, start, offset));
|
return Task.CompletedTask;
|
||||||
Buffer.AppendHtml(NewLine);
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
return _inner.WriteLineAsync(value, start, offset);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override Task WriteLineAsync(string value)
|
public override Task WriteLineAsync(string value)
|
||||||
{
|
{
|
||||||
if (IsBuffering)
|
Buffer.AppendHtml(value);
|
||||||
{
|
Buffer.AppendHtml(NewLine);
|
||||||
Buffer.AppendHtml(value);
|
return Task.CompletedTask;
|
||||||
Buffer.AppendHtml(NewLine);
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
return _inner.WriteLineAsync(value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override Task WriteLineAsync()
|
public override Task WriteLineAsync()
|
||||||
{
|
{
|
||||||
if (IsBuffering)
|
Buffer.AppendHtml(NewLine);
|
||||||
{
|
return Task.CompletedTask;
|
||||||
Buffer.AppendHtml(NewLine);
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
return _inner.WriteLineAsync();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Copies the buffered content to the unbuffered writer and invokes flush on it.
|
/// Copies the buffered content to the unbuffered writer and invokes flush on it.
|
||||||
/// Additionally causes this instance to no longer buffer and direct all write operations
|
|
||||||
/// to the unbuffered writer.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public override void Flush()
|
public override void Flush()
|
||||||
{
|
{
|
||||||
|
|
@ -396,20 +299,16 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Buffers
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IsBuffering)
|
Flushed = true;
|
||||||
{
|
|
||||||
IsBuffering = false;
|
Buffer.WriteTo(_inner, _htmlEncoder);
|
||||||
Buffer.WriteTo(_inner, _htmlEncoder);
|
Buffer.Clear();
|
||||||
Buffer.Clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
_inner.Flush();
|
_inner.Flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Copies the buffered content to the unbuffered writer and invokes flush on it.
|
/// Copies the buffered content to the unbuffered writer and invokes flush on it.
|
||||||
/// Additionally causes this instance to no longer buffer and direct all write operations
|
|
||||||
/// to the unbuffered writer.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>A <see cref="Task"/> that represents the asynchronous copy and flush operations.</returns>
|
/// <returns>A <see cref="Task"/> that represents the asynchronous copy and flush operations.</returns>
|
||||||
public override async Task FlushAsync()
|
public override async Task FlushAsync()
|
||||||
|
|
@ -419,12 +318,10 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Buffers
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IsBuffering)
|
Flushed = true;
|
||||||
{
|
|
||||||
IsBuffering = false;
|
await Buffer.WriteToAsync(_inner, _htmlEncoder);
|
||||||
await Buffer.WriteToAsync(_inner, _htmlEncoder);
|
Buffer.Clear();
|
||||||
Buffer.Clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
await _inner.FlushAsync();
|
await _inner.FlushAsync();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.IO;
|
||||||
using System.Text.Encodings.Web;
|
using System.Text.Encodings.Web;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.AspNetCore.Html;
|
using Microsoft.AspNetCore.Html;
|
||||||
|
|
@ -9,8 +10,8 @@ using Microsoft.AspNetCore.Mvc.Formatters;
|
||||||
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||||
|
using Microsoft.AspNetCore.Mvc.ViewFeatures.Buffers;
|
||||||
using Microsoft.AspNetCore.Mvc.ViewFeatures.Filters;
|
using Microsoft.AspNetCore.Mvc.ViewFeatures.Filters;
|
||||||
using Microsoft.AspNetCore.WebUtilities;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
@ -24,13 +25,26 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
|
||||||
private readonly ILogger<ViewComponentResult> _logger;
|
private readonly ILogger<ViewComponentResult> _logger;
|
||||||
private readonly IModelMetadataProvider _modelMetadataProvider;
|
private readonly IModelMetadataProvider _modelMetadataProvider;
|
||||||
private readonly ITempDataDictionaryFactory _tempDataDictionaryFactory;
|
private readonly ITempDataDictionaryFactory _tempDataDictionaryFactory;
|
||||||
|
private IHttpResponseStreamWriterFactory _writerFactory;
|
||||||
|
|
||||||
|
[Obsolete("This constructor is obsolete and will be removed in a future version.")]
|
||||||
public ViewComponentResultExecutor(
|
public ViewComponentResultExecutor(
|
||||||
IOptions<MvcViewOptions> mvcHelperOptions,
|
IOptions<MvcViewOptions> mvcHelperOptions,
|
||||||
ILoggerFactory loggerFactory,
|
ILoggerFactory loggerFactory,
|
||||||
HtmlEncoder htmlEncoder,
|
HtmlEncoder htmlEncoder,
|
||||||
IModelMetadataProvider modelMetadataProvider,
|
IModelMetadataProvider modelMetadataProvider,
|
||||||
ITempDataDictionaryFactory tempDataDictionaryFactory)
|
ITempDataDictionaryFactory tempDataDictionaryFactory)
|
||||||
|
: this(mvcHelperOptions, loggerFactory, htmlEncoder, modelMetadataProvider, tempDataDictionaryFactory, null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public ViewComponentResultExecutor(
|
||||||
|
IOptions<MvcViewOptions> mvcHelperOptions,
|
||||||
|
ILoggerFactory loggerFactory,
|
||||||
|
HtmlEncoder htmlEncoder,
|
||||||
|
IModelMetadataProvider modelMetadataProvider,
|
||||||
|
ITempDataDictionaryFactory tempDataDictionaryFactory,
|
||||||
|
IHttpResponseStreamWriterFactory writerFactory)
|
||||||
{
|
{
|
||||||
if (mvcHelperOptions == null)
|
if (mvcHelperOptions == null)
|
||||||
{
|
{
|
||||||
|
|
@ -62,6 +76,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
|
||||||
_htmlEncoder = htmlEncoder;
|
_htmlEncoder = htmlEncoder;
|
||||||
_modelMetadataProvider = modelMetadataProvider;
|
_modelMetadataProvider = modelMetadataProvider;
|
||||||
_tempDataDictionaryFactory = tempDataDictionaryFactory;
|
_tempDataDictionaryFactory = tempDataDictionaryFactory;
|
||||||
|
_writerFactory = writerFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|
@ -105,14 +120,9 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
|
||||||
response.StatusCode = result.StatusCode.Value;
|
response.StatusCode = result.StatusCode.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Opt into sync IO support until we can work out an alternative https://github.com/aspnet/AspNetCore/issues/6397
|
_writerFactory ??= context.HttpContext.RequestServices.GetRequiredService<IHttpResponseStreamWriterFactory>();
|
||||||
var syncIOFeature = context.HttpContext.Features.Get<Http.Features.IHttpBodyControlFeature>();
|
|
||||||
if (syncIOFeature != null)
|
|
||||||
{
|
|
||||||
syncIOFeature.AllowSynchronousIO = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
using (var writer = new HttpResponseStreamWriter(response.Body, resolvedContentTypeEncoding))
|
using (var writer = _writerFactory.CreateWriter(response.Body, resolvedContentTypeEncoding))
|
||||||
{
|
{
|
||||||
var viewContext = new ViewContext(
|
var viewContext = new ViewContext(
|
||||||
context,
|
context,
|
||||||
|
|
@ -127,9 +137,26 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
|
||||||
// IViewComponentHelper is stateful, we want to make sure to retrieve it every time we need it.
|
// IViewComponentHelper is stateful, we want to make sure to retrieve it every time we need it.
|
||||||
var viewComponentHelper = context.HttpContext.RequestServices.GetRequiredService<IViewComponentHelper>();
|
var viewComponentHelper = context.HttpContext.RequestServices.GetRequiredService<IViewComponentHelper>();
|
||||||
(viewComponentHelper as IViewContextAware)?.Contextualize(viewContext);
|
(viewComponentHelper as IViewContextAware)?.Contextualize(viewContext);
|
||||||
|
|
||||||
var viewComponentResult = await GetViewComponentResult(viewComponentHelper, _logger, result);
|
var viewComponentResult = await GetViewComponentResult(viewComponentHelper, _logger, result);
|
||||||
viewComponentResult.WriteTo(writer, _htmlEncoder);
|
|
||||||
|
if (viewComponentResult is ViewBuffer viewBuffer)
|
||||||
|
{
|
||||||
|
// In the ordinary case, DefaultViewComponentHelper will return an instance of ViewBuffer. We can simply
|
||||||
|
// invoke WriteToAsync on it.
|
||||||
|
await viewBuffer.WriteToAsync(writer, _htmlEncoder);
|
||||||
|
await writer.FlushAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
using var memoryStream = new MemoryStream();
|
||||||
|
using (var intermediateWriter = _writerFactory.CreateWriter(response.Body, resolvedContentTypeEncoding))
|
||||||
|
{
|
||||||
|
viewComponentResult.WriteTo(intermediateWriter, _htmlEncoder);
|
||||||
|
}
|
||||||
|
|
||||||
|
memoryStream.Position = 0;
|
||||||
|
await memoryStream.CopyToAsync(response.Body);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.AspNetCore.Html;
|
|
||||||
using Microsoft.AspNetCore.Testing;
|
using Microsoft.AspNetCore.Testing;
|
||||||
using Microsoft.Extensions.WebEncoders.Testing;
|
using Microsoft.Extensions.WebEncoders.Testing;
|
||||||
using Moq;
|
using Moq;
|
||||||
|
|
@ -19,7 +18,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Buffers
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
[ReplaceCulture]
|
[ReplaceCulture]
|
||||||
public void Write_WritesDataTypes_ToBuffer()
|
public void Write_WritesDataTypes()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var expected = new object[] { "True", "3", "18446744073709551615", "Hello world", "3.14", "2.718", "m" };
|
var expected = new object[] { "True", "3", "18446744073709551615", "Hello world", "3.14", "2.718", "m" };
|
||||||
|
|
@ -41,86 +40,31 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Buffers
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
[ReplaceCulture]
|
[ReplaceCulture]
|
||||||
public void Write_WritesDataTypes_ToUnderlyingStream_WhenNotBuffering()
|
public async Task Write_WritesDataTypes_AfterFlush()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var expected = new[] { "True", "3", "18446744073709551615", "Hello world", "3.14", "2.718" };
|
var expected = new object[] { "True", "3", "18446744073709551615", "Hello world", "3.14", "2.718", "m" };
|
||||||
var inner = new Mock<TextWriter>();
|
|
||||||
var buffer = new ViewBuffer(new TestViewBufferScope(), "some-name", pageSize: 4);
|
var buffer = new ViewBuffer(new TestViewBufferScope(), "some-name", pageSize: 4);
|
||||||
var writer = new ViewBufferTextWriter(buffer, Encoding.UTF8, new HtmlTestEncoder(), inner.Object);
|
var writer = new ViewBufferTextWriter(buffer, Encoding.UTF8);
|
||||||
var testClass = new TestClass();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
writer.Flush();
|
|
||||||
writer.Write(true);
|
|
||||||
writer.Write(3);
|
|
||||||
writer.Write(ulong.MaxValue);
|
|
||||||
writer.Write(testClass);
|
|
||||||
writer.Write(3.14);
|
|
||||||
writer.Write(2.718m);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.Equal(0, buffer.Count);
|
|
||||||
foreach (var item in expected)
|
|
||||||
{
|
|
||||||
inner.Verify(v => v.Write(item), Times.Once());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
[ReplaceCulture]
|
|
||||||
public async Task Write_WritesCharValues_ToUnderlyingStream_WhenNotBuffering()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var inner = new Mock<TextWriter> { CallBase = true };
|
|
||||||
var buffer = new ViewBuffer(new TestViewBufferScope(), "some-name", pageSize: 4);
|
|
||||||
var writer = new ViewBufferTextWriter(buffer, Encoding.UTF8, new HtmlTestEncoder(), inner.Object);
|
|
||||||
var buffer1 = new[] { 'a', 'b', 'c', 'd' };
|
|
||||||
var buffer2 = new[] { 'd', 'e', 'f' };
|
|
||||||
|
|
||||||
// Act
|
|
||||||
writer.Flush();
|
|
||||||
writer.Write('x');
|
|
||||||
writer.Write(buffer1, 1, 2);
|
|
||||||
writer.Write(buffer2);
|
|
||||||
await writer.WriteAsync(buffer2, 1, 1);
|
|
||||||
await writer.WriteLineAsync(buffer1);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
inner.Verify(v => v.Write('x'), Times.Once());
|
|
||||||
inner.Verify(v => v.Write(buffer1, 1, 2), Times.Once());
|
|
||||||
inner.Verify(v => v.Write(buffer1, 0, 4), Times.Once());
|
|
||||||
inner.Verify(v => v.Write(buffer2, 0, 3), Times.Once());
|
|
||||||
inner.Verify(v => v.WriteAsync(buffer2, 1, 1), Times.Once());
|
|
||||||
inner.Verify(v => v.WriteLine(), Times.Once());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
[ReplaceCulture]
|
|
||||||
public async Task Write_WritesStringValues_ToUnbufferedStream_WhenNotBuffering()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var inner = new Mock<TextWriter>();
|
|
||||||
var buffer = new ViewBuffer(new TestViewBufferScope(), "some-name", pageSize: 4);
|
|
||||||
var writer = new ViewBufferTextWriter(buffer, Encoding.UTF8, new HtmlTestEncoder(), inner.Object);
|
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await writer.FlushAsync();
|
await writer.FlushAsync();
|
||||||
writer.Write("a");
|
|
||||||
writer.WriteLine("ab");
|
writer.Write(true);
|
||||||
await writer.WriteAsync("ef");
|
writer.Write(3);
|
||||||
await writer.WriteLineAsync("gh");
|
writer.Write(ulong.MaxValue);
|
||||||
|
writer.Write(new TestClass());
|
||||||
|
writer.Write(3.14);
|
||||||
|
writer.Write(2.718m);
|
||||||
|
writer.Write('m');
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
inner.Verify(v => v.Write("a"), Times.Once());
|
Assert.Equal(expected, GetValues(buffer));
|
||||||
inner.Verify(v => v.WriteLine("ab"), Times.Once());
|
|
||||||
inner.Verify(v => v.WriteAsync("ef"), Times.Once());
|
|
||||||
inner.Verify(v => v.WriteLineAsync("gh"), Times.Once());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
[ReplaceCulture]
|
[ReplaceCulture]
|
||||||
public void WriteLine_WritesDataTypes_ToBuffer()
|
public void WriteLine_WritesDataTypes()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var newLine = Environment.NewLine;
|
var newLine = Environment.NewLine;
|
||||||
|
|
@ -139,9 +83,11 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Buffers
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
[ReplaceCulture]
|
[ReplaceCulture]
|
||||||
public void WriteLine_WritesDataTypes_ToUnbufferedStream_WhenNotBuffering()
|
public void WriteLine_WritesDataType_AfterFlush()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
var newLine = Environment.NewLine;
|
||||||
|
var expected = new List<object> { "False", newLine, "1.1", newLine, "3", newLine };
|
||||||
var inner = new Mock<TextWriter>();
|
var inner = new Mock<TextWriter>();
|
||||||
var buffer = new ViewBuffer(new TestViewBufferScope(), "some-name", pageSize: 4);
|
var buffer = new ViewBuffer(new TestViewBufferScope(), "some-name", pageSize: 4);
|
||||||
var writer = new ViewBufferTextWriter(buffer, Encoding.UTF8, new HtmlTestEncoder(), inner.Object);
|
var writer = new ViewBufferTextWriter(buffer, Encoding.UTF8, new HtmlTestEncoder(), inner.Object);
|
||||||
|
|
@ -153,10 +99,12 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Buffers
|
||||||
writer.WriteLine(3L);
|
writer.WriteLine(3L);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
inner.Verify(v => v.Write("False"), Times.Once());
|
inner.Verify(v => v.Write("False"), Times.Never());
|
||||||
inner.Verify(v => v.Write("1.1"), Times.Once());
|
inner.Verify(v => v.Write("1.1"), Times.Never());
|
||||||
inner.Verify(v => v.Write("3"), Times.Once());
|
inner.Verify(v => v.Write("3"), Times.Never());
|
||||||
inner.Verify(v => v.WriteLine(), Times.Exactly(3));
|
inner.Verify(v => v.WriteLine(), Times.Never());
|
||||||
|
|
||||||
|
Assert.Equal(expected, GetValues(buffer));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -199,25 +147,6 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Buffers
|
||||||
Assert.Equal<object>(new[] { input1, input2, newLine, input3, input4, newLine }, actual);
|
Assert.Equal<object>(new[] { input1, input2, newLine, input3, input4, newLine }, actual);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Write_HtmlContent_AfterFlush_GoesToStream()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var inner = new StringWriter();
|
|
||||||
var buffer = new ViewBuffer(new TestViewBufferScope(), "some-name", pageSize: 4);
|
|
||||||
var writer = new ViewBufferTextWriter(buffer, Encoding.UTF8, new HtmlTestEncoder(), inner);
|
|
||||||
|
|
||||||
writer.Flush();
|
|
||||||
|
|
||||||
var content = new HtmlString("Hello, world!");
|
|
||||||
|
|
||||||
// Act
|
|
||||||
writer.Write(content);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.Equal("Hello, world!", inner.ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static object[] GetValues(ViewBuffer buffer)
|
private static object[] GetValues(ViewBuffer buffer)
|
||||||
{
|
{
|
||||||
var pages = new List<ViewBufferPage>();
|
var pages = new List<ViewBufferPage>();
|
||||||
|
|
|
||||||
|
|
@ -397,6 +397,48 @@ namespace Microsoft.AspNetCore.Mvc
|
||||||
Assert.Equal("Hello, World!", body);
|
Assert.Equal("Hello, World!", body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExecuteResultAsync_WithCustomViewComponentHelper()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var expected = "Hello from custom helper";
|
||||||
|
var methodInfo = typeof(TextViewComponent).GetMethod(nameof(TextViewComponent.Invoke));
|
||||||
|
var descriptor = new ViewComponentDescriptor()
|
||||||
|
{
|
||||||
|
FullName = "Full.Name.Text",
|
||||||
|
ShortName = "Text",
|
||||||
|
TypeInfo = typeof(TextViewComponent).GetTypeInfo(),
|
||||||
|
MethodInfo = methodInfo,
|
||||||
|
Parameters = methodInfo.GetParameters(),
|
||||||
|
};
|
||||||
|
var result = Task.FromResult<IHtmlContent>(new HtmlContentBuilder().AppendHtml(expected));
|
||||||
|
|
||||||
|
var helper = Mock.Of<IViewComponentHelper>(h => h.InvokeAsync(It.IsAny<Type>(), It.IsAny<object>()) == result);
|
||||||
|
|
||||||
|
var httpContext = new DefaultHttpContext();
|
||||||
|
var services = CreateServices(diagnosticListener: null, httpContext, new[] { descriptor });
|
||||||
|
services.AddSingleton<IViewComponentHelper>(helper);
|
||||||
|
|
||||||
|
httpContext.RequestServices = services.BuildServiceProvider();
|
||||||
|
httpContext.Response.Body = new MemoryStream();
|
||||||
|
|
||||||
|
var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor());
|
||||||
|
|
||||||
|
var viewComponentResult = new ViewComponentResult()
|
||||||
|
{
|
||||||
|
Arguments = new { name = "World!" },
|
||||||
|
ViewComponentType = typeof(TextViewComponent),
|
||||||
|
TempData = _tempDataDictionary,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await viewComponentResult.ExecuteResultAsync(actionContext);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var body = ReadBody(actionContext.HttpContext.Response);
|
||||||
|
Assert.Equal(expected, body);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ExecuteResultAsync_SetsStatusCode()
|
public async Task ExecuteResultAsync_SetsStatusCode()
|
||||||
{
|
{
|
||||||
|
|
@ -600,6 +642,7 @@ namespace Microsoft.AspNetCore.Mvc
|
||||||
services.AddSingleton<HtmlEncoder, HtmlTestEncoder>();
|
services.AddSingleton<HtmlEncoder, HtmlTestEncoder>();
|
||||||
services.AddSingleton<IViewBufferScope, TestViewBufferScope>();
|
services.AddSingleton<IViewBufferScope, TestViewBufferScope>();
|
||||||
services.AddSingleton<IActionResultExecutor<ViewComponentResult>, ViewComponentResultExecutor>();
|
services.AddSingleton<IActionResultExecutor<ViewComponentResult>, ViewComponentResultExecutor>();
|
||||||
|
services.AddSingleton<IHttpResponseStreamWriterFactory, TestHttpResponseStreamWriterFactory>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
@ -625,7 +668,6 @@ namespace Microsoft.AspNetCore.Mvc
|
||||||
return CreateActionContext(null, descriptors);
|
return CreateActionContext(null, descriptors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private class FixedSetViewComponentDescriptorProvider : IViewComponentDescriptorProvider
|
private class FixedSetViewComponentDescriptorProvider : IViewComponentDescriptorProvider
|
||||||
{
|
{
|
||||||
private readonly ViewComponentDescriptor[] _descriptors;
|
private readonly ViewComponentDescriptor[] _descriptors;
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,33 @@ RenderBody content
|
||||||
Assert.Equal(expected, body, ignoreLineEndingDifferences: true);
|
Assert.Equal(expected, body, ignoreLineEndingDifferences: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FlushFollowedByLargeContent()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var expected = new string('a', 1024 * 1024);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var document = await Client.GetHtmlDocumentAsync("http://localhost/FlushPoint/FlushFollowedByLargeContent");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var largeContent = document.RequiredQuerySelector("#large-content");
|
||||||
|
Assert.StartsWith(expected, largeContent.TextContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FlushInvokedInComponent()
|
||||||
|
{
|
||||||
|
var expected = new string('a', 1024 * 1024);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var document = await Client.GetHtmlDocumentAsync("http://localhost/FlushPoint/FlushInvokedInComponent");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var largeContent = document.RequiredQuerySelector("#large-content");
|
||||||
|
Assert.StartsWith(expected, largeContent.TextContent);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task FlushPointsAreExecutedForPagesWithoutLayouts()
|
public async Task FlushPointsAreExecutedForPagesWithoutLayouts()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
// 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.Mvc;
|
||||||
|
|
||||||
|
namespace MvcSample.Web.Components
|
||||||
|
{
|
||||||
|
[ViewComponent(Name = "ComponentWithFlush")]
|
||||||
|
public class ComponentWithFlush : ViewComponent
|
||||||
|
{
|
||||||
|
public IViewComponentResult Invoke()
|
||||||
|
{
|
||||||
|
return View();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,10 @@ namespace RazorWebSite
|
||||||
return View();
|
return View();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public IActionResult FlushFollowedByLargeContent() => View();
|
||||||
|
|
||||||
|
public IActionResult FlushInvokedInComponent() => View();
|
||||||
|
|
||||||
public IActionResult PageWithoutLayout()
|
public IActionResult PageWithoutLayout()
|
||||||
{
|
{
|
||||||
return View();
|
return View();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
Header content
|
||||||
|
@{
|
||||||
|
await FlushAsync();
|
||||||
|
var largeContent = new string('a', 1024 * 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
<span id="large-content">@largeContent</span>
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
@await Component.InvokeAsync("ComponentWithFlush")
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
Hello from component
|
||||||
|
@{
|
||||||
|
await FlushAsync();
|
||||||
|
var largeContent = new string('a', 1024 * 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
<span id="large-content">@largeContent</span>
|
||||||
|
|
||||||
Loading…
Reference in New Issue