From ac769e656b122ff569c3f1534701b71e00fed586 Mon Sep 17 00:00:00 2001 From: crupest Date: Tue, 27 Oct 2020 19:21:35 +0800 Subject: Split front and back end. --- .../Controllers/ControllerAuthExtensions.cs | 40 ++ .../Controllers/Testing/TestingAuthController.cs | 32 ++ BackEnd/Timeline/Controllers/TimelineController.cs | 491 +++++++++++++++++++++ BackEnd/Timeline/Controllers/TokenController.cs | 142 ++++++ .../Timeline/Controllers/UserAvatarController.cs | 174 ++++++++ BackEnd/Timeline/Controllers/UserController.cs | 195 ++++++++ 6 files changed, 1074 insertions(+) create mode 100644 BackEnd/Timeline/Controllers/ControllerAuthExtensions.cs create mode 100644 BackEnd/Timeline/Controllers/Testing/TestingAuthController.cs create mode 100644 BackEnd/Timeline/Controllers/TimelineController.cs create mode 100644 BackEnd/Timeline/Controllers/TokenController.cs create mode 100644 BackEnd/Timeline/Controllers/UserAvatarController.cs create mode 100644 BackEnd/Timeline/Controllers/UserController.cs (limited to 'BackEnd/Timeline/Controllers') diff --git a/BackEnd/Timeline/Controllers/ControllerAuthExtensions.cs b/BackEnd/Timeline/Controllers/ControllerAuthExtensions.cs new file mode 100644 index 00000000..00a65454 --- /dev/null +++ b/BackEnd/Timeline/Controllers/ControllerAuthExtensions.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Mvc; +using System; +using System.Security.Claims; +using Timeline.Auth; +using static Timeline.Resources.Controllers.ControllerAuthExtensions; + +namespace Timeline.Controllers +{ + public static class ControllerAuthExtensions + { + public static bool IsAdministrator(this ControllerBase controller) + { + return controller.User != null && controller.User.IsAdministrator(); + } + + public static long GetUserId(this ControllerBase controller) + { + var claim = controller.User.FindFirst(ClaimTypes.NameIdentifier); + if (claim == null) + throw new InvalidOperationException(ExceptionNoUserIdentifierClaim); + + if (long.TryParse(claim.Value, out var value)) + return value; + + throw new InvalidOperationException(ExceptionUserIdentifierClaimBadFormat); + } + + public static long? GetOptionalUserId(this ControllerBase controller) + { + var claim = controller.User.FindFirst(ClaimTypes.NameIdentifier); + if (claim == null) + return null; + + if (long.TryParse(claim.Value, out var value)) + return value; + + throw new InvalidOperationException(ExceptionUserIdentifierClaimBadFormat); + } + } +} diff --git a/BackEnd/Timeline/Controllers/Testing/TestingAuthController.cs b/BackEnd/Timeline/Controllers/Testing/TestingAuthController.cs new file mode 100644 index 00000000..4d3b3ec7 --- /dev/null +++ b/BackEnd/Timeline/Controllers/Testing/TestingAuthController.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Timeline.Auth; + +namespace Timeline.Controllers.Testing +{ + [Route("testing/auth")] + [ApiController] + public class TestingAuthController : Controller + { + [HttpGet("[action]")] + [Authorize] + public ActionResult Authorize() + { + return Ok(); + } + + [HttpGet("[action]")] + [UserAuthorize] + public new ActionResult User() + { + return Ok(); + } + + [HttpGet("[action]")] + [AdminAuthorize] + public ActionResult Admin() + { + return Ok(); + } + } +} diff --git a/BackEnd/Timeline/Controllers/TimelineController.cs b/BackEnd/Timeline/Controllers/TimelineController.cs new file mode 100644 index 00000000..9a3147ea --- /dev/null +++ b/BackEnd/Timeline/Controllers/TimelineController.cs @@ -0,0 +1,491 @@ +using AutoMapper; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; +using Timeline.Filters; +using Timeline.Helpers; +using Timeline.Models; +using Timeline.Models.Http; +using Timeline.Models.Validation; +using Timeline.Services; +using Timeline.Services.Exceptions; + +namespace Timeline.Controllers +{ + /// + /// Operations about timeline. + /// + [ApiController] + [CatchTimelineNotExistException] + [ProducesErrorResponseType(typeof(CommonResponse))] + public class TimelineController : Controller + { + private readonly ILogger _logger; + + private readonly IUserService _userService; + private readonly ITimelineService _service; + + private readonly IMapper _mapper; + + /// + /// + /// + public TimelineController(ILogger logger, IUserService userService, ITimelineService service, IMapper mapper) + { + _logger = logger; + _userService = userService; + _service = service; + _mapper = mapper; + } + + /// + /// List all timelines. + /// + /// 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. + /// The timeline list. + [HttpGet("timelines")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task>> TimelineList([FromQuery][Username] string? relate, [FromQuery][RegularExpression("(own)|(join)")] string? relateType, [FromQuery] string? visibility) + { + List? visibilityFilter = null; + if (visibility != null) + { + visibilityFilter = new List(); + var items = visibility.Split('|'); + foreach (var item in items) + { + if (item.Equals(nameof(TimelineVisibility.Private), StringComparison.OrdinalIgnoreCase)) + { + if (!visibilityFilter.Contains(TimelineVisibility.Private)) + visibilityFilter.Add(TimelineVisibility.Private); + } + else if (item.Equals(nameof(TimelineVisibility.Register), StringComparison.OrdinalIgnoreCase)) + { + if (!visibilityFilter.Contains(TimelineVisibility.Register)) + visibilityFilter.Add(TimelineVisibility.Register); + } + else if (item.Equals(nameof(TimelineVisibility.Public), StringComparison.OrdinalIgnoreCase)) + { + if (!visibilityFilter.Contains(TimelineVisibility.Public)) + visibilityFilter.Add(TimelineVisibility.Public); + } + else + { + return BadRequest(ErrorResponse.Common.CustomMessage_InvalidModel(Resources.Messages.TimelineController_QueryVisibilityUnknown, item)); + } + } + } + + TimelineUserRelationship? relationship = null; + if (relate != null) + { + try + { + var relatedUserId = await _userService.GetUserIdByUsername(relate); + + relationship = new TimelineUserRelationship(relateType switch + { + "own" => TimelineUserRelationshipType.Own, + "join" => TimelineUserRelationshipType.Join, + _ => TimelineUserRelationshipType.Default + }, relatedUserId); + } + catch (UserNotExistException) + { + return BadRequest(ErrorResponse.TimelineController.QueryRelateNotExist()); + } + } + + var timelines = await _service.GetTimelines(relationship, visibilityFilter); + var result = _mapper.Map>(timelines); + return result; + } + + /// + /// Get info of a timeline. + /// + /// The timeline name. + /// 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. + /// The timeline info. + [HttpGet("timelines/{name}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status304NotModified)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> TimelineGet([FromRoute][GeneralTimelineName] string name, [FromQuery] string? checkUniqueId, [FromQuery(Name = "ifModifiedSince")] DateTime? queryIfModifiedSince, [FromHeader(Name = "If-Modified-Since")] DateTime? headerIfModifiedSince) + { + DateTime? ifModifiedSince = null; + if (queryIfModifiedSince.HasValue) + { + ifModifiedSince = queryIfModifiedSince.Value; + } + else if (headerIfModifiedSince != null) + { + ifModifiedSince = headerIfModifiedSince.Value; + } + + bool returnNotModified = false; + + if (ifModifiedSince.HasValue) + { + var lastModified = await _service.GetTimelineLastModifiedTime(name); + if (lastModified < ifModifiedSince.Value) + { + if (checkUniqueId != null) + { + var uniqueId = await _service.GetTimelineUniqueId(name); + if (uniqueId == checkUniqueId) + { + returnNotModified = true; + } + } + else + { + returnNotModified = true; + } + } + } + + if (returnNotModified) + { + return StatusCode(StatusCodes.Status304NotModified); + } + else + { + var timeline = await _service.GetTimeline(name); + var result = _mapper.Map(timeline); + return result; + } + } + + /// + /// 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. + /// The post list. + [HttpGet("timelines/{name}/posts")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> PostListGet([FromRoute][GeneralTimelineName] string name, [FromQuery] DateTime? modifiedSince, [FromQuery] bool? includeDeleted) + { + if (!this.IsAdministrator() && !await _service.HasReadPermission(name, this.GetOptionalUserId())) + { + return StatusCode(StatusCodes.Status403Forbidden, ErrorResponse.Common.Forbid()); + } + + List posts = await _service.GetPosts(name, modifiedSince, includeDeleted ?? false); + + var result = _mapper.Map>(posts); + return result; + } + + /// + /// Get the data of a post. Usually a image post. + /// + /// Timeline name. + /// The id of the post. + /// If-None-Match header. + /// The data. + [HttpGet("timelines/{name}/posts/{id}/data")] + [Produces("image/png", "image/jpeg", "image/gif", "image/webp", "application/json", "text/json")] + [ProducesResponseType(typeof(byte[]), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(void), StatusCodes.Status304NotModified)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task PostDataGet([FromRoute][GeneralTimelineName] string name, [FromRoute] long id, [FromHeader(Name = "If-None-Match")] string? ifNoneMatch) + { + _ = ifNoneMatch; + if (!this.IsAdministrator() && !await _service.HasReadPermission(name, this.GetOptionalUserId())) + { + return StatusCode(StatusCodes.Status403Forbidden, ErrorResponse.Common.Forbid()); + } + + try + { + return await DataCacheHelper.GenerateActionResult(this, () => _service.GetPostDataETag(name, id), async () => + { + var data = await _service.GetPostData(name, id); + return data; + }); + } + catch (TimelinePostNotExistException) + { + return NotFound(ErrorResponse.TimelineController.PostNotExist()); + } + catch (TimelinePostNoDataException) + { + return BadRequest(ErrorResponse.TimelineController.PostNoData()); + } + } + + /// + /// Create a new post. + /// + /// Timeline name. + /// + /// Info of new post. + [HttpPost("timelines/{name}/posts")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> PostPost([FromRoute][GeneralTimelineName] string name, [FromBody] TimelinePostCreateRequest body) + { + var id = this.GetUserId(); + if (!this.IsAdministrator() && !await _service.IsMemberOf(name, id)) + { + return StatusCode(StatusCodes.Status403Forbidden, ErrorResponse.Common.Forbid()); + } + + var content = body.Content; + + TimelinePost post; + + if (content.Type == TimelinePostContentTypes.Text) + { + var text = content.Text; + if (text == null) + { + return BadRequest(ErrorResponse.Common.CustomMessage_InvalidModel(Resources.Messages.TimelineController_TextContentTextRequired)); + } + post = await _service.CreateTextPost(name, id, text, body.Time); + } + else if (content.Type == TimelinePostContentTypes.Image) + { + var base64Data = content.Data; + if (base64Data == null) + { + return BadRequest(ErrorResponse.Common.CustomMessage_InvalidModel(Resources.Messages.TimelineController_ImageContentDataRequired)); + } + byte[] data; + try + { + data = Convert.FromBase64String(base64Data); + } + catch (FormatException) + { + return BadRequest(ErrorResponse.Common.CustomMessage_InvalidModel(Resources.Messages.TimelineController_ImageContentDataNotBase64)); + } + + try + { + post = await _service.CreateImagePost(name, id, data, body.Time); + } + catch (ImageException) + { + return BadRequest(ErrorResponse.Common.CustomMessage_InvalidModel(Resources.Messages.TimelineController_ImageContentDataNotImage)); + } + } + else + { + return BadRequest(ErrorResponse.Common.CustomMessage_InvalidModel(Resources.Messages.TimelineController_ContentUnknownType)); + } + + var result = _mapper.Map(post); + return result; + } + + /// + /// Delete a post. + /// + /// Timeline name. + /// Post id. + /// Info of deletion. + [HttpDelete("timelines/{name}/posts/{id}")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> PostDelete([FromRoute][GeneralTimelineName] string name, [FromRoute] long id) + { + if (!this.IsAdministrator() && !await _service.HasPostModifyPermission(name, id, this.GetUserId())) + { + return StatusCode(StatusCodes.Status403Forbidden, ErrorResponse.Common.Forbid()); + } + try + { + await _service.DeletePost(name, id); + return CommonDeleteResponse.Delete(); + } + catch (TimelinePostNotExistException) + { + return CommonDeleteResponse.NotExist(); + } + } + + /// + /// Change properties of a timeline. + /// + /// Timeline name. + /// + /// The new info. + [HttpPatch("timelines/{name}")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> TimelinePatch([FromRoute][GeneralTimelineName] string name, [FromBody] TimelinePatchRequest body) + { + if (!this.IsAdministrator() && !(await _service.HasManagePermission(name, this.GetUserId()))) + { + return StatusCode(StatusCodes.Status403Forbidden, ErrorResponse.Common.Forbid()); + } + await _service.ChangeProperty(name, _mapper.Map(body)); + var timeline = await _service.GetTimeline(name); + var result = _mapper.Map(timeline); + return result; + } + + /// + /// Add a member to timeline. + /// + /// Timeline name. + /// The new member's username. + [HttpPut("timelines/{name}/members/{member}")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task TimelineMemberPut([FromRoute][GeneralTimelineName] string name, [FromRoute][Username] string member) + { + if (!this.IsAdministrator() && !(await _service.HasManagePermission(name, this.GetUserId()))) + { + return StatusCode(StatusCodes.Status403Forbidden, ErrorResponse.Common.Forbid()); + } + + try + { + await _service.ChangeMember(name, new List { member }, null); + return Ok(); + } + catch (UserNotExistException) + { + return BadRequest(ErrorResponse.TimelineController.MemberPut_NotExist()); + } + } + + /// + /// Remove a member from timeline. + /// + /// Timeline name. + /// The member's username. + [HttpDelete("timelines/{name}/members/{member}")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task TimelineMemberDelete([FromRoute][GeneralTimelineName] string name, [FromRoute][Username] string member) + { + if (!this.IsAdministrator() && !(await _service.HasManagePermission(name, this.GetUserId()))) + { + return StatusCode(StatusCodes.Status403Forbidden, ErrorResponse.Common.Forbid()); + } + + try + { + await _service.ChangeMember(name, null, new List { member }); + return Ok(CommonDeleteResponse.Delete()); + } + catch (UserNotExistException) + { + return Ok(CommonDeleteResponse.NotExist()); + } + } + + /// + /// Create a timeline. + /// + /// + /// Info of new timeline. + [HttpPost("timelines")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task> TimelineCreate([FromBody] TimelineCreateRequest body) + { + var userId = this.GetUserId(); + + try + { + var timeline = await _service.CreateTimeline(body.Name, userId); + var result = _mapper.Map(timeline); + return result; + } + catch (EntityAlreadyExistException e) when (e.EntityName == EntityNames.Timeline) + { + return BadRequest(ErrorResponse.TimelineController.NameConflict()); + } + } + + /// + /// Delete a timeline. + /// + /// Timeline name. + /// Info of deletion. + [HttpDelete("timelines/{name}")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> TimelineDelete([FromRoute][TimelineName] string name) + { + if (!this.IsAdministrator() && !(await _service.HasManagePermission(name, this.GetUserId()))) + { + return StatusCode(StatusCodes.Status403Forbidden, ErrorResponse.Common.Forbid()); + } + + try + { + await _service.DeleteTimeline(name); + return CommonDeleteResponse.Delete(); + } + catch (TimelineNotExistException) + { + return CommonDeleteResponse.NotExist(); + } + } + + [HttpPost("timelineop/changename")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> TimelineOpChangeName([FromBody] TimelineChangeNameRequest body) + { + if (!this.IsAdministrator() && !(await _service.HasManagePermission(body.OldName, this.GetUserId()))) + { + return StatusCode(StatusCodes.Status403Forbidden, ErrorResponse.Common.Forbid()); + } + + try + { + var timeline = await _service.ChangeTimelineName(body.OldName, body.NewName); + return Ok(_mapper.Map(timeline)); + } + catch (EntityAlreadyExistException) + { + return BadRequest(ErrorResponse.TimelineController.NameConflict()); + } + } + } +} diff --git a/BackEnd/Timeline/Controllers/TokenController.cs b/BackEnd/Timeline/Controllers/TokenController.cs new file mode 100644 index 00000000..8f2ca600 --- /dev/null +++ b/BackEnd/Timeline/Controllers/TokenController.cs @@ -0,0 +1,142 @@ +using AutoMapper; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using System; +using System.Globalization; +using System.Threading.Tasks; +using Timeline.Helpers; +using Timeline.Models.Http; +using Timeline.Services; +using Timeline.Services.Exceptions; +using static Timeline.Resources.Controllers.TokenController; + +namespace Timeline.Controllers +{ + /// + /// Operation about tokens. + /// + [Route("token")] + [ApiController] + [ProducesErrorResponseType(typeof(CommonResponse))] + public class TokenController : Controller + { + private readonly IUserTokenManager _userTokenManager; + private readonly ILogger _logger; + private readonly IClock _clock; + + private readonly IMapper _mapper; + + /// + public TokenController(IUserTokenManager userTokenManager, ILogger logger, IClock clock, IMapper mapper) + { + _userTokenManager = userTokenManager; + _logger = logger; + _clock = clock; + _mapper = mapper; + } + + /// + /// Create a new token for a user. + /// + /// Result of token creation. + [HttpPost("create")] + [AllowAnonymous] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> Create([FromBody] CreateTokenRequest request) + { + void LogFailure(string reason, Exception? e = null) + { + _logger.LogInformation(e, Log.Format(LogCreateFailure, + ("Reason", reason), + ("Username", request.Username), + ("Password", request.Password), + ("Expire (in days)", request.Expire) + )); + } + + try + { + DateTime? expireTime = null; + if (request.Expire != null) + expireTime = _clock.GetCurrentTime().AddDays(request.Expire.Value); + + var result = await _userTokenManager.CreateToken(request.Username, request.Password, expireTime); + + _logger.LogInformation(Log.Format(LogCreateSuccess, + ("Username", request.Username), + ("Expire At", expireTime?.ToString(CultureInfo.CurrentCulture.DateTimeFormat) ?? "default") + )); + return Ok(new CreateTokenResponse + { + Token = result.Token, + User = _mapper.Map(result.User) + }); + } + catch (UserNotExistException e) + { + LogFailure(LogUserNotExist, e); + return BadRequest(ErrorResponse.TokenController.Create_BadCredential()); + } + catch (BadPasswordException e) + { + LogFailure(LogBadPassword, e); + return BadRequest(ErrorResponse.TokenController.Create_BadCredential()); + } + } + + /// + /// Verify a token. + /// + /// Result of token verification. + [HttpPost("verify")] + [AllowAnonymous] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> Verify([FromBody] VerifyTokenRequest request) + { + void LogFailure(string reason, Exception? e = null, params (string, object?)[] otherProperties) + { + var properties = new (string, object?)[2 + otherProperties.Length]; + properties[0] = ("Reason", reason); + properties[1] = ("Token", request.Token); + otherProperties.CopyTo(properties, 2); + _logger.LogInformation(e, Log.Format(LogVerifyFailure, properties)); + } + + try + { + var result = await _userTokenManager.VerifyToken(request.Token); + _logger.LogInformation(Log.Format(LogVerifySuccess, + ("Username", result.Username), ("Token", request.Token))); + return Ok(new VerifyTokenResponse + { + User = _mapper.Map(result) + }); + } + catch (UserTokenTimeExpireException e) + { + LogFailure(LogVerifyExpire, e, ("Expire Time", e.ExpireTime), ("Verify Time", e.VerifyTime)); + return BadRequest(ErrorResponse.TokenController.Verify_TimeExpired()); + } + catch (UserTokenBadVersionException e) + { + LogFailure(LogVerifyOldVersion, e, ("Token Version", e.TokenVersion), ("Required Version", e.RequiredVersion)); + return BadRequest(ErrorResponse.TokenController.Verify_OldVersion()); + + } + catch (UserTokenBadFormatException e) + { + LogFailure(LogVerifyBadFormat, e); + return BadRequest(ErrorResponse.TokenController.Verify_BadFormat()); + } + catch (UserNotExistException e) + { + LogFailure(LogVerifyUserNotExist, e); + return BadRequest(ErrorResponse.TokenController.Verify_UserNotExist()); + } + } + } +} diff --git a/BackEnd/Timeline/Controllers/UserAvatarController.cs b/BackEnd/Timeline/Controllers/UserAvatarController.cs new file mode 100644 index 00000000..bc4afa30 --- /dev/null +++ b/BackEnd/Timeline/Controllers/UserAvatarController.cs @@ -0,0 +1,174 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Microsoft.Net.Http.Headers; +using System; +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; +using Timeline.Services.Exceptions; +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; + + private readonly IUserService _userService; + private readonly IUserAvatarService _service; + + /// + /// + /// + public UserAvatarController(ILogger logger, IUserService userService, IUserAvatarService service) + { + _logger = logger; + _userService = userService; + _service = service; + } + + /// + /// Get avatar of a user. + /// + /// Username of the user to get avatar of. + /// If-None-Match header. + /// Avatar data. + [HttpGet("users/{username}/avatar")] + [Produces("image/png", "image/jpeg", "image/gif", "image/webp", "application/json", "text/json")] + [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 + { + id = await _userService.GetUserIdByUsername(username); + } + catch (UserNotExistException e) + { + _logger.LogInformation(e, Log.Format(LogGetUserNotExist, ("Username", username))); + return NotFound(ErrorResponse.UserCommon.NotExist()); + } + + return await DataCacheHelper.GenerateActionResult(this, () => _service.GetAvatarETag(id), async () => + { + var avatar = await _service.GetAvatar(id); + return avatar.ToCacheableData(); + }); + } + + /// + /// 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] + [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, [FromBody] ByteData body) + { + if (!User.IsAdministrator() && User.Identity.Name != username) + { + _logger.LogInformation(Log.Format(LogPutForbid, + ("Operator Username", User.Identity.Name), ("Username To Put Avatar", username))); + return StatusCode(StatusCodes.Status403Forbidden, ErrorResponse.Common.Forbid()); + } + + long id; + try + { + id = await _userService.GetUserIdByUsername(username); + } + catch (UserNotExistException e) + { + _logger.LogInformation(e, Log.Format(LogPutUserNotExist, ("Username", username))); + return BadRequest(ErrorResponse.UserCommon.NotExist()); + } + + try + { + var etag = await _service.SetAvatar(id, new Avatar + { + Data = body.Data, + Type = body.ContentType + }); + + _logger.LogInformation(Log.Format(LogPutSuccess, + ("Username", username), ("Mime Type", Request.ContentType))); + + Response.Headers.Append("ETag", new EntityTagHeaderValue($"\"{etag}\"").ToString()); + + return Ok(); + } + catch (ImageException e) + { + _logger.LogInformation(e, Log.Format(LogPutUserBadFormat, ("Username", username))); + return BadRequest(e.Error switch + { + ImageException.ErrorReason.CantDecode => ErrorResponse.UserAvatar.BadFormat_CantDecode(), + ImageException.ErrorReason.UnmatchedFormat => ErrorResponse.UserAvatar.BadFormat_UnmatchedFormat(), + ImageException.ErrorReason.NotSquare => ErrorResponse.UserAvatar.BadFormat_BadSize(), + _ => + throw new Exception(ExceptionUnknownAvatarFormatError) + }); + } + } + + /// + /// 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) + { + if (!User.IsAdministrator() && User.Identity.Name != username) + { + _logger.LogInformation(Log.Format(LogDeleteForbid, + ("Operator Username", User.Identity.Name), ("Username To Delete Avatar", username))); + return StatusCode(StatusCodes.Status403Forbidden, ErrorResponse.Common.Forbid()); + } + + long id; + try + { + id = await _userService.GetUserIdByUsername(username); + } + catch (UserNotExistException e) + { + _logger.LogInformation(e, Log.Format(LogDeleteNotExist, ("Username", username))); + return BadRequest(ErrorResponse.UserCommon.NotExist()); + } + + await _service.SetAvatar(id, null); + return Ok(); + } + } +} diff --git a/BackEnd/Timeline/Controllers/UserController.cs b/BackEnd/Timeline/Controllers/UserController.cs new file mode 100644 index 00000000..02c09aab --- /dev/null +++ b/BackEnd/Timeline/Controllers/UserController.cs @@ -0,0 +1,195 @@ +using AutoMapper; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using System.Linq; +using System.Threading.Tasks; +using Timeline.Auth; +using Timeline.Helpers; +using Timeline.Models; +using Timeline.Models.Http; +using Timeline.Models.Validation; +using Timeline.Services; +using Timeline.Services.Exceptions; +using static Timeline.Resources.Controllers.UserController; +using static Timeline.Resources.Messages; + +namespace Timeline.Controllers +{ + /// + /// Operations about users. + /// + [ApiController] + [ProducesErrorResponseType(typeof(CommonResponse))] + public class UserController : Controller + { + private readonly ILogger _logger; + private readonly IUserService _userService; + private readonly IUserDeleteService _userDeleteService; + private readonly IMapper _mapper; + + /// + public UserController(ILogger logger, IUserService userService, IUserDeleteService userDeleteService, IMapper mapper) + { + _logger = logger; + _userService = userService; + _userDeleteService = userDeleteService; + _mapper = mapper; + } + + private UserInfo ConvertToUserInfo(User user) => _mapper.Map(user); + + /// + /// Get all users. + /// + /// All user list. + [HttpGet("users")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> List() + { + var users = await _userService.GetUsers(); + var result = users.Select(u => ConvertToUserInfo(u)).ToArray(); + return Ok(result); + } + + /// + /// Get a user's info. + /// + /// Username of the user. + /// User info. + [HttpGet("users/{username}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> Get([FromRoute][Username] string username) + { + try + { + var user = await _userService.GetUserByUsername(username); + return Ok(ConvertToUserInfo(user)); + } + catch (UserNotExistException e) + { + _logger.LogInformation(e, Log.Format(LogGetUserNotExist, ("Username", username))); + return NotFound(ErrorResponse.UserCommon.NotExist()); + } + } + + /// + /// Change a user's property. + /// + /// + /// Username of the user to change. + /// The new user info. + [HttpPatch("users/{username}"), Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> Patch([FromBody] UserPatchRequest body, [FromRoute][Username] string username) + { + if (this.IsAdministrator()) + { + try + { + var user = await _userService.ModifyUser(username, _mapper.Map(body)); + return Ok(ConvertToUserInfo(user)); + } + catch (UserNotExistException e) + { + _logger.LogInformation(e, Log.Format(LogPatchUserNotExist, ("Username", username))); + return NotFound(ErrorResponse.UserCommon.NotExist()); + } + catch (EntityAlreadyExistException e) when (e.EntityName == EntityNames.User) + { + return BadRequest(ErrorResponse.UserController.UsernameConflict()); + } + } + else + { + if (User.Identity.Name != username) + return StatusCode(StatusCodes.Status403Forbidden, + ErrorResponse.Common.CustomMessage_Forbid(Common_Forbid_NotSelf)); + + if (body.Username != null) + return StatusCode(StatusCodes.Status403Forbidden, + ErrorResponse.Common.CustomMessage_Forbid(UserController_Patch_Forbid_Username)); + + if (body.Password != null) + return StatusCode(StatusCodes.Status403Forbidden, + ErrorResponse.Common.CustomMessage_Forbid(UserController_Patch_Forbid_Password)); + + if (body.Administrator != null) + return StatusCode(StatusCodes.Status403Forbidden, + ErrorResponse.Common.CustomMessage_Forbid(UserController_Patch_Forbid_Administrator)); + + var user = await _userService.ModifyUser(this.GetUserId(), _mapper.Map(body)); + return Ok(ConvertToUserInfo(user)); + } + } + + /// + /// Delete a user and all his related data. You have to be administrator. + /// + /// Username of the user to delete. + /// Info of deletion. + [HttpDelete("users/{username}"), AdminAuthorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> Delete([FromRoute][Username] string username) + { + var delete = await _userDeleteService.DeleteUser(username); + if (delete) + return Ok(CommonDeleteResponse.Delete()); + else + return Ok(CommonDeleteResponse.NotExist()); + } + + /// + /// Create a new user. You have to be administrator. + /// + /// The new user's info. + [HttpPost("userop/createuser"), AdminAuthorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> CreateUser([FromBody] CreateUserRequest body) + { + try + { + var user = await _userService.CreateUser(_mapper.Map(body)); + return Ok(ConvertToUserInfo(user)); + } + catch (EntityAlreadyExistException e) when (e.EntityName == EntityNames.User) + { + return BadRequest(ErrorResponse.UserController.UsernameConflict()); + } + } + + /// + /// Change password with old password. + /// + [HttpPost("userop/changepassword"), Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task ChangePassword([FromBody] ChangePasswordRequest request) + { + try + { + await _userService.ChangePassword(this.GetUserId(), request.OldPassword, request.NewPassword); + return Ok(); + } + catch (BadPasswordException e) + { + _logger.LogInformation(e, Log.Format(LogChangePasswordBadPassword, + ("Username", User.Identity.Name), ("Old Password", request.OldPassword))); + return BadRequest(ErrorResponse.UserController.ChangePassword_BadOldPassword()); + } + // User can't be non-existent or the token is bad. + } + } +} -- cgit v1.2.3