aboutsummaryrefslogtreecommitdiff
path: root/BackEnd/Timeline/Controllers
diff options
context:
space:
mode:
authorcrupest <crupest@outlook.com>2020-10-27 19:21:35 +0800
committercrupest <crupest@outlook.com>2020-10-27 19:21:35 +0800
commit05ccb4d8f1bbe3fb64e117136b4a89bcfb0b0b33 (patch)
tree929e514de85eb82a5acb96ecffc6e6d2d95f878f /BackEnd/Timeline/Controllers
parent986c6f2e3b858d6332eba0b42acc6861cd4d0227 (diff)
downloadtimeline-05ccb4d8f1bbe3fb64e117136b4a89bcfb0b0b33.tar.gz
timeline-05ccb4d8f1bbe3fb64e117136b4a89bcfb0b0b33.tar.bz2
timeline-05ccb4d8f1bbe3fb64e117136b4a89bcfb0b0b33.zip
Split front and back end.
Diffstat (limited to 'BackEnd/Timeline/Controllers')
-rw-r--r--BackEnd/Timeline/Controllers/ControllerAuthExtensions.cs40
-rw-r--r--BackEnd/Timeline/Controllers/Testing/TestingAuthController.cs32
-rw-r--r--BackEnd/Timeline/Controllers/TimelineController.cs491
-rw-r--r--BackEnd/Timeline/Controllers/TokenController.cs142
-rw-r--r--BackEnd/Timeline/Controllers/UserAvatarController.cs174
-rw-r--r--BackEnd/Timeline/Controllers/UserController.cs195
6 files changed, 1074 insertions, 0 deletions
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
+{
+ /// <summary>
+ /// Operations about timeline.
+ /// </summary>
+ [ApiController]
+ [CatchTimelineNotExistException]
+ [ProducesErrorResponseType(typeof(CommonResponse))]
+ public class TimelineController : Controller
+ {
+ private readonly ILogger<TimelineController> _logger;
+
+ private readonly IUserService _userService;
+ private readonly ITimelineService _service;
+
+ private readonly IMapper _mapper;
+
+ /// <summary>
+ ///
+ /// </summary>
+ public TimelineController(ILogger<TimelineController> logger, IUserService userService, ITimelineService service, IMapper mapper)
+ {
+ _logger = logger;
+ _userService = userService;
+ _service = service;
+ _mapper = mapper;
+ }
+
+ /// <summary>
+ /// List all timelines.
+ /// </summary>
+ /// <param name="relate">A username. If set, only timelines related to the user will return.</param>
+ /// <param name="relateType">Specify the relation type, may be 'own' or 'join'. If not set, both type will return.</param>
+ /// <param name="visibility">"Private" or "Register" or "Public". If set, only timelines whose visibility is specified one will return.</param>
+ /// <returns>The timeline list.</returns>
+ [HttpGet("timelines")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ public async Task<ActionResult<List<TimelineInfo>>> TimelineList([FromQuery][Username] string? relate, [FromQuery][RegularExpression("(own)|(join)")] string? relateType, [FromQuery] string? visibility)
+ {
+ List<TimelineVisibility>? visibilityFilter = null;
+ if (visibility != null)
+ {
+ visibilityFilter = new List<TimelineVisibility>();
+ 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<List<TimelineInfo>>(timelines);
+ return result;
+ }
+
+ /// <summary>
+ /// Get info of a timeline.
+ /// </summary>
+ /// <param name="name">The timeline name.</param>
+ /// <param name="checkUniqueId">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.</param>
+ /// <param name="queryIfModifiedSince">Same effect as If-Modified-Since header and take precedence than it.</param>
+ /// <param name="headerIfModifiedSince">If specified, will return 304 if not modified.</param>
+ /// <returns>The timeline info.</returns>
+ [HttpGet("timelines/{name}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status304NotModified)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task<ActionResult<TimelineInfo>> 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<TimelineInfo>(timeline);
+ return result;
+ }
+ }
+
+ /// <summary>
+ /// Get posts of a timeline.
+ /// </summary>
+ /// <param name="name">The name of the timeline.</param>
+ /// <param name="modifiedSince">If set, only posts modified since the time will return.</param>
+ /// <param name="includeDeleted">If set to true, deleted post will also return.</param>
+ /// <returns>The post list.</returns>
+ [HttpGet("timelines/{name}/posts")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task<ActionResult<List<TimelinePostInfo>>> 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<TimelinePost> posts = await _service.GetPosts(name, modifiedSince, includeDeleted ?? false);
+
+ var result = _mapper.Map<List<TimelinePostInfo>>(posts);
+ return result;
+ }
+
+ /// <summary>
+ /// Get the data of a post. Usually a image post.
+ /// </summary>
+ /// <param name="name">Timeline name.</param>
+ /// <param name="id">The id of the post.</param>
+ /// <param name="ifNoneMatch">If-None-Match header.</param>
+ /// <returns>The data.</returns>
+ [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<IActionResult> 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());
+ }
+ }
+
+ /// <summary>
+ /// Create a new post.
+ /// </summary>
+ /// <param name="name">Timeline name.</param>
+ /// <param name="body"></param>
+ /// <returns>Info of new post.</returns>
+ [HttpPost("timelines/{name}/posts")]
+ [Authorize]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ public async Task<ActionResult<TimelinePostInfo>> 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<TimelinePostInfo>(post);
+ return result;
+ }
+
+ /// <summary>
+ /// Delete a post.
+ /// </summary>
+ /// <param name="name">Timeline name.</param>
+ /// <param name="id">Post id.</param>
+ /// <returns>Info of deletion.</returns>
+ [HttpDelete("timelines/{name}/posts/{id}")]
+ [Authorize]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ public async Task<ActionResult<CommonDeleteResponse>> 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();
+ }
+ }
+
+ /// <summary>
+ /// Change properties of a timeline.
+ /// </summary>
+ /// <param name="name">Timeline name.</param>
+ /// <param name="body"></param>
+ /// <returns>The new info.</returns>
+ [HttpPatch("timelines/{name}")]
+ [Authorize]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ public async Task<ActionResult<TimelineInfo>> 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<TimelineChangePropertyRequest>(body));
+ var timeline = await _service.GetTimeline(name);
+ var result = _mapper.Map<TimelineInfo>(timeline);
+ return result;
+ }
+
+ /// <summary>
+ /// Add a member to timeline.
+ /// </summary>
+ /// <param name="name">Timeline name.</param>
+ /// <param name="member">The new member's username.</param>
+ [HttpPut("timelines/{name}/members/{member}")]
+ [Authorize]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ public async Task<ActionResult> 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<string> { member }, null);
+ return Ok();
+ }
+ catch (UserNotExistException)
+ {
+ return BadRequest(ErrorResponse.TimelineController.MemberPut_NotExist());
+ }
+ }
+
+ /// <summary>
+ /// Remove a member from timeline.
+ /// </summary>
+ /// <param name="name">Timeline name.</param>
+ /// <param name="member">The member's username.</param>
+ [HttpDelete("timelines/{name}/members/{member}")]
+ [Authorize]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ public async Task<ActionResult> 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<string> { member });
+ return Ok(CommonDeleteResponse.Delete());
+ }
+ catch (UserNotExistException)
+ {
+ return Ok(CommonDeleteResponse.NotExist());
+ }
+ }
+
+ /// <summary>
+ /// Create a timeline.
+ /// </summary>
+ /// <param name="body"></param>
+ /// <returns>Info of new timeline.</returns>
+ [HttpPost("timelines")]
+ [Authorize]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ public async Task<ActionResult<TimelineInfo>> TimelineCreate([FromBody] TimelineCreateRequest body)
+ {
+ var userId = this.GetUserId();
+
+ try
+ {
+ var timeline = await _service.CreateTimeline(body.Name, userId);
+ var result = _mapper.Map<TimelineInfo>(timeline);
+ return result;
+ }
+ catch (EntityAlreadyExistException e) when (e.EntityName == EntityNames.Timeline)
+ {
+ return BadRequest(ErrorResponse.TimelineController.NameConflict());
+ }
+ }
+
+ /// <summary>
+ /// Delete a timeline.
+ /// </summary>
+ /// <param name="name">Timeline name.</param>
+ /// <returns>Info of deletion.</returns>
+ [HttpDelete("timelines/{name}")]
+ [Authorize]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ public async Task<ActionResult<CommonDeleteResponse>> 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<ActionResult<TimelineInfo>> 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<TimelineInfo>(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
+{
+ /// <summary>
+ /// Operation about tokens.
+ /// </summary>
+ [Route("token")]
+ [ApiController]
+ [ProducesErrorResponseType(typeof(CommonResponse))]
+ public class TokenController : Controller
+ {
+ private readonly IUserTokenManager _userTokenManager;
+ private readonly ILogger<TokenController> _logger;
+ private readonly IClock _clock;
+
+ private readonly IMapper _mapper;
+
+ /// <summary></summary>
+ public TokenController(IUserTokenManager userTokenManager, ILogger<TokenController> logger, IClock clock, IMapper mapper)
+ {
+ _userTokenManager = userTokenManager;
+ _logger = logger;
+ _clock = clock;
+ _mapper = mapper;
+ }
+
+ /// <summary>
+ /// Create a new token for a user.
+ /// </summary>
+ /// <returns>Result of token creation.</returns>
+ [HttpPost("create")]
+ [AllowAnonymous]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ public async Task<ActionResult<CreateTokenResponse>> 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<UserInfo>(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());
+ }
+ }
+
+ /// <summary>
+ /// Verify a token.
+ /// </summary>
+ /// <returns>Result of token verification.</returns>
+ [HttpPost("verify")]
+ [AllowAnonymous]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ public async Task<ActionResult<VerifyTokenResponse>> 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<UserInfo>(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
+{
+ /// <summary>
+ /// Operations about user avatar.
+ /// </summary>
+ [ApiController]
+ [ProducesErrorResponseType(typeof(CommonResponse))]
+ public class UserAvatarController : Controller
+ {
+ private readonly ILogger<UserAvatarController> _logger;
+
+ private readonly IUserService _userService;
+ private readonly IUserAvatarService _service;
+
+ /// <summary>
+ ///
+ /// </summary>
+ public UserAvatarController(ILogger<UserAvatarController> logger, IUserService userService, IUserAvatarService service)
+ {
+ _logger = logger;
+ _userService = userService;
+ _service = service;
+ }
+
+ /// <summary>
+ /// Get avatar of a user.
+ /// </summary>
+ /// <param name="username">Username of the user to get avatar of.</param>
+ /// <param name="ifNoneMatch">If-None-Match header.</param>
+ /// <returns>Avatar data.</returns>
+ [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<IActionResult> 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();
+ });
+ }
+
+ /// <summary>
+ /// 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]
+ [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, [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)
+ });
+ }
+ }
+
+ /// <summary>
+ /// Reset the avatar to the default one. You have to be administrator to reset other's.
+ /// </summary>
+ /// <param name="username">Username of the user.</param>
+ /// <response code="200">Succeeded to reset.</response>
+ /// <response code="400">Error code is 10010001 if user does not exist.</response>
+ /// <response code="401">You have not logged in.</response>
+ /// <response code="403">You are not administrator.</response>
+ [HttpDelete("users/{username}/avatar")]
+ [ProducesResponseType(typeof(void), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [Authorize]
+ public async Task<IActionResult> 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
+{
+ /// <summary>
+ /// Operations about users.
+ /// </summary>
+ [ApiController]
+ [ProducesErrorResponseType(typeof(CommonResponse))]
+ public class UserController : Controller
+ {
+ private readonly ILogger<UserController> _logger;
+ private readonly IUserService _userService;
+ private readonly IUserDeleteService _userDeleteService;
+ private readonly IMapper _mapper;
+
+ /// <summary></summary>
+ public UserController(ILogger<UserController> logger, IUserService userService, IUserDeleteService userDeleteService, IMapper mapper)
+ {
+ _logger = logger;
+ _userService = userService;
+ _userDeleteService = userDeleteService;
+ _mapper = mapper;
+ }
+
+ private UserInfo ConvertToUserInfo(User user) => _mapper.Map<UserInfo>(user);
+
+ /// <summary>
+ /// Get all users.
+ /// </summary>
+ /// <returns>All user list.</returns>
+ [HttpGet("users")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task<ActionResult<UserInfo[]>> List()
+ {
+ var users = await _userService.GetUsers();
+ var result = users.Select(u => ConvertToUserInfo(u)).ToArray();
+ return Ok(result);
+ }
+
+ /// <summary>
+ /// Get a user's info.
+ /// </summary>
+ /// <param name="username">Username of the user.</param>
+ /// <returns>User info.</returns>
+ [HttpGet("users/{username}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task<ActionResult<UserInfo>> 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());
+ }
+ }
+
+ /// <summary>
+ /// Change a user's property.
+ /// </summary>
+ /// <param name="body"></param>
+ /// <param name="username">Username of the user to change.</param>
+ /// <returns>The new user info.</returns>
+ [HttpPatch("users/{username}"), Authorize]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task<ActionResult<UserInfo>> Patch([FromBody] UserPatchRequest body, [FromRoute][Username] string username)
+ {
+ if (this.IsAdministrator())
+ {
+ try
+ {
+ var user = await _userService.ModifyUser(username, _mapper.Map<User>(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<User>(body));
+ return Ok(ConvertToUserInfo(user));
+ }
+ }
+
+ /// <summary>
+ /// Delete a user and all his related data. You have to be administrator.
+ /// </summary>
+ /// <param name="username">Username of the user to delete.</param>
+ /// <returns>Info of deletion.</returns>
+ [HttpDelete("users/{username}"), AdminAuthorize]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ public async Task<ActionResult<CommonDeleteResponse>> Delete([FromRoute][Username] string username)
+ {
+ var delete = await _userDeleteService.DeleteUser(username);
+ if (delete)
+ return Ok(CommonDeleteResponse.Delete());
+ else
+ return Ok(CommonDeleteResponse.NotExist());
+ }
+
+ /// <summary>
+ /// Create a new user. You have to be administrator.
+ /// </summary>
+ /// <returns>The new user's info.</returns>
+ [HttpPost("userop/createuser"), AdminAuthorize]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ public async Task<ActionResult<UserInfo>> CreateUser([FromBody] CreateUserRequest body)
+ {
+ try
+ {
+ var user = await _userService.CreateUser(_mapper.Map<User>(body));
+ return Ok(ConvertToUserInfo(user));
+ }
+ catch (EntityAlreadyExistException e) when (e.EntityName == EntityNames.User)
+ {
+ return BadRequest(ErrorResponse.UserController.UsernameConflict());
+ }
+ }
+
+ /// <summary>
+ /// Change password with old password.
+ /// </summary>
+ [HttpPost("userop/changepassword"), Authorize]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ public async Task<ActionResult> 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.
+ }
+ }
+}