diff options
-rw-r--r-- | Timeline.Tests/IntegratedTests/UserAvatarTest.cs | 15 | ||||
-rw-r--r-- | Timeline/Controllers/UserAvatarController.cs | 25 | ||||
-rw-r--r-- | Timeline/Filters/Header.cs | 69 | ||||
-rw-r--r-- | Timeline/Formatters/BytesInputFormatter.cs | 79 | ||||
-rw-r--r-- | Timeline/Models/ByteData.cs | 33 | ||||
-rw-r--r-- | Timeline/Startup.cs | 1 |
6 files changed, 167 insertions, 55 deletions
diff --git a/Timeline.Tests/IntegratedTests/UserAvatarTest.cs b/Timeline.Tests/IntegratedTests/UserAvatarTest.cs index 91986cda..507b05ba 100644 --- a/Timeline.Tests/IntegratedTests/UserAvatarTest.cs +++ b/Timeline.Tests/IntegratedTests/UserAvatarTest.cs @@ -66,16 +66,14 @@ namespace Timeline.Tests.IntegratedTests using var content = new ByteArrayContent(new[] { (byte)0x00 });
content.Headers.ContentType = new MediaTypeHeaderValue("image/png");
var res = await client.PutAsync("users/user1/avatar", content);
- res.Should().HaveStatusCode(HttpStatusCode.BadRequest)
- .And.HaveCommonBody().Which.Code.Should().Be(ErrorCodes.Common.Header.ContentLength_Missing); ;
+ res.Should().BeInvalidModel();
}
{
using var content = new ByteArrayContent(new[] { (byte)0x00 });
content.Headers.ContentLength = 1;
var res = await client.PutAsync("users/user1/avatar", content);
- res.Should().HaveStatusCode(HttpStatusCode.BadRequest)
- .And.HaveCommonBody().Which.Code.Should().Be(ErrorCodes.Common.Header.ContentType_Missing);
+ res.Should().BeInvalidModel();
}
{
@@ -83,8 +81,7 @@ namespace Timeline.Tests.IntegratedTests content.Headers.ContentLength = 0;
content.Headers.ContentType = new MediaTypeHeaderValue("image/png");
var res = await client.PutAsync("users/user1/avatar", content);
- res.Should().HaveStatusCode(HttpStatusCode.BadRequest)
- .And.HaveCommonBody().Which.Code.Should().Be(ErrorCodes.Common.Header.ContentLength_Zero);
+ res.Should().BeInvalidModel();
}
{
@@ -106,8 +103,7 @@ namespace Timeline.Tests.IntegratedTests content.Headers.ContentLength = 2;
content.Headers.ContentType = new MediaTypeHeaderValue("image/png");
var res = await client.PutAsync("users/user1/avatar", content);
- res.Should().HaveStatusCode(HttpStatusCode.BadRequest)
- .And.HaveCommonBody().Which.Code.Should().Be(ErrorCodes.Common.Content.UnmatchedLength_Smaller);
+ res.Should().BeInvalidModel();
}
{
@@ -115,8 +111,7 @@ namespace Timeline.Tests.IntegratedTests content.Headers.ContentLength = 1;
content.Headers.ContentType = new MediaTypeHeaderValue("image/png");
var res = await client.PutAsync("users/user1/avatar", content);
- res.Should().HaveStatusCode(HttpStatusCode.BadRequest)
- .And.HaveCommonBody().Which.Code.Should().Be(ErrorCodes.Common.Content.UnmatchedLength_Bigger);
+ res.Should().BeInvalidModel();
}
{
diff --git a/Timeline/Controllers/UserAvatarController.cs b/Timeline/Controllers/UserAvatarController.cs index 52e87df2..32f63fc6 100644 --- a/Timeline/Controllers/UserAvatarController.cs +++ b/Timeline/Controllers/UserAvatarController.cs @@ -3,10 +3,12 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using System;
+using System.IO;
using System.Threading.Tasks;
using Timeline.Auth;
using Timeline.Filters;
using Timeline.Helpers;
+using Timeline.Models;
using Timeline.Models.Http;
using Timeline.Models.Validation;
using Timeline.Services;
@@ -72,20 +74,17 @@ namespace Timeline.Controllers /// Set avatar of a user. You have to be administrator to change other's.
/// </summary>
/// <param name="username">Username of the user to set avatar of.</param>
+ /// <param name="body">The avatar data.</param>
[HttpPut("users/{username}/avatar")]
[Authorize]
- [RequireContentType, RequireContentLength]
[Consumes("image/png", "image/jpeg", "image/gif", "image/webp")]
+ [MaxContentLength(1000 * 1000 * 10)]
[ProducesResponseType(typeof(void), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
- public async Task<IActionResult> Put([FromRoute][Username] string username)
+ public async Task<IActionResult> Put([FromRoute][Username] string username, [FromBody] ByteData body)
{
- var contentLength = Request.ContentLength!.Value;
- if (contentLength > 1000 * 1000 * 10)
- return BadRequest(ErrorResponse.Common.Content.TooBig("10MB"));
-
if (!User.IsAdministrator() && User.Identity.Name != username)
{
_logger.LogInformation(Log.Format(LogPutForbid,
@@ -106,20 +105,10 @@ namespace Timeline.Controllers try
{
- var data = new byte[contentLength];
- var bytesRead = await Request.Body.ReadAsync(data);
-
- if (bytesRead != contentLength)
- return BadRequest(ErrorResponse.Common.Content.UnmatchedLength_Smaller());
-
- var extraByte = new byte[1];
- if (await Request.Body.ReadAsync(extraByte) != 0)
- return BadRequest(ErrorResponse.Common.Content.UnmatchedLength_Bigger());
-
await _service.SetAvatar(id, new Avatar
{
- Data = data,
- Type = Request.ContentType
+ Data = body.Data,
+ Type = body.ContentType
});
_logger.LogInformation(Log.Format(LogPutSuccess,
diff --git a/Timeline/Filters/Header.cs b/Timeline/Filters/Header.cs index 0db11faf..cc5ddd9f 100644 --- a/Timeline/Filters/Header.cs +++ b/Timeline/Filters/Header.cs @@ -4,45 +4,60 @@ using Timeline.Models.Http; namespace Timeline.Filters
{
- public class RequireContentTypeAttribute : ActionFilterAttribute
+ /// <summary>
+ /// Restrict max content length.
+ /// </summary>
+ public class MaxContentLengthFilter : IResourceFilter
{
- public override void OnActionExecuting(ActionExecutingContext context)
+ /// <summary>
+ ///
+ /// </summary>
+ /// <param name="maxByteLength">Max length.</param>
+ public MaxContentLengthFilter(long maxByteLength)
{
- if (context.HttpContext.Request.ContentType == null)
- {
- context.Result = new BadRequestObjectResult(ErrorResponse.Common.Header.ContentType_Missing());
- }
+ MaxByteLength = maxByteLength;
}
- }
-
- public class RequireContentLengthAttribute : ActionFilterAttribute
- {
- public RequireContentLengthAttribute()
- : this(true)
- {
- }
+ /// <summary>
+ /// Max length.
+ /// </summary>
+ public long MaxByteLength { get; set; }
- public RequireContentLengthAttribute(bool requireNonZero)
+ /// <inheritdoc/>
+ public void OnResourceExecuted(ResourceExecutedContext context)
{
- RequireNonZero = requireNonZero;
}
- public bool RequireNonZero { get; set; }
-
- public override void OnActionExecuting(ActionExecutingContext context)
+ /// <inheritdoc/>
+ public void OnResourceExecuting(ResourceExecutingContext context)
{
- if (context.HttpContext.Request.ContentLength == null)
+ var contentLength = context.HttpContext.Request.ContentLength;
+ if (contentLength != null && contentLength > MaxByteLength)
{
- context.Result = new BadRequestObjectResult(ErrorResponse.Common.Header.ContentLength_Missing());
- return;
+ context.Result = new BadRequestObjectResult(ErrorResponse.Common.Content.TooBig(MaxByteLength + "B"));
}
+ }
+ }
- if (RequireNonZero && context.HttpContext.Request.ContentLength.Value == 0)
- {
- context.Result = new BadRequestObjectResult(ErrorResponse.Common.Header.ContentLength_Zero());
- return;
- }
+ /// <summary>
+ /// Restrict max content length.
+ /// </summary>
+ public class MaxContentLengthAttribute : TypeFilterAttribute
+ {
+ /// <summary>
+ ///
+ /// </summary>
+ /// <param name="maxByteLength">Max length.</param>
+ public MaxContentLengthAttribute(long maxByteLength)
+ : base(typeof(MaxContentLengthFilter))
+ {
+ MaxByteLength = maxByteLength;
+ Arguments = new object[] { maxByteLength };
}
+
+ /// <summary>
+ /// Max length.
+ /// </summary>
+ public long MaxByteLength { get; }
}
}
diff --git a/Timeline/Formatters/BytesInputFormatter.cs b/Timeline/Formatters/BytesInputFormatter.cs new file mode 100644 index 00000000..ac6537c9 --- /dev/null +++ b/Timeline/Formatters/BytesInputFormatter.cs @@ -0,0 +1,79 @@ +using Microsoft.AspNetCore.Mvc.Formatters;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Net.Http.Headers;
+using System;
+using System.Threading.Tasks;
+using Timeline.Models;
+
+namespace Timeline.Formatters
+{
+ /// <summary>
+ /// Formatter that reads body as bytes.
+ /// </summary>
+ public class BytesInputFormatter : InputFormatter
+ {
+ /// <summary>
+ ///
+ /// </summary>
+ public BytesInputFormatter()
+ {
+ SupportedMediaTypes.Add(new MediaTypeHeaderValue("image/png"));
+ SupportedMediaTypes.Add(new MediaTypeHeaderValue("image/jpeg"));
+ SupportedMediaTypes.Add(new MediaTypeHeaderValue("image/gif"));
+ SupportedMediaTypes.Add(new MediaTypeHeaderValue("image/webp"));
+ }
+
+ /// <inheritdoc/>
+ public override bool CanRead(InputFormatterContext context)
+ {
+ if (context == null) throw new ArgumentNullException(nameof(context));
+
+ if (context.ModelType == typeof(ByteData))
+ return true;
+
+ return false;
+ }
+
+ /// <inheritdoc/>
+ public override async Task<InputFormatterResult> ReadRequestBodyAsync(InputFormatterContext context)
+ {
+ var request = context.HttpContext.Request;
+ var contentLength = request.ContentLength;
+
+ var logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<BytesInputFormatter>>();
+
+ if (contentLength == null)
+ {
+ logger.LogInformation("Failed to read body as bytes. Content-Length is not set.");
+ return await InputFormatterResult.FailureAsync();
+ }
+
+ if (contentLength == 0)
+ {
+ logger.LogInformation("Failed to read body as bytes. Content-Length is 0.");
+ return await InputFormatterResult.FailureAsync();
+ }
+
+ var bodyStream = request.Body;
+
+ var data = new byte[contentLength.Value];
+ var bytesRead = await bodyStream.ReadAsync(data);
+
+ if (bytesRead != contentLength)
+ {
+ logger.LogInformation("Failed to read body as bytes. Actual length of body is smaller than Content-Length.");
+ return await InputFormatterResult.FailureAsync();
+ }
+
+ var extraByte = new byte[1];
+ if (await bodyStream.ReadAsync(extraByte) != 0)
+ {
+ logger.LogInformation("Failed to read body as bytes. Actual length of body is greater than Content-Length.");
+ return await InputFormatterResult.FailureAsync();
+ }
+
+ return await InputFormatterResult.SuccessAsync(new ByteData(data, request.ContentType));
+ }
+ }
+}
diff --git a/Timeline/Models/ByteData.cs b/Timeline/Models/ByteData.cs new file mode 100644 index 00000000..7b832eb5 --- /dev/null +++ b/Timeline/Models/ByteData.cs @@ -0,0 +1,33 @@ +using NSwag.Annotations;
+
+namespace Timeline.Models
+{
+ /// <summary>
+ /// Model for reading http body as bytes.
+ /// </summary>
+ [OpenApiFile]
+ public class ByteData
+ {
+ /// <summary>
+ /// </summary>
+ /// <param name="data">The data.</param>
+ /// <param name="contentType">The content type.</param>
+ public ByteData(byte[] data, string contentType)
+ {
+ Data = data;
+ ContentType = contentType;
+ }
+
+ /// <summary>
+ /// Data.
+ /// </summary>
+#pragma warning disable CA1819 // Properties should not return arrays
+ public byte[] Data { get; }
+#pragma warning restore CA1819 // Properties should not return arrays
+
+ /// <summary>
+ /// Content type.
+ /// </summary>
+ public string ContentType { get; }
+ }
+}
diff --git a/Timeline/Startup.cs b/Timeline/Startup.cs index 960bbc2c..408057e5 100644 --- a/Timeline/Startup.cs +++ b/Timeline/Startup.cs @@ -49,6 +49,7 @@ namespace Timeline services.AddControllers(setup =>
{
setup.InputFormatters.Add(new StringInputFormatter());
+ setup.InputFormatters.Add(new BytesInputFormatter());
setup.UseApiRoutePrefix("api");
})
.AddJsonOptions(options =>
|