From aee40970978d723f915071c4e1d3ee08aed796c8 Mon Sep 17 00:00:00 2001 From: crupest Date: Thu, 20 Aug 2020 01:46:15 +0800 Subject: Add document for user avatar controller. --- Timeline/Controllers/UserAvatarController.cs | 45 +++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) (limited to 'Timeline/Controllers/UserAvatarController.cs') diff --git a/Timeline/Controllers/UserAvatarController.cs b/Timeline/Controllers/UserAvatarController.cs index b2e2e852..3d3bc983 100644 --- a/Timeline/Controllers/UserAvatarController.cs +++ b/Timeline/Controllers/UserAvatarController.cs @@ -15,7 +15,11 @@ using static Timeline.Resources.Controllers.UserAvatarController; namespace Timeline.Controllers { + /// + /// Operations about user avatar. + /// [ApiController] + [ProducesErrorResponseType(typeof(CommonResponse))] public class UserAvatarController : Controller { private readonly ILogger _logger; @@ -23,6 +27,9 @@ namespace Timeline.Controllers private readonly IUserService _userService; private readonly IUserAvatarService _service; + /// + /// + /// public UserAvatarController(ILogger logger, IUserService userService, IUserAvatarService service) { _logger = logger; @@ -30,9 +37,21 @@ namespace Timeline.Controllers _service = service; } + /// + /// Get avatar of a user. + /// + /// Username of the user to get avatar of. + /// If-None-Match header. + /// Succeeded to get the avatar. + /// The avatar does not change. + /// The user does not exist. [HttpGet("users/{username}/avatar")] - public async Task Get([FromRoute][Username] string username) + [ProducesResponseType(typeof(byte[]), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(void), StatusCodes.Status304NotModified)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Get([FromRoute][Username] string username, [FromHeader(Name = "If-None-Match")] string? ifNoneMatch) { + _ = ifNoneMatch; long id; try { @@ -51,10 +70,22 @@ namespace Timeline.Controllers }); } + /// + /// Set avatar of a user. You have to be administrator to change other's. + /// + /// Username of the user to set avatar of. + /// Succeeded to set avatar. + /// Error code is 10010001 if user does not exist. Or avatar is of bad format. + /// You have not logged in. + /// You are not administrator. [HttpPut("users/{username}/avatar")] [Authorize] [RequireContentType, RequireContentLength] [Consumes("image/png", "image/jpeg", "image/gif", "image/webp")] + [ProducesResponseType(typeof(void), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] public async Task Put([FromRoute][Username] string username) { var contentLength = Request.ContentLength!.Value; @@ -115,7 +146,19 @@ namespace Timeline.Controllers } } + /// + /// Reset the avatar to the default one. You have to be administrator to reset other's. + /// + /// Username of the user. + /// Succeeded to reset. + /// Error code is 10010001 if user does not exist. + /// You have not logged in. + /// You are not administrator. [HttpDelete("users/{username}/avatar")] + [ProducesResponseType(typeof(void), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] [Authorize] public async Task Delete([FromRoute][Username] string username) { -- cgit v1.2.3 From dcb0a10e1adaec24961f2484e7f9a9a1f1f5da15 Mon Sep 17 00:00:00 2001 From: crupest Date: Fri, 21 Aug 2020 21:25:33 +0800 Subject: ... --- Timeline/Controllers/TimelineController.cs | 48 ++++------------------ Timeline/Controllers/TokenController.cs | 10 ++--- Timeline/Controllers/UserAvatarController.cs | 8 +--- Timeline/Controllers/UserController.cs | 39 +++++++----------- Timeline/Startup.cs | 2 + Timeline/Swagger/ApiConvention.cs | 15 +++++++ .../DefaultDescriptionOperationProcessor.cs | 39 ++++++++++++++++++ Timeline/Timeline.csproj | 1 + 8 files changed, 86 insertions(+), 76 deletions(-) create mode 100644 Timeline/Swagger/ApiConvention.cs create mode 100644 Timeline/Swagger/DefaultDescriptionOperationProcessor.cs (limited to 'Timeline/Controllers/UserAvatarController.cs') diff --git a/Timeline/Controllers/TimelineController.cs b/Timeline/Controllers/TimelineController.cs index b376bce5..43178ac6 100644 --- a/Timeline/Controllers/TimelineController.cs +++ b/Timeline/Controllers/TimelineController.cs @@ -49,8 +49,7 @@ namespace Timeline.Controllers /// A username. If set, only timelines related to the user will return. /// Specify the relation type, may be 'own' or 'join'. If not set, both type will return. /// "Private" or "Register" or "Public". If set, only timelines whose visibility is specified one will return. - /// Succeeded to get timelines. - /// Model is invalid. Or user specified by "relate" param does not exist. + /// The timeline list. [HttpGet("timelines")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] @@ -117,9 +116,7 @@ namespace Timeline.Controllers /// A unique id. If specified and if-modified-since is also specified, the timeline info will return when unique id is not the specified one even if it is not modified. /// Same effect as If-Modified-Since header and take precedence than it. /// If specified, will return 304 if not modified. - /// Succeeded to get timeline info. - /// Timeline not change. - /// Timeline does not exist. + /// The timeline info. [HttpGet("timelines/{name}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status304NotModified)] @@ -171,14 +168,12 @@ namespace Timeline.Controllers } /// - /// Get posts of a timeline. You need to have permission. + /// Get posts of a timeline. /// /// The name of the timeline. /// If set, only posts modified since the time will return. /// If set to true, deleted post will also return. - /// Succeeded to get posts. - /// You have no permission. - /// The timeline does not exist. + /// The post list. [HttpGet("timelines/{name}/posts")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status403Forbidden)] @@ -197,16 +192,12 @@ namespace Timeline.Controllers } /// - /// Get the data of a post. Usually a image post. You need to have permission. + /// Get the data of a post. Usually a image post. /// /// Timeline name. /// The id of the post. /// If-None-Match header. - /// Succeeded to get data. - /// Data not changed. - /// Error code is 11040502 if post has no data. - /// You have no permission. - /// Timeline or post does not exist. + /// The data. [HttpGet("timelines/{name}/posts/{id}/data")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(void), StatusCodes.Status304NotModified)] @@ -240,15 +231,11 @@ namespace Timeline.Controllers } /// - /// Create a new post. You need to have permission. + /// Create a new post. /// /// Timeline name. /// /// Info of new post. - /// Succeeded to create post and return info of new post. - /// Body model is invalid. - /// You have not logged in. - /// You have no permission. [HttpPost("timelines/{name}/posts")] [Authorize] [ProducesResponseType(StatusCodes.Status200OK)] @@ -316,9 +303,7 @@ namespace Timeline.Controllers /// /// Timeline name. /// Post id. - /// Succeeded to delete post. Or post does not exist. - /// You have not logged in. - /// You have no permission. + /// Info of deletion. [HttpDelete("timelines/{name}/posts/{id}")] [Authorize] [ProducesResponseType(StatusCodes.Status200OK)] @@ -347,9 +332,6 @@ namespace Timeline.Controllers /// Timeline name. /// /// The new info. - /// Succeeded to change properties of timeline. Return the new info. - /// You have not logged in. - /// You have no permission. [HttpPatch("timelines/{name}")] [Authorize] [ProducesResponseType(StatusCodes.Status200OK)] @@ -372,10 +354,6 @@ namespace Timeline.Controllers /// /// Timeline name. /// The new member's username. - /// Succeeded. - /// User does not exist. - /// You have not logged in. - /// You have no permission. [HttpPut("timelines/{name}/members/{member}")] [Authorize] [ProducesResponseType(StatusCodes.Status200OK)] @@ -405,9 +383,6 @@ namespace Timeline.Controllers /// /// Timeline name. /// The member's username. - /// Succeeded. Or the user is not a member. - /// You have not logged in. - /// You have no permission. [HttpDelete("timelines/{name}/members/{member}")] [Authorize] [ProducesResponseType(StatusCodes.Status200OK)] @@ -436,9 +411,6 @@ namespace Timeline.Controllers /// /// /// Info of new timeline. - /// Succeeded and return info of new timeline. - /// Timeline name is conflict. - /// You have not logged in. [HttpPost("timelines")] [Authorize] [ProducesResponseType(StatusCodes.Status200OK)] @@ -464,9 +436,7 @@ namespace Timeline.Controllers /// Delete a timeline. /// /// Timeline name. - /// Succeeded. Or timeline does not exist. - /// You have not logged in. - /// You have no permission. + /// Info of deletion. [HttpDelete("timelines/{name}")] [Authorize] [ProducesResponseType(StatusCodes.Status200OK)] diff --git a/Timeline/Controllers/TokenController.cs b/Timeline/Controllers/TokenController.cs index 7792b318..8f2ca600 100644 --- a/Timeline/Controllers/TokenController.cs +++ b/Timeline/Controllers/TokenController.cs @@ -40,11 +40,10 @@ namespace Timeline.Controllers /// /// Create a new token for a user. /// - /// Succeed to create token. - /// Error code is 11010101 if user does not exist or password is wrong. + /// Result of token creation. [HttpPost("create")] [AllowAnonymous] - [ProducesResponseType(typeof(CreateTokenResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task> Create([FromBody] CreateTokenRequest request) { @@ -91,11 +90,10 @@ namespace Timeline.Controllers /// /// Verify a token. /// - /// Token is valid. - /// Error code is 11010201 if token is of bad format (it may not be created by this server). Error code is 11010202 if user does not exist. Error code is 11010203 if token is of old version (user may have changed password). Error code is 11010204 if token is expired. + /// Result of token verification. [HttpPost("verify")] [AllowAnonymous] - [ProducesResponseType(typeof(VerifyTokenResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task> Verify([FromBody] VerifyTokenRequest request) { diff --git a/Timeline/Controllers/UserAvatarController.cs b/Timeline/Controllers/UserAvatarController.cs index 3d3bc983..52e87df2 100644 --- a/Timeline/Controllers/UserAvatarController.cs +++ b/Timeline/Controllers/UserAvatarController.cs @@ -42,9 +42,7 @@ namespace Timeline.Controllers /// /// Username of the user to get avatar of. /// If-None-Match header. - /// Succeeded to get the avatar. - /// The avatar does not change. - /// The user does not exist. + /// Avatar data. [HttpGet("users/{username}/avatar")] [ProducesResponseType(typeof(byte[]), StatusCodes.Status200OK)] [ProducesResponseType(typeof(void), StatusCodes.Status304NotModified)] @@ -74,10 +72,6 @@ namespace Timeline.Controllers /// Set avatar of a user. You have to be administrator to change other's. /// /// Username of the user to set avatar of. - /// Succeeded to set avatar. - /// Error code is 10010001 if user does not exist. Or avatar is of bad format. - /// You have not logged in. - /// You are not administrator. [HttpPut("users/{username}/avatar")] [Authorize] [RequireContentType, RequireContentLength] diff --git a/Timeline/Controllers/UserController.cs b/Timeline/Controllers/UserController.cs index fa2d37d8..02c09aab 100644 --- a/Timeline/Controllers/UserController.cs +++ b/Timeline/Controllers/UserController.cs @@ -43,9 +43,9 @@ namespace Timeline.Controllers /// /// Get all users. /// - /// The user list. + /// All user list. [HttpGet("users")] - [ProducesResponseType(typeof(UserInfo[]), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status200OK)] public async Task> List() { var users = await _userService.GetUsers(); @@ -54,12 +54,13 @@ namespace Timeline.Controllers } /// - /// Get a user info. + /// Get a user's info. /// /// Username of the user. - /// The user info. + /// User info. [HttpGet("users/{username}")] - [ProducesResponseType(typeof(UserInfo), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task> Get([FromRoute][Username] string username) { try @@ -75,16 +76,14 @@ namespace Timeline.Controllers } /// - /// Change a user's property. You have to be administrator in some condition. + /// Change a user's property. /// /// /// Username of the user to change. - /// Succeed to change the user and return the new user info. - /// You have not logged in. - /// You are not administrator. - /// The user to change does not exist. + /// The new user info. [HttpPatch("users/{username}"), Authorize] - [ProducesResponseType(typeof(UserInfo), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] @@ -134,11 +133,9 @@ namespace Timeline.Controllers /// Delete a user and all his related data. You have to be administrator. /// /// Username of the user to delete. - /// Succeeded to delete or the user does not exist. - /// You have not logged in. - /// You are not administrator. + /// Info of deletion. [HttpDelete("users/{username}"), AdminAuthorize] - [ProducesResponseType(typeof(CommonDeleteResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] public async Task> Delete([FromRoute][Username] string username) @@ -153,12 +150,9 @@ namespace Timeline.Controllers /// /// Create a new user. You have to be administrator. /// - /// Succeeded to create a new user and return his user info. - /// Error code is 11020101 if a user with given username already exists. - /// You have not logged in. - /// You are not administrator. + /// The new user's info. [HttpPost("userop/createuser"), AdminAuthorize] - [ProducesResponseType(typeof(UserInfo), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] @@ -178,11 +172,8 @@ namespace Timeline.Controllers /// /// Change password with old password. /// - /// Succeeded to change password. - /// Error code is 11020201 if old password is wrong. - /// You have not logged in. [HttpPost("userop/changepassword"), Authorize] - [ProducesResponseType(typeof(void), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] public async Task ChangePassword([FromBody] ChangePasswordRequest request) diff --git a/Timeline/Startup.cs b/Timeline/Startup.cs index 3e5dd24d..960bbc2c 100644 --- a/Timeline/Startup.cs +++ b/Timeline/Startup.cs @@ -20,6 +20,7 @@ using Timeline.Helpers; using Timeline.Models.Converters; using Timeline.Routes; using Timeline.Services; +using Timeline.Swagger; namespace Timeline { @@ -112,6 +113,7 @@ namespace Timeline Description = "Type into the textbox: Bearer {your JWT token}." })); document.OperationProcessors.Add(new AspNetCoreOperationSecurityScopeProcessor("JWT")); + document.OperationProcessors.Add(new DefaultDescriptionOperationProcessor()); }); if (!disableFrontEnd) diff --git a/Timeline/Swagger/ApiConvention.cs b/Timeline/Swagger/ApiConvention.cs new file mode 100644 index 00000000..dbf0b2fe --- /dev/null +++ b/Timeline/Swagger/ApiConvention.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Mvc; + +[assembly: ApiConventionType(typeof(Timeline.Controllers.ApiConvention))] + +namespace Timeline.Controllers +{ + // There is some bug if nullable is enable. So disable it. +#nullable disable + /// + /// My api convention. + /// + public static class ApiConvention + { + } +} diff --git a/Timeline/Swagger/DefaultDescriptionOperationProcessor.cs b/Timeline/Swagger/DefaultDescriptionOperationProcessor.cs new file mode 100644 index 00000000..4967cc6a --- /dev/null +++ b/Timeline/Swagger/DefaultDescriptionOperationProcessor.cs @@ -0,0 +1,39 @@ +using NSwag.Generation.Processors; +using NSwag.Generation.Processors.Contexts; +using System.Collections.Generic; + +namespace Timeline.Swagger +{ + /// + /// Swagger operation processor that adds default description to response. + /// + public class DefaultDescriptionOperationProcessor : IOperationProcessor + { + private readonly Dictionary defaultDescriptionMap = new Dictionary + { + ["200"] = "Succeeded to perform the operation.", + ["304"] = "Item does not change.", + ["400"] = "See code and message for error info.", + ["401"] = "You need to log in to perform this operation.", + ["403"] = "You have no permission to perform the operation.", + ["404"] = "Item does not exist. See code and message for error info." + }; + + /// + public bool Process(OperationProcessorContext context) + { + var responses = context.OperationDescription.Operation.Responses; + + foreach (var (httpStatusCode, res) in responses) + { + if (!string.IsNullOrEmpty(res.Description)) continue; + if (defaultDescriptionMap.ContainsKey(httpStatusCode)) + { + res.Description = defaultDescriptionMap[httpStatusCode]; + } + } + + return true; + } + } +} diff --git a/Timeline/Timeline.csproj b/Timeline/Timeline.csproj index 5c2f7adf..5fc69fc8 100644 --- a/Timeline/Timeline.csproj +++ b/Timeline/Timeline.csproj @@ -17,6 +17,7 @@ 0.3.0 true + true -- cgit v1.2.3 From 4475de3c0c86c4096b843d8bee8aff48b7e31896 Mon Sep 17 00:00:00 2001 From: crupest Date: Fri, 21 Aug 2020 22:49:48 +0800 Subject: ... --- Timeline.Tests/IntegratedTests/UserAvatarTest.cs | 15 ++--- Timeline/Controllers/UserAvatarController.cs | 25 +++----- Timeline/Filters/Header.cs | 69 +++++++++++++-------- Timeline/Formatters/BytesInputFormatter.cs | 79 ++++++++++++++++++++++++ Timeline/Models/ByteData.cs | 33 ++++++++++ Timeline/Startup.cs | 1 + 6 files changed, 167 insertions(+), 55 deletions(-) create mode 100644 Timeline/Formatters/BytesInputFormatter.cs create mode 100644 Timeline/Models/ByteData.cs (limited to 'Timeline/Controllers/UserAvatarController.cs') 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. /// /// Username of the user to set avatar of. + /// The avatar data. [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 Put([FromRoute][Username] string username) + public async Task 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 + /// + /// Restrict max content length. + /// + public class MaxContentLengthFilter : IResourceFilter { - public override void OnActionExecuting(ActionExecutingContext context) + /// + /// + /// + /// Max length. + 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) - { - } + /// + /// Max length. + /// + public long MaxByteLength { get; set; } - public RequireContentLengthAttribute(bool requireNonZero) + /// + public void OnResourceExecuted(ResourceExecutedContext context) { - RequireNonZero = requireNonZero; } - public bool RequireNonZero { get; set; } - - public override void OnActionExecuting(ActionExecutingContext context) + /// + 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; - } + /// + /// Restrict max content length. + /// + public class MaxContentLengthAttribute : TypeFilterAttribute + { + /// + /// + /// + /// Max length. + public MaxContentLengthAttribute(long maxByteLength) + : base(typeof(MaxContentLengthFilter)) + { + MaxByteLength = maxByteLength; + Arguments = new object[] { maxByteLength }; } + + /// + /// Max length. + /// + 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 +{ + /// + /// Formatter that reads body as bytes. + /// + public class BytesInputFormatter : InputFormatter + { + /// + /// + /// + 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")); + } + + /// + public override bool CanRead(InputFormatterContext context) + { + if (context == null) throw new ArgumentNullException(nameof(context)); + + if (context.ModelType == typeof(ByteData)) + return true; + + return false; + } + + /// + public override async Task ReadRequestBodyAsync(InputFormatterContext context) + { + var request = context.HttpContext.Request; + var contentLength = request.ContentLength; + + var logger = context.HttpContext.RequestServices.GetRequiredService>(); + + 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 +{ + /// + /// Model for reading http body as bytes. + /// + [OpenApiFile] + public class ByteData + { + /// + /// + /// The data. + /// The content type. + public ByteData(byte[] data, string contentType) + { + Data = data; + ContentType = contentType; + } + + /// + /// Data. + /// +#pragma warning disable CA1819 // Properties should not return arrays + public byte[] Data { get; } +#pragma warning restore CA1819 // Properties should not return arrays + + /// + /// Content type. + /// + 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 => -- cgit v1.2.3