From 4ea535d93753826ec900879560d876cec4d58c38 Mon Sep 17 00:00:00 2001 From: crupest Date: Wed, 10 Feb 2021 02:03:06 +0800 Subject: ... --- .../Timeline/Controllers/TimelinePostController.cs | 28 ++++- .../Timeline/Controllers/UserAvatarController.cs | 15 +-- BackEnd/Timeline/Entities/DatabaseContext.cs | 1 + .../Timeline/Entities/TimelinePostDataEntity.cs | 31 +++++ BackEnd/Timeline/Entities/TimelinePostEntity.cs | 12 +- .../Timeline/Helpers/Cache/CacheableDataDigest.cs | 16 +++ BackEnd/Timeline/Helpers/Cache/DataCacheHelper.cs | 82 ++++++++++++++ .../Helpers/Cache/DelegateCacheableDataProvider.cs | 28 +++++ .../Timeline/Helpers/Cache/ICacheableDataDigest.cs | 10 ++ .../Helpers/Cache/ICacheableDataProvider.cs | 11 ++ BackEnd/Timeline/Helpers/DataCacheHelper.cs | 125 -------------------- .../Migrations/20200312112552_AddImagePost.cs | 2 +- BackEnd/Timeline/Models/Http/HttpTimelinePost.cs | 11 +- .../Models/Http/HttpTimelinePostContent.cs | 35 ------ .../Models/Http/HttpTimelinePostCreateRequest.cs | 22 +++- .../Http/HttpTimelinePostCreateRequestContent.cs | 26 ----- .../Models/Http/HttpTimelinePostDataDigest.cs | 23 ++++ BackEnd/Timeline/Models/Mapper/TimelineMapper.cs | 33 +----- .../Timeline/Models/TimelinePostContentTypes.cs | 7 +- .../Validation/TimelinePostContentTypeValidator.cs | 9 +- BackEnd/Timeline/Services/TimelinePostService.cs | 126 ++++++++------------- BackEnd/Timeline/Services/UserAvatarService.cs | 37 ++---- 22 files changed, 326 insertions(+), 364 deletions(-) create mode 100644 BackEnd/Timeline/Entities/TimelinePostDataEntity.cs create mode 100644 BackEnd/Timeline/Helpers/Cache/CacheableDataDigest.cs create mode 100644 BackEnd/Timeline/Helpers/Cache/DataCacheHelper.cs create mode 100644 BackEnd/Timeline/Helpers/Cache/DelegateCacheableDataProvider.cs create mode 100644 BackEnd/Timeline/Helpers/Cache/ICacheableDataDigest.cs create mode 100644 BackEnd/Timeline/Helpers/Cache/ICacheableDataProvider.cs delete mode 100644 BackEnd/Timeline/Helpers/DataCacheHelper.cs delete mode 100644 BackEnd/Timeline/Models/Http/HttpTimelinePostContent.cs delete mode 100644 BackEnd/Timeline/Models/Http/HttpTimelinePostCreateRequestContent.cs create mode 100644 BackEnd/Timeline/Models/Http/HttpTimelinePostDataDigest.cs (limited to 'BackEnd/Timeline') diff --git a/BackEnd/Timeline/Controllers/TimelinePostController.cs b/BackEnd/Timeline/Controllers/TimelinePostController.cs index 44498c58..a0fd1687 100644 --- a/BackEnd/Timeline/Controllers/TimelinePostController.cs +++ b/BackEnd/Timeline/Controllers/TimelinePostController.cs @@ -112,7 +112,7 @@ namespace Timeline.Controllers [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task DataGet([FromRoute][GeneralTimelineName] string timeline, [FromRoute] long post, [FromHeader(Name = "If-None-Match")] string? ifNoneMatch) + public async Task DataIndexGet([FromRoute][GeneralTimelineName] string timeline, [FromRoute] long post, [FromHeader(Name = "If-None-Match")] string? ifNoneMatch) { _ = ifNoneMatch; @@ -139,6 +139,24 @@ namespace Timeline.Controllers } } + /// + /// Get the data of a post. Usually a image post. + /// + /// Timeline name. + /// The id of the post. + /// If-None-Match header. + /// The data. + [HttpGet("{post}/data/{data_index}")] + [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 DataGet([FromRoute][GeneralTimelineName] string timeline, [FromRoute] long post, [FromHeader(Name = "If-None-Match")] string? ifNoneMatch) + { + } + /// /// Create a new post. /// @@ -163,18 +181,18 @@ namespace Timeline.Controllers var requestContent = body.Content; - TimelinePostCreateRequestContent createContent; + TimelinePostCreateRequestData createContent; switch (requestContent.Type) { - case TimelinePostContentTypes.Text: + case TimelinePostDataKind.Text: if (requestContent.Text is null) { return BadRequest(ErrorResponse.Common.CustomMessage_InvalidModel(Resources.Messages.TimelineController_TextContentTextRequired)); } createContent = new TimelinePostCreateRequestTextContent(requestContent.Text); break; - case TimelinePostContentTypes.Image: + case TimelinePostDataKind.Image: if (requestContent.Data is null) return BadRequest(ErrorResponse.Common.CustomMessage_InvalidModel(Resources.Messages.TimelineController_ImageContentDataRequired)); @@ -189,7 +207,7 @@ namespace Timeline.Controllers return BadRequest(ErrorResponse.Common.CustomMessage_InvalidModel(Resources.Messages.TimelineController_ImageContentDataNotBase64)); } - createContent = new TimelinePostCreateRequestImageContent(data); + createContent = new TimelinePostCreateRequestImageData(data); break; default: return BadRequest(ErrorResponse.Common.CustomMessage_InvalidModel(Resources.Messages.TimelineController_ContentUnknownType)); diff --git a/BackEnd/Timeline/Controllers/UserAvatarController.cs b/BackEnd/Timeline/Controllers/UserAvatarController.cs index f3b7fff8..8ac2d21a 100644 --- a/BackEnd/Timeline/Controllers/UserAvatarController.cs +++ b/BackEnd/Timeline/Controllers/UserAvatarController.cs @@ -7,6 +7,7 @@ using System; using System.Threading.Tasks; using Timeline.Filters; using Timeline.Helpers; +using Timeline.Helpers.Cache; using Timeline.Models; using Timeline.Models.Http; using Timeline.Models.Validation; @@ -63,11 +64,7 @@ namespace Timeline.Controllers return NotFound(ErrorResponse.UserCommon.NotExist()); } - return await DataCacheHelper.GenerateActionResult(this, () => _service.GetAvatarETag(id), async () => - { - var avatar = await _service.GetAvatar(id); - return avatar.ToCacheableData(); - }); + return await DataCacheHelper.GenerateActionResult(this, () => _service.GetAvatarDigest(id), () => _service.GetAvatar(id)); } /// @@ -105,11 +102,7 @@ namespace Timeline.Controllers try { - var etag = await _service.SetAvatar(id, new Avatar - { - Data = body.Data, - Type = body.ContentType - }); + var etag = await _service.SetAvatar(id, body); _logger.LogInformation(Log.Format(LogPutSuccess, ("Username", username), ("Mime Type", Request.ContentType))); @@ -166,7 +159,7 @@ namespace Timeline.Controllers return BadRequest(ErrorResponse.UserCommon.NotExist()); } - await _service.SetAvatar(id, null); + await _service.DeleteAvatar(id); return Ok(); } } diff --git a/BackEnd/Timeline/Entities/DatabaseContext.cs b/BackEnd/Timeline/Entities/DatabaseContext.cs index 513cdc95..a0b59d1f 100644 --- a/BackEnd/Timeline/Entities/DatabaseContext.cs +++ b/BackEnd/Timeline/Entities/DatabaseContext.cs @@ -28,6 +28,7 @@ namespace Timeline.Entities public DbSet UserPermission { get; set; } = default!; public DbSet Timelines { get; set; } = default!; public DbSet TimelinePosts { get; set; } = default!; + public DbSet TimelinePostData { get; set; } = default!; public DbSet TimelineMembers { get; set; } = default!; public DbSet HighlightTimelines { get; set; } = default!; public DbSet BookmarkTimelines { get; set; } = default!; diff --git a/BackEnd/Timeline/Entities/TimelinePostDataEntity.cs b/BackEnd/Timeline/Entities/TimelinePostDataEntity.cs new file mode 100644 index 00000000..6ae8fd24 --- /dev/null +++ b/BackEnd/Timeline/Entities/TimelinePostDataEntity.cs @@ -0,0 +1,31 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Timeline.Entities +{ + [Table("timeline_post_data")] + public class TimelinePostDataEntity + { + [Column("id"), Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public long Id { get; set; } + + [Column("post")] + public long PostId { get; set; } + + [ForeignKey(nameof(PostId))] + public TimelinePostEntity Timeline { get; set; } = default!; + + [Column("index")] + public long Index { get; set; } + + [Column("kind")] + public string Kind { get; set; } = default!; + + [Column("data_tag")] + public string DataTag { get; set; } = default!; + + [Column("last_updated")] + public DateTime LastUpdated { get; set; } + } +} diff --git a/BackEnd/Timeline/Entities/TimelinePostEntity.cs b/BackEnd/Timeline/Entities/TimelinePostEntity.cs index 39b11a5b..c65ef929 100644 --- a/BackEnd/Timeline/Entities/TimelinePostEntity.cs +++ b/BackEnd/Timeline/Entities/TimelinePostEntity.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; @@ -25,14 +26,7 @@ namespace Timeline.Entities [ForeignKey(nameof(AuthorId))] public UserEntity? Author { get; set; } = default!; - [Column("content_type"), Required] - public string ContentType { get; set; } = default!; - - [Column("content")] - public string? Content { get; set; } - - [Column("extra_content")] - public string? ExtraContent { get; set; } + public bool Deleted { get; set; } [Column("color")] public string? Color { get; set; } @@ -42,5 +36,7 @@ namespace Timeline.Entities [Column("last_updated")] public DateTime LastUpdated { get; set; } + + public List DataList { get; set; } = default!; } } diff --git a/BackEnd/Timeline/Helpers/Cache/CacheableDataDigest.cs b/BackEnd/Timeline/Helpers/Cache/CacheableDataDigest.cs new file mode 100644 index 00000000..3b5bcf52 --- /dev/null +++ b/BackEnd/Timeline/Helpers/Cache/CacheableDataDigest.cs @@ -0,0 +1,16 @@ +using System; + +namespace Timeline.Helpers.Cache +{ + public class CacheableDataDigest + { + public CacheableDataDigest(string eTag, DateTime lastModified) + { + ETag = eTag; + LastModified = lastModified; + } + + public string ETag { get; set; } + public DateTime LastModified { get; set; } + } +} diff --git a/BackEnd/Timeline/Helpers/Cache/DataCacheHelper.cs b/BackEnd/Timeline/Helpers/Cache/DataCacheHelper.cs new file mode 100644 index 00000000..c26bdddc --- /dev/null +++ b/BackEnd/Timeline/Helpers/Cache/DataCacheHelper.cs @@ -0,0 +1,82 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Net.Http.Headers; +using System; +using System.Linq; +using System.Threading.Tasks; +using Timeline.Models; +using Timeline.Models.Http; + +namespace Timeline.Helpers.Cache +{ + public static class DataCacheHelper + { + public static async Task GenerateActionResult(Controller controller, ICacheableDataProvider provider, TimeSpan? maxAge = null) + { + const string CacheControlHeaderKey = "Cache-Control"; + const string IfNonMatchHeaderKey = "If-None-Match"; + const string IfModifiedSinceHeaderKey = "If-Modified-Since"; + const string ETagHeaderKey = "ETag"; + const string LastModifiedHeaderKey = "Last-Modified"; + + string GenerateCacheControlHeaderValue() + { + var cacheControlHeader = new CacheControlHeaderValue() + { + NoCache = true, + NoStore = false, + MaxAge = maxAge ?? TimeSpan.FromDays(14), + Private = true, + MustRevalidate = true + }; + return cacheControlHeader.ToString(); + } + + var digest = await provider.GetDigest(); + var eTagValue = '"' + digest.ETag + '"'; + var eTag = new EntityTagHeaderValue(eTagValue); + + ActionResult Generate304Result() + { + controller.Response.Headers.Add(ETagHeaderKey, eTagValue); + controller.Response.Headers.Add(LastModifiedHeaderKey, digest.LastModified.ToString("R")); + controller.Response.Headers.Add(CacheControlHeaderKey, GenerateCacheControlHeaderValue()); + return controller.StatusCode(StatusCodes.Status304NotModified, null); + } + + if (controller.Request.Headers.TryGetValue(IfNonMatchHeaderKey, out var ifNonMatchHeaderValue)) + { + if (!EntityTagHeaderValue.TryParseList(ifNonMatchHeaderValue, out var eTagList)) + { + return controller.BadRequest(ErrorResponse.Common.Header.IfNonMatch_BadFormat()); + } + + if (eTagList.FirstOrDefault(e => e.Equals(eTag)) != null) + { + return Generate304Result(); + } + } + else if (controller.Request.Headers.TryGetValue(IfModifiedSinceHeaderKey, out var ifModifiedSinceHeaderValue)) + { + if (!DateTime.TryParse(ifModifiedSinceHeaderValue, out var headerValue)) + { + return controller.BadRequest(new CommonResponse(ErrorCodes.Common.Header.IfModifiedSince_BadFormat, "Header If-Modified-Since is of bad format.")); + } + + if (headerValue > digest.LastModified) + { + return Generate304Result(); + } + } + + var data = await provider.GetData(); + controller.Response.Headers.Add(CacheControlHeaderKey, GenerateCacheControlHeaderValue()); + return controller.File(data.Data, data.ContentType, digest.LastModified, eTag); + } + + public static Task GenerateActionResult(Controller controller, Func> getDigestDelegate, Func> getDataDelegate, TimeSpan? maxAge = null) + { + return GenerateActionResult(controller, new DelegateCacheableDataProvider(getDigestDelegate, getDataDelegate), maxAge); + } + } +} diff --git a/BackEnd/Timeline/Helpers/Cache/DelegateCacheableDataProvider.cs b/BackEnd/Timeline/Helpers/Cache/DelegateCacheableDataProvider.cs new file mode 100644 index 00000000..80cb66c7 --- /dev/null +++ b/BackEnd/Timeline/Helpers/Cache/DelegateCacheableDataProvider.cs @@ -0,0 +1,28 @@ +using System; +using System.Threading.Tasks; +using Timeline.Models; + +namespace Timeline.Helpers.Cache +{ + public class DelegateCacheableDataProvider : ICacheableDataProvider + { + private readonly Func> _getDigestDelegate; + private readonly Func> _getDataDelegate; + + public DelegateCacheableDataProvider(Func> getDigestDelegate, Func> getDataDelegate) + { + _getDigestDelegate = getDigestDelegate; + _getDataDelegate = getDataDelegate; + } + + public Task GetDigest() + { + return _getDigestDelegate(); + } + + public Task GetData() + { + return _getDataDelegate(); + } + } +} diff --git a/BackEnd/Timeline/Helpers/Cache/ICacheableDataDigest.cs b/BackEnd/Timeline/Helpers/Cache/ICacheableDataDigest.cs new file mode 100644 index 00000000..32519d7e --- /dev/null +++ b/BackEnd/Timeline/Helpers/Cache/ICacheableDataDigest.cs @@ -0,0 +1,10 @@ +using System; + +namespace Timeline.Helpers.Cache +{ + public interface ICacheableDataDigest + { + string ETag { get; } + DateTime LastModified { get; } + } +} diff --git a/BackEnd/Timeline/Helpers/Cache/ICacheableDataProvider.cs b/BackEnd/Timeline/Helpers/Cache/ICacheableDataProvider.cs new file mode 100644 index 00000000..b270fb1d --- /dev/null +++ b/BackEnd/Timeline/Helpers/Cache/ICacheableDataProvider.cs @@ -0,0 +1,11 @@ +using System.Threading.Tasks; +using Timeline.Models; + +namespace Timeline.Helpers.Cache +{ + public interface ICacheableDataProvider + { + Task GetDigest(); + Task GetData(); + } +} diff --git a/BackEnd/Timeline/Helpers/DataCacheHelper.cs b/BackEnd/Timeline/Helpers/DataCacheHelper.cs deleted file mode 100644 index 1ad69708..00000000 --- a/BackEnd/Timeline/Helpers/DataCacheHelper.cs +++ /dev/null @@ -1,125 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Net.Http.Headers; -using System; -using System.Linq; -using System.Threading.Tasks; -using Timeline.Models.Http; -using static Timeline.Resources.Helper.DataCacheHelper; - -namespace Timeline.Helpers -{ - public interface ICacheableData - { - string Type { get; } -#pragma warning disable CA1819 // Properties should not return arrays - byte[] Data { get; } -#pragma warning restore CA1819 // Properties should not return arrays - DateTime? LastModified { get; } - } - - public class CacheableData : ICacheableData - { - public CacheableData(string type, byte[] data, DateTime? lastModified) - { - Type = type; - Data = data; - LastModified = lastModified; - } - - public string Type { get; set; } -#pragma warning disable CA1819 // Properties should not return arrays - public byte[] Data { get; set; } -#pragma warning restore CA1819 // Properties should not return arrays - public DateTime? LastModified { get; set; } - } - - public interface ICacheableDataProvider - { - Task GetDataETag(); - Task GetData(); - } - - public class DelegateCacheableDataProvider : ICacheableDataProvider - { - private readonly Func> _getDataETagDelegate; - private readonly Func> _getDataDelegate; - - public DelegateCacheableDataProvider(Func> getDataETagDelegate, Func> getDataDelegate) - { - _getDataETagDelegate = getDataETagDelegate; - _getDataDelegate = getDataDelegate; - } - - public Task GetData() - { - return _getDataDelegate(); - } - - public Task GetDataETag() - { - return _getDataETagDelegate(); - } - } - - public static class DataCacheHelper - { - public static async Task GenerateActionResult(Controller controller, ICacheableDataProvider provider, TimeSpan? maxAge = null) - { - const string CacheControlHeaderKey = "Cache-Control"; - const string IfNonMatchHeaderKey = "If-None-Match"; - const string ETagHeaderKey = "ETag"; - - string GenerateCacheControlHeaderValue() - { - var cacheControlHeader = new CacheControlHeaderValue() - { - NoCache = true, - NoStore = false, - MaxAge = maxAge ?? TimeSpan.FromDays(14), - Private = true, - MustRevalidate = true - }; - return cacheControlHeader.ToString(); - } - - var loggerFactory = controller.HttpContext.RequestServices.GetRequiredService(); - var logger = loggerFactory.CreateLogger(typeof(DataCacheHelper)); - - var eTagValue = await provider.GetDataETag(); - eTagValue = '"' + eTagValue + '"'; - var eTag = new EntityTagHeaderValue(eTagValue); - - - if (controller.Request.Headers.TryGetValue(IfNonMatchHeaderKey, out var value)) - { - if (!EntityTagHeaderValue.TryParseStrictList(value, out var eTagList)) - { - logger.LogInformation(Log.Format(LogBadIfNoneMatch, ("Header Value", value))); - return controller.BadRequest(ErrorResponse.Common.Header.IfNonMatch_BadFormat()); - } - - if (eTagList.FirstOrDefault(e => e.Equals(eTag)) != null) - { - logger.LogInformation(LogResultNotModified); - controller.Response.Headers.Add(ETagHeaderKey, eTagValue); - controller.Response.Headers.Add(CacheControlHeaderKey, GenerateCacheControlHeaderValue()); - - return controller.StatusCode(StatusCodes.Status304NotModified, null); - } - } - - var data = await provider.GetData(); - logger.LogInformation(LogResultData); - controller.Response.Headers.Add(CacheControlHeaderKey, GenerateCacheControlHeaderValue()); - return controller.File(data.Data, data.Type, data.LastModified, eTag); - } - - public static Task GenerateActionResult(Controller controller, Func> getDataETagDelegate, Func> getDataDelegate, TimeSpan? maxAge = null) - { - return GenerateActionResult(controller, new DelegateCacheableDataProvider(getDataETagDelegate, getDataDelegate), maxAge); - } - } -} diff --git a/BackEnd/Timeline/Migrations/20200312112552_AddImagePost.cs b/BackEnd/Timeline/Migrations/20200312112552_AddImagePost.cs index d5098ce0..b6cc29a3 100644 --- a/BackEnd/Timeline/Migrations/20200312112552_AddImagePost.cs +++ b/BackEnd/Timeline/Migrations/20200312112552_AddImagePost.cs @@ -20,7 +20,7 @@ namespace Timeline.Migrations migrationBuilder.Sql($@" UPDATE timeline_posts -SET content_type = '{TimelinePostContentTypes.Text}'; +SET content_type = '{TimelinePostDataKind.Text}'; "); } diff --git a/BackEnd/Timeline/Models/Http/HttpTimelinePost.cs b/BackEnd/Timeline/Models/Http/HttpTimelinePost.cs index 5981d7a4..165c92da 100644 --- a/BackEnd/Timeline/Models/Http/HttpTimelinePost.cs +++ b/BackEnd/Timeline/Models/Http/HttpTimelinePost.cs @@ -1,7 +1,9 @@ using System; +using System.Collections.Generic; namespace Timeline.Models.Http { + /// /// Info of a post. /// @@ -9,10 +11,10 @@ namespace Timeline.Models.Http { public HttpTimelinePost() { } - public HttpTimelinePost(long id, HttpTimelinePostContent? content, bool deleted, DateTime time, HttpUser? author, string? color, DateTime lastUpdated) + public HttpTimelinePost(long id, List dataList, bool deleted, DateTime time, HttpUser? author, string? color, DateTime lastUpdated) { Id = id; - Content = content; + DataList = dataList; Deleted = deleted; Time = time; Author = author; @@ -24,10 +26,7 @@ namespace Timeline.Models.Http /// Post id. /// public long Id { get; set; } - /// - /// Content of the post. May be null if post is deleted. - /// - public HttpTimelinePostContent? Content { get; set; } + public List DataList { get; set; } = default!; /// /// True if post is deleted. /// diff --git a/BackEnd/Timeline/Models/Http/HttpTimelinePostContent.cs b/BackEnd/Timeline/Models/Http/HttpTimelinePostContent.cs deleted file mode 100644 index 55ff1ac2..00000000 --- a/BackEnd/Timeline/Models/Http/HttpTimelinePostContent.cs +++ /dev/null @@ -1,35 +0,0 @@ -namespace Timeline.Models.Http -{ - /// - /// Info of post content. - /// - public class HttpTimelinePostContent - { - public HttpTimelinePostContent() { } - - public HttpTimelinePostContent(string type, string? text, string? url, string? eTag) - { - Type = type; - Text = text; - Url = url; - ETag = eTag; - } - - /// - /// Type of the post content. - /// - public string Type { get; set; } = default!; - /// - /// If post is of text type. This is the text. - /// - public string? Text { get; set; } - /// - /// If post is of image type. This is the image url. - /// - public string? Url { get; set; } - /// - /// If post has data (currently it means it's a image post), this is the data etag. - /// - public string? ETag { get; set; } - } -} diff --git a/BackEnd/Timeline/Models/Http/HttpTimelinePostCreateRequest.cs b/BackEnd/Timeline/Models/Http/HttpTimelinePostCreateRequest.cs index b25adf36..20d1a25b 100644 --- a/BackEnd/Timeline/Models/Http/HttpTimelinePostCreateRequest.cs +++ b/BackEnd/Timeline/Models/Http/HttpTimelinePostCreateRequest.cs @@ -1,16 +1,34 @@ using System; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using Timeline.Models.Validation; namespace Timeline.Models.Http { + public class HttpTimelinePostCreateRequestData + { + /// + /// Kind of the data. + /// + [Required] + [TimelinePostDataKind] + public string Kind { get; set; } = default!; + + /// + /// The true data. If kind is text or markdown, this is a string. If kind is image, this is base64 of data. + /// + [Required] + public string Data { get; set; } = default!; + } + public class HttpTimelinePostCreateRequest { /// - /// Content of the new post. + /// Data list of the new content. /// [Required] - public HttpTimelinePostCreateRequestContent Content { get; set; } = default!; + [MinLength(1)] + public List DataList { get; set; } = default!; /// /// Time of the post. If not set, current time will be used. diff --git a/BackEnd/Timeline/Models/Http/HttpTimelinePostCreateRequestContent.cs b/BackEnd/Timeline/Models/Http/HttpTimelinePostCreateRequestContent.cs deleted file mode 100644 index 12ab407f..00000000 --- a/BackEnd/Timeline/Models/Http/HttpTimelinePostCreateRequestContent.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using Timeline.Models.Validation; - -namespace Timeline.Models.Http -{ - /// - /// Content of post create request. - /// - public class HttpTimelinePostCreateRequestContent - { - /// - /// Type of post content. - /// - [Required] - [TimelinePostContentType] - public string Type { get; set; } = default!; - /// - /// If post is of text type, this is the text. - /// - public string? Text { get; set; } - /// - /// If post is of image type, this is base64 of image data. - /// - public string? Data { get; set; } - } -} diff --git a/BackEnd/Timeline/Models/Http/HttpTimelinePostDataDigest.cs b/BackEnd/Timeline/Models/Http/HttpTimelinePostDataDigest.cs new file mode 100644 index 00000000..61d35e15 --- /dev/null +++ b/BackEnd/Timeline/Models/Http/HttpTimelinePostDataDigest.cs @@ -0,0 +1,23 @@ +using System; + +namespace Timeline.Models.Http +{ + public class HttpTimelinePostDataDigest + { + public HttpTimelinePostDataDigest() + { + + } + + public HttpTimelinePostDataDigest(string kind, string eTag, DateTime lastUpdated) + { + Kind = kind; + ETag = eTag; + LastUpdated = lastUpdated; + } + + public string Kind { get; set; } = default!; + public string ETag { get; set; } = default!; + public DateTime LastUpdated { get; set; } + } +} diff --git a/BackEnd/Timeline/Models/Mapper/TimelineMapper.cs b/BackEnd/Timeline/Models/Mapper/TimelineMapper.cs index 88c96d8a..33ee9593 100644 --- a/BackEnd/Timeline/Models/Mapper/TimelineMapper.cs +++ b/BackEnd/Timeline/Models/Mapper/TimelineMapper.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Threading.Tasks; using Timeline.Controllers; @@ -67,34 +66,12 @@ namespace Timeline.Models.Mapper public async Task MapToHttp(TimelinePostEntity entity, string timelineName, IUrlHelper urlHelper) { - HttpTimelinePostContent? content = null; - - if (entity.Content != null) - { - content = entity.ContentType switch - { - TimelinePostContentTypes.Text => new HttpTimelinePostContent - ( - type: TimelinePostContentTypes.Text, - text: entity.Content, - url: null, - eTag: null - ), - TimelinePostContentTypes.Image => new HttpTimelinePostContent - ( - type: TimelinePostContentTypes.Image, - text: null, - url: urlHelper.ActionLink(nameof(TimelinePostController.DataGet), nameof(TimelinePostController)[0..^nameof(Controller).Length], new { timeline = timelineName, post = entity.LocalId }), - eTag: $"\"{entity.Content}\"" - ), - _ => throw new DatabaseCorruptedException(string.Format(CultureInfo.InvariantCulture, "Unknown timeline post type {0}.", entity.ContentType)) - }; - } - + await _database.Entry(entity).Collection(p => p.DataList).LoadAsync(); await _database.Entry(entity).Reference(e => e.Author).LoadAsync(); - HttpUser? author = null; + List dataDigestList = entity.DataList.OrderBy(d => d.Index).Select(d => new HttpTimelinePostDataDigest(d.Kind, d.DataTag, d.LastUpdated)).ToList(); + HttpUser? author = null; if (entity.Author is not null) { author = await _userMapper.MapToHttp(entity.Author, urlHelper); @@ -102,11 +79,11 @@ namespace Timeline.Models.Mapper return new HttpTimelinePost( id: entity.LocalId, - content: content, - deleted: content is null, + dataList: dataDigestList, time: entity.Time, author: author, color: entity.Color, + deleted: entity.Deleted, lastUpdated: entity.LastUpdated ); } diff --git a/BackEnd/Timeline/Models/TimelinePostContentTypes.cs b/BackEnd/Timeline/Models/TimelinePostContentTypes.cs index ca5e79e1..d432e03c 100644 --- a/BackEnd/Timeline/Models/TimelinePostContentTypes.cs +++ b/BackEnd/Timeline/Models/TimelinePostContentTypes.cs @@ -2,13 +2,12 @@ namespace Timeline.Models { - public static class TimelinePostContentTypes + public static class TimelinePostDataKind { -#pragma warning disable CA1819 // Properties should not return arrays - public static string[] AllTypes { get; } = new string[] { Text, Image }; -#pragma warning restore CA1819 // Properties should not return arrays + public static IReadOnlyList AllTypes { get; } = new List { Text, Image, Markdown }; public const string Text = "text"; public const string Image = "image"; + public const string Markdown = "markdown"; } } diff --git a/BackEnd/Timeline/Models/Validation/TimelinePostContentTypeValidator.cs b/BackEnd/Timeline/Models/Validation/TimelinePostContentTypeValidator.cs index 483cce06..b65c846c 100644 --- a/BackEnd/Timeline/Models/Validation/TimelinePostContentTypeValidator.cs +++ b/BackEnd/Timeline/Models/Validation/TimelinePostContentTypeValidator.cs @@ -1,16 +1,17 @@ using System; +using System.Linq; namespace Timeline.Models.Validation { - public class TimelinePostContentTypeValidator : StringSetValidator + public class TimelinePostDataKindValidator : StringSetValidator { - public TimelinePostContentTypeValidator() : base(TimelinePostContentTypes.AllTypes) { } + public TimelinePostDataKindValidator() : base(TimelinePostDataKind.AllTypes.ToArray()) { } } [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter, AllowMultiple = false)] - public class TimelinePostContentTypeAttribute : ValidateWithAttribute + public class TimelinePostDataKindAttribute : ValidateWithAttribute { - public TimelinePostContentTypeAttribute() : base(typeof(TimelinePostContentTypeValidator)) + public TimelinePostDataKindAttribute() : base(typeof(TimelinePostDataKindValidator)) { } diff --git a/BackEnd/Timeline/Services/TimelinePostService.cs b/BackEnd/Timeline/Services/TimelinePostService.cs index 66ec8090..98841478 100644 --- a/BackEnd/Timeline/Services/TimelinePostService.cs +++ b/BackEnd/Timeline/Services/TimelinePostService.cs @@ -14,95 +14,69 @@ using static Timeline.Resources.Services.TimelineService; namespace Timeline.Services { - public class PostData : ICacheableData + public class TimelinePostDataDigest { -#pragma warning disable CA1819 // Properties should not return arrays - public byte[] Data { get; set; } = default!; -#pragma warning restore CA1819 // Properties should not return arrays - public string Type { get; set; } = default!; - public string ETag { get; set; } = default!; - public DateTime? LastModified { get; set; } // TODO: Why nullable? - } + public TimelinePostDataDigest(string kind, string eTag, DateTime lastModified) + { + Kind = kind; + ETag = eTag; + LastModified = lastModified; + } - public abstract class TimelinePostCreateRequestContent - { - public abstract string TypeName { get; } + public string Kind { get; set; } + public string ETag { get; set; } + public DateTime LastModified { get; set; } } - public class TimelinePostCreateRequestTextContent : TimelinePostCreateRequestContent + public class TimelinePostData { - private string _text; - - public TimelinePostCreateRequestTextContent(string text) + public TimelinePostData(string kind, byte[] data, string eTag, DateTime lastModified) { - if (text is null) - throw new ArgumentNullException(nameof(text)); - - _text = text; + Kind = kind; + Data = data; + ETag = eTag; + LastModified = lastModified; } - public override string TypeName => TimelinePostContentTypes.Text; + public string Kind { get; set; } - public string Text - { - get => _text; - set - { - if (value is null) - throw new ArgumentNullException(nameof(value)); - _text = value; - } - } +#pragma warning disable CA1819 // Properties should not return arrays + public byte[] Data { get; set; } +#pragma warning restore CA1819 // Properties should not return arrays + + public string ETag { get; set; } + public DateTime LastModified { get; set; } } - public class TimelinePostCreateRequestImageContent : TimelinePostCreateRequestContent + public class TimelinePostCreateRequestData { - private byte[] _data; - - public TimelinePostCreateRequestImageContent(byte[] data) + public TimelinePostCreateRequestData(string kind, byte[] data) { - if (data is null) - throw new ArgumentNullException(nameof(data)); - - _data = data; + Kind = kind; + Data = data; } - public override string TypeName => TimelinePostContentTypes.Image; - + public string Kind { get; set; } #pragma warning disable CA1819 // Properties should not return arrays - public byte[] Data - { - get => _data; - set - { - if (value is null) - throw new ArgumentNullException(nameof(value)); - _data = value; - } - } + public byte[] Data { get; set; } #pragma warning restore CA1819 // Properties should not return arrays } public class TimelinePostCreateRequest { - public TimelinePostCreateRequest(TimelinePostCreateRequestContent content) - { - Content = content; - } - public string? Color { get; set; } /// If not set, current time is used. public DateTime? Time { get; set; } - public TimelinePostCreateRequestContent Content { get; set; } + public List Content { get; set; } = new List(); } public class TimelinePostPatchRequest { public string? Color { get; set; } public DateTime? Time { get; set; } - public TimelinePostCreateRequestContent? Content { get; set; } + public List? Content { get; set; } } public interface ITimelinePostService @@ -128,16 +102,7 @@ namespace Timeline.Services /// Thrown when post of does not exist or has been deleted. Task GetPost(long timelineId, long postId, bool includeDelete = false); - /// - /// Get the etag of data of a post. - /// - /// The id of the timeline of the post. - /// The id of the post. - /// The etag of the data. - /// Thrown when timeline does not exist. - /// Thrown when post of does not exist or has been deleted. - /// Thrown when post has no data. - Task GetPostDataETag(long timelineId, long postId); + Task GetPostDataDigest(long timelineId, long postId, long dataIndex); /// /// Get the data of a post. @@ -148,8 +113,7 @@ namespace Timeline.Services /// Thrown when timeline does not exist. /// Thrown when post of does not exist or has been deleted. /// Thrown when post has no data. - /// - Task GetPostData(long timelineId, long postId); + Task GetPostData(long timelineId, long postId, long dataIndex); /// /// Create a new post in timeline. @@ -305,7 +269,7 @@ namespace Timeline.Services if (postEntity.Content == null) throw new TimelinePostNotExistException(timelineId, postId, true); - if (postEntity.ContentType != TimelinePostContentTypes.Image) + if (postEntity.ContentType != TimelinePostDataKind.Image) throw new TimelinePostNoDataException(ExceptionGetDataNonImagePost); var tag = postEntity.Content; @@ -313,7 +277,7 @@ namespace Timeline.Services return tag; } - public async Task GetPostData(long timelineId, long postId) + public async Task GetPostData(long timelineId, long postId) { await CheckTimelineExistence(timelineId); @@ -325,7 +289,7 @@ namespace Timeline.Services if (postEntity.Content == null) throw new TimelinePostNotExistException(timelineId, postId, true); - if (postEntity.ContentType != TimelinePostContentTypes.Image) + if (postEntity.ContentType != TimelinePostDataKind.Image) throw new TimelinePostNoDataException(ExceptionGetDataNonImagePost); var tag = postEntity.Content; @@ -349,7 +313,7 @@ namespace Timeline.Services await _database.SaveChangesAsync(); } - return new PostData + return new TimelinePostData { Data = data, Type = postEntity.ExtraContent, @@ -358,21 +322,21 @@ namespace Timeline.Services }; } - private async Task SaveContent(TimelinePostEntity entity, TimelinePostCreateRequestContent content) + private async Task SaveContent(TimelinePostEntity entity, TimelinePostCreateRequestData content) { switch (content) { - case TimelinePostCreateRequestTextContent c: - entity.ContentType = c.TypeName; - entity.Content = c.Text; + case TimelinePostCreateRequestTextData c: + entity.ContentType = c.Kind; + entity.Content = c.Data; break; - case TimelinePostCreateRequestImageContent c: + case TimelinePostCreateRequestImageData c: var imageFormat = await _imageValidator.Validate(c.Data); var imageFormatText = imageFormat.DefaultMimeType; var tag = await _dataManager.RetainEntry(c.Data); - entity.ContentType = content.TypeName; + entity.ContentType = content.Kind; entity.Content = tag; entity.ExtraContent = imageFormatText; break; @@ -383,7 +347,7 @@ namespace Timeline.Services private async Task CleanContent(TimelinePostEntity entity) { - if (entity.Content is not null && entity.ContentType == TimelinePostContentTypes.Image) + if (entity.Content is not null && entity.ContentType == TimelinePostDataKind.Image) await _dataManager.FreeEntry(entity.Content); entity.Content = null; } @@ -516,7 +480,7 @@ namespace Timeline.Services { if (post.Content != null) { - if (post.ContentType == TimelinePostContentTypes.Image) + if (post.ContentType == TimelinePostDataKind.Image) { dataTags.Add(post.Content); } diff --git a/BackEnd/Timeline/Services/UserAvatarService.cs b/BackEnd/Timeline/Services/UserAvatarService.cs index b41c45fd..afd6cf0a 100644 --- a/BackEnd/Timeline/Services/UserAvatarService.cs +++ b/BackEnd/Timeline/Services/UserAvatarService.cs @@ -8,28 +8,12 @@ using System.Linq; using System.Threading.Tasks; using Timeline.Entities; using Timeline.Helpers; +using Timeline.Helpers.Cache; +using Timeline.Models; using Timeline.Services.Exceptions; namespace Timeline.Services { - public class Avatar - { - public string Type { get; set; } = default!; - [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1819:Properties should not return arrays", Justification = "DTO Object")] - public byte[] Data { get; set; } = default!; - } - - public class AvatarInfo - { - public Avatar Avatar { get; set; } = default!; - public DateTime LastModified { get; set; } - - public CacheableData ToCacheableData() - { - return new CacheableData(Avatar.Type, Avatar.Data, LastModified); - } - } - /// /// Provider for default user avatar. /// @@ -42,29 +26,24 @@ namespace Timeline.Services /// Get the etag of default avatar. /// /// - Task GetDefaultAvatarETag(); + Task GetDefaultAvatarETag(); /// /// Get the default avatar. /// - Task GetDefaultAvatar(); + Task GetDefaultAvatar(); } public interface IUserAvatarService { - /// - /// Get the etag of a user's avatar. Warning: This method does not check the user existence. - /// - /// The id of the user to get avatar etag of. - /// The etag. - Task GetAvatarETag(long id); + Task GetAvatarDigest(long id); /// /// Get avatar of a user. If the user has no avatar set, a default one is returned. Warning: This method does not check the user existence. /// /// The id of the user to get avatar of. /// The avatar info. - Task GetAvatar(long id); + Task GetAvatar(long id); /// /// Set avatar for a user. Warning: This method does not check the user existence. @@ -74,7 +53,9 @@ namespace Timeline.Services /// The etag of the avatar. /// Thrown if any field in is null when is not null. /// Thrown if avatar is of bad format. - Task SetAvatar(long id, Avatar? avatar); + Task SetAvatar(long id, ByteData avatar); + + Task DeleteAvatar(long id); } // TODO! : Make this configurable. -- cgit v1.2.3 From 98df0fb9fe94b703325f09bf765904a11e8a7d4c Mon Sep 17 00:00:00 2001 From: crupest Date: Wed, 10 Feb 2021 14:31:31 +0800 Subject: ... --- BackEnd/Timeline.ErrorCodes/ErrorCodes.cs | 2 +- .../Timeline/Controllers/TimelinePostController.cs | 158 ++++++++------------- .../CatchTimelineNotExistExceptionAttribute.cs | 32 +++++ ...chTimelinePostDataNotExistExceptionAttribute.cs | 24 ++++ .../CatchTimelinePostNotExistExceptionAttribute.cs | 24 ++++ BackEnd/Timeline/Filters/Timeline.cs | 32 ----- .../Timeline/Formatters/ByteDataInputFormatter.cs | 80 +++++++++++ BackEnd/Timeline/Formatters/BytesInputFormatter.cs | 79 ----------- .../Migrations/20200312112552_AddImagePost.cs | 2 +- BackEnd/Timeline/Models/Http/ErrorResponse.cs | 11 -- .../Models/Http/HttpTimelinePostCreateRequest.cs | 17 +-- .../Http/HttpTimelinePostCreateRequestData.cs | 19 +++ BackEnd/Timeline/Models/MimeTypes.cs | 14 ++ .../Timeline/Models/TimelinePostContentTypes.cs | 13 -- .../Validation/TimelinePostContentTypeValidator.cs | 19 --- .../Exceptions/TimelinePostNoDataException.cs | 15 -- .../Services/TimelinePostCreateDataException.cs | 16 +++ .../Services/TimelinePostDataNotExistException.cs | 15 ++ BackEnd/Timeline/Services/TimelinePostService.cs | 68 +++------ BackEnd/Timeline/Startup.cs | 2 +- 20 files changed, 308 insertions(+), 334 deletions(-) create mode 100644 BackEnd/Timeline/Filters/CatchTimelineNotExistExceptionAttribute.cs create mode 100644 BackEnd/Timeline/Filters/CatchTimelinePostDataNotExistExceptionAttribute.cs create mode 100644 BackEnd/Timeline/Filters/CatchTimelinePostNotExistExceptionAttribute.cs delete mode 100644 BackEnd/Timeline/Filters/Timeline.cs create mode 100644 BackEnd/Timeline/Formatters/ByteDataInputFormatter.cs delete mode 100644 BackEnd/Timeline/Formatters/BytesInputFormatter.cs create mode 100644 BackEnd/Timeline/Models/Http/HttpTimelinePostCreateRequestData.cs create mode 100644 BackEnd/Timeline/Models/MimeTypes.cs delete mode 100644 BackEnd/Timeline/Models/TimelinePostContentTypes.cs delete mode 100644 BackEnd/Timeline/Models/Validation/TimelinePostContentTypeValidator.cs delete mode 100644 BackEnd/Timeline/Services/Exceptions/TimelinePostNoDataException.cs create mode 100644 BackEnd/Timeline/Services/TimelinePostCreateDataException.cs create mode 100644 BackEnd/Timeline/Services/TimelinePostDataNotExistException.cs (limited to 'BackEnd/Timeline') diff --git a/BackEnd/Timeline.ErrorCodes/ErrorCodes.cs b/BackEnd/Timeline.ErrorCodes/ErrorCodes.cs index 8211a0cc..4c3b6cd8 100644 --- a/BackEnd/Timeline.ErrorCodes/ErrorCodes.cs +++ b/BackEnd/Timeline.ErrorCodes/ErrorCodes.cs @@ -61,7 +61,7 @@ public const int NotExist = 1_104_02_01; public const int QueryRelateNotExist = 1_104_04_01; public const int PostNotExist = 1_104_05_01; - public const int PostNoData = 1_104_05_02; + public const int PostDataNotExist = 1_104_05_02; } public static class HighlightTimelineController diff --git a/BackEnd/Timeline/Controllers/TimelinePostController.cs b/BackEnd/Timeline/Controllers/TimelinePostController.cs index a0fd1687..06082f0f 100644 --- a/BackEnd/Timeline/Controllers/TimelinePostController.cs +++ b/BackEnd/Timeline/Controllers/TimelinePostController.cs @@ -4,14 +4,14 @@ using Microsoft.AspNetCore.Mvc; using System; using System.Collections.Generic; using System.Threading.Tasks; +using System.ComponentModel.DataAnnotations; using Timeline.Filters; -using Timeline.Helpers; +using Timeline.Helpers.Cache; using Timeline.Models; using Timeline.Models.Http; using Timeline.Models.Mapper; using Timeline.Models.Validation; using Timeline.Services; -using Timeline.Services.Exceptions; namespace Timeline.Controllers { @@ -21,6 +21,8 @@ namespace Timeline.Controllers [ApiController] [Route("timelines/{timeline}/posts")] [CatchTimelineNotExistException] + [CatchTimelinePostNotExistException] + [CatchTimelinePostDataNotExistException] [ProducesErrorResponseType(typeof(CommonResponse))] public class TimelinePostController : Controller { @@ -86,57 +88,27 @@ namespace Timeline.Controllers return StatusCode(StatusCodes.Status403Forbidden, ErrorResponse.Common.Forbid()); } - try - { - var post = await _postService.GetPost(timelineId, postId); - var result = await _timelineMapper.MapToHttp(post, timeline, Url); - return result; - } - catch (TimelinePostNotExistException) - { - return NotFound(ErrorResponse.TimelineController.PostNotExist()); - } + var post = await _postService.GetPost(timelineId, postId); + var result = await _timelineMapper.MapToHttp(post, timeline, Url); + return result; } /// - /// Get the data of a post. Usually a image post. + /// Get the first data of a post. /// /// Timeline name. /// The id of the post. - /// If-None-Match header. /// The data. [HttpGet("{post}/data")] - [Produces("image/png", "image/jpeg", "image/gif", "image/webp", "application/json", "text/json")] + [Produces(MimeTypes.ImagePng, MimeTypes.ImageJpeg, MimeTypes.ImageGif, MimeTypes.ImageWebp, MimeTypes.TextPlain, MimeTypes.TextMarkdown, MimeTypes.TextPlain, MimeTypes.ApplicationJson)] [ProducesResponseType(typeof(byte[]), StatusCodes.Status200OK)] [ProducesResponseType(typeof(void), StatusCodes.Status304NotModified)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task DataIndexGet([FromRoute][GeneralTimelineName] string timeline, [FromRoute] long post, [FromHeader(Name = "If-None-Match")] string? ifNoneMatch) + public async Task> DataIndexGet([FromRoute][GeneralTimelineName] string timeline, [FromRoute] long post) { - _ = ifNoneMatch; - - var timelineId = await _timelineService.GetTimelineIdByName(timeline); - - if (!UserHasAllTimelineManagementPermission && !await _timelineService.HasReadPermission(timelineId, this.GetOptionalUserId())) - { - return StatusCode(StatusCodes.Status403Forbidden, ErrorResponse.Common.Forbid()); - } - - try - { - return await DataCacheHelper.GenerateActionResult(this, - () => _postService.GetPostDataETag(timelineId, post), - async () => await _postService.GetPostData(timelineId, post)); - } - catch (TimelinePostNotExistException) - { - return NotFound(ErrorResponse.TimelineController.PostNotExist()); - } - catch (TimelinePostNoDataException) - { - return BadRequest(ErrorResponse.TimelineController.PostNoData()); - } + return await DataGet(timeline, post, 0); } /// @@ -144,17 +116,28 @@ namespace Timeline.Controllers /// /// Timeline name. /// The id of the post. - /// If-None-Match header. + /// Index of the data. /// The data. [HttpGet("{post}/data/{data_index}")] - [Produces("image/png", "image/jpeg", "image/gif", "image/webp", "application/json", "text/json")] + [Produces(MimeTypes.ImagePng, MimeTypes.ImageJpeg, MimeTypes.ImageGif, MimeTypes.ImageWebp, MimeTypes.TextPlain, MimeTypes.TextMarkdown, MimeTypes.TextPlain, MimeTypes.ApplicationJson)] [ProducesResponseType(typeof(byte[]), StatusCodes.Status200OK)] [ProducesResponseType(typeof(void), StatusCodes.Status304NotModified)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task DataGet([FromRoute][GeneralTimelineName] string timeline, [FromRoute] long post, [FromHeader(Name = "If-None-Match")] string? ifNoneMatch) + public async Task DataGet([FromRoute][GeneralTimelineName] string timeline, [FromRoute] long post, [FromRoute(Name = "data_index")][Range(0, 100)] long dataIndex) { + var timelineId = await _timelineService.GetTimelineIdByName(timeline); + + if (!UserHasAllTimelineManagementPermission && !await _timelineService.HasReadPermission(timelineId, this.GetOptionalUserId())) + { + return StatusCode(StatusCodes.Status403Forbidden, ErrorResponse.Common.Forbid()); + } + + return await DataCacheHelper.GenerateActionResult(this, + () => _postService.GetPostDataDigest(timelineId, post, dataIndex), + () => _postService.GetPostData(timelineId, post, dataIndex) + ); } /// @@ -179,50 +162,36 @@ namespace Timeline.Controllers return StatusCode(StatusCodes.Status403Forbidden, ErrorResponse.Common.Forbid()); } - var requestContent = body.Content; - - TimelinePostCreateRequestData createContent; - - switch (requestContent.Type) + var createRequest = new TimelinePostCreateRequest() { - case TimelinePostDataKind.Text: - if (requestContent.Text is null) - { - return BadRequest(ErrorResponse.Common.CustomMessage_InvalidModel(Resources.Messages.TimelineController_TextContentTextRequired)); - } - createContent = new TimelinePostCreateRequestTextContent(requestContent.Text); - break; - case TimelinePostDataKind.Image: - if (requestContent.Data is null) - return BadRequest(ErrorResponse.Common.CustomMessage_InvalidModel(Resources.Messages.TimelineController_ImageContentDataRequired)); - - // decode base64 - byte[] data; - try - { - data = Convert.FromBase64String(requestContent.Data); - } - catch (FormatException) - { - return BadRequest(ErrorResponse.Common.CustomMessage_InvalidModel(Resources.Messages.TimelineController_ImageContentDataNotBase64)); - } - - createContent = new TimelinePostCreateRequestImageData(data); - break; - default: - return BadRequest(ErrorResponse.Common.CustomMessage_InvalidModel(Resources.Messages.TimelineController_ContentUnknownType)); + Time = body.Time, + Color = body.Color + }; + for (int i = 0; i < body.DataList.Count; i++) + { + var data = body.DataList[i]; + try + { + var d = Convert.FromBase64String(data.Data); + createRequest.DataList.Add(new TimelinePostCreateRequestData(data.ContentType, d)); + } + catch (FormatException) + { + return BadRequest(new CommonResponse(ErrorCodes.Common.InvalidModel, $"Data at index {i} is not a valid base64 string.")); + } } + try { - var post = await _postService.CreatePost(timelineId, userId, new TimelinePostCreateRequest(createContent) { Time = body.Time, Color = body.Color }); + var post = await _postService.CreatePost(timelineId, userId, createRequest); var result = await _timelineMapper.MapToHttp(post, timeline, Url); return result; } - catch (ImageException) + catch (TimelinePostCreateDataException e) { - return BadRequest(ErrorResponse.Common.CustomMessage_InvalidModel(Resources.Messages.TimelineController_ImageContentDataNotImage)); + return BadRequest(new CommonResponse(ErrorCodes.Common.InvalidModel, $"Data at index {e.Index} is invalid. {e.Message}")); } } @@ -243,21 +212,15 @@ namespace Timeline.Controllers { var timelineId = await _timelineService.GetTimelineIdByName(timeline); - try + if (!UserHasAllTimelineManagementPermission && !await _postService.HasPostModifyPermission(timelineId, post, this.GetUserId(), true)) { - if (!UserHasAllTimelineManagementPermission && !await _postService.HasPostModifyPermission(timelineId, post, this.GetUserId(), true)) - { - return StatusCode(StatusCodes.Status403Forbidden, ErrorResponse.Common.Forbid()); - } - - var entity = await _postService.PatchPost(timelineId, post, new TimelinePostPatchRequest { Time = body.Time, Color = body.Color }); - var result = await _timelineMapper.MapToHttp(entity, timeline, Url); - return Ok(result); - } - catch (TimelinePostNotExistException) - { - return BadRequest(ErrorResponse.TimelineController.PostNotExist()); + return StatusCode(StatusCodes.Status403Forbidden, ErrorResponse.Common.Forbid()); } + + var entity = await _postService.PatchPost(timelineId, post, new TimelinePostPatchRequest { Time = body.Time, Color = body.Color }); + var result = await _timelineMapper.MapToHttp(entity, timeline, Url); + + return Ok(result); } /// @@ -276,19 +239,14 @@ namespace Timeline.Controllers { var timelineId = await _timelineService.GetTimelineIdByName(timeline); - try + if (!UserHasAllTimelineManagementPermission && !await _postService.HasPostModifyPermission(timelineId, post, this.GetUserId(), true)) { - if (!UserHasAllTimelineManagementPermission && !await _postService.HasPostModifyPermission(timelineId, post, this.GetUserId(), true)) - { - return StatusCode(StatusCodes.Status403Forbidden, ErrorResponse.Common.Forbid()); - } - await _postService.DeletePost(timelineId, post); - return Ok(); - } - catch (TimelinePostNotExistException) - { - return BadRequest(ErrorResponse.TimelineController.PostNotExist()); + return StatusCode(StatusCodes.Status403Forbidden, ErrorResponse.Common.Forbid()); } + + await _postService.DeletePost(timelineId, post); + + return Ok(); } } } diff --git a/BackEnd/Timeline/Filters/CatchTimelineNotExistExceptionAttribute.cs b/BackEnd/Timeline/Filters/CatchTimelineNotExistExceptionAttribute.cs new file mode 100644 index 00000000..857d1d2b --- /dev/null +++ b/BackEnd/Timeline/Filters/CatchTimelineNotExistExceptionAttribute.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Timeline.Models.Http; +using Timeline.Services.Exceptions; + +namespace Timeline.Filters +{ + public class CatchTimelineNotExistExceptionAttribute : ExceptionFilterAttribute + { + public override void OnException(ExceptionContext context) + { + if (context.Exception is TimelineNotExistException e) + { + if (e.InnerException is UserNotExistException) + { + if (HttpMethods.IsGet(context.HttpContext.Request.Method)) + context.Result = new NotFoundObjectResult(ErrorResponse.UserCommon.NotExist()); + else + context.Result = new BadRequestObjectResult(ErrorResponse.UserCommon.NotExist()); + } + else + { + if (HttpMethods.IsGet(context.HttpContext.Request.Method)) + context.Result = new NotFoundObjectResult(ErrorResponse.TimelineController.NotExist()); + else + context.Result = new BadRequestObjectResult(ErrorResponse.TimelineController.NotExist()); + } + } + } + } +} diff --git a/BackEnd/Timeline/Filters/CatchTimelinePostDataNotExistExceptionAttribute.cs b/BackEnd/Timeline/Filters/CatchTimelinePostDataNotExistExceptionAttribute.cs new file mode 100644 index 00000000..8b5868aa --- /dev/null +++ b/BackEnd/Timeline/Filters/CatchTimelinePostDataNotExistExceptionAttribute.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Timeline.Models.Http; +using Timeline.Services; + +namespace Timeline.Filters +{ + public class CatchTimelinePostDataNotExistExceptionAttribute : ExceptionFilterAttribute + { + public override void OnException(ExceptionContext context) + { + const string message = "Timeline post data does not exist."; + + if (context.Exception is TimelinePostDataNotExistException e) + { + if (HttpMethods.IsGet(context.HttpContext.Request.Method)) + context.Result = new NotFoundObjectResult(new CommonResponse(ErrorCodes.TimelineController.PostNotExist, message)); + else + context.Result = new BadRequestObjectResult(new CommonResponse(ErrorCodes.TimelineController.PostNotExist, message)); + } + } + } +} diff --git a/BackEnd/Timeline/Filters/CatchTimelinePostNotExistExceptionAttribute.cs b/BackEnd/Timeline/Filters/CatchTimelinePostNotExistExceptionAttribute.cs new file mode 100644 index 00000000..ac3789c7 --- /dev/null +++ b/BackEnd/Timeline/Filters/CatchTimelinePostNotExistExceptionAttribute.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Timeline.Models.Http; +using Timeline.Services.Exceptions; + +namespace Timeline.Filters +{ + public class CatchTimelinePostNotExistExceptionAttribute : ExceptionFilterAttribute + { + public override void OnException(ExceptionContext context) + { + const string message = "Timeline post does not exist."; + + if (context.Exception is TimelinePostNotExistException e) + { + if (HttpMethods.IsGet(context.HttpContext.Request.Method)) + context.Result = new NotFoundObjectResult(new CommonResponse(ErrorCodes.TimelineController.PostNotExist, message)); + else + context.Result = new BadRequestObjectResult(new CommonResponse(ErrorCodes.TimelineController.PostNotExist, message)); + } + } + } +} diff --git a/BackEnd/Timeline/Filters/Timeline.cs b/BackEnd/Timeline/Filters/Timeline.cs deleted file mode 100644 index 6a730ee7..00000000 --- a/BackEnd/Timeline/Filters/Timeline.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Filters; -using Timeline.Models.Http; -using Timeline.Services.Exceptions; - -namespace Timeline.Filters -{ - public class CatchTimelineNotExistExceptionAttribute : ExceptionFilterAttribute - { - public override void OnException(ExceptionContext context) - { - if (context.Exception is TimelineNotExistException e) - { - if (e.InnerException is UserNotExistException) - { - if (HttpMethods.IsGet(context.HttpContext.Request.Method)) - context.Result = new NotFoundObjectResult(ErrorResponse.UserCommon.NotExist()); - else - context.Result = new BadRequestObjectResult(ErrorResponse.UserCommon.NotExist()); - } - else - { - if (HttpMethods.IsGet(context.HttpContext.Request.Method)) - context.Result = new NotFoundObjectResult(ErrorResponse.TimelineController.NotExist()); - else - context.Result = new BadRequestObjectResult(ErrorResponse.TimelineController.NotExist()); - } - } - } - } -} diff --git a/BackEnd/Timeline/Formatters/ByteDataInputFormatter.cs b/BackEnd/Timeline/Formatters/ByteDataInputFormatter.cs new file mode 100644 index 00000000..2451ead6 --- /dev/null +++ b/BackEnd/Timeline/Formatters/ByteDataInputFormatter.cs @@ -0,0 +1,80 @@ +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System; +using System.Threading.Tasks; +using Timeline.Models; + +namespace Timeline.Formatters +{ + /// + /// Formatter that reads body as byte data. + /// + public class ByteDataInputFormatter : InputFormatter + { + /// + /// + /// + public ByteDataInputFormatter() + { + SupportedMediaTypes.Add(MimeTypes.ImagePng); + SupportedMediaTypes.Add(MimeTypes.ImageJpeg); + SupportedMediaTypes.Add(MimeTypes.ImageGif); + SupportedMediaTypes.Add(MimeTypes.ImageWebp); + SupportedMediaTypes.Add(MimeTypes.TextPlain); + SupportedMediaTypes.Add(MimeTypes.TextMarkdown); + } + + /// + 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/BackEnd/Timeline/Formatters/BytesInputFormatter.cs b/BackEnd/Timeline/Formatters/BytesInputFormatter.cs deleted file mode 100644 index ac6537c9..00000000 --- a/BackEnd/Timeline/Formatters/BytesInputFormatter.cs +++ /dev/null @@ -1,79 +0,0 @@ -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/BackEnd/Timeline/Migrations/20200312112552_AddImagePost.cs b/BackEnd/Timeline/Migrations/20200312112552_AddImagePost.cs index b6cc29a3..7d9c6614 100644 --- a/BackEnd/Timeline/Migrations/20200312112552_AddImagePost.cs +++ b/BackEnd/Timeline/Migrations/20200312112552_AddImagePost.cs @@ -20,7 +20,7 @@ namespace Timeline.Migrations migrationBuilder.Sql($@" UPDATE timeline_posts -SET content_type = '{TimelinePostDataKind.Text}'; +SET content_type = 'text'; "); } diff --git a/BackEnd/Timeline/Models/Http/ErrorResponse.cs b/BackEnd/Timeline/Models/Http/ErrorResponse.cs index 1bc46680..3812471d 100644 --- a/BackEnd/Timeline/Models/Http/ErrorResponse.cs +++ b/BackEnd/Timeline/Models/Http/ErrorResponse.cs @@ -253,17 +253,6 @@ namespace Timeline.Models.Http { return new CommonResponse(ErrorCodes.TimelineController.PostNotExist, string.Format(message, formatArgs)); } - - public static CommonResponse PostNoData(params object?[] formatArgs) - { - return new CommonResponse(ErrorCodes.TimelineController.PostNoData, string.Format(TimelineController_PostNoData, formatArgs)); - } - - public static CommonResponse CustomMessage_PostNoData(string message, params object?[] formatArgs) - { - return new CommonResponse(ErrorCodes.TimelineController.PostNoData, string.Format(message, formatArgs)); - } - } } diff --git a/BackEnd/Timeline/Models/Http/HttpTimelinePostCreateRequest.cs b/BackEnd/Timeline/Models/Http/HttpTimelinePostCreateRequest.cs index 20d1a25b..07d823ad 100644 --- a/BackEnd/Timeline/Models/Http/HttpTimelinePostCreateRequest.cs +++ b/BackEnd/Timeline/Models/Http/HttpTimelinePostCreateRequest.cs @@ -5,22 +5,6 @@ using Timeline.Models.Validation; namespace Timeline.Models.Http { - public class HttpTimelinePostCreateRequestData - { - /// - /// Kind of the data. - /// - [Required] - [TimelinePostDataKind] - public string Kind { get; set; } = default!; - - /// - /// The true data. If kind is text or markdown, this is a string. If kind is image, this is base64 of data. - /// - [Required] - public string Data { get; set; } = default!; - } - public class HttpTimelinePostCreateRequest { /// @@ -28,6 +12,7 @@ namespace Timeline.Models.Http /// [Required] [MinLength(1)] + [MaxLength(100)] public List DataList { get; set; } = default!; /// diff --git a/BackEnd/Timeline/Models/Http/HttpTimelinePostCreateRequestData.cs b/BackEnd/Timeline/Models/Http/HttpTimelinePostCreateRequestData.cs new file mode 100644 index 00000000..94ee5aa7 --- /dev/null +++ b/BackEnd/Timeline/Models/Http/HttpTimelinePostCreateRequestData.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; + +namespace Timeline.Models.Http +{ + public class HttpTimelinePostCreateRequestData + { + /// + /// Mime type of the data. + /// + [Required] + public string ContentType { get; set; } = default!; + + /// + /// Base64 of data. + /// + [Required] + public string Data { get; set; } = default!; + } +} diff --git a/BackEnd/Timeline/Models/MimeTypes.cs b/BackEnd/Timeline/Models/MimeTypes.cs new file mode 100644 index 00000000..37d3a893 --- /dev/null +++ b/BackEnd/Timeline/Models/MimeTypes.cs @@ -0,0 +1,14 @@ +namespace Timeline.Models +{ + public static class MimeTypes + { + public const string ImagePng = "image/png"; + public const string ImageJpeg = "image/jpeg"; + public const string ImageGif = "image/gif"; + public const string ImageWebp = "image/webp"; + public const string TextPlain = "text/plain"; + public const string TextMarkdown = "text/markdown"; + public const string TextJson = "text/json"; + public const string ApplicationJson = "application/json"; + } +} diff --git a/BackEnd/Timeline/Models/TimelinePostContentTypes.cs b/BackEnd/Timeline/Models/TimelinePostContentTypes.cs deleted file mode 100644 index d432e03c..00000000 --- a/BackEnd/Timeline/Models/TimelinePostContentTypes.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Collections.Generic; - -namespace Timeline.Models -{ - public static class TimelinePostDataKind - { - public static IReadOnlyList AllTypes { get; } = new List { Text, Image, Markdown }; - - public const string Text = "text"; - public const string Image = "image"; - public const string Markdown = "markdown"; - } -} diff --git a/BackEnd/Timeline/Models/Validation/TimelinePostContentTypeValidator.cs b/BackEnd/Timeline/Models/Validation/TimelinePostContentTypeValidator.cs deleted file mode 100644 index b65c846c..00000000 --- a/BackEnd/Timeline/Models/Validation/TimelinePostContentTypeValidator.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using System.Linq; - -namespace Timeline.Models.Validation -{ - public class TimelinePostDataKindValidator : StringSetValidator - { - public TimelinePostDataKindValidator() : base(TimelinePostDataKind.AllTypes.ToArray()) { } - } - - [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter, AllowMultiple = false)] - public class TimelinePostDataKindAttribute : ValidateWithAttribute - { - public TimelinePostDataKindAttribute() : base(typeof(TimelinePostDataKindValidator)) - { - - } - } -} diff --git a/BackEnd/Timeline/Services/Exceptions/TimelinePostNoDataException.cs b/BackEnd/Timeline/Services/Exceptions/TimelinePostNoDataException.cs deleted file mode 100644 index c4b6bf62..00000000 --- a/BackEnd/Timeline/Services/Exceptions/TimelinePostNoDataException.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; - -namespace Timeline.Services.Exceptions -{ - [Serializable] - public class TimelinePostNoDataException : Exception - { - public TimelinePostNoDataException() : this(null, null) { } - public TimelinePostNoDataException(string? message) : this(message, null) { } - public TimelinePostNoDataException(string? message, Exception? inner) : base(Resources.Services.Exceptions.TimelineNoDataException.AppendAdditionalMessage(message), inner) { } - protected TimelinePostNoDataException( - System.Runtime.Serialization.SerializationInfo info, - System.Runtime.Serialization.StreamingContext context) : base(info, context) { } - } -} diff --git a/BackEnd/Timeline/Services/TimelinePostCreateDataException.cs b/BackEnd/Timeline/Services/TimelinePostCreateDataException.cs new file mode 100644 index 00000000..fd1e6664 --- /dev/null +++ b/BackEnd/Timeline/Services/TimelinePostCreateDataException.cs @@ -0,0 +1,16 @@ +namespace Timeline.Services +{ + [System.Serializable] + public class TimelinePostCreateDataException : System.Exception + { + public TimelinePostCreateDataException() { } + public TimelinePostCreateDataException(string message) : base(message) { } + public TimelinePostCreateDataException(string message, System.Exception inner) : base(message, inner) { } + public TimelinePostCreateDataException(long index, string? message, System.Exception? inner = null) : base(message, inner) { Index = index; } + protected TimelinePostCreateDataException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + + public long Index { get; } + } +} diff --git a/BackEnd/Timeline/Services/TimelinePostDataNotExistException.cs b/BackEnd/Timeline/Services/TimelinePostDataNotExistException.cs new file mode 100644 index 00000000..eac7a771 --- /dev/null +++ b/BackEnd/Timeline/Services/TimelinePostDataNotExistException.cs @@ -0,0 +1,15 @@ +using System; + +namespace Timeline.Services +{ + [Serializable] + public class TimelinePostDataNotExistException : Exception + { + public TimelinePostDataNotExistException() : this(null, null) { } + public TimelinePostDataNotExistException(string? message) : this(message, null) { } + public TimelinePostDataNotExistException(string? message, Exception? inner) : base(message, inner) { } + protected TimelinePostDataNotExistException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + } +} diff --git a/BackEnd/Timeline/Services/TimelinePostService.cs b/BackEnd/Timeline/Services/TimelinePostService.cs index 98841478..cea702a1 100644 --- a/BackEnd/Timeline/Services/TimelinePostService.cs +++ b/BackEnd/Timeline/Services/TimelinePostService.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Threading.Tasks; using Timeline.Entities; using Timeline.Helpers; +using Timeline.Helpers.Cache; using Timeline.Models; using Timeline.Models.Validation; using Timeline.Services.Exceptions; @@ -14,49 +15,15 @@ using static Timeline.Resources.Services.TimelineService; namespace Timeline.Services { - public class TimelinePostDataDigest - { - public TimelinePostDataDigest(string kind, string eTag, DateTime lastModified) - { - Kind = kind; - ETag = eTag; - LastModified = lastModified; - } - - public string Kind { get; set; } - public string ETag { get; set; } - public DateTime LastModified { get; set; } - } - - public class TimelinePostData - { - public TimelinePostData(string kind, byte[] data, string eTag, DateTime lastModified) - { - Kind = kind; - Data = data; - ETag = eTag; - LastModified = lastModified; - } - - public string Kind { get; set; } - -#pragma warning disable CA1819 // Properties should not return arrays - public byte[] Data { get; set; } -#pragma warning restore CA1819 // Properties should not return arrays - - public string ETag { get; set; } - public DateTime LastModified { get; set; } - } - public class TimelinePostCreateRequestData { - public TimelinePostCreateRequestData(string kind, byte[] data) + public TimelinePostCreateRequestData(string contentType, byte[] data) { - Kind = kind; + ContentType = contentType; Data = data; } - public string Kind { get; set; } + public string ContentType { get; set; } #pragma warning disable CA1819 // Properties should not return arrays public byte[] Data { get; set; } #pragma warning restore CA1819 // Properties should not return arrays @@ -69,14 +36,13 @@ namespace Timeline.Services /// If not set, current time is used. public DateTime? Time { get; set; } - public List Content { get; set; } = new List(); + public List DataList { get; set; } = new List(); } public class TimelinePostPatchRequest { public string? Color { get; set; } public DateTime? Time { get; set; } - public List? Content { get; set; } } public interface ITimelinePostService @@ -102,18 +68,29 @@ namespace Timeline.Services /// Thrown when post of does not exist or has been deleted. Task GetPost(long timelineId, long postId, bool includeDelete = false); - Task GetPostDataDigest(long timelineId, long postId, long dataIndex); + /// + /// Get the data digest of a post. + /// + /// The timeline id. + /// The post id. + /// The index of the data. + /// The data digest. + /// Thrown when timeline does not exist. + /// Thrown when post of does not exist or has been deleted. + /// Thrown when data of that index does not exist. + Task GetPostDataDigest(long timelineId, long postId, long dataIndex); /// /// Get the data of a post. /// - /// The id of the timeline of the post. - /// The id of the post. - /// The etag of the data. + /// The timeline id. + /// The post id. + /// The index of the data. + /// The data. /// Thrown when timeline does not exist. /// Thrown when post of does not exist or has been deleted. - /// Thrown when post has no data. - Task GetPostData(long timelineId, long postId, long dataIndex); + /// Thrown when data of that index does not exist. + Task GetPostData(long timelineId, long postId, long dataIndex); /// /// Create a new post in timeline. @@ -140,7 +117,6 @@ namespace Timeline.Services /// Thrown when is of invalid format. /// Thrown when timeline does not exist. /// Thrown when post does not exist. - /// Thrown if data is not a image. Validated by . Task PatchPost(long timelineId, long postId, TimelinePostPatchRequest request); /// diff --git a/BackEnd/Timeline/Startup.cs b/BackEnd/Timeline/Startup.cs index 0fab798b..5951dc80 100644 --- a/BackEnd/Timeline/Startup.cs +++ b/BackEnd/Timeline/Startup.cs @@ -61,7 +61,7 @@ namespace Timeline services.AddControllers(setup => { setup.InputFormatters.Add(new StringInputFormatter()); - setup.InputFormatters.Add(new BytesInputFormatter()); + setup.InputFormatters.Add(new ByteDataInputFormatter()); setup.Filters.Add(new ConsumesAttribute(MediaTypeNames.Application.Json, "text/json")); setup.Filters.Add(new ProducesAttribute(MediaTypeNames.Application.Json, "text/json")); setup.UseApiRoutePrefix("api"); -- cgit v1.2.3 From e21b5f85f0d66f51e23a7c1cbf260f2981a83a49 Mon Sep 17 00:00:00 2001 From: crupest Date: Wed, 10 Feb 2021 19:17:08 +0800 Subject: ... --- .../Timeline/Helpers/Cache/CacheableDataDigest.cs | 2 +- BackEnd/Timeline/Models/ByteData.cs | 8 +- BackEnd/Timeline/Services/BasicUserService.cs | 29 +++ BackEnd/Timeline/Services/DataManager.cs | 27 ++- BackEnd/Timeline/Services/UserAvatarService.cs | 245 +++++++++++---------- 5 files changed, 189 insertions(+), 122 deletions(-) (limited to 'BackEnd/Timeline') diff --git a/BackEnd/Timeline/Helpers/Cache/CacheableDataDigest.cs b/BackEnd/Timeline/Helpers/Cache/CacheableDataDigest.cs index 3b5bcf52..18a6c894 100644 --- a/BackEnd/Timeline/Helpers/Cache/CacheableDataDigest.cs +++ b/BackEnd/Timeline/Helpers/Cache/CacheableDataDigest.cs @@ -2,7 +2,7 @@ using System; namespace Timeline.Helpers.Cache { - public class CacheableDataDigest + public class CacheableDataDigest : ICacheableDataDigest { public CacheableDataDigest(string eTag, DateTime lastModified) { diff --git a/BackEnd/Timeline/Models/ByteData.cs b/BackEnd/Timeline/Models/ByteData.cs index 7b832eb5..a1a0c238 100644 --- a/BackEnd/Timeline/Models/ByteData.cs +++ b/BackEnd/Timeline/Models/ByteData.cs @@ -1,4 +1,5 @@ -using NSwag.Annotations; +using System; +using NSwag.Annotations; namespace Timeline.Models { @@ -14,6 +15,11 @@ namespace Timeline.Models /// The content type. public ByteData(byte[] data, string contentType) { + if (data is null) + throw new ArgumentNullException(nameof(data)); + if (contentType is null) + throw new ArgumentNullException(nameof(contentType)); + Data = data; ContentType = contentType; } diff --git a/BackEnd/Timeline/Services/BasicUserService.cs b/BackEnd/Timeline/Services/BasicUserService.cs index fbbb6677..de0829ee 100644 --- a/BackEnd/Timeline/Services/BasicUserService.cs +++ b/BackEnd/Timeline/Services/BasicUserService.cs @@ -29,6 +29,14 @@ namespace Timeline.Services /// Thrown when is of bad format. /// Thrown when the user with given username does not exist. Task GetUserIdByUsername(string username); + + /// + /// Get the username modified time of a user. + /// + /// User id. + /// The time. + /// Thrown when user does not exist. + Task GetUsernameLastModifiedTime(long userId); } public class BasicUserService : IBasicUserService @@ -62,5 +70,26 @@ namespace Timeline.Services return entity.Id; } + + public async Task GetUsernameLastModifiedTime(long userId) + { + var entity = await _database.Users.Where(u => u.Id == userId).Select(u => new { u.UsernameChangeTime }).SingleOrDefaultAsync(); + + if (entity is null) + throw new UserNotExistException(userId); + + return entity.UsernameChangeTime; + } + } + + public static class BasicUserServiceExtensions + { + public static async Task ThrowIfUserNotExist(this IBasicUserService service, long userId) + { + if (!await service.CheckUserExistence(userId)) + { + throw new UserNotExistException(userId); + } + } } } diff --git a/BackEnd/Timeline/Services/DataManager.cs b/BackEnd/Timeline/Services/DataManager.cs index d447b0d5..f24bb59b 100644 --- a/BackEnd/Timeline/Services/DataManager.cs +++ b/BackEnd/Timeline/Services/DataManager.cs @@ -38,13 +38,12 @@ namespace Timeline.Services public Task FreeEntry(string tag); /// - /// Retrieve the entry with given tag. + /// Retrieve the entry with given tag. If not exist, returns null. /// /// The tag of the entry. - /// The data of the entry. + /// The data of the entry. If not exist, returns null. /// Thrown when is null. - /// Thrown when entry with given tag does not exist. - public Task GetEntry(string tag); + public Task GetEntry(string tag); } public class DataManager : IDataManager @@ -106,17 +105,31 @@ namespace Timeline.Services } } - public async Task GetEntry(string tag) + public async Task GetEntry(string tag) { if (tag == null) throw new ArgumentNullException(nameof(tag)); var entity = await _database.Data.Where(d => d.Tag == tag).Select(d => new { d.Data }).SingleOrDefaultAsync(); - if (entity == null) - throw new InvalidOperationException(Resources.Services.DataManager.ExceptionEntryNotExist); + if (entity is null) + return null; return entity.Data; } } + + public static class DataManagerExtensions + { + /// + /// Try to get an entry and throw if not exist. + /// + public static async Task GetEntryAndCheck(this IDataManager dataManager, string tag, string notExistMessage) + { + var data = await dataManager.GetEntry(tag); + if (data is null) + throw new DatabaseCorruptedException($"Can't get data of tag {tag}. {notExistMessage}"); + return data; + } + } } diff --git a/BackEnd/Timeline/Services/UserAvatarService.cs b/BackEnd/Timeline/Services/UserAvatarService.cs index afd6cf0a..5a6d013e 100644 --- a/BackEnd/Timeline/Services/UserAvatarService.cs +++ b/BackEnd/Timeline/Services/UserAvatarService.cs @@ -2,12 +2,12 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using SixLabors.ImageSharp; using System; using System.IO; using System.Linq; using System.Threading.Tasks; using Timeline.Entities; -using Timeline.Helpers; using Timeline.Helpers.Cache; using Timeline.Models; using Timeline.Services.Exceptions; @@ -23,39 +23,53 @@ namespace Timeline.Services public interface IDefaultUserAvatarProvider { /// - /// Get the etag of default avatar. + /// Get the digest of default avatar. /// - /// - Task GetDefaultAvatarETag(); + /// The digest. + Task GetDefaultAvatarDigest(); /// /// Get the default avatar. /// + /// The avatar. Task GetDefaultAvatar(); } public interface IUserAvatarService { - Task GetAvatarDigest(long id); + /// + /// Get avatar digest of a user. + /// + /// User id. + /// The avatar digest. + /// Thrown when user does not exist. + Task GetAvatarDigest(long userId); /// - /// Get avatar of a user. If the user has no avatar set, a default one is returned. Warning: This method does not check the user existence. + /// Get avatar of a user. If the user has no avatar set, a default one is returned. /// - /// The id of the user to get avatar of. - /// The avatar info. - Task GetAvatar(long id); + /// User id. + /// The avatar. + /// Thrown when user does not exist. + Task GetAvatar(long userId); /// - /// Set avatar for a user. Warning: This method does not check the user existence. + /// Set avatar for a user. /// - /// The id of the user to set avatar for. - /// The avatar. Can be null to delete the saved avatar. - /// The etag of the avatar. - /// Thrown if any field in is null when is not null. + /// User id. + /// The new avatar data. + /// The digest of the avatar. + /// Thrown if is null. + /// Thrown when user does not exist. /// Thrown if avatar is of bad format. - Task SetAvatar(long id, ByteData avatar); + Task SetAvatar(long userId, ByteData avatar); - Task DeleteAvatar(long id); + /// + /// Remove avatar of a user. + /// + /// User id. + /// Thrown when user does not exist. + Task DeleteAvatar(long userId); } // TODO! : Make this configurable. @@ -65,9 +79,8 @@ namespace Timeline.Services private readonly string _avatarPath; - private byte[] _cacheData = default!; - private DateTime _cacheLastModified; - private string _cacheETag = default!; + private CacheableDataDigest? _cacheDigest; + private ByteData? _cacheData; public DefaultUserAvatarProvider(IWebHostEnvironment environment, IETagGenerator eTagGenerator) { @@ -78,53 +91,42 @@ namespace Timeline.Services private async Task CheckAndInit() { var path = _avatarPath; - if (_cacheData == null || File.GetLastWriteTime(path) > _cacheLastModified) + if (_cacheData == null || File.GetLastWriteTime(path) > _cacheDigest!.LastModified) { - _cacheData = await File.ReadAllBytesAsync(path); - _cacheLastModified = File.GetLastWriteTime(path); - _cacheETag = await _eTagGenerator.Generate(_cacheData); + var data = await File.ReadAllBytesAsync(path); + _cacheDigest = new CacheableDataDigest(await _eTagGenerator.Generate(data), File.GetLastWriteTime(path)); + Image.Identify(data, out var format); + _cacheData = new ByteData(data, format.DefaultMimeType); } } - public async Task GetDefaultAvatarETag() + public async Task GetDefaultAvatarDigest() { await CheckAndInit(); - return _cacheETag; + return _cacheDigest!; } - public async Task GetDefaultAvatar() + public async Task GetDefaultAvatar() { await CheckAndInit(); - return new AvatarInfo - { - Avatar = new Avatar - { - Type = "image/png", - Data = _cacheData - }, - LastModified = _cacheLastModified - }; + return _cacheData!; } } public class UserAvatarService : IUserAvatarService { - private readonly ILogger _logger; - private readonly DatabaseContext _database; - + private readonly IBasicUserService _basicUserService; private readonly IDefaultUserAvatarProvider _defaultUserAvatarProvider; - private readonly IImageValidator _imageValidator; - private readonly IDataManager _dataManager; - private readonly IClock _clock; public UserAvatarService( ILogger logger, DatabaseContext database, + IBasicUserService basicUserService, IDefaultUserAvatarProvider defaultUserAvatarProvider, IImageValidator imageValidator, IDataManager dataManager, @@ -132,106 +134,123 @@ namespace Timeline.Services { _logger = logger; _database = database; + _basicUserService = basicUserService; _defaultUserAvatarProvider = defaultUserAvatarProvider; _imageValidator = imageValidator; _dataManager = dataManager; _clock = clock; } - public async Task GetAvatarETag(long id) + public async Task GetAvatarDigest(long userId) { - var eTag = (await _database.UserAvatars.Where(a => a.UserId == id).Select(a => new { a.DataTag }).SingleOrDefaultAsync())?.DataTag; - if (eTag == null) - return await _defaultUserAvatarProvider.GetDefaultAvatarETag(); + var usernameChangeTime = await _basicUserService.GetUsernameLastModifiedTime(userId); + + var entity = await _database.UserAvatars.Where(a => a.UserId == userId).Select(a => new { a.DataTag, a.LastModified }).SingleOrDefaultAsync(); + + if (entity is null) + { + var defaultAvatarDigest = await _defaultUserAvatarProvider.GetDefaultAvatarDigest(); + return new CacheableDataDigest(defaultAvatarDigest.ETag, new DateTime[] { usernameChangeTime, defaultAvatarDigest.LastModified }.Max()); + } + else if (entity.DataTag is null) + { + var defaultAvatarDigest = await _defaultUserAvatarProvider.GetDefaultAvatarDigest(); + return new CacheableDataDigest(defaultAvatarDigest.ETag, new DateTime[] { usernameChangeTime, defaultAvatarDigest.LastModified, entity.LastModified }.Max()); + } else - return eTag; + { + return new CacheableDataDigest(entity.DataTag, new DateTime[] { usernameChangeTime, entity.LastModified }.Max()); + } } - public async Task GetAvatar(long id) + public async Task GetAvatar(long userId) { - var avatarEntity = await _database.UserAvatars.Where(a => a.UserId == id).Select(a => new { a.Type, a.DataTag, a.LastModified }).SingleOrDefaultAsync(); + await _basicUserService.ThrowIfUserNotExist(userId); + + var entity = await _database.UserAvatars.Where(a => a.UserId == userId).SingleOrDefaultAsync(); - if (avatarEntity != null) + if (entity is null || entity.DataTag is null) { - if (!LanguageHelper.AreSame(avatarEntity.DataTag == null, avatarEntity.Type == null)) - { - var message = Resources.Services.UserAvatarService.ExceptionDatabaseCorruptedDataAndTypeNotSame; - _logger.LogCritical(message); - throw new DatabaseCorruptedException(message); - } + return await _defaultUserAvatarProvider.GetDefaultAvatar(); + } + var data = await _dataManager.GetEntryAndCheck(entity.DataTag, $"This is required by avatar of {userId}."); - if (avatarEntity.DataTag != null) - { - var data = await _dataManager.GetEntry(avatarEntity.DataTag); - return new AvatarInfo - { - Avatar = new Avatar - { - Type = avatarEntity.Type!, - Data = data - }, - LastModified = avatarEntity.LastModified - }; - } + if (entity.Type is null) + { + Image.Identify(data, out var format); + entity.Type = format.DefaultMimeType; + await _database.SaveChangesAsync(); } - var defaultAvatar = await _defaultUserAvatarProvider.GetDefaultAvatar(); - if (avatarEntity != null) - defaultAvatar.LastModified = defaultAvatar.LastModified > avatarEntity.LastModified ? defaultAvatar.LastModified : avatarEntity.LastModified; - return defaultAvatar; + + return new ByteData(data, entity.Type); } - public async Task SetAvatar(long id, Avatar? avatar) + public async Task SetAvatar(long userId, ByteData avatar) { - if (avatar != null) - { - if (avatar.Data == null) - throw new ArgumentException(Resources.Services.UserAvatarService.ExceptionAvatarDataNull, nameof(avatar)); - if (string.IsNullOrEmpty(avatar.Type)) - throw new ArgumentException(Resources.Services.UserAvatarService.ExceptionAvatarTypeNullOrEmpty, nameof(avatar)); - } + if (avatar is null) + throw new ArgumentNullException(nameof(avatar)); + + await _imageValidator.Validate(avatar.Data, avatar.ContentType, true); + + await _basicUserService.ThrowIfUserNotExist(userId); + + var entity = await _database.UserAvatars.Where(a => a.UserId == userId).SingleOrDefaultAsync(); - var avatarEntity = await _database.UserAvatars.Where(a => a.UserId == id).SingleOrDefaultAsync(); + await using var transaction = await _database.Database.BeginTransactionAsync(); - if (avatar == null) + var tag = await _dataManager.RetainEntry(avatar.Data); + + var now = _clock.GetCurrentTime(); + + if (entity is null) { - if (avatarEntity != null && avatarEntity.DataTag != null) + var newEntity = new UserAvatarEntity { - await _dataManager.FreeEntry(avatarEntity.DataTag); - avatarEntity.DataTag = null; - avatarEntity.Type = null; - avatarEntity.LastModified = _clock.GetCurrentTime(); - await _database.SaveChangesAsync(); - _logger.LogInformation(Resources.Services.UserAvatarService.LogUpdateEntity); - } - return await _defaultUserAvatarProvider.GetDefaultAvatarETag(); + DataTag = tag, + Type = avatar.ContentType, + LastModified = now, + UserId = userId + }; + _database.Add(newEntity); } else { - await _imageValidator.Validate(avatar.Data, avatar.Type, true); - var tag = await _dataManager.RetainEntry(avatar.Data); - var oldTag = avatarEntity?.DataTag; - var create = avatarEntity == null; - if (avatarEntity == null) - { - avatarEntity = new UserAvatarEntity(); - _database.UserAvatars.Add(avatarEntity); - } - avatarEntity.DataTag = tag; - avatarEntity.Type = avatar.Type; - avatarEntity.LastModified = _clock.GetCurrentTime(); - avatarEntity.UserId = id; - await _database.SaveChangesAsync(); - _logger.LogInformation(create ? - Resources.Services.UserAvatarService.LogCreateEntity - : Resources.Services.UserAvatarService.LogUpdateEntity); - if (oldTag != null) - { - await _dataManager.FreeEntry(oldTag); - } + if (entity.DataTag is not null) + await _dataManager.FreeEntry(entity.DataTag); - return avatarEntity.DataTag; + entity.DataTag = tag; + entity.Type = avatar.ContentType; + entity.LastModified = now; } + + await _database.SaveChangesAsync(); + + await transaction.CommitAsync(); + + return new CacheableDataDigest(tag, now); + } + + public async Task DeleteAvatar(long userId) + { + await _basicUserService.ThrowIfUserNotExist(userId); + + var entity = await _database.UserAvatars.Where(a => a.UserId == userId).SingleOrDefaultAsync(); + + if (entity is null || entity.DataTag is null) + return; + + await using var transaction = await _database.Database.BeginTransactionAsync(); + + await _dataManager.FreeEntry(entity.DataTag); + + entity.DataTag = null; + entity.Type = null; + entity.LastModified = _clock.GetCurrentTime(); + + await _database.SaveChangesAsync(); + + await transaction.CommitAsync(); } } -- cgit v1.2.3 From d1317bd9fe08a933a13df88ba692343cde549123 Mon Sep 17 00:00:00 2001 From: crupest Date: Thu, 11 Feb 2021 20:10:56 +0800 Subject: ... --- .../Timeline/Entities/TimelinePostDataEntity.cs | 3 +- BackEnd/Timeline/Services/DataManager.cs | 19 +- .../Services/TimelinePostCreateDataException.cs | 2 +- .../Services/TimelinePostDataNotExistException.cs | 10 + BackEnd/Timeline/Services/TimelinePostService.cs | 225 ++++++++++----------- 5 files changed, 130 insertions(+), 129 deletions(-) (limited to 'BackEnd/Timeline') diff --git a/BackEnd/Timeline/Entities/TimelinePostDataEntity.cs b/BackEnd/Timeline/Entities/TimelinePostDataEntity.cs index 6ae8fd24..9bc5d3e8 100644 --- a/BackEnd/Timeline/Entities/TimelinePostDataEntity.cs +++ b/BackEnd/Timeline/Entities/TimelinePostDataEntity.cs @@ -10,11 +10,12 @@ namespace Timeline.Entities [Column("id"), Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)] public long Id { get; set; } + [Required] [Column("post")] public long PostId { get; set; } [ForeignKey(nameof(PostId))] - public TimelinePostEntity Timeline { get; set; } = default!; + public TimelinePostEntity Post { get; set; } = default!; [Column("index")] public long Index { get; set; } diff --git a/BackEnd/Timeline/Services/DataManager.cs b/BackEnd/Timeline/Services/DataManager.cs index f24bb59b..b697630c 100644 --- a/BackEnd/Timeline/Services/DataManager.cs +++ b/BackEnd/Timeline/Services/DataManager.cs @@ -22,20 +22,22 @@ namespace Timeline.Services /// increases its ref count and returns a tag to the entry. /// /// The data. Can't be null. + /// If true save database change. Otherwise it does not save database change. /// The tag of the created entry. /// Thrown when is null. - public Task RetainEntry(byte[] data); + public Task RetainEntry(byte[] data, bool saveDatabaseChange = true); /// /// Decrease the the ref count of the entry. /// Remove it if ref count is zero. /// /// The tag of the entry. + /// If true save database change. Otherwise it does not save database change. /// Thrown when is null. /// /// It's no-op if entry with tag does not exist. /// - public Task FreeEntry(string tag); + public Task FreeEntry(string tag, bool saveDatabaseChange = true); /// /// Retrieve the entry with given tag. If not exist, returns null. @@ -57,7 +59,7 @@ namespace Timeline.Services _eTagGenerator = eTagGenerator; } - public async Task RetainEntry(byte[] data) + public async Task RetainEntry(byte[] data, bool saveDatabaseChange = true) { if (data == null) throw new ArgumentNullException(nameof(data)); @@ -80,11 +82,14 @@ namespace Timeline.Services { entity.Ref += 1; } - await _database.SaveChangesAsync(); + + if (saveDatabaseChange) + await _database.SaveChangesAsync(); + return tag; } - public async Task FreeEntry(string tag) + public async Task FreeEntry(string tag, bool saveDatabaseChange) { if (tag == null) throw new ArgumentNullException(nameof(tag)); @@ -101,7 +106,9 @@ namespace Timeline.Services { entity.Ref -= 1; } - await _database.SaveChangesAsync(); + + if (saveDatabaseChange) + await _database.SaveChangesAsync(); } } diff --git a/BackEnd/Timeline/Services/TimelinePostCreateDataException.cs b/BackEnd/Timeline/Services/TimelinePostCreateDataException.cs index fd1e6664..10a09de7 100644 --- a/BackEnd/Timeline/Services/TimelinePostCreateDataException.cs +++ b/BackEnd/Timeline/Services/TimelinePostCreateDataException.cs @@ -6,7 +6,7 @@ namespace Timeline.Services public TimelinePostCreateDataException() { } public TimelinePostCreateDataException(string message) : base(message) { } public TimelinePostCreateDataException(string message, System.Exception inner) : base(message, inner) { } - public TimelinePostCreateDataException(long index, string? message, System.Exception? inner = null) : base(message, inner) { Index = index; } + public TimelinePostCreateDataException(long index, string? message, System.Exception? inner = null) : base($"Data at index {index} is invalid.{(message is null ? "" : " " + message)}", inner) { Index = index; } protected TimelinePostCreateDataException( System.Runtime.Serialization.SerializationInfo info, System.Runtime.Serialization.StreamingContext context) : base(info, context) { } diff --git a/BackEnd/Timeline/Services/TimelinePostDataNotExistException.cs b/BackEnd/Timeline/Services/TimelinePostDataNotExistException.cs index eac7a771..c70f5d9c 100644 --- a/BackEnd/Timeline/Services/TimelinePostDataNotExistException.cs +++ b/BackEnd/Timeline/Services/TimelinePostDataNotExistException.cs @@ -8,8 +8,18 @@ namespace Timeline.Services public TimelinePostDataNotExistException() : this(null, null) { } public TimelinePostDataNotExistException(string? message) : this(message, null) { } public TimelinePostDataNotExistException(string? message, Exception? inner) : base(message, inner) { } + public TimelinePostDataNotExistException(long timelineId, long postId, long dataIndex, string? message = null, Exception? inner = null) : base(message, inner) + { + TimelineId = timelineId; + PostId = postId; + DataIndex = dataIndex; + } protected TimelinePostDataNotExistException( System.Runtime.Serialization.SerializationInfo info, System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + + public long TimelineId { get; set; } + public long PostId { get; set; } + public long DataIndex { get; set; } } } diff --git a/BackEnd/Timeline/Services/TimelinePostService.cs b/BackEnd/Timeline/Services/TimelinePostService.cs index cea702a1..8afd0770 100644 --- a/BackEnd/Timeline/Services/TimelinePostService.cs +++ b/BackEnd/Timeline/Services/TimelinePostService.cs @@ -4,6 +4,7 @@ using SixLabors.ImageSharp; using System; using System.Collections.Generic; using System.Linq; +using System.Text; using System.Threading.Tasks; using Timeline.Entities; using Timeline.Helpers; @@ -62,11 +63,11 @@ namespace Timeline.Services /// /// The id of the timeline of the post. /// The id of the post. - /// If true, return the entity even if it is deleted. + /// If true, return the entity even if it is deleted. /// The post. /// Thrown when timeline does not exist. /// Thrown when post of does not exist or has been deleted. - Task GetPost(long timelineId, long postId, bool includeDelete = false); + Task GetPost(long timelineId, long postId, bool includeDeleted = false); /// /// Get the data digest of a post. @@ -201,7 +202,7 @@ namespace Timeline.Services if (!includeDeleted) { - query = query.Where(p => p.Content != null); + query = query.Where(p => !p.Deleted); } if (modifiedSince.HasValue) @@ -214,7 +215,7 @@ namespace Timeline.Services return await query.ToListAsync(); } - public async Task GetPost(long timelineId, long postId, bool includeDelete = false) + public async Task GetPost(long timelineId, long postId, bool includeDeleted = false) { await CheckTimelineExistence(timelineId); @@ -225,7 +226,7 @@ namespace Timeline.Services throw new TimelinePostNotExistException(timelineId, postId, false); } - if (!includeDelete && post.Content is null) + if (!includeDeleted && post.Deleted) { throw new TimelinePostNotExistException(timelineId, postId, true); } @@ -233,99 +234,46 @@ namespace Timeline.Services return post; } - public async Task GetPostDataETag(long timelineId, long postId) + public async Task GetPostDataDigest(long timelineId, long postId, long dataIndex) { await CheckTimelineExistence(timelineId); - var postEntity = await _database.TimelinePosts.Where(p => p.TimelineId == timelineId && p.LocalId == postId).SingleOrDefaultAsync(); + var postEntity = await _database.TimelinePosts.Where(p => p.TimelineId == timelineId && p.LocalId == postId).Select(p => new { p.Id, p.Deleted }).SingleOrDefaultAsync(); - if (postEntity == null) + if (postEntity is null) throw new TimelinePostNotExistException(timelineId, postId, false); - if (postEntity.Content == null) + if (postEntity.Deleted) throw new TimelinePostNotExistException(timelineId, postId, true); - if (postEntity.ContentType != TimelinePostDataKind.Image) - throw new TimelinePostNoDataException(ExceptionGetDataNonImagePost); + var dataEntity = await _database.TimelinePostData.Where(d => d.PostId == postEntity.Id && d.Index == dataIndex).SingleOrDefaultAsync(); - var tag = postEntity.Content; + if (dataEntity is null) + throw new TimelinePostDataNotExistException(timelineId, postId, dataIndex); - return tag; + return new CacheableDataDigest(dataEntity.DataTag, dataEntity.LastUpdated); } - public async Task GetPostData(long timelineId, long postId) + public async Task GetPostData(long timelineId, long postId, long dataIndex) { await CheckTimelineExistence(timelineId); - var postEntity = await _database.TimelinePosts.Where(p => p.TimelineId == timelineId && p.LocalId == postId).SingleOrDefaultAsync(); + var postEntity = await _database.TimelinePosts.Where(p => p.TimelineId == timelineId && p.LocalId == postId).Select(p => new { p.Id, p.Deleted }).SingleOrDefaultAsync(); - if (postEntity == null) + if (postEntity is null) throw new TimelinePostNotExistException(timelineId, postId, false); - if (postEntity.Content == null) + if (postEntity.Deleted) throw new TimelinePostNotExistException(timelineId, postId, true); - if (postEntity.ContentType != TimelinePostDataKind.Image) - throw new TimelinePostNoDataException(ExceptionGetDataNonImagePost); + var dataEntity = await _database.TimelinePostData.Where(d => d.PostId == postEntity.Id && d.Index == dataIndex).SingleOrDefaultAsync(); - var tag = postEntity.Content; + if (dataEntity is null) + throw new TimelinePostDataNotExistException(timelineId, postId, dataIndex); - byte[] data; + var data = await _dataManager.GetEntryAndCheck(dataEntity.DataTag, $"Timeline {timelineId}, post {postId}, data {dataIndex} requires this data."); - try - { - data = await _dataManager.GetEntry(tag); - } - catch (InvalidOperationException e) - { - throw new DatabaseCorruptedException(ExceptionGetDataDataEntryNotExist, e); - } - - if (postEntity.ExtraContent == null) - { - _logger.LogWarning(LogGetDataNoFormat); - var format = Image.DetectFormat(data); - postEntity.ExtraContent = format.DefaultMimeType; - await _database.SaveChangesAsync(); - } - - return new TimelinePostData - { - Data = data, - Type = postEntity.ExtraContent, - ETag = tag, - LastModified = postEntity.LastUpdated - }; - } - - private async Task SaveContent(TimelinePostEntity entity, TimelinePostCreateRequestData content) - { - switch (content) - { - case TimelinePostCreateRequestTextData c: - entity.ContentType = c.Kind; - entity.Content = c.Data; - break; - case TimelinePostCreateRequestImageData c: - var imageFormat = await _imageValidator.Validate(c.Data); - var imageFormatText = imageFormat.DefaultMimeType; - - var tag = await _dataManager.RetainEntry(c.Data); - - entity.ContentType = content.Kind; - entity.Content = tag; - entity.ExtraContent = imageFormatText; - break; - default: - throw new ArgumentException("Unknown content type.", nameof(content)); - }; - } - - private async Task CleanContent(TimelinePostEntity entity) - { - if (entity.Content is not null && entity.ContentType == TimelinePostDataKind.Image) - await _dataManager.FreeEntry(entity.Content); - entity.Content = null; + return new ByteData(data, dataEntity.Kind); } public async Task CreatePost(long timelineId, long authorId, TimelinePostCreateRequest request) @@ -333,15 +281,55 @@ namespace Timeline.Services if (request is null) throw new ArgumentNullException(nameof(request)); - - if (request.Content is null) - throw new ArgumentException("Content is null.", nameof(request)); - { if (!_colorValidator.Validate(request.Color, out var message)) throw new ArgumentException("Color is not valid.", nameof(request)); } + if (request.DataList is null) + throw new ArgumentException("Data list can't be null.", nameof(request)); + + if (request.DataList.Count == 0) + throw new ArgumentException("Data list can't be empty.", nameof(request)); + + if (request.DataList.Count > 100) + throw new ArgumentException("Data list count can't be bigger than 100.", nameof(request)); + + for (int index = 0; index < request.DataList.Count; index++) + { + var data = request.DataList[index]; + + switch (data.ContentType) + { + case MimeTypes.ImageGif: + case MimeTypes.ImageJpeg: + case MimeTypes.ImagePng: + case MimeTypes.ImageWebp: + try + { + await _imageValidator.Validate(data.Data, data.ContentType); + } + catch (ImageException e) + { + throw new TimelinePostCreateDataException(index, "Image validation failed.", e); + } + break; + case MimeTypes.TextPlain: + case MimeTypes.TextMarkdown: + try + { + new UTF8Encoding(false, true).GetString(data.Data); + } + catch (DecoderFallbackException e) + { + throw new TimelinePostCreateDataException(index, "Text is not a valid utf-8 sequence.", e); + } + break; + default: + throw new TimelinePostCreateDataException(index, "Unsupported content type."); + } + } + request.Time = request.Time?.MyToUtc(); await CheckTimelineExistence(timelineId); @@ -361,13 +349,29 @@ namespace Timeline.Services Color = request.Color }; - await SaveContent(postEntity, request.Content); - var timelineEntity = await _database.Timelines.Where(t => t.Id == timelineId).SingleAsync(); timelineEntity.CurrentPostLocalId += 1; postEntity.LocalId = timelineEntity.CurrentPostLocalId; - _database.TimelinePosts.Add(postEntity); + await _database.SaveChangesAsync(); + + List dataTags = new List(); + + for (int index = 0; index < request.DataList.Count; index++) + { + var data = request.DataList[index]; + + var tag = await _dataManager.RetainEntry(data.Data, false); + + _database.TimelinePostData.Add(new TimelinePostDataEntity + { + DataTag = tag, + Kind = data.ContentType, + Index = index, + PostId = postEntity.Id, + LastUpdated = currentTime, + }); + } await _database.SaveChangesAsync(); @@ -392,12 +396,10 @@ namespace Timeline.Services var entity = await _database.TimelinePosts.Where(p => p.TimelineId == timelineId && p.LocalId == postId).SingleOrDefaultAsync(); - await using var transaction = await _database.Database.BeginTransactionAsync(); - if (entity is null) throw new TimelinePostNotExistException(timelineId, postId, false); - if (entity.Content is null) + if (entity.Deleted) throw new TimelinePostNotExistException(timelineId, postId, true); if (request.Time.HasValue) @@ -406,18 +408,10 @@ namespace Timeline.Services if (request.Color is not null) entity.Color = request.Color; - if (request.Content is not null) - { - await CleanContent(entity); - await SaveContent(entity, request.Content); - } - entity.LastUpdated = _clock.GetCurrentTime(); await _database.SaveChangesAsync(); - await transaction.CommitAsync(); - return entity; } @@ -430,15 +424,23 @@ namespace Timeline.Services if (entity == null) throw new TimelinePostNotExistException(timelineId, postId, false); - if (entity.Content == null) + if (entity.Deleted) throw new TimelinePostNotExistException(timelineId, postId, true); await using var transaction = await _database.Database.BeginTransactionAsync(); - await CleanContent(entity); - + entity.Deleted = true; entity.LastUpdated = _clock.GetCurrentTime(); + var dataEntities = await _database.TimelinePostData.Where(d => d.PostId == entity.Id).ToListAsync(); + + foreach (var dataEntity in dataEntities) + { + await _dataManager.FreeEntry(dataEntity.DataTag, false); + } + + _database.TimelinePostData.RemoveRange(dataEntities); + await _database.SaveChangesAsync(); await transaction.CommitAsync(); @@ -446,30 +448,11 @@ namespace Timeline.Services public async Task DeleteAllPostsOfUser(long userId) { - var posts = await _database.TimelinePosts.Where(p => p.AuthorId == userId).ToListAsync(); - - var now = _clock.GetCurrentTime(); - - var dataTags = new List(); - - foreach (var post in posts) - { - if (post.Content != null) - { - if (post.ContentType == TimelinePostDataKind.Image) - { - dataTags.Add(post.Content); - } - post.Content = null; - } - post.LastUpdated = now; - } - - await _database.SaveChangesAsync(); + var postEntities = await _database.TimelinePosts.Where(p => p.AuthorId == userId).Select(p => new { p.TimelineId, p.LocalId }).ToListAsync(); - foreach (var dataTag in dataTags) + foreach (var postEntity in postEntities) { - await _dataManager.FreeEntry(dataTag); + await this.DeletePost(postEntity.TimelineId, postEntity.LocalId); } } @@ -479,9 +462,9 @@ namespace Timeline.Services var timelineEntity = await _database.Timelines.Where(t => t.Id == timelineId).Select(t => new { t.OwnerId }).SingleAsync(); - var postEntity = await _database.TimelinePosts.Where(p => p.Id == postId).Select(p => new { p.Content, p.AuthorId }).SingleOrDefaultAsync(); + var postEntity = await _database.TimelinePosts.Where(p => p.Id == postId).Select(p => new { p.Deleted, p.AuthorId }).SingleOrDefaultAsync(); - if (postEntity == null) + if (postEntity is null) { if (throwOnPostNotExist) throw new TimelinePostNotExistException(timelineId, postId, false); @@ -489,7 +472,7 @@ namespace Timeline.Services return true; } - if (postEntity.Content == null && throwOnPostNotExist) + if (postEntity.Deleted && throwOnPostNotExist) { throw new TimelinePostNotExistException(timelineId, postId, true); } -- cgit v1.2.3 From b5376f71157f68f06aa04bde79389f9ab291d84a Mon Sep 17 00:00:00 2001 From: crupest Date: Thu, 11 Feb 2021 22:21:12 +0800 Subject: ... --- .../IntegratedTests/TimelinePostTest.cs | 338 ++++----------------- .../IntegratedTests/UserAvatarTest.cs | 17 +- .../Timeline/Controllers/UserAvatarController.cs | 4 +- BackEnd/Timeline/Entities/TimelinePostEntity.cs | 2 + .../Timeline/Formatters/ByteDataInputFormatter.cs | 6 + BackEnd/Timeline/Models/Http/HttpTimelinePost.cs | 5 + .../Models/Http/HttpTimelinePostCreateRequest.cs | 2 + BackEnd/Timeline/Models/Mapper/TimelineMapper.cs | 2 + BackEnd/Timeline/Services/TimelinePostService.cs | 4 +- 9 files changed, 87 insertions(+), 293 deletions(-) (limited to 'BackEnd/Timeline') diff --git a/BackEnd/Timeline.Tests/IntegratedTests/TimelinePostTest.cs b/BackEnd/Timeline.Tests/IntegratedTests/TimelinePostTest.cs index 4caff416..bd79ae18 100644 --- a/BackEnd/Timeline.Tests/IntegratedTests/TimelinePostTest.cs +++ b/BackEnd/Timeline.Tests/IntegratedTests/TimelinePostTest.cs @@ -1,44 +1,43 @@ using FluentAssertions; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.Formats.Png; using System; using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Text; using System.Threading.Tasks; -using Timeline.Entities; using Timeline.Models; using Timeline.Models.Http; -using Timeline.Tests.Helpers; using Xunit; +using Xunit.Abstractions; namespace Timeline.Tests.IntegratedTests { - public static class TimelineHelper + public class TimelinePostTest : BaseTimelineTest { - public static HttpTimelinePostContent TextPostContent(string text) + private static HttpTimelinePostCreateRequest CreateTextPostRequest(string text, DateTime? time = null, string? color = null) { - return new HttpTimelinePostContent("text", text, null, null); - } - - public static HttpTimelinePostCreateRequest TextPostCreateRequest(string text, DateTime? time = null) - { - return new HttpTimelinePostCreateRequest + return new HttpTimelinePostCreateRequest() { - Content = new HttpTimelinePostCreateRequestContent + Time = time, + Color = color, + DataList = new List() { - Type = "text", - Text = text - }, - Time = time + new HttpTimelinePostCreateRequestData() + { + ContentType = MimeTypes.TextPlain, + Data = Convert.ToBase64String(Encoding.UTF8.GetBytes(text)) + } + } }; } - } - public class TimelinePostTest : BaseTimelineTest - { + private readonly ITestOutputHelper _outputHelper; + + public TimelinePostTest(ITestOutputHelper outputHelper) + { + _outputHelper = outputHelper; + } + [Theory] [MemberData(nameof(TimelineNameGeneratorTestData))] public async Task GetPostsAndVisibility_Should_Work(TimelineNameGenerator generator) @@ -102,15 +101,13 @@ namespace Timeline.Tests.IntegratedTests { using var client = await CreateClientAsUser(); - var postContentList = new List { "a", "b", "c", "d" }; var posts = new List(); - foreach (var content in postContentList) + for (int i = 0; i < 4; i++) { - var post = await client.TestPostAsync($"timelines/{generator(1)}/posts", - new HttpTimelinePostCreateRequest { Content = new HttpTimelinePostCreateRequestContent { Text = content, Type = TimelinePostDataKind.Text } }); + var post = await client.TestPostAsync($"timelines/{generator(1)}/posts", CreateTextPostRequest("a")); posts.Add(post); - await Task.Delay(1000); + await Task.Delay(TimeSpan.FromSeconds(1)); } await client.TestDeleteAsync($"timelines/{generator(1)}/posts/{posts[2].Id}"); @@ -118,64 +115,57 @@ namespace Timeline.Tests.IntegratedTests { var body = await client.TestGetAsync>($"timelines/{generator(1)}/posts?modifiedSince={posts[1].LastUpdated.ToString("s", CultureInfo.InvariantCulture) }"); body.Should().HaveCount(2) - .And.Subject.Select(p => p.Content!.Text).Should().Equal("b", "d"); + .And.Subject.Select(p => p.Id).Should().Equal(posts[1].Id, posts[3].Id); } } [Theory] [MemberData(nameof(TimelineNameGeneratorTestData))] - public async Task Post_ModifiedSince_And_IncludeDeleted(TimelineNameGenerator generator) + public async Task PostList_IncludeDeleted(TimelineNameGenerator generator) { using var client = await CreateClientAsUser(); - var postContentList = new List { "a", "b", "c", "d" }; var posts = new List(); - foreach (var (content, index) in postContentList.Select((v, i) => (v, i))) + for (int i = 0; i < 4; i++) { - var post = await client.TestPostAsync($"timelines/{generator(1)}/posts", - new HttpTimelinePostCreateRequest { Content = new HttpTimelinePostCreateRequestContent { Text = content, Type = TimelinePostDataKind.Text } }); - posts.Add(post); - await Task.Delay(1000); + var body = await client.TestPostAsync($"timelines/{generator(1)}/posts", CreateTextPostRequest("a")); + posts.Add(body); } - await client.TestDeleteAsync($"timelines/{generator(1)}/posts/{posts[2].Id}"); - + foreach (var id in new long[] { posts[0].Id, posts[2].Id }) { + await client.TestDeleteAsync($"timelines/{generator(1)}/posts/{id}"); + } - posts = await client.TestGetAsync>($"timelines/{generator(1)}/posts?modifiedSince={posts[1].LastUpdated.ToString("s", CultureInfo.InvariantCulture)}&includeDeleted=true"); - posts.Should().HaveCount(3); - posts.Select(p => p.Deleted).Should().Equal(false, true, false); - posts.Select(p => p.Content == null).Should().Equal(false, true, false); + { + posts = await client.TestGetAsync>($"timelines/{generator(1)}/posts?includeDeleted=true"); + posts.Should().HaveCount(4); + posts.Select(p => p.Deleted).Should().Equal(true, false, true, false); } } [Theory] [MemberData(nameof(TimelineNameGeneratorTestData))] - public async Task PostList_IncludeDeleted(TimelineNameGenerator generator) + public async Task Post_ModifiedSince_And_IncludeDeleted(TimelineNameGenerator generator) { using var client = await CreateClientAsUser(); - var postContentList = new List { "a", "b", "c", "d" }; var posts = new List(); - foreach (var content in postContentList) + for (int i = 0; i < 4; i++) { - var body = await client.TestPostAsync($"timelines/{generator(1)}/posts", - new HttpTimelinePostCreateRequest { Content = new HttpTimelinePostCreateRequestContent { Text = content, Type = TimelinePostDataKind.Text } }); - posts.Add(body); + var post = await client.TestPostAsync($"timelines/{generator(1)}/posts", CreateTextPostRequest("a")); + posts.Add(post); + await Task.Delay(TimeSpan.FromSeconds(1)); } - foreach (var id in new long[] { posts[0].Id, posts[2].Id }) - { - await client.TestDeleteAsync($"timelines/{generator(1)}/posts/{id}"); - } + await client.TestDeleteAsync($"timelines/{generator(1)}/posts/{posts[2].Id}"); { - posts = await client.TestGetAsync>($"timelines/{generator(1)}/posts?includeDeleted=true"); - posts.Should().HaveCount(4); - posts.Select(p => p.Deleted).Should().Equal(true, false, true, false); - posts.Select(p => p.Content == null).Should().Equal(true, false, true, false); + posts = await client.TestGetAsync>($"timelines/{generator(1)}/posts?modifiedSince={posts[1].LastUpdated.ToString("s", CultureInfo.InvariantCulture)}&includeDeleted=true"); + posts.Should().HaveCount(3); + posts.Select(p => p.Deleted).Should().Equal(false, true, false); } } @@ -190,25 +180,25 @@ namespace Timeline.Tests.IntegratedTests using (var client = await CreateDefaultClient()) { // no auth should get 401 - await client.TestPostAssertUnauthorizedAsync($"timelines/{generator(1)}/posts", TimelineHelper.TextPostCreateRequest("aaa")); + await client.TestPostAssertUnauthorizedAsync($"timelines/{generator(1)}/posts", CreateTextPostRequest("aaa")); } using (var client = await CreateClientAsUser()) { // post self's - await client.TestPostAsync($"timelines/{generator(1)}/posts", TimelineHelper.TextPostCreateRequest("aaa")); + await client.TestPostAsync($"timelines/{generator(1)}/posts", CreateTextPostRequest("aaa")); // post other not as a member should get 403 - await client.TestPostAssertForbiddenAsync($"timelines/{generator(0)}/posts", TimelineHelper.TextPostCreateRequest("aaa")); + await client.TestPostAssertForbiddenAsync($"timelines/{generator(0)}/posts", CreateTextPostRequest("aaa")); } using (var client = await CreateClientAsAdministrator()) { // post as admin - await client.TestPostAsync($"timelines/{generator(1)}/posts", TimelineHelper.TextPostCreateRequest("aaa")); + await client.TestPostAsync($"timelines/{generator(1)}/posts", CreateTextPostRequest("aaa")); } using (var client = await CreateClientAs(2)) { // post as member - await client.TestPostAsync($"timelines/{generator(1)}/posts", TimelineHelper.TextPostCreateRequest("aaa")); + await client.TestPostAsync($"timelines/{generator(1)}/posts", CreateTextPostRequest("aaa")); } } @@ -219,7 +209,7 @@ namespace Timeline.Tests.IntegratedTests async Task CreatePost(int userNumber) { using var client = await CreateClientAs(userNumber); - var body = await client.TestPostAsync($"timelines/{generator(1)}/posts", TimelineHelper.TextPostCreateRequest("aaa")); + var body = await client.TestPostAsync($"timelines/{generator(1)}/posts", CreateTextPostRequest("aaa")); return body.Id; } @@ -265,57 +255,6 @@ namespace Timeline.Tests.IntegratedTests } } - [Theory] - [MemberData(nameof(TimelineNameGeneratorTestData))] - public async Task TextPost_Should_Work(TimelineNameGenerator generator) - { - using var client = await CreateClientAsUser(); - - { - var body = await client.TestGetAsync>($"timelines/{generator(1)}/posts"); - body.Should().BeEmpty(); - } - - const string mockContent = "aaa"; - HttpTimelinePost createRes; - { - var body = await client.TestPostAsync($"timelines/{generator(1)}/posts", TimelineHelper.TextPostCreateRequest(mockContent)); - body.Content.Should().BeEquivalentTo(TimelineHelper.TextPostContent(mockContent)); - body.Author.Should().BeEquivalentTo(await client.GetUserAsync("user1")); - body.Deleted.Should().BeFalse(); - createRes = body; - } - { - var body = await client.TestGetAsync>($"timelines/{generator(1)}/posts"); - body.Should().BeEquivalentTo(createRes); - } - const string mockContent2 = "bbb"; - var mockTime2 = DateTime.UtcNow.AddDays(-1); - HttpTimelinePost createRes2; - { - var body = await client.TestPostAsync($"timelines/{generator(1)}/posts", TimelineHelper.TextPostCreateRequest(mockContent2, mockTime2)); - body.Should().NotBeNull(); - body.Content.Should().BeEquivalentTo(TimelineHelper.TextPostContent(mockContent2)); - body.Author.Should().BeEquivalentTo(await client.GetUserAsync("user1")); - body.Time.Should().BeCloseTo(mockTime2, 1000); - body.Deleted.Should().BeFalse(); - createRes2 = body; - } - { - var body = await client.TestGetAsync>($"timelines/{generator(1)}/posts"); - body.Should().BeEquivalentTo(createRes, createRes2); - } - { - await client.TestDeleteAsync($"timelines/{generator(1)}/posts/{createRes.Id}"); - await client.TestDeleteAssertErrorAsync($"timelines/{generator(1)}/posts/{createRes.Id}"); - await client.TestDeleteAssertErrorAsync($"timelines/{generator(1)}/posts/30000"); - } - { - var body = await client.TestGetAsync>($"timelines/{generator(1)}/posts"); - body.Should().BeEquivalentTo(createRes2); - } - } - [Theory] [MemberData(nameof(TimelineNameGeneratorTestData))] public async Task GetPost_Should_Ordered(TimelineNameGenerator generator) @@ -324,7 +263,7 @@ namespace Timeline.Tests.IntegratedTests async Task CreatePost(DateTime time) { - var body = await client.TestPostAsync($"timelines/{generator(1)}/posts", TimelineHelper.TextPostCreateRequest("aaa", time)); + var body = await client.TestPostAsync($"timelines/{generator(1)}/posts", CreateTextPostRequest("aaa", time)); return body.Id; } @@ -339,166 +278,19 @@ namespace Timeline.Tests.IntegratedTests } } - [Theory] - [MemberData(nameof(TimelineNameGeneratorTestData))] - public async Task CreatePost_InvalidModel(TimelineNameGenerator generator) - { - using var client = await CreateClientAsUser(); - var postUrl = $"timelines/{generator(1)}/posts"; - await client.TestPostAssertInvalidModelAsync(postUrl, new HttpTimelinePostCreateRequest { Content = null! }); - await client.TestPostAssertInvalidModelAsync(postUrl, new HttpTimelinePostCreateRequest { Content = new HttpTimelinePostCreateRequestContent { Type = null! } }); - await client.TestPostAssertInvalidModelAsync(postUrl, new HttpTimelinePostCreateRequest { Content = new HttpTimelinePostCreateRequestContent { Type = "hahaha" } }); - await client.TestPostAssertInvalidModelAsync(postUrl, new HttpTimelinePostCreateRequest { Content = new HttpTimelinePostCreateRequestContent { Type = "text", Text = null } }); - await client.TestPostAssertInvalidModelAsync(postUrl, new HttpTimelinePostCreateRequest { Content = new HttpTimelinePostCreateRequestContent { Type = "image", Data = null } }); - // image not base64 - await client.TestPostAssertInvalidModelAsync(postUrl, new HttpTimelinePostCreateRequest { Content = new HttpTimelinePostCreateRequestContent { Type = "image", Data = "!!!" } }); - // image base64 not image - await client.TestPostAssertInvalidModelAsync(postUrl, new HttpTimelinePostCreateRequest { Content = new HttpTimelinePostCreateRequestContent { Type = "image", Data = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 }) } }); - } - - [Theory] - [MemberData(nameof(TimelineNameGeneratorTestData))] - public async Task ImagePost_ShouldWork(TimelineNameGenerator generator) - { - var imageData = ImageHelper.CreatePngWithSize(100, 200); - - long postId; - string postImageUrl; - - void AssertPostContent(HttpTimelinePostContent content) - { - content.Type.Should().Be(TimelinePostDataKind.Image); - content.Url.Should().EndWith($"timelines/{generator(1)}/posts/{postId}/data"); - content.Text.Should().Be(null); - } - - using var client = await CreateClientAsUser(); - - { - var body = await client.TestPostAsync($"timelines/{generator(1)}/posts", - new HttpTimelinePostCreateRequest - { - Content = new HttpTimelinePostCreateRequestContent - { - Type = TimelinePostDataKind.Image, - Data = Convert.ToBase64String(imageData) - } - }); - postId = body.Id; - postImageUrl = body.Content!.Url!; - AssertPostContent(body.Content); - } - - { - var body = await client.TestGetAsync>($"timelines/{generator(1)}/posts"); - body.Should().HaveCount(1); - var post = body[0]; - post.Id.Should().Be(postId); - AssertPostContent(post.Content!); - } - - { - var res = await client.GetAsync($"timelines/{generator(1)}/posts/{postId}/data"); - res.Content.Headers.ContentType!.MediaType.Should().Be("image/png"); - var data = await res.Content.ReadAsByteArrayAsync(); - var image = Image.Load(data, out var format); - image.Width.Should().Be(100); - image.Height.Should().Be(200); - format.Name.Should().Be(PngFormat.Instance.Name); - } - - await CacheTestHelper.TestCache(client, $"timelines/{generator(1)}/posts/{postId}/data"); - await client.TestDeleteAsync($"timelines/{generator(1)}/posts/{postId}"); - await client.TestDeleteAssertErrorAsync($"timelines/{generator(1)}/posts/{postId}"); - - { - var body = await client.TestGetAsync>($"timelines/{generator(1)}/posts"); - body.Should().BeEmpty(); - } - - { - using var scope = TestApp.Host.Services.CreateScope(); - var database = scope.ServiceProvider.GetRequiredService(); - var count = await database.Data.CountAsync(); - count.Should().Be(0); - } - } - - [Theory] - [MemberData(nameof(TimelineNameGeneratorTestData))] - public async Task ImagePost_400(TimelineNameGenerator generator) - { - using var client = await CreateClientAsUser(); - - await client.TestGetAssertNotFoundAsync($"timelines/{generator(1)}/posts/11234/data", errorCode: ErrorCodes.TimelineController.PostNotExist); - - long postId; - { - var body = await client.TestPostAsync($"timelines/{generator(1)}/posts", TimelineHelper.TextPostCreateRequest("aaa")); - postId = body.Id; - } - - await client.TestGetAssertErrorAsync($"timelines/{generator(1)}/posts/{postId}/data", errorCode: ErrorCodes.TimelineController.PostNoData); - } - - [Theory] - [MemberData(nameof(TimelineNameGeneratorTestData))] - public async Task PostDataETag(TimelineNameGenerator generator) - { - using var client = await CreateClientAsUser(); - - long id; - string etag; - - { - var body = await client.TestPostAsync($"timelines/{generator(1)}/posts", new HttpTimelinePostCreateRequest - { - Content = new HttpTimelinePostCreateRequestContent - { - Type = TimelinePostDataKind.Image, - Data = Convert.ToBase64String(ImageHelper.CreatePngWithSize(100, 50)) - } - }); - body.Content!.ETag.Should().NotBeNullOrEmpty(); - - id = body.Id; - etag = body.Content.ETag!; - } - - { - var res = await client.GetAsync($"timelines/{generator(1)}/posts/{id}/data"); - res.StatusCode.Should().Be(200); - res.Headers.ETag.Should().NotBeNull(); - res.Headers.ETag!.ToString().Should().Be(etag); - } - } - [Theory] [MemberData(nameof(TimelineNameGeneratorTestData))] public async Task Color(TimelineNameGenerator generator) { using var client = await CreateClientAsUser(); - HttpTimelinePostCreateRequestContent CreateRequestContent() => new() - { - Type = "text", - Text = "aaa" - }; - - await client.TestPostAssertInvalidModelAsync($"timelines/{generator(1)}/posts", new HttpTimelinePostCreateRequest - { - Content = CreateRequestContent(), - Color = "#1" - }); + await client.TestPostAssertInvalidModelAsync($"timelines/{generator(1)}/posts", CreateTextPostRequest("a", color: "aa")); long id; { - var post = await client.TestPostAsync($"timelines/{generator(1)}/posts", new HttpTimelinePostCreateRequest - { - Content = CreateRequestContent(), - Color = "#aabbcc" - }); + var post = await client.TestPostAsync($"timelines/{generator(1)}/posts", + CreateTextPostRequest("a", color: "#aabbcc")); post.Color.Should().Be("#aabbcc"); id = post.Id; } @@ -515,18 +307,10 @@ namespace Timeline.Tests.IntegratedTests { using var client = await CreateClientAsUser(); - HttpTimelinePostCreateRequestContent CreateRequestContent() => new() - { - Type = "text", - Text = "aaa" - }; await client.TestGetAssertNotFoundAsync($"timelines/{generator(1)}/posts/1"); - var post = await client.TestPostAsync($"timelines/{generator(1)}/posts", new HttpTimelinePostCreateRequest - { - Content = CreateRequestContent(), - }); + var post = await client.TestPostAsync($"timelines/{generator(1)}/posts", CreateTextPostRequest("a")); var post2 = await client.TestGetAsync($"timelines/{generator(1)}/posts/{post.Id}"); post2.Should().BeEquivalentTo(post); @@ -542,14 +326,8 @@ namespace Timeline.Tests.IntegratedTests { using var client = await CreateClientAsUser(); - var post = await client.TestPostAsync($"timelines/{generator(1)}/posts", new HttpTimelinePostCreateRequest - { - Content = new() - { - Type = "text", - Text = "aaa" - } - }); + var post = await client.TestPostAsync($"timelines/{generator(1)}/posts", + CreateTextPostRequest("a")); var date = new DateTime(2000, 10, 1); diff --git a/BackEnd/Timeline.Tests/IntegratedTests/UserAvatarTest.cs b/BackEnd/Timeline.Tests/IntegratedTests/UserAvatarTest.cs index 893a5d28..708120b1 100644 --- a/BackEnd/Timeline.Tests/IntegratedTests/UserAvatarTest.cs +++ b/BackEnd/Timeline.Tests/IntegratedTests/UserAvatarTest.cs @@ -11,8 +11,8 @@ using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Threading.Tasks; +using Timeline.Models; using Timeline.Models.Http; -using Timeline.Services; using Timeline.Tests.Helpers; using Xunit; @@ -23,11 +23,10 @@ namespace Timeline.Tests.IntegratedTests [Fact] public async Task Test() { - Avatar mockAvatar = new Avatar - { - Data = ImageHelper.CreatePngWithSize(100, 100), - Type = PngFormat.Instance.DefaultMimeType - }; + ByteData mockAvatar = new ByteData( + ImageHelper.CreatePngWithSize(100, 100), + PngFormat.Instance.DefaultMimeType + ); using (var client = await CreateClientAsUser()) { @@ -106,7 +105,7 @@ namespace Timeline.Tests.IntegratedTests } { - await client.TestPutByteArrayAsync("users/user1/avatar", mockAvatar.Data, mockAvatar.Type); + await client.TestPutByteArrayAsync("users/user1/avatar", mockAvatar.Data, mockAvatar.ContentType); await TestAvatar("user1", mockAvatar.Data); } @@ -137,7 +136,7 @@ namespace Timeline.Tests.IntegratedTests // Authorization check. using (var client = await CreateClientAsAdministrator()) { - await client.TestPutByteArrayAsync("users/user1/avatar", mockAvatar.Data, mockAvatar.Type); + await client.TestPutByteArrayAsync("users/user1/avatar", mockAvatar.Data, mockAvatar.ContentType); await client.TestDeleteAsync("users/user1/avatar"); await client.TestPutByteArrayAssertErrorAsync("users/usernotexist/avatar", new[] { (byte)0x00 }, "image/png", errorCode: ErrorCodes.UserCommon.NotExist); await client.TestDeleteAssertErrorAsync("users/usernotexist/avatar", errorCode: ErrorCodes.UserCommon.NotExist); @@ -175,4 +174,4 @@ namespace Timeline.Tests.IntegratedTests } } } -} \ No newline at end of file +} diff --git a/BackEnd/Timeline/Controllers/UserAvatarController.cs b/BackEnd/Timeline/Controllers/UserAvatarController.cs index 8ac2d21a..180d1f9b 100644 --- a/BackEnd/Timeline/Controllers/UserAvatarController.cs +++ b/BackEnd/Timeline/Controllers/UserAvatarController.cs @@ -102,12 +102,12 @@ namespace Timeline.Controllers try { - var etag = await _service.SetAvatar(id, body); + var digest = await _service.SetAvatar(id, body); _logger.LogInformation(Log.Format(LogPutSuccess, ("Username", username), ("Mime Type", Request.ContentType))); - Response.Headers.Append("ETag", new EntityTagHeaderValue($"\"{etag}\"").ToString()); + Response.Headers.Append("ETag", new EntityTagHeaderValue($"\"{digest.ETag}\"").ToString()); return Ok(); } diff --git a/BackEnd/Timeline/Entities/TimelinePostEntity.cs b/BackEnd/Timeline/Entities/TimelinePostEntity.cs index c65ef929..1f0270cb 100644 --- a/BackEnd/Timeline/Entities/TimelinePostEntity.cs +++ b/BackEnd/Timeline/Entities/TimelinePostEntity.cs @@ -37,6 +37,8 @@ namespace Timeline.Entities [Column("last_updated")] public DateTime LastUpdated { get; set; } +#pragma warning disable CA2227 public List DataList { get; set; } = default!; +#pragma warning restore CA2227 } } diff --git a/BackEnd/Timeline/Formatters/ByteDataInputFormatter.cs b/BackEnd/Timeline/Formatters/ByteDataInputFormatter.cs index 2451ead6..49f8221a 100644 --- a/BackEnd/Timeline/Formatters/ByteDataInputFormatter.cs +++ b/BackEnd/Timeline/Formatters/ByteDataInputFormatter.cs @@ -44,6 +44,12 @@ namespace Timeline.Formatters var logger = context.HttpContext.RequestServices.GetRequiredService>(); + if (request.ContentType is null) + { + logger.LogInformation("Failed to read body as bytes. Content-Type is not set."); + return await InputFormatterResult.FailureAsync(); + } + if (contentLength == null) { logger.LogInformation("Failed to read body as bytes. Content-Length is not set."); diff --git a/BackEnd/Timeline/Models/Http/HttpTimelinePost.cs b/BackEnd/Timeline/Models/Http/HttpTimelinePost.cs index 165c92da..26e1a92d 100644 --- a/BackEnd/Timeline/Models/Http/HttpTimelinePost.cs +++ b/BackEnd/Timeline/Models/Http/HttpTimelinePost.cs @@ -26,7 +26,12 @@ namespace Timeline.Models.Http /// Post id. /// public long Id { get; set; } + /// + /// The data list. + /// +#pragma warning disable CA2227 public List DataList { get; set; } = default!; +#pragma warning restore CA2227 /// /// True if post is deleted. /// diff --git a/BackEnd/Timeline/Models/Http/HttpTimelinePostCreateRequest.cs b/BackEnd/Timeline/Models/Http/HttpTimelinePostCreateRequest.cs index 07d823ad..2a973c72 100644 --- a/BackEnd/Timeline/Models/Http/HttpTimelinePostCreateRequest.cs +++ b/BackEnd/Timeline/Models/Http/HttpTimelinePostCreateRequest.cs @@ -13,7 +13,9 @@ namespace Timeline.Models.Http [Required] [MinLength(1)] [MaxLength(100)] +#pragma warning disable CA2227 public List DataList { get; set; } = default!; +#pragma warning restore CA2227 /// /// Time of the post. If not set, current time will be used. diff --git a/BackEnd/Timeline/Models/Mapper/TimelineMapper.cs b/BackEnd/Timeline/Models/Mapper/TimelineMapper.cs index 33ee9593..1f10c123 100644 --- a/BackEnd/Timeline/Models/Mapper/TimelineMapper.cs +++ b/BackEnd/Timeline/Models/Mapper/TimelineMapper.cs @@ -66,6 +66,8 @@ namespace Timeline.Models.Mapper public async Task MapToHttp(TimelinePostEntity entity, string timelineName, IUrlHelper urlHelper) { + _ = timelineName; + await _database.Entry(entity).Collection(p => p.DataList).LoadAsync(); await _database.Entry(entity).Reference(e => e.Author).LoadAsync(); diff --git a/BackEnd/Timeline/Services/TimelinePostService.cs b/BackEnd/Timeline/Services/TimelinePostService.cs index 8afd0770..62bc43cc 100644 --- a/BackEnd/Timeline/Services/TimelinePostService.cs +++ b/BackEnd/Timeline/Services/TimelinePostService.cs @@ -1,6 +1,5 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using SixLabors.ImageSharp; using System; using System.Collections.Generic; using System.Linq; @@ -12,7 +11,6 @@ using Timeline.Helpers.Cache; using Timeline.Models; using Timeline.Models.Validation; using Timeline.Services.Exceptions; -using static Timeline.Resources.Services.TimelineService; namespace Timeline.Services { @@ -37,7 +35,9 @@ namespace Timeline.Services /// If not set, current time is used. public DateTime? Time { get; set; } +#pragma warning disable CA2227 public List DataList { get; set; } = new List(); +#pragma warning restore CA2227 } public class TimelinePostPatchRequest -- cgit v1.2.3 From 3b60ec8b8fe13710f954338c27ed98b46e1ed1fd Mon Sep 17 00:00:00 2001 From: crupest Date: Fri, 12 Feb 2021 16:42:56 +0800 Subject: ... --- .../IntegratedTests/TimelinePostTest.cs | 171 +++++++-------------- .../Timeline/Controllers/TimelinePostController.cs | 4 + 2 files changed, 59 insertions(+), 116 deletions(-) (limited to 'BackEnd/Timeline') diff --git a/BackEnd/Timeline.Tests/IntegratedTests/TimelinePostTest.cs b/BackEnd/Timeline.Tests/IntegratedTests/TimelinePostTest.cs index 08f88dfc..0f264774 100644 --- a/BackEnd/Timeline.Tests/IntegratedTests/TimelinePostTest.cs +++ b/BackEnd/Timeline.Tests/IntegratedTests/TimelinePostTest.cs @@ -345,114 +345,74 @@ namespace Timeline.Tests.IntegratedTests post3.Color.Should().Be("#aabbcc"); } - [Theory] - [MemberData(nameof(TimelineNameGeneratorTestData))] - public async Task CreatePost_InvalidModel(TimelineNameGenerator generator) + public static IEnumerable CreatePost_InvalidModelTest_TestData() { - using var client = await CreateClientAsUser(); - - await client.TestPostAssertInvalidModelAsync( - $"timelines/{generator(1)}/posts", - new HttpTimelinePostCreateRequest { DataList = null! } - ); - await client.TestPostAssertInvalidModelAsync( - $"timelines/{generator(1)}/posts", - new HttpTimelinePostCreateRequest { DataList = new List() } - ); - await client.TestPostAssertInvalidModelAsync( - $"timelines/{generator(1)}/posts", - new HttpTimelinePostCreateRequest - { - DataList = Enumerable.Repeat(new HttpTimelinePostCreateRequestData + var testDataList = new List?>() + { + null, + new List(), + Enumerable.Repeat(new HttpTimelinePostCreateRequestData { ContentType = "text/plain", Data = Convert.ToBase64String(Encoding.UTF8.GetBytes("a")) - }, 200).ToList() - } - ); - await client.TestPostAssertInvalidModelAsync( - $"timelines/{generator(1)}/posts", - new HttpTimelinePostCreateRequest + }, 200).ToList(), + }; + + var testData = new List() + { + null, + new HttpTimelinePostCreateRequestData { - DataList = new List() - { - new HttpTimelinePostCreateRequestData - { - ContentType = null!, - Data = Convert.ToBase64String(Encoding.UTF8.GetBytes("a")) - } - } - } - ); - await client.TestPostAssertInvalidModelAsync( - $"timelines/{generator(1)}/posts", - new HttpTimelinePostCreateRequest + ContentType = null!, + Data = Convert.ToBase64String(Encoding.UTF8.GetBytes("a")) + }, + new HttpTimelinePostCreateRequestData { - DataList = new List() - { - new HttpTimelinePostCreateRequestData - { - ContentType = "text/plain", - Data = null! - } - } - } - ); - await client.TestPostAssertInvalidModelAsync( - $"timelines/{generator(1)}/posts", - new HttpTimelinePostCreateRequest + ContentType = "text/plain", + Data = null! + }, + new HttpTimelinePostCreateRequestData { - DataList = new List() - { - new HttpTimelinePostCreateRequestData - { - ContentType = "text/xxxxxxx", - Data = Convert.ToBase64String(Encoding.UTF8.GetBytes("a")) - } - } - } - ); - await client.TestPostAssertInvalidModelAsync( - $"timelines/{generator(1)}/posts", - new HttpTimelinePostCreateRequest + ContentType = "text/xxxxxxx", + Data = Convert.ToBase64String(Encoding.UTF8.GetBytes("a")) + }, + new HttpTimelinePostCreateRequestData { - DataList = new List() - { - new HttpTimelinePostCreateRequestData - { - ContentType = "text/plain", - Data = "aaa" - } - } + ContentType = "text/plain", + Data = "aaa" + }, + new HttpTimelinePostCreateRequestData + { + ContentType = "text/plain", + Data = Convert.ToBase64String(new byte[] {0xE4, 0x1, 0xA0}) + }, + new HttpTimelinePostCreateRequestData + { + ContentType = "image/jpeg", + Data = Convert.ToBase64String(ImageHelper.CreatePngWithSize(100, 100)) + }, + new HttpTimelinePostCreateRequestData + { + ContentType = "image/jpeg", + Data = Convert.ToBase64String(new byte[] { 100, 200 }) } - ); - } - [Theory] - [MemberData(nameof(TimelineNameGeneratorTestData))] - public async Task CreatePost_InvalidModel_NonUtf8(TimelineNameGenerator generator) - { - using var client = await CreateClientAsUser(); + }; - await client.TestPostAssertInvalidModelAsync( - $"timelines/{generator(1)}/posts", - new HttpTimelinePostCreateRequest - { - DataList = new List() - { - new HttpTimelinePostCreateRequestData - { - ContentType = "text/plain", - Data = Convert.ToBase64String(new byte[] {0xE4, 0x1, 0xA0}) - } - } - } - ); + testDataList.AddRange(testData.Select(d => new List() { d! })); + + foreach (var generatorTestData in TimelineNameGeneratorTestData()) + { + var generator = generatorTestData[0]; + + foreach (var d in testDataList) + yield return new object?[] { generator, d }; + } } [Theory] - [MemberData(nameof(TimelineNameGeneratorTestData))] - public async Task CreatePost_InvalidModel_Image(TimelineNameGenerator generator) + [MemberData(nameof(CreatePost_InvalidModelTest_TestData))] + public async Task CreatePost_InvalidModel(TimelineNameGenerator generator, List dataList) { using var client = await CreateClientAsUser(); @@ -460,28 +420,7 @@ namespace Timeline.Tests.IntegratedTests $"timelines/{generator(1)}/posts", new HttpTimelinePostCreateRequest { - DataList = new List() - { - new HttpTimelinePostCreateRequestData - { - ContentType = "image/jpeg", - Data = Convert.ToBase64String(ImageHelper.CreatePngWithSize(100, 100)) - } - } - } - ); - await client.TestPostAssertInvalidModelAsync( - $"timelines/{generator(1)}/posts", - new HttpTimelinePostCreateRequest - { - DataList = new List() - { - new HttpTimelinePostCreateRequestData - { - ContentType = "image/jpeg", - Data = Convert.ToBase64String(new byte[] { 100, 200 }) - } - } + DataList = dataList } ); } diff --git a/BackEnd/Timeline/Controllers/TimelinePostController.cs b/BackEnd/Timeline/Controllers/TimelinePostController.cs index 06082f0f..6904e28d 100644 --- a/BackEnd/Timeline/Controllers/TimelinePostController.cs +++ b/BackEnd/Timeline/Controllers/TimelinePostController.cs @@ -171,6 +171,10 @@ namespace Timeline.Controllers for (int i = 0; i < body.DataList.Count; i++) { var data = body.DataList[i]; + + if (data is null) + return BadRequest(new CommonResponse(ErrorCodes.Common.InvalidModel, $"Data at index {i} is null.")); + try { var d = Convert.FromBase64String(data.Data); -- cgit v1.2.3 From b5b758c41a01ab7f78f0711debe92f6add470c64 Mon Sep 17 00:00:00 2001 From: crupest Date: Fri, 12 Feb 2021 17:45:09 +0800 Subject: test: Add create post integrated tests. --- .../IntegratedTests/TimelinePostTest.cs | 70 ++++++++++++++++++++++ .../Timeline/Controllers/UserAvatarController.cs | 2 +- BackEnd/Timeline/Helpers/Cache/DataCacheHelper.cs | 2 +- BackEnd/Timeline/Models/Mapper/TimelineMapper.cs | 2 +- 4 files changed, 73 insertions(+), 3 deletions(-) (limited to 'BackEnd/Timeline') diff --git a/BackEnd/Timeline.Tests/IntegratedTests/TimelinePostTest.cs b/BackEnd/Timeline.Tests/IntegratedTests/TimelinePostTest.cs index 85db0908..4563db3a 100644 --- a/BackEnd/Timeline.Tests/IntegratedTests/TimelinePostTest.cs +++ b/BackEnd/Timeline.Tests/IntegratedTests/TimelinePostTest.cs @@ -10,6 +10,10 @@ using Timeline.Models.Http; using Timeline.Tests.Helpers; using Xunit; using Xunit.Abstractions; +using SixLabors.ImageSharp.Formats.Gif; +using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.Formats.Jpeg; +using System.Net; namespace Timeline.Tests.IntegratedTests { @@ -418,5 +422,71 @@ namespace Timeline.Tests.IntegratedTests } ); } + + public static IEnumerable CreatePost_ShouldWork_TestData() + { + var testByteDatas = new List() + { + new ByteData(Encoding.UTF8.GetBytes("aaa"), MimeTypes.TextPlain), + new ByteData(Encoding.UTF8.GetBytes("aaa"), MimeTypes.TextMarkdown), + new ByteData(ImageHelper.CreateImageWithSize(100, 50, PngFormat.Instance), MimeTypes.ImagePng), + new ByteData(ImageHelper.CreateImageWithSize(100, 50, JpegFormat.Instance), MimeTypes.ImageJpeg), + new ByteData(ImageHelper.CreateImageWithSize(100, 50, GifFormat.Instance), MimeTypes.ImageGif), + }; + + return TimelineNameGeneratorTestData().AppendTestData(testByteDatas); + } + + [Theory] + [MemberData(nameof(CreatePost_ShouldWork_TestData))] + public async Task CreatePost_ShouldWork(TimelineNameGenerator generator, ByteData data) + { + using var client = await CreateClientAsUser(); + + var post = await client.TestPostAsync( + $"timelines/{generator(1)}/posts", + new HttpTimelinePostCreateRequest + { + DataList = new List + { + new HttpTimelinePostCreateRequestData + { + ContentType = data.ContentType, + Data = Convert.ToBase64String(data.Data) + } + } + } + ); + + post.DataList.Should().NotBeNull().And.HaveCount(1); + var postData = post.DataList[0]; + postData.Should().NotBeNull(); + var postDataEtag = postData.ETag; + postDataEtag.Should().NotBeNullOrEmpty(); + + { + var response = await client.GetAsync($"timelines/{generator(1)}/posts/{post.Id}/data"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Headers.ETag.Should().NotBeNull(); + response.Headers.ETag!.Tag.Should().Be(postDataEtag); + response.Content.Headers.ContentType.Should().NotBeNull(); + response.Content.Headers.ContentType!.MediaType.Should().Be(data.ContentType); + + var body = await response.Content.ReadAsByteArrayAsync(); + body.Should().Equal(data.Data); + } + + { + var response = await client.GetAsync($"timelines/{generator(1)}/posts/{post.Id}/data/0"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Headers.ETag.Should().NotBeNull(); + response.Headers.ETag!.Tag.Should().Be(postDataEtag); + response.Content.Headers.ContentType.Should().NotBeNull(); + response.Content.Headers.ContentType!.MediaType.Should().Be(data.ContentType); + + var body = await response.Content.ReadAsByteArrayAsync(); + body.Should().Equal(data.Data); + } + } } } diff --git a/BackEnd/Timeline/Controllers/UserAvatarController.cs b/BackEnd/Timeline/Controllers/UserAvatarController.cs index 180d1f9b..fa13f0f6 100644 --- a/BackEnd/Timeline/Controllers/UserAvatarController.cs +++ b/BackEnd/Timeline/Controllers/UserAvatarController.cs @@ -107,7 +107,7 @@ namespace Timeline.Controllers _logger.LogInformation(Log.Format(LogPutSuccess, ("Username", username), ("Mime Type", Request.ContentType))); - Response.Headers.Append("ETag", new EntityTagHeaderValue($"\"{digest.ETag}\"").ToString()); + Response.Headers.Append("ETag", $"\"{digest.ETag}\""); return Ok(); } diff --git a/BackEnd/Timeline/Helpers/Cache/DataCacheHelper.cs b/BackEnd/Timeline/Helpers/Cache/DataCacheHelper.cs index c26bdddc..b7d86b18 100644 --- a/BackEnd/Timeline/Helpers/Cache/DataCacheHelper.cs +++ b/BackEnd/Timeline/Helpers/Cache/DataCacheHelper.cs @@ -33,7 +33,7 @@ namespace Timeline.Helpers.Cache } var digest = await provider.GetDigest(); - var eTagValue = '"' + digest.ETag + '"'; + var eTagValue = $"\"{digest.ETag}\""; var eTag = new EntityTagHeaderValue(eTagValue); ActionResult Generate304Result() diff --git a/BackEnd/Timeline/Models/Mapper/TimelineMapper.cs b/BackEnd/Timeline/Models/Mapper/TimelineMapper.cs index 1f10c123..5c46fa81 100644 --- a/BackEnd/Timeline/Models/Mapper/TimelineMapper.cs +++ b/BackEnd/Timeline/Models/Mapper/TimelineMapper.cs @@ -71,7 +71,7 @@ namespace Timeline.Models.Mapper await _database.Entry(entity).Collection(p => p.DataList).LoadAsync(); await _database.Entry(entity).Reference(e => e.Author).LoadAsync(); - List dataDigestList = entity.DataList.OrderBy(d => d.Index).Select(d => new HttpTimelinePostDataDigest(d.Kind, d.DataTag, d.LastUpdated)).ToList(); + List dataDigestList = entity.DataList.OrderBy(d => d.Index).Select(d => new HttpTimelinePostDataDigest(d.Kind, $"\"{d.DataTag}\"", d.LastUpdated)).ToList(); HttpUser? author = null; if (entity.Author is not null) -- cgit v1.2.3 From 85ac5c4513bd464d29696bc99fcda45ac228d1a2 Mon Sep 17 00:00:00 2001 From: crupest Date: Fri, 12 Feb 2021 18:31:14 +0800 Subject: feat: Revert timeline post entity change. --- BackEnd/Timeline/Entities/TimelinePostEntity.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) (limited to 'BackEnd/Timeline') diff --git a/BackEnd/Timeline/Entities/TimelinePostEntity.cs b/BackEnd/Timeline/Entities/TimelinePostEntity.cs index 1f0270cb..317d43fc 100644 --- a/BackEnd/Timeline/Entities/TimelinePostEntity.cs +++ b/BackEnd/Timeline/Entities/TimelinePostEntity.cs @@ -26,6 +26,16 @@ namespace Timeline.Entities [ForeignKey(nameof(AuthorId))] public UserEntity? Author { get; set; } = default!; + [Column("content_type")] + public string? ContentType { get; set; } + + [Column("content")] + public string? Content { get; set; } + + [Column("extra_content")] + public string? ExtraContent { get; set; } + + [Column("deleted")] public bool Deleted { get; set; } [Column("color")] -- cgit v1.2.3 From 1aec1eb3f0822e17793d43e040efdf127ea8e561 Mon Sep 17 00:00:00 2001 From: crupest Date: Fri, 12 Feb 2021 22:10:56 +0800 Subject: feat: Add databse custom migration service. --- BackEnd/Timeline/Entities/DatabaseContext.cs | 2 + BackEnd/Timeline/Entities/MigrationEntity.cs | 15 +++++ BackEnd/Timeline/Entities/TimelinePostEntity.cs | 3 + BackEnd/Timeline/Program.cs | 19 +++--- .../Services/Migration/CustomMigrationManager.cs | 55 +++++++++++++++++ .../Services/Migration/ICustomMigration.cs | 11 ++++ .../MigationServiceCollectionExtensions.cs | 14 +++++ .../TimelinePostContentToDataMigration.cs | 69 ++++++++++++++++++++++ BackEnd/Timeline/Startup.cs | 3 + 9 files changed, 181 insertions(+), 10 deletions(-) create mode 100644 BackEnd/Timeline/Entities/MigrationEntity.cs create mode 100644 BackEnd/Timeline/Services/Migration/CustomMigrationManager.cs create mode 100644 BackEnd/Timeline/Services/Migration/ICustomMigration.cs create mode 100644 BackEnd/Timeline/Services/Migration/MigationServiceCollectionExtensions.cs create mode 100644 BackEnd/Timeline/Services/Migration/TimelinePostContentToDataMigration.cs (limited to 'BackEnd/Timeline') diff --git a/BackEnd/Timeline/Entities/DatabaseContext.cs b/BackEnd/Timeline/Entities/DatabaseContext.cs index a0b59d1f..8ccdabb5 100644 --- a/BackEnd/Timeline/Entities/DatabaseContext.cs +++ b/BackEnd/Timeline/Entities/DatabaseContext.cs @@ -35,5 +35,7 @@ namespace Timeline.Entities public DbSet JwtToken { get; set; } = default!; public DbSet Data { get; set; } = default!; + + public DbSet Migrations { get; set; } = default!; } } diff --git a/BackEnd/Timeline/Entities/MigrationEntity.cs b/BackEnd/Timeline/Entities/MigrationEntity.cs new file mode 100644 index 00000000..0472f24e --- /dev/null +++ b/BackEnd/Timeline/Entities/MigrationEntity.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Timeline.Entities +{ + [Table("migrations")] + public class MigrationEntity + { + [Column("id"), Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public long Id { get; set; } + + [Column("name"), Required] + public string Name { get; set; } = default!; + } +} diff --git a/BackEnd/Timeline/Entities/TimelinePostEntity.cs b/BackEnd/Timeline/Entities/TimelinePostEntity.cs index 317d43fc..451f56cd 100644 --- a/BackEnd/Timeline/Entities/TimelinePostEntity.cs +++ b/BackEnd/Timeline/Entities/TimelinePostEntity.cs @@ -26,12 +26,15 @@ namespace Timeline.Entities [ForeignKey(nameof(AuthorId))] public UserEntity? Author { get; set; } = default!; + [Obsolete("Use post data instead.")] [Column("content_type")] public string? ContentType { get; set; } + [Obsolete("Use post data instead.")] [Column("content")] public string? Content { get; set; } + [Obsolete("Use post data instead.")] [Column("extra_content")] public string? ExtraContent { get; set; } diff --git a/BackEnd/Timeline/Program.cs b/BackEnd/Timeline/Program.cs index 75bf6154..0f75908f 100644 --- a/BackEnd/Timeline/Program.cs +++ b/BackEnd/Timeline/Program.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Hosting; using System.Resources; using Timeline.Entities; using Timeline.Services; +using Timeline.Services.Migration; [assembly: NeutralResourcesLanguage("en")] @@ -17,18 +18,16 @@ namespace Timeline { var host = CreateWebHostBuilder(args).Build(); - var env = host.Services.GetRequiredService(); + using (var scope = host.Services.CreateScope()) + { + var databaseBackupService = scope.ServiceProvider.GetRequiredService(); + databaseBackupService.BackupNow(); - var databaseBackupService = host.Services.GetRequiredService(); - databaseBackupService.BackupNow(); + var databaseContext = scope.ServiceProvider.GetRequiredService(); + databaseContext.Database.Migrate(); - if (env.IsProduction()) - { - using (var scope = host.Services.CreateScope()) - { - var databaseContext = scope.ServiceProvider.GetRequiredService(); - databaseContext.Database.Migrate(); - } + var customMigrationManager = scope.ServiceProvider.GetRequiredService(); + customMigrationManager.Migrate(); } host.Run(); diff --git a/BackEnd/Timeline/Services/Migration/CustomMigrationManager.cs b/BackEnd/Timeline/Services/Migration/CustomMigrationManager.cs new file mode 100644 index 00000000..ba86e10b --- /dev/null +++ b/BackEnd/Timeline/Services/Migration/CustomMigrationManager.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Timeline.Entities; + +namespace Timeline.Services.Migration +{ + public interface ICustomMigrationManager + { + Task Migrate(); + } + + public class CustomMigrationManager : ICustomMigrationManager + { + private IEnumerable _migrations; + private DatabaseContext _database; + + private ILogger _logger; + + public CustomMigrationManager(IEnumerable migrations, DatabaseContext database, ILogger logger) + { + _migrations = migrations; + _database = database; + _logger = logger; + } + + public async Task Migrate() + { + foreach (var migration in _migrations) + { + var name = migration.GetName(); + var did = await _database.Migrations.AnyAsync(m => m.Name == name); + + _logger.LogInformation("Found custom migration '{0}'. Did: {1}.", name, did); + + if (!did) + { + _logger.LogInformation("Begin custom migration '{0}'.", name); + + await using var transaction = await _database.Database.BeginTransactionAsync(); + + await migration.Execute(_database); + + _database.Migrations.Add(new MigrationEntity { Name = name }); + await _database.SaveChangesAsync(); + + await transaction.CommitAsync(); + + _logger.LogInformation("End custom migration '{0}'.", name); + } + } + } + } +} diff --git a/BackEnd/Timeline/Services/Migration/ICustomMigration.cs b/BackEnd/Timeline/Services/Migration/ICustomMigration.cs new file mode 100644 index 00000000..1f47df1e --- /dev/null +++ b/BackEnd/Timeline/Services/Migration/ICustomMigration.cs @@ -0,0 +1,11 @@ +using System.Threading.Tasks; +using Timeline.Entities; + +namespace Timeline.Services.Migration +{ + public interface ICustomMigration + { + string GetName(); + Task Execute(DatabaseContext database); + } +} diff --git a/BackEnd/Timeline/Services/Migration/MigationServiceCollectionExtensions.cs b/BackEnd/Timeline/Services/Migration/MigationServiceCollectionExtensions.cs new file mode 100644 index 00000000..0e6f6c0a --- /dev/null +++ b/BackEnd/Timeline/Services/Migration/MigationServiceCollectionExtensions.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Timeline.Services.Migration +{ + public static class MigrationServiceCollectionExtensions + { + public static IServiceCollection AddCustomMigration(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + return services; + } + } +} \ No newline at end of file diff --git a/BackEnd/Timeline/Services/Migration/TimelinePostContentToDataMigration.cs b/BackEnd/Timeline/Services/Migration/TimelinePostContentToDataMigration.cs new file mode 100644 index 00000000..de2e2183 --- /dev/null +++ b/BackEnd/Timeline/Services/Migration/TimelinePostContentToDataMigration.cs @@ -0,0 +1,69 @@ +using System.Text; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using SixLabors.ImageSharp; +using Timeline.Entities; +using Timeline.Models; + +namespace Timeline.Services.Migration +{ + public class TimelinePostContentToDataMigration : ICustomMigration + { + private readonly IDataManager _dataManager; + + public TimelinePostContentToDataMigration(IDataManager dataManager) + { + _dataManager = dataManager; + } + + public string GetName() => "TimelinePostContentToData"; + + public async Task Execute(DatabaseContext database) + { +#pragma warning disable CS0618 + var postEntities = await database.TimelinePosts.ToListAsync(); + + foreach (var postEntity in postEntities) + { + if (postEntity.Content is null) + { + postEntity.Deleted = true; + } + else + { + if (postEntity.ContentType == "text") + { + var tag = await _dataManager.RetainEntry(Encoding.UTF8.GetBytes(postEntity.Content), false); + database.TimelinePostData.Add(new TimelinePostDataEntity + { + DataTag = tag, + Kind = MimeTypes.TextPlain, + Index = 0, + PostId = postEntity.Id, + LastUpdated = postEntity.LastUpdated + }); + } + else + { + var data = await _dataManager.GetEntryAndCheck(postEntity.Content, "Old image content does not have corresponding data with the tag."); + var format = Image.DetectFormat(data); + database.TimelinePostData.Add(new TimelinePostDataEntity + { + DataTag = postEntity.Content, + Kind = format.DefaultMimeType, + Index = 0, + PostId = postEntity.Id, + LastUpdated = postEntity.LastUpdated + }); + } + } + postEntity.Content = null; + postEntity.ContentType = null; + postEntity.ExtraContent = null; + } + + await database.SaveChangesAsync(); +#pragma warning restore CS0618 + } + } +} diff --git a/BackEnd/Timeline/Startup.cs b/BackEnd/Timeline/Startup.cs index 5951dc80..26ba3bfc 100644 --- a/BackEnd/Timeline/Startup.cs +++ b/BackEnd/Timeline/Startup.cs @@ -21,6 +21,7 @@ using Timeline.Models.Converters; using Timeline.Models.Mapper; using Timeline.Routes; using Timeline.Services; +using Timeline.Services.Migration; using Timeline.Swagger; namespace Timeline @@ -86,7 +87,9 @@ namespace Timeline services.TryAddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddCustomMigration(); services.AddAutoMapper(GetType().Assembly); services.AddMappers(); -- cgit v1.2.3 From e74195ae38450b54b5e5f376796d78e6da4fc1ae Mon Sep 17 00:00:00 2001 From: crupest Date: Fri, 12 Feb 2021 22:16:04 +0800 Subject: feat: Add post data database schema migration. --- .../Migrations/20210212141443_PostData.Designer.cs | 578 +++++++++++++++++++++ .../Timeline/Migrations/20210212141443_PostData.cs | 90 ++++ .../Migrations/DatabaseContextModelSnapshot.cs | 74 ++- 3 files changed, 741 insertions(+), 1 deletion(-) create mode 100644 BackEnd/Timeline/Migrations/20210212141443_PostData.Designer.cs create mode 100644 BackEnd/Timeline/Migrations/20210212141443_PostData.cs (limited to 'BackEnd/Timeline') diff --git a/BackEnd/Timeline/Migrations/20210212141443_PostData.Designer.cs b/BackEnd/Timeline/Migrations/20210212141443_PostData.Designer.cs new file mode 100644 index 00000000..98262c98 --- /dev/null +++ b/BackEnd/Timeline/Migrations/20210212141443_PostData.Designer.cs @@ -0,0 +1,578 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Timeline.Entities; + +namespace Timeline.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20210212141443_PostData")] + partial class PostData + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "5.0.0"); + + modelBuilder.Entity("Timeline.Entities.BookmarkTimelineEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("Rank") + .HasColumnType("INTEGER") + .HasColumnName("rank"); + + b.Property("TimelineId") + .HasColumnType("INTEGER") + .HasColumnName("timeline"); + + b.Property("UserId") + .HasColumnType("INTEGER") + .HasColumnName("user"); + + b.HasKey("Id"); + + b.HasIndex("TimelineId"); + + b.HasIndex("UserId"); + + b.ToTable("bookmark_timelines"); + }); + + modelBuilder.Entity("Timeline.Entities.DataEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("Data") + .IsRequired() + .HasColumnType("BLOB") + .HasColumnName("data"); + + b.Property("Ref") + .HasColumnType("INTEGER") + .HasColumnName("ref"); + + b.Property("Tag") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tag"); + + b.HasKey("Id"); + + b.HasIndex("Tag") + .IsUnique(); + + b.ToTable("data"); + }); + + modelBuilder.Entity("Timeline.Entities.HighlightTimelineEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("AddTime") + .HasColumnType("TEXT") + .HasColumnName("add_time"); + + b.Property("OperatorId") + .HasColumnType("INTEGER") + .HasColumnName("operator_id"); + + b.Property("Order") + .HasColumnType("INTEGER") + .HasColumnName("order"); + + b.Property("TimelineId") + .HasColumnType("INTEGER") + .HasColumnName("timeline_id"); + + b.HasKey("Id"); + + b.HasIndex("OperatorId"); + + b.HasIndex("TimelineId"); + + b.ToTable("highlight_timelines"); + }); + + modelBuilder.Entity("Timeline.Entities.JwtTokenEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("Key") + .IsRequired() + .HasColumnType("BLOB") + .HasColumnName("key"); + + b.HasKey("Id"); + + b.ToTable("jwt_token"); + }); + + modelBuilder.Entity("Timeline.Entities.MigrationEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("migrations"); + }); + + modelBuilder.Entity("Timeline.Entities.TimelineEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("Color") + .HasColumnType("TEXT") + .HasColumnName("color"); + + b.Property("CreateTime") + .HasColumnType("TEXT") + .HasColumnName("create_time"); + + b.Property("CurrentPostLocalId") + .HasColumnType("INTEGER") + .HasColumnName("current_post_local_id"); + + b.Property("Description") + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("LastModified") + .HasColumnType("TEXT") + .HasColumnName("last_modified"); + + b.Property("Name") + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("NameLastModified") + .HasColumnType("TEXT") + .HasColumnName("name_last_modified"); + + b.Property("OwnerId") + .HasColumnType("INTEGER") + .HasColumnName("owner"); + + b.Property("Title") + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.Property("UniqueId") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("unique_id") + .HasDefaultValueSql("lower(hex(randomblob(16)))"); + + b.Property("Visibility") + .HasColumnType("INTEGER") + .HasColumnName("visibility"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.ToTable("timelines"); + }); + + modelBuilder.Entity("Timeline.Entities.TimelineMemberEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("TimelineId") + .HasColumnType("INTEGER") + .HasColumnName("timeline"); + + b.Property("UserId") + .HasColumnType("INTEGER") + .HasColumnName("user"); + + b.HasKey("Id"); + + b.HasIndex("TimelineId"); + + b.HasIndex("UserId"); + + b.ToTable("timeline_members"); + }); + + modelBuilder.Entity("Timeline.Entities.TimelinePostDataEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("DataTag") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("data_tag"); + + b.Property("Index") + .HasColumnType("INTEGER") + .HasColumnName("index"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("kind"); + + b.Property("LastUpdated") + .HasColumnType("TEXT") + .HasColumnName("last_updated"); + + b.Property("PostId") + .HasColumnType("INTEGER") + .HasColumnName("post"); + + b.HasKey("Id"); + + b.HasIndex("PostId"); + + b.ToTable("timeline_post_data"); + }); + + modelBuilder.Entity("Timeline.Entities.TimelinePostEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("AuthorId") + .HasColumnType("INTEGER") + .HasColumnName("author"); + + b.Property("Color") + .HasColumnType("TEXT") + .HasColumnName("color"); + + b.Property("Content") + .HasColumnType("TEXT") + .HasColumnName("content"); + + b.Property("ContentType") + .HasColumnType("TEXT") + .HasColumnName("content_type"); + + b.Property("Deleted") + .HasColumnType("INTEGER") + .HasColumnName("deleted"); + + b.Property("ExtraContent") + .HasColumnType("TEXT") + .HasColumnName("extra_content"); + + b.Property("LastUpdated") + .HasColumnType("TEXT") + .HasColumnName("last_updated"); + + b.Property("LocalId") + .HasColumnType("INTEGER") + .HasColumnName("local_id"); + + b.Property("Time") + .HasColumnType("TEXT") + .HasColumnName("time"); + + b.Property("TimelineId") + .HasColumnType("INTEGER") + .HasColumnName("timeline"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("TimelineId"); + + b.ToTable("timeline_posts"); + }); + + modelBuilder.Entity("Timeline.Entities.UserAvatarEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("DataTag") + .HasColumnType("TEXT") + .HasColumnName("data_tag"); + + b.Property("LastModified") + .HasColumnType("TEXT") + .HasColumnName("last_modified"); + + b.Property("Type") + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("INTEGER") + .HasColumnName("user"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("user_avatars"); + }); + + modelBuilder.Entity("Timeline.Entities.UserEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("CreateTime") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("create_time") + .HasDefaultValueSql("datetime('now', 'utc')"); + + b.Property("LastModified") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("last_modified") + .HasDefaultValueSql("datetime('now', 'utc')"); + + b.Property("Nickname") + .HasColumnType("TEXT") + .HasColumnName("nickname"); + + b.Property("Password") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("password"); + + b.Property("UniqueId") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("unique_id") + .HasDefaultValueSql("lower(hex(randomblob(16)))"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.Property("UsernameChangeTime") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("username_change_time") + .HasDefaultValueSql("datetime('now', 'utc')"); + + b.Property("Version") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0L) + .HasColumnName("version"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users"); + }); + + modelBuilder.Entity("Timeline.Entities.UserPermissionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("Permission") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("permission"); + + b.Property("UserId") + .HasColumnType("INTEGER") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("user_permission"); + }); + + modelBuilder.Entity("Timeline.Entities.BookmarkTimelineEntity", b => + { + b.HasOne("Timeline.Entities.TimelineEntity", "Timeline") + .WithMany() + .HasForeignKey("TimelineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Timeline.Entities.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Timeline"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Timeline.Entities.HighlightTimelineEntity", b => + { + b.HasOne("Timeline.Entities.UserEntity", "Operator") + .WithMany() + .HasForeignKey("OperatorId"); + + b.HasOne("Timeline.Entities.TimelineEntity", "Timeline") + .WithMany() + .HasForeignKey("TimelineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Operator"); + + b.Navigation("Timeline"); + }); + + modelBuilder.Entity("Timeline.Entities.TimelineEntity", b => + { + b.HasOne("Timeline.Entities.UserEntity", "Owner") + .WithMany("Timelines") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Timeline.Entities.TimelineMemberEntity", b => + { + b.HasOne("Timeline.Entities.TimelineEntity", "Timeline") + .WithMany("Members") + .HasForeignKey("TimelineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Timeline.Entities.UserEntity", "User") + .WithMany("TimelinesJoined") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Timeline"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Timeline.Entities.TimelinePostDataEntity", b => + { + b.HasOne("Timeline.Entities.TimelinePostEntity", "Post") + .WithMany("DataList") + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + }); + + modelBuilder.Entity("Timeline.Entities.TimelinePostEntity", b => + { + b.HasOne("Timeline.Entities.UserEntity", "Author") + .WithMany("TimelinePosts") + .HasForeignKey("AuthorId"); + + b.HasOne("Timeline.Entities.TimelineEntity", "Timeline") + .WithMany("Posts") + .HasForeignKey("TimelineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Author"); + + b.Navigation("Timeline"); + }); + + modelBuilder.Entity("Timeline.Entities.UserAvatarEntity", b => + { + b.HasOne("Timeline.Entities.UserEntity", "User") + .WithOne("Avatar") + .HasForeignKey("Timeline.Entities.UserAvatarEntity", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Timeline.Entities.UserPermissionEntity", b => + { + b.HasOne("Timeline.Entities.UserEntity", "User") + .WithMany("Permissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Timeline.Entities.TimelineEntity", b => + { + b.Navigation("Members"); + + b.Navigation("Posts"); + }); + + modelBuilder.Entity("Timeline.Entities.TimelinePostEntity", b => + { + b.Navigation("DataList"); + }); + + modelBuilder.Entity("Timeline.Entities.UserEntity", b => + { + b.Navigation("Avatar"); + + b.Navigation("Permissions"); + + b.Navigation("TimelinePosts"); + + b.Navigation("Timelines"); + + b.Navigation("TimelinesJoined"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/BackEnd/Timeline/Migrations/20210212141443_PostData.cs b/BackEnd/Timeline/Migrations/20210212141443_PostData.cs new file mode 100644 index 00000000..5a0c6179 --- /dev/null +++ b/BackEnd/Timeline/Migrations/20210212141443_PostData.cs @@ -0,0 +1,90 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Timeline.Migrations +{ + public partial class PostData : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "content_type", + table: "timeline_posts", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + + migrationBuilder.AddColumn( + name: "deleted", + table: "timeline_posts", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.CreateTable( + name: "migrations", + columns: table => new + { + id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + name = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_migrations", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "timeline_post_data", + columns: table => new + { + id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + post = table.Column(type: "INTEGER", nullable: false), + index = table.Column(type: "INTEGER", nullable: false), + kind = table.Column(type: "TEXT", nullable: false), + data_tag = table.Column(type: "TEXT", nullable: false), + last_updated = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_timeline_post_data", x => x.id); + table.ForeignKey( + name: "FK_timeline_post_data_timeline_posts_post", + column: x => x.post, + principalTable: "timeline_posts", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_timeline_post_data_post", + table: "timeline_post_data", + column: "post"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "migrations"); + + migrationBuilder.DropTable( + name: "timeline_post_data"); + + migrationBuilder.DropColumn( + name: "deleted", + table: "timeline_posts"); + + migrationBuilder.AlterColumn( + name: "content_type", + table: "timeline_posts", + type: "TEXT", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + } + } +} diff --git a/BackEnd/Timeline/Migrations/DatabaseContextModelSnapshot.cs b/BackEnd/Timeline/Migrations/DatabaseContextModelSnapshot.cs index 2e2b0d36..26a77e8a 100644 --- a/BackEnd/Timeline/Migrations/DatabaseContextModelSnapshot.cs +++ b/BackEnd/Timeline/Migrations/DatabaseContextModelSnapshot.cs @@ -122,6 +122,23 @@ namespace Timeline.Migrations b.ToTable("jwt_token"); }); + modelBuilder.Entity("Timeline.Entities.MigrationEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("migrations"); + }); + modelBuilder.Entity("Timeline.Entities.TimelineEntity", b => { b.Property("Id") @@ -207,6 +224,42 @@ namespace Timeline.Migrations b.ToTable("timeline_members"); }); + modelBuilder.Entity("Timeline.Entities.TimelinePostDataEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("DataTag") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("data_tag"); + + b.Property("Index") + .HasColumnType("INTEGER") + .HasColumnName("index"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("kind"); + + b.Property("LastUpdated") + .HasColumnType("TEXT") + .HasColumnName("last_updated"); + + b.Property("PostId") + .HasColumnType("INTEGER") + .HasColumnName("post"); + + b.HasKey("Id"); + + b.HasIndex("PostId"); + + b.ToTable("timeline_post_data"); + }); + modelBuilder.Entity("Timeline.Entities.TimelinePostEntity", b => { b.Property("Id") @@ -227,10 +280,13 @@ namespace Timeline.Migrations .HasColumnName("content"); b.Property("ContentType") - .IsRequired() .HasColumnType("TEXT") .HasColumnName("content_type"); + b.Property("Deleted") + .HasColumnType("INTEGER") + .HasColumnName("deleted"); + b.Property("ExtraContent") .HasColumnType("TEXT") .HasColumnName("extra_content"); @@ -440,6 +496,17 @@ namespace Timeline.Migrations b.Navigation("User"); }); + modelBuilder.Entity("Timeline.Entities.TimelinePostDataEntity", b => + { + b.HasOne("Timeline.Entities.TimelinePostEntity", "Post") + .WithMany("DataList") + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + }); + modelBuilder.Entity("Timeline.Entities.TimelinePostEntity", b => { b.HasOne("Timeline.Entities.UserEntity", "Author") @@ -486,6 +553,11 @@ namespace Timeline.Migrations b.Navigation("Posts"); }); + modelBuilder.Entity("Timeline.Entities.TimelinePostEntity", b => + { + b.Navigation("DataList"); + }); + modelBuilder.Entity("Timeline.Entities.UserEntity", b => { b.Navigation("Avatar"); -- cgit v1.2.3 From 83910122bfd0aa9bd207b6d5f631774415312716 Mon Sep 17 00:00:00 2001 From: crupest Date: Fri, 12 Feb 2021 22:32:10 +0800 Subject: fix: Fix migration bug. --- BackEnd/Timeline/Program.cs | 14 +++++++------- BackEnd/Timeline/Properties/launchSettings.json | 5 ++--- .../Timeline/Services/Migration/CustomMigrationManager.cs | 4 ++-- 3 files changed, 11 insertions(+), 12 deletions(-) (limited to 'BackEnd/Timeline') diff --git a/BackEnd/Timeline/Program.cs b/BackEnd/Timeline/Program.cs index 0f75908f..19fa6e37 100644 --- a/BackEnd/Timeline/Program.cs +++ b/BackEnd/Timeline/Program.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using System.Resources; +using System.Threading.Tasks; using Timeline.Entities; using Timeline.Services; using Timeline.Services.Migration; @@ -14,20 +15,19 @@ namespace Timeline { public static class Program { - public static void Main(string[] args) + public async static Task Main(string[] args) { var host = CreateWebHostBuilder(args).Build(); + var databaseBackupService = host.Services.GetRequiredService(); + databaseBackupService.BackupNow(); + using (var scope = host.Services.CreateScope()) { - var databaseBackupService = scope.ServiceProvider.GetRequiredService(); - databaseBackupService.BackupNow(); - var databaseContext = scope.ServiceProvider.GetRequiredService(); - databaseContext.Database.Migrate(); - + await databaseContext.Database.MigrateAsync(); var customMigrationManager = scope.ServiceProvider.GetRequiredService(); - customMigrationManager.Migrate(); + await customMigrationManager.Migrate(); } host.Run(); diff --git a/BackEnd/Timeline/Properties/launchSettings.json b/BackEnd/Timeline/Properties/launchSettings.json index 851fc6a8..3c8a465b 100644 --- a/BackEnd/Timeline/Properties/launchSettings.json +++ b/BackEnd/Timeline/Properties/launchSettings.json @@ -5,8 +5,7 @@ "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", "ASPNETCORE_FRONTEND": "Proxy" - }, - "applicationUrl": "http://0.0.0.0:5000" + } }, "Dev-Mock": { "commandName": "Project", @@ -30,4 +29,4 @@ } } } -} \ No newline at end of file +} diff --git a/BackEnd/Timeline/Services/Migration/CustomMigrationManager.cs b/BackEnd/Timeline/Services/Migration/CustomMigrationManager.cs index ba86e10b..f6f156cc 100644 --- a/BackEnd/Timeline/Services/Migration/CustomMigrationManager.cs +++ b/BackEnd/Timeline/Services/Migration/CustomMigrationManager.cs @@ -36,7 +36,7 @@ namespace Timeline.Services.Migration if (!did) { - _logger.LogInformation("Begin custom migration '{0}'.", name); + _logger.LogWarning("Begin custom migration '{0}'.", name); await using var transaction = await _database.Database.BeginTransactionAsync(); @@ -47,7 +47,7 @@ namespace Timeline.Services.Migration await transaction.CommitAsync(); - _logger.LogInformation("End custom migration '{0}'.", name); + _logger.LogWarning("End custom migration '{0}'.", name); } } } -- cgit v1.2.3