diff options
author | crupest <crupest@outlook.com> | 2021-02-12 22:39:57 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-02-12 22:39:57 +0800 |
commit | c3d0a5f88de0fbdf6bc584548832017087ab1248 (patch) | |
tree | c1c992987263897fb1c091c5129c6d1f1e64073d | |
parent | e232e31de839dc0c0de691c5856f29dcb92cf0fc (diff) | |
parent | 5849d34d9fcf1ccfb7fe5cc0842765129f7198b4 (diff) | |
download | timeline-c3d0a5f88de0fbdf6bc584548832017087ab1248.tar.gz timeline-c3d0a5f88de0fbdf6bc584548832017087ab1248.tar.bz2 timeline-c3d0a5f88de0fbdf6bc584548832017087ab1248.zip |
Merge pull request #267 from crupest/backend
春节大换血 Spring festival big change.
50 files changed, 2022 insertions, 1032 deletions
diff --git a/BackEnd/Timeline.ErrorCodes/ErrorCodes.cs b/BackEnd/Timeline.ErrorCodes/ErrorCodes.cs index 53a03b69..4c3b6cd8 100644 --- a/BackEnd/Timeline.ErrorCodes/ErrorCodes.cs +++ b/BackEnd/Timeline.ErrorCodes/ErrorCodes.cs @@ -17,6 +17,7 @@ public static class Header
{
public const int IfNonMatch_BadFormat = 1_000_01_01;
+ public const int IfModifiedSince_BadFormat = 1_000_01_02;
}
public static class Content
@@ -60,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.Tests/IntegratedTests/TimelinePostTest.cs b/BackEnd/Timeline.Tests/IntegratedTests/TimelinePostTest.cs index 17c85f22..c5ff507f 100644 --- a/BackEnd/Timeline.Tests/IntegratedTests/TimelinePostTest.cs +++ b/BackEnd/Timeline.Tests/IntegratedTests/TimelinePostTest.cs @@ -1,44 +1,48 @@ 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;
+using SixLabors.ImageSharp.Formats.Gif;
+using SixLabors.ImageSharp.Formats.Png;
+using SixLabors.ImageSharp.Formats.Jpeg;
+using System.Net;
namespace Timeline.Tests.IntegratedTests
{
- public static class TimelineHelper
+ public class TimelinePostTest : BaseTimelineTest
{
- public static HttpTimelinePostContent TextPostContent(string text)
- {
- return new HttpTimelinePostContent("text", text, null, null);
- }
-
- public static HttpTimelinePostCreateRequest TextPostCreateRequest(string text, DateTime? time = null)
+ private static HttpTimelinePostCreateRequest CreateTextPostRequest(string text, DateTime? time = null, string? color = null)
{
- return new HttpTimelinePostCreateRequest
+ return new HttpTimelinePostCreateRequest()
{
- Content = new HttpTimelinePostCreateRequestContent
+ Time = time,
+ Color = color,
+ DataList = new List<HttpTimelinePostCreateRequestData>()
{
- 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 +106,13 @@ namespace Timeline.Tests.IntegratedTests {
using var client = await CreateClientAsUser();
- var postContentList = new List<string> { "a", "b", "c", "d" };
var posts = new List<HttpTimelinePost>();
- foreach (var content in postContentList)
+ for (int i = 0; i < 4; i++)
{
- var post = await client.TestPostAsync<HttpTimelinePost>($"timelines/{generator(1)}/posts",
- new HttpTimelinePostCreateRequest { Content = new HttpTimelinePostCreateRequestContent { Text = content, Type = TimelinePostContentTypes.Text } });
+ var post = await client.TestPostAsync<HttpTimelinePost>($"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 +120,57 @@ namespace Timeline.Tests.IntegratedTests {
var body = await client.TestGetAsync<List<HttpTimelinePost>>($"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<string> { "a", "b", "c", "d" };
var posts = new List<HttpTimelinePost>();
- foreach (var (content, index) in postContentList.Select((v, i) => (v, i)))
+ for (int i = 0; i < 4; i++)
{
- var post = await client.TestPostAsync<HttpTimelinePost>($"timelines/{generator(1)}/posts",
- new HttpTimelinePostCreateRequest { Content = new HttpTimelinePostCreateRequestContent { Text = content, Type = TimelinePostContentTypes.Text } });
- posts.Add(post);
- await Task.Delay(1000);
+ var body = await client.TestPostAsync<HttpTimelinePost>($"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<List<HttpTimelinePost>>($"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<List<HttpTimelinePost>>($"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<string> { "a", "b", "c", "d" };
var posts = new List<HttpTimelinePost>();
- foreach (var content in postContentList)
+ for (int i = 0; i < 4; i++)
{
- var body = await client.TestPostAsync<HttpTimelinePost>($"timelines/{generator(1)}/posts",
- new HttpTimelinePostCreateRequest { Content = new HttpTimelinePostCreateRequestContent { Text = content, Type = TimelinePostContentTypes.Text } });
- posts.Add(body);
+ var post = await client.TestPostAsync<HttpTimelinePost>($"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<List<HttpTimelinePost>>($"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<List<HttpTimelinePost>>($"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 +185,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 +214,7 @@ namespace Timeline.Tests.IntegratedTests async Task<long> CreatePost(int userNumber)
{
using var client = await CreateClientAs(userNumber);
- var body = await client.TestPostAsync<HttpTimelinePost>($"timelines/{generator(1)}/posts", TimelineHelper.TextPostCreateRequest("aaa"));
+ var body = await client.TestPostAsync<HttpTimelinePost>($"timelines/{generator(1)}/posts", CreateTextPostRequest("aaa"));
return body.Id;
}
@@ -267,64 +262,13 @@ 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<List<HttpTimelinePost>>($"timelines/{generator(1)}/posts");
- body.Should().BeEmpty();
- }
-
- const string mockContent = "aaa";
- HttpTimelinePost createRes;
- {
- var body = await client.TestPostAsync<HttpTimelinePost>($"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<List<HttpTimelinePost>>($"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<HttpTimelinePost>($"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<List<HttpTimelinePost>>($"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<List<HttpTimelinePost>>($"timelines/{generator(1)}/posts");
- body.Should().BeEquivalentTo(createRes2);
- }
- }
-
- [Theory]
- [MemberData(nameof(TimelineNameGeneratorTestData))]
public async Task GetPost_Should_Ordered(TimelineNameGenerator generator)
{
using var client = await CreateClientAsUser();
async Task<long> CreatePost(DateTime time)
{
- var body = await client.TestPostAsync<HttpTimelinePost>($"timelines/{generator(1)}/posts", TimelineHelper.TextPostCreateRequest("aaa", time));
+ var body = await client.TestPostAsync<HttpTimelinePost>($"timelines/{generator(1)}/posts", CreateTextPostRequest("aaa", time));
return body.Id;
}
@@ -341,229 +285,286 @@ namespace Timeline.Tests.IntegratedTests [Theory]
[MemberData(nameof(TimelineNameGeneratorTestData))]
- public async Task CreatePost_InvalidModel(TimelineNameGenerator generator)
+ public async Task Color(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);
+ await client.TestPostAssertInvalidModelAsync($"timelines/{generator(1)}/posts", CreateTextPostRequest("a", color: "aa"));
- long postId;
- string postImageUrl;
+ long id;
- void AssertPostContent(HttpTimelinePostContent content)
{
- content.Type.Should().Be(TimelinePostContentTypes.Image);
- content.Url.Should().EndWith($"timelines/{generator(1)}/posts/{postId}/data");
- content.Text.Should().Be(null);
+ var post = await client.TestPostAsync<HttpTimelinePost>($"timelines/{generator(1)}/posts",
+ CreateTextPostRequest("a", color: "#aabbcc"));
+ post.Color.Should().Be("#aabbcc");
+ id = post.Id;
}
- using var client = await CreateClientAsUser();
-
{
- var body = await client.TestPostAsync<HttpTimelinePost>($"timelines/{generator(1)}/posts",
- new HttpTimelinePostCreateRequest
- {
- Content = new HttpTimelinePostCreateRequestContent
- {
- Type = TimelinePostContentTypes.Image,
- Data = Convert.ToBase64String(imageData)
- }
- });
- postId = body.Id;
- postImageUrl = body.Content!.Url!;
- AssertPostContent(body.Content);
+ var post = await client.TestGetAsync<HttpTimelinePost>($"timelines/{generator(1)}/posts/{id}");
+ post.Color.Should().Be("#aabbcc");
}
+ }
- {
- var body = await client.TestGetAsync<List<HttpTimelinePost>>($"timelines/{generator(1)}/posts");
- body.Should().HaveCount(1);
- var post = body[0];
- post.Id.Should().Be(postId);
- AssertPostContent(post.Content!);
- }
+ [Theory]
+ [MemberData(nameof(TimelineNameGeneratorTestData))]
+ public async Task GetPost(TimelineNameGenerator generator)
+ {
+ using var client = await CreateClientAsUser();
- {
- 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}");
+ await client.TestGetAssertNotFoundAsync($"timelines/{generator(1)}/posts/1");
- {
- var body = await client.TestGetAsync<List<HttpTimelinePost>>($"timelines/{generator(1)}/posts");
- body.Should().BeEmpty();
- }
+ var post = await client.TestPostAsync<HttpTimelinePost>($"timelines/{generator(1)}/posts", CreateTextPostRequest("a"));
- {
- using var scope = TestApp.Host.Services.CreateScope();
- var database = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
- var count = await database.Data.CountAsync();
- count.Should().Be(0);
- }
+ var post2 = await client.TestGetAsync<HttpTimelinePost>($"timelines/{generator(1)}/posts/{post.Id}");
+ post2.Should().BeEquivalentTo(post);
+
+ await client.TestDeleteAsync($"timelines/{generator(1)}/posts/{post.Id}");
+
+ await client.TestGetAssertNotFoundAsync($"timelines/{generator(1)}/posts/{post.Id}");
}
[Theory]
[MemberData(nameof(TimelineNameGeneratorTestData))]
- public async Task ImagePost_400(TimelineNameGenerator generator)
+ public async Task PatchPost(TimelineNameGenerator generator)
{
using var client = await CreateClientAsUser();
- await client.TestGetAssertNotFoundAsync($"timelines/{generator(1)}/posts/11234/data", errorCode: ErrorCodes.TimelineController.PostNotExist);
+ var post = await client.TestPostAsync<HttpTimelinePost>($"timelines/{generator(1)}/posts",
+ CreateTextPostRequest("a"));
+
+ var date = new DateTime(2000, 10, 1);
- long postId;
+ var post2 = await client.TestPatchAsync<HttpTimelinePost>($"timelines/{generator(1)}/posts/{post.Id}", new HttpTimelinePostPatchRequest
{
- var body = await client.TestPostAsync<HttpTimelinePost>($"timelines/{generator(1)}/posts", TimelineHelper.TextPostCreateRequest("aaa"));
- postId = body.Id;
- }
+ Time = date,
+ Color = "#aabbcc"
+ });
+ post2.Time.Should().Be(date);
+ post2.Color.Should().Be("#aabbcc");
- await client.TestGetAssertErrorAsync($"timelines/{generator(1)}/posts/{postId}/data", errorCode: ErrorCodes.TimelineController.PostNoData);
+ var post3 = await client.TestGetAsync<HttpTimelinePost>($"timelines/{generator(1)}/posts/{post.Id}");
+ post3.Time.Should().Be(date);
+ post3.Color.Should().Be("#aabbcc");
}
- [Theory]
- [MemberData(nameof(TimelineNameGeneratorTestData))]
- public async Task PostDataETag(TimelineNameGenerator generator)
+ public static IEnumerable<object?[]> CreatePost_InvalidModelTest_TestData()
{
- using var client = await CreateClientAsUser();
-
- long id;
- string etag;
+ var testDataList = new List<List<HttpTimelinePostCreateRequestData>?>()
+ {
+ null,
+ new List<HttpTimelinePostCreateRequestData>(),
+ Enumerable.Repeat<HttpTimelinePostCreateRequestData>(new HttpTimelinePostCreateRequestData
+ {
+ ContentType = "text/plain",
+ Data = Convert.ToBase64String(Encoding.UTF8.GetBytes("a"))
+ }, 200).ToList(),
+ };
+ var testData = new List<HttpTimelinePostCreateRequestData?>()
{
- var body = await client.TestPostAsync<HttpTimelinePost>($"timelines/{generator(1)}/posts", new HttpTimelinePostCreateRequest
+ null,
+ new HttpTimelinePostCreateRequestData
{
- Content = new HttpTimelinePostCreateRequestContent
- {
- Type = TimelinePostContentTypes.Image,
- Data = Convert.ToBase64String(ImageHelper.CreatePngWithSize(100, 50))
- }
- });
- body.Content!.ETag.Should().NotBeNullOrEmpty();
+ ContentType = null!,
+ Data = Convert.ToBase64String(Encoding.UTF8.GetBytes("a"))
+ },
+ new HttpTimelinePostCreateRequestData
+ {
+ ContentType = "text/plain",
+ Data = null!
+ },
+ new HttpTimelinePostCreateRequestData
+ {
+ ContentType = "text/xxxxxxx",
+ Data = Convert.ToBase64String(Encoding.UTF8.GetBytes("a"))
+ },
+ new HttpTimelinePostCreateRequestData
+ {
+ 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 })
+ }
- 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);
- }
+ testDataList.AddRange(testData.Select(d => new List<HttpTimelinePostCreateRequestData>() { d! }));
+
+ return TimelineNameGeneratorTestData().AppendTestData(testDataList);
}
[Theory]
- [MemberData(nameof(TimelineNameGeneratorTestData))]
- public async Task Color(TimelineNameGenerator generator)
+ [MemberData(nameof(CreatePost_InvalidModelTest_TestData))]
+ public async Task CreatePost_InvalidModel(TimelineNameGenerator generator, List<HttpTimelinePostCreateRequestData> dataList)
{
using var client = await CreateClientAsUser();
- HttpTimelinePostCreateRequestContent CreateRequestContent() => new()
+ await client.TestPostAssertInvalidModelAsync(
+ $"timelines/{generator(1)}/posts",
+ new HttpTimelinePostCreateRequest
+ {
+ DataList = dataList
+ }
+ );
+ }
+
+ public static IEnumerable<object?[]> CreatePost_ShouldWork_TestData()
+ {
+ var testByteDatas = new List<ByteData>()
{
- Type = "text",
- Text = "aaa"
+ 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),
};
- await client.TestPostAssertInvalidModelAsync($"timelines/{generator(1)}/posts", new HttpTimelinePostCreateRequest
- {
- Content = CreateRequestContent(),
- Color = "#1"
- });
+ return TimelineNameGeneratorTestData().AppendTestData(testByteDatas);
+ }
- long id;
+ [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<HttpTimelinePost>($"timelines/{generator(1)}/posts", new HttpTimelinePostCreateRequest
+ var post = await client.TestPostAsync<HttpTimelinePost>(
+ $"timelines/{generator(1)}/posts",
+ new HttpTimelinePostCreateRequest
{
- Content = CreateRequestContent(),
- Color = "#aabbcc"
- });
- post.Color.Should().Be("#aabbcc");
- id = post.Id;
+ DataList = new List<HttpTimelinePostCreateRequestData>
+ {
+ 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 post = await client.TestGetAsync<HttpTimelinePost>($"timelines/{generator(1)}/posts/{id}");
- post.Color.Should().Be("#aabbcc");
+ 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);
}
}
[Theory]
[MemberData(nameof(TimelineNameGeneratorTestData))]
- public async Task GetPost(TimelineNameGenerator generator)
+ public async Task CreatePost_MultipleData_ShouldWork(TimelineNameGenerator generator)
{
using var client = await CreateClientAsUser();
- HttpTimelinePostCreateRequestContent CreateRequestContent() => new()
- {
- Type = "text",
- Text = "aaa"
- };
+ var textData = Encoding.UTF8.GetBytes("aaa");
+ var imageData = ImageHelper.CreatePngWithSize(100, 50);
- await client.TestGetAssertNotFoundAsync($"timelines/{generator(1)}/posts/1");
+ var post = await client.TestPostAsync<HttpTimelinePost>(
+ $"timelines/{generator(1)}/posts",
+ new HttpTimelinePostCreateRequest
+ {
+ DataList = new List<HttpTimelinePostCreateRequestData>
+ {
+ new HttpTimelinePostCreateRequestData
+ {
+ ContentType = MimeTypes.TextMarkdown,
+ Data = Convert.ToBase64String(textData)
+ },
+ new HttpTimelinePostCreateRequestData
+ {
+ ContentType = MimeTypes.ImagePng,
+ Data = Convert.ToBase64String(imageData)
+ }
+ }
+ }
+ );
- var post = await client.TestPostAsync<HttpTimelinePost>($"timelines/{generator(1)}/posts", new HttpTimelinePostCreateRequest
- {
- Content = CreateRequestContent(),
- });
+ post.DataList.Should().NotBeNull().And.HaveCount(2);
- var post2 = await client.TestGetAsync<HttpTimelinePost>($"timelines/{generator(1)}/posts/{post.Id}");
- post2.Should().BeEquivalentTo(post);
+ var postData0 = post.DataList[0];
+ postData0.Should().NotBeNull();
+ var postDataEtag0 = postData0.ETag;
+ postDataEtag0.Should().NotBeNullOrEmpty();
- await client.TestDeleteAsync($"timelines/{generator(1)}/posts/{post.Id}");
+ var postData1 = post.DataList[1];
+ postData1.Should().NotBeNull();
+ var postDataEtag1 = postData1.ETag;
+ postDataEtag1.Should().NotBeNullOrEmpty();
- await client.TestGetAssertNotFoundAsync($"timelines/{generator(1)}/posts/{post.Id}");
- }
+ {
+ 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(postDataEtag0);
+ response.Content.Headers.ContentType.Should().NotBeNull();
+ response.Content.Headers.ContentType!.MediaType.Should().Be(MimeTypes.TextMarkdown);
- [Theory]
- [MemberData(nameof(TimelineNameGeneratorTestData))]
- public async Task PatchPost(TimelineNameGenerator generator)
- {
- using var client = await CreateClientAsUser();
+ var body = await response.Content.ReadAsByteArrayAsync();
+ body.Should().Equal(textData);
+ }
- var post = await client.TestPostAsync<HttpTimelinePost>($"timelines/{generator(1)}/posts", new HttpTimelinePostCreateRequest
{
- Content = new()
- {
- Type = "text",
- Text = "aaa"
- }
- });
+ 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(postDataEtag0);
+ response.Content.Headers.ContentType.Should().NotBeNull();
+ response.Content.Headers.ContentType!.MediaType.Should().Be(MimeTypes.TextMarkdown);
- var date = new DateTime(2000, 10, 1);
+ var body = await response.Content.ReadAsByteArrayAsync();
+ body.Should().Equal(textData);
+ }
- var post2 = await client.TestPatchAsync<HttpTimelinePost>($"timelines/{generator(1)}/posts/{post.Id}", new HttpTimelinePostPatchRequest
{
- Time = date,
- Color = "#aabbcc"
- });
- post2.Time.Should().Be(date);
- post2.Color.Should().Be("#aabbcc");
+ var response = await client.GetAsync($"timelines/{generator(1)}/posts/{post.Id}/data/1");
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ response.Headers.ETag.Should().NotBeNull();
+ response.Headers.ETag!.Tag.Should().Be(postDataEtag1);
+ response.Content.Headers.ContentType.Should().NotBeNull();
+ response.Content.Headers.ContentType!.MediaType.Should().Be(MimeTypes.ImagePng);
- var post3 = await client.TestGetAsync<HttpTimelinePost>($"timelines/{generator(1)}/posts/{post.Id}");
- post3.Time.Should().Be(date);
- post3.Color.Should().Be("#aabbcc");
+ var body = await response.Content.ReadAsByteArrayAsync();
+ body.Should().Equal(imageData);
+ }
}
}
}
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.Tests/XUnitHelper.cs b/BackEnd/Timeline.Tests/XUnitHelper.cs new file mode 100644 index 00000000..a2812ad3 --- /dev/null +++ b/BackEnd/Timeline.Tests/XUnitHelper.cs @@ -0,0 +1,44 @@ +using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Timeline.Tests
+{
+ public static class XUnitHelper
+ {
+ public static IEnumerable<object?[]> ComposeTestData(params IEnumerable<object?[]>[] testDatas)
+ {
+ return ComposeTestData(new ArraySegment<IEnumerable<object?[]>>(testDatas));
+ }
+
+ public static IEnumerable<object?[]> ComposeTestData(ArraySegment<IEnumerable<object?[]>> testDatas)
+ {
+ if (testDatas.Count == 0)
+ throw new ArgumentException("Test data list can't be empty.", nameof(testDatas));
+
+ if (testDatas.Count == 1)
+ {
+ foreach (var d in testDatas[0])
+ yield return d;
+ }
+ else
+ {
+ foreach (var head in testDatas[0])
+ foreach (var rest in ComposeTestData(testDatas.Slice(1)))
+ yield return head.Concat(rest).ToArray();
+ }
+ }
+
+ public static IEnumerable<object?[]> AppendTestData(this IEnumerable<object?[]> origin, params IEnumerable<object?>[] toAppend)
+ {
+ IEnumerable<object?[]> result = origin;
+
+ foreach (var oneToAppend in toAppend)
+ {
+ result = ComposeTestData(result, oneToAppend.Select(testData => new object?[] { testData }));
+ }
+
+ return result;
+ }
+ }
+}
diff --git a/BackEnd/Timeline/Controllers/TimelinePostController.cs b/BackEnd/Timeline/Controllers/TimelinePostController.cs index 44498c58..6904e28d 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,36 +88,45 @@ 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;
}
/// <summary>
- /// Get the data of a post. Usually a image post.
+ /// Get the first data of a post.
/// </summary>
/// <param name="timeline">Timeline name.</param>
/// <param name="post">The id of the post.</param>
- /// <param name="ifNoneMatch">If-None-Match header.</param>
/// <returns>The data.</returns>
[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<IActionResult> DataGet([FromRoute][GeneralTimelineName] string timeline, [FromRoute] long post, [FromHeader(Name = "If-None-Match")] string? ifNoneMatch)
+ public async Task<ActionResult<ByteData>> DataIndexGet([FromRoute][GeneralTimelineName] string timeline, [FromRoute] long post)
{
- _ = ifNoneMatch;
+ return await DataGet(timeline, post, 0);
+ }
+ /// <summary>
+ /// Get the data of a post. Usually a image post.
+ /// </summary>
+ /// <param name="timeline">Timeline name.</param>
+ /// <param name="post">The id of the post.</param>
+ /// <param name="dataIndex">Index of the data.</param>
+ /// <returns>The data.</returns>
+ [HttpGet("{post}/data/{data_index}")]
+ [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<ActionResult> 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()))
@@ -123,20 +134,10 @@ namespace Timeline.Controllers 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 DataCacheHelper.GenerateActionResult(this,
+ () => _postService.GetPostDataDigest(timelineId, post, dataIndex),
+ () => _postService.GetPostData(timelineId, post, dataIndex)
+ );
}
/// <summary>
@@ -161,50 +162,40 @@ namespace Timeline.Controllers return StatusCode(StatusCodes.Status403Forbidden, ErrorResponse.Common.Forbid());
}
- var requestContent = body.Content;
-
- TimelinePostCreateRequestContent createContent;
-
- switch (requestContent.Type)
+ var createRequest = new TimelinePostCreateRequest()
{
- case TimelinePostContentTypes.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:
- if (requestContent.Data is null)
- return BadRequest(ErrorResponse.Common.CustomMessage_InvalidModel(Resources.Messages.TimelineController_ImageContentDataRequired));
+ Time = body.Time,
+ Color = body.Color
+ };
- // decode base64
- byte[] data;
- try
- {
- data = Convert.FromBase64String(requestContent.Data);
- }
- catch (FormatException)
- {
- return BadRequest(ErrorResponse.Common.CustomMessage_InvalidModel(Resources.Messages.TimelineController_ImageContentDataNotBase64));
- }
+ for (int i = 0; i < body.DataList.Count; i++)
+ {
+ var data = body.DataList[i];
- createContent = new TimelinePostCreateRequestImageContent(data);
- break;
- default:
- return BadRequest(ErrorResponse.Common.CustomMessage_InvalidModel(Resources.Messages.TimelineController_ContentUnknownType));
+ if (data is null)
+ return BadRequest(new CommonResponse(ErrorCodes.Common.InvalidModel, $"Data at index {i} is null."));
+ 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}"));
}
}
@@ -225,21 +216,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);
}
/// <summary>
@@ -258,19 +243,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/Controllers/UserAvatarController.cs b/BackEnd/Timeline/Controllers/UserAvatarController.cs index f3b7fff8..fa13f0f6 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));
}
/// <summary>
@@ -105,16 +102,12 @@ namespace Timeline.Controllers try
{
- var etag = await _service.SetAvatar(id, new Avatar
- {
- Data = body.Data,
- Type = body.ContentType
- });
+ 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", $"\"{digest.ETag}\"");
return Ok();
}
@@ -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..8ccdabb5 100644 --- a/BackEnd/Timeline/Entities/DatabaseContext.cs +++ b/BackEnd/Timeline/Entities/DatabaseContext.cs @@ -28,11 +28,14 @@ namespace Timeline.Entities public DbSet<UserPermissionEntity> UserPermission { get; set; } = default!;
public DbSet<TimelineEntity> Timelines { get; set; } = default!;
public DbSet<TimelinePostEntity> TimelinePosts { get; set; } = default!;
+ public DbSet<TimelinePostDataEntity> TimelinePostData { get; set; } = default!;
public DbSet<TimelineMemberEntity> TimelineMembers { get; set; } = default!;
public DbSet<HighlightTimelineEntity> HighlightTimelines { get; set; } = default!;
public DbSet<BookmarkTimelineEntity> BookmarkTimelines { get; set; } = default!;
public DbSet<JwtTokenEntity> JwtToken { get; set; } = default!;
public DbSet<DataEntity> Data { get; set; } = default!;
+
+ public DbSet<MigrationEntity> 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/TimelinePostDataEntity.cs b/BackEnd/Timeline/Entities/TimelinePostDataEntity.cs new file mode 100644 index 00000000..9bc5d3e8 --- /dev/null +++ b/BackEnd/Timeline/Entities/TimelinePostDataEntity.cs @@ -0,0 +1,32 @@ +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; }
+
+ [Required]
+ [Column("post")]
+ public long PostId { get; set; }
+
+ [ForeignKey(nameof(PostId))]
+ public TimelinePostEntity Post { 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..451f56cd 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,15 +26,21 @@ namespace Timeline.Entities [ForeignKey(nameof(AuthorId))]
public UserEntity? Author { get; set; } = default!;
- [Column("content_type"), Required]
- public string ContentType { 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; }
+ [Column("deleted")]
+ public bool Deleted { get; set; }
+
[Column("color")]
public string? Color { get; set; }
@@ -42,5 +49,9 @@ namespace Timeline.Entities [Column("last_updated")]
public DateTime LastUpdated { get; set; }
+
+#pragma warning disable CA2227
+ public List<TimelinePostDataEntity> DataList { get; set; } = default!;
+#pragma warning restore CA2227
}
}
diff --git a/BackEnd/Timeline/Filters/Timeline.cs b/BackEnd/Timeline/Filters/CatchTimelineNotExistExceptionAttribute.cs index 6a730ee7..857d1d2b 100644 --- a/BackEnd/Timeline/Filters/Timeline.cs +++ b/BackEnd/Timeline/Filters/CatchTimelineNotExistExceptionAttribute.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Timeline.Models.Http;
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/Formatters/BytesInputFormatter.cs b/BackEnd/Timeline/Formatters/ByteDataInputFormatter.cs index ac6537c9..49f8221a 100644 --- a/BackEnd/Timeline/Formatters/BytesInputFormatter.cs +++ b/BackEnd/Timeline/Formatters/ByteDataInputFormatter.cs @@ -1,7 +1,6 @@ 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;
@@ -9,19 +8,21 @@ using Timeline.Models; namespace Timeline.Formatters
{
/// <summary>
- /// Formatter that reads body as bytes.
+ /// Formatter that reads body as byte data.
/// </summary>
- public class BytesInputFormatter : InputFormatter
+ public class ByteDataInputFormatter : InputFormatter
{
/// <summary>
///
/// </summary>
- public BytesInputFormatter()
+ public ByteDataInputFormatter()
{
- SupportedMediaTypes.Add(new MediaTypeHeaderValue("image/png"));
- SupportedMediaTypes.Add(new MediaTypeHeaderValue("image/jpeg"));
- SupportedMediaTypes.Add(new MediaTypeHeaderValue("image/gif"));
- SupportedMediaTypes.Add(new MediaTypeHeaderValue("image/webp"));
+ SupportedMediaTypes.Add(MimeTypes.ImagePng);
+ SupportedMediaTypes.Add(MimeTypes.ImageJpeg);
+ SupportedMediaTypes.Add(MimeTypes.ImageGif);
+ SupportedMediaTypes.Add(MimeTypes.ImageWebp);
+ SupportedMediaTypes.Add(MimeTypes.TextPlain);
+ SupportedMediaTypes.Add(MimeTypes.TextMarkdown);
}
/// <inheritdoc/>
@@ -41,7 +42,13 @@ namespace Timeline.Formatters var request = context.HttpContext.Request;
var contentLength = request.ContentLength;
- var logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<BytesInputFormatter>>();
+ var logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<ByteDataInputFormatter>>();
+
+ 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)
{
diff --git a/BackEnd/Timeline/Helpers/Cache/CacheableDataDigest.cs b/BackEnd/Timeline/Helpers/Cache/CacheableDataDigest.cs new file mode 100644 index 00000000..18a6c894 --- /dev/null +++ b/BackEnd/Timeline/Helpers/Cache/CacheableDataDigest.cs @@ -0,0 +1,16 @@ +using System;
+
+namespace Timeline.Helpers.Cache
+{
+ public class CacheableDataDigest : ICacheableDataDigest
+ {
+ 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..b7d86b18 --- /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<ActionResult> 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<ActionResult> GenerateActionResult(Controller controller, Func<Task<ICacheableDataDigest>> getDigestDelegate, Func<Task<ByteData>> 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<Task<ICacheableDataDigest>> _getDigestDelegate;
+ private readonly Func<Task<ByteData>> _getDataDelegate;
+
+ public DelegateCacheableDataProvider(Func<Task<ICacheableDataDigest>> getDigestDelegate, Func<Task<ByteData>> getDataDelegate)
+ {
+ _getDigestDelegate = getDigestDelegate;
+ _getDataDelegate = getDataDelegate;
+ }
+
+ public Task<ICacheableDataDigest> GetDigest()
+ {
+ return _getDigestDelegate();
+ }
+
+ public Task<ByteData> 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<ICacheableDataDigest> GetDigest();
+ Task<ByteData> 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<string> GetDataETag();
- Task<ICacheableData> GetData();
- }
-
- public class DelegateCacheableDataProvider : ICacheableDataProvider
- {
- private readonly Func<Task<string>> _getDataETagDelegate;
- private readonly Func<Task<ICacheableData>> _getDataDelegate;
-
- public DelegateCacheableDataProvider(Func<Task<string>> getDataETagDelegate, Func<Task<ICacheableData>> getDataDelegate)
- {
- _getDataETagDelegate = getDataETagDelegate;
- _getDataDelegate = getDataDelegate;
- }
-
- public Task<ICacheableData> GetData()
- {
- return _getDataDelegate();
- }
-
- public Task<string> GetDataETag()
- {
- return _getDataETagDelegate();
- }
- }
-
- public static class DataCacheHelper
- {
- public static async Task<ActionResult> 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<ILoggerFactory>();
- 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<ActionResult> GenerateActionResult(Controller controller, Func<Task<string>> getDataETagDelegate, Func<Task<ICacheableData>> 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..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 = '{TimelinePostContentTypes.Text}';
+SET content_type = 'text';
");
}
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 @@ +// <auto-generated />
+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<long>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasColumnName("id");
+
+ b.Property<long>("Rank")
+ .HasColumnType("INTEGER")
+ .HasColumnName("rank");
+
+ b.Property<long>("TimelineId")
+ .HasColumnType("INTEGER")
+ .HasColumnName("timeline");
+
+ b.Property<long>("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<long>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasColumnName("id");
+
+ b.Property<byte[]>("Data")
+ .IsRequired()
+ .HasColumnType("BLOB")
+ .HasColumnName("data");
+
+ b.Property<int>("Ref")
+ .HasColumnType("INTEGER")
+ .HasColumnName("ref");
+
+ b.Property<string>("Tag")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("tag");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Tag")
+ .IsUnique();
+
+ b.ToTable("data");
+ });
+
+ modelBuilder.Entity("Timeline.Entities.HighlightTimelineEntity", b =>
+ {
+ b.Property<long>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("AddTime")
+ .HasColumnType("TEXT")
+ .HasColumnName("add_time");
+
+ b.Property<long?>("OperatorId")
+ .HasColumnType("INTEGER")
+ .HasColumnName("operator_id");
+
+ b.Property<long>("Order")
+ .HasColumnType("INTEGER")
+ .HasColumnName("order");
+
+ b.Property<long>("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<long>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasColumnName("id");
+
+ b.Property<byte[]>("Key")
+ .IsRequired()
+ .HasColumnType("BLOB")
+ .HasColumnName("key");
+
+ b.HasKey("Id");
+
+ b.ToTable("jwt_token");
+ });
+
+ modelBuilder.Entity("Timeline.Entities.MigrationEntity", b =>
+ {
+ b.Property<long>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasColumnName("id");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("name");
+
+ b.HasKey("Id");
+
+ b.ToTable("migrations");
+ });
+
+ modelBuilder.Entity("Timeline.Entities.TimelineEntity", b =>
+ {
+ b.Property<long>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("TEXT")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("CreateTime")
+ .HasColumnType("TEXT")
+ .HasColumnName("create_time");
+
+ b.Property<long>("CurrentPostLocalId")
+ .HasColumnType("INTEGER")
+ .HasColumnName("current_post_local_id");
+
+ b.Property<string>("Description")
+ .HasColumnType("TEXT")
+ .HasColumnName("description");
+
+ b.Property<DateTime>("LastModified")
+ .HasColumnType("TEXT")
+ .HasColumnName("last_modified");
+
+ b.Property<string>("Name")
+ .HasColumnType("TEXT")
+ .HasColumnName("name");
+
+ b.Property<DateTime>("NameLastModified")
+ .HasColumnType("TEXT")
+ .HasColumnName("name_last_modified");
+
+ b.Property<long>("OwnerId")
+ .HasColumnType("INTEGER")
+ .HasColumnName("owner");
+
+ b.Property<string>("Title")
+ .HasColumnType("TEXT")
+ .HasColumnName("title");
+
+ b.Property<string>("UniqueId")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasColumnName("unique_id")
+ .HasDefaultValueSql("lower(hex(randomblob(16)))");
+
+ b.Property<int>("Visibility")
+ .HasColumnType("INTEGER")
+ .HasColumnName("visibility");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OwnerId");
+
+ b.ToTable("timelines");
+ });
+
+ modelBuilder.Entity("Timeline.Entities.TimelineMemberEntity", b =>
+ {
+ b.Property<long>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasColumnName("id");
+
+ b.Property<long>("TimelineId")
+ .HasColumnType("INTEGER")
+ .HasColumnName("timeline");
+
+ b.Property<long>("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<long>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasColumnName("id");
+
+ b.Property<string>("DataTag")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("data_tag");
+
+ b.Property<long>("Index")
+ .HasColumnType("INTEGER")
+ .HasColumnName("index");
+
+ b.Property<string>("Kind")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("kind");
+
+ b.Property<DateTime>("LastUpdated")
+ .HasColumnType("TEXT")
+ .HasColumnName("last_updated");
+
+ b.Property<long>("PostId")
+ .HasColumnType("INTEGER")
+ .HasColumnName("post");
+
+ b.HasKey("Id");
+
+ b.HasIndex("PostId");
+
+ b.ToTable("timeline_post_data");
+ });
+
+ modelBuilder.Entity("Timeline.Entities.TimelinePostEntity", b =>
+ {
+ b.Property<long>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasColumnName("id");
+
+ b.Property<long?>("AuthorId")
+ .HasColumnType("INTEGER")
+ .HasColumnName("author");
+
+ b.Property<string>("Color")
+ .HasColumnType("TEXT")
+ .HasColumnName("color");
+
+ b.Property<string>("Content")
+ .HasColumnType("TEXT")
+ .HasColumnName("content");
+
+ b.Property<string>("ContentType")
+ .HasColumnType("TEXT")
+ .HasColumnName("content_type");
+
+ b.Property<bool>("Deleted")
+ .HasColumnType("INTEGER")
+ .HasColumnName("deleted");
+
+ b.Property<string>("ExtraContent")
+ .HasColumnType("TEXT")
+ .HasColumnName("extra_content");
+
+ b.Property<DateTime>("LastUpdated")
+ .HasColumnType("TEXT")
+ .HasColumnName("last_updated");
+
+ b.Property<long>("LocalId")
+ .HasColumnType("INTEGER")
+ .HasColumnName("local_id");
+
+ b.Property<DateTime>("Time")
+ .HasColumnType("TEXT")
+ .HasColumnName("time");
+
+ b.Property<long>("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<long>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasColumnName("id");
+
+ b.Property<string>("DataTag")
+ .HasColumnType("TEXT")
+ .HasColumnName("data_tag");
+
+ b.Property<DateTime>("LastModified")
+ .HasColumnType("TEXT")
+ .HasColumnName("last_modified");
+
+ b.Property<string>("Type")
+ .HasColumnType("TEXT")
+ .HasColumnName("type");
+
+ b.Property<long>("UserId")
+ .HasColumnType("INTEGER")
+ .HasColumnName("user");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId")
+ .IsUnique();
+
+ b.ToTable("user_avatars");
+ });
+
+ modelBuilder.Entity("Timeline.Entities.UserEntity", b =>
+ {
+ b.Property<long>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("CreateTime")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasColumnName("create_time")
+ .HasDefaultValueSql("datetime('now', 'utc')");
+
+ b.Property<DateTime>("LastModified")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasColumnName("last_modified")
+ .HasDefaultValueSql("datetime('now', 'utc')");
+
+ b.Property<string>("Nickname")
+ .HasColumnType("TEXT")
+ .HasColumnName("nickname");
+
+ b.Property<string>("Password")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("password");
+
+ b.Property<string>("UniqueId")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasColumnName("unique_id")
+ .HasDefaultValueSql("lower(hex(randomblob(16)))");
+
+ b.Property<string>("Username")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("username");
+
+ b.Property<DateTime>("UsernameChangeTime")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasColumnName("username_change_time")
+ .HasDefaultValueSql("datetime('now', 'utc')");
+
+ b.Property<long>("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<long>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasColumnName("id");
+
+ b.Property<string>("Permission")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("permission");
+
+ b.Property<long>("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<string>(
+ name: "content_type",
+ table: "timeline_posts",
+ type: "TEXT",
+ nullable: true,
+ oldClrType: typeof(string),
+ oldType: "TEXT");
+
+ migrationBuilder.AddColumn<bool>(
+ name: "deleted",
+ table: "timeline_posts",
+ type: "INTEGER",
+ nullable: false,
+ defaultValue: false);
+
+ migrationBuilder.CreateTable(
+ name: "migrations",
+ columns: table => new
+ {
+ id = table.Column<long>(type: "INTEGER", nullable: false)
+ .Annotation("Sqlite:Autoincrement", true),
+ name = table.Column<string>(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<long>(type: "INTEGER", nullable: false)
+ .Annotation("Sqlite:Autoincrement", true),
+ post = table.Column<long>(type: "INTEGER", nullable: false),
+ index = table.Column<long>(type: "INTEGER", nullable: false),
+ kind = table.Column<string>(type: "TEXT", nullable: false),
+ data_tag = table.Column<string>(type: "TEXT", nullable: false),
+ last_updated = table.Column<DateTime>(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<string>(
+ 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<long>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasColumnName("id");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("name");
+
+ b.HasKey("Id");
+
+ b.ToTable("migrations");
+ });
+
modelBuilder.Entity("Timeline.Entities.TimelineEntity", b =>
{
b.Property<long>("Id")
@@ -207,6 +224,42 @@ namespace Timeline.Migrations b.ToTable("timeline_members");
});
+ modelBuilder.Entity("Timeline.Entities.TimelinePostDataEntity", b =>
+ {
+ b.Property<long>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasColumnName("id");
+
+ b.Property<string>("DataTag")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("data_tag");
+
+ b.Property<long>("Index")
+ .HasColumnType("INTEGER")
+ .HasColumnName("index");
+
+ b.Property<string>("Kind")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("kind");
+
+ b.Property<DateTime>("LastUpdated")
+ .HasColumnType("TEXT")
+ .HasColumnName("last_updated");
+
+ b.Property<long>("PostId")
+ .HasColumnType("INTEGER")
+ .HasColumnName("post");
+
+ b.HasKey("Id");
+
+ b.HasIndex("PostId");
+
+ b.ToTable("timeline_post_data");
+ });
+
modelBuilder.Entity("Timeline.Entities.TimelinePostEntity", b =>
{
b.Property<long>("Id")
@@ -227,10 +280,13 @@ namespace Timeline.Migrations .HasColumnName("content");
b.Property<string>("ContentType")
- .IsRequired()
.HasColumnType("TEXT")
.HasColumnName("content_type");
+ b.Property<bool>("Deleted")
+ .HasColumnType("INTEGER")
+ .HasColumnName("deleted");
+
b.Property<string>("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");
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 /// <param name="contentType">The content type.</param>
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/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/HttpTimelinePost.cs b/BackEnd/Timeline/Models/Http/HttpTimelinePost.cs index 5981d7a4..26e1a92d 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
{
+
/// <summary>
/// Info of a post.
/// </summary>
@@ -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<HttpTimelinePostDataDigest> dataList, bool deleted, DateTime time, HttpUser? author, string? color, DateTime lastUpdated)
{
Id = id;
- Content = content;
+ DataList = dataList;
Deleted = deleted;
Time = time;
Author = author;
@@ -25,9 +27,11 @@ namespace Timeline.Models.Http /// </summary>
public long Id { get; set; }
/// <summary>
- /// Content of the post. May be null if post is deleted.
+ /// The data list.
/// </summary>
- public HttpTimelinePostContent? Content { get; set; }
+#pragma warning disable CA2227
+ public List<HttpTimelinePostDataDigest> DataList { get; set; } = default!;
+#pragma warning restore CA2227
/// <summary>
/// True if post is deleted.
/// </summary>
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
-{
- /// <summary>
- /// Info of post content.
- /// </summary>
- public class HttpTimelinePostContent
- {
- public HttpTimelinePostContent() { }
-
- public HttpTimelinePostContent(string type, string? text, string? url, string? eTag)
- {
- Type = type;
- Text = text;
- Url = url;
- ETag = eTag;
- }
-
- /// <summary>
- /// Type of the post content.
- /// </summary>
- public string Type { get; set; } = default!;
- /// <summary>
- /// If post is of text type. This is the text.
- /// </summary>
- public string? Text { get; set; }
- /// <summary>
- /// If post is of image type. This is the image url.
- /// </summary>
- public string? Url { get; set; }
- /// <summary>
- /// If post has data (currently it means it's a image post), this is the data etag.
- /// </summary>
- public string? ETag { get; set; }
- }
-}
diff --git a/BackEnd/Timeline/Models/Http/HttpTimelinePostCreateRequest.cs b/BackEnd/Timeline/Models/Http/HttpTimelinePostCreateRequest.cs index b25adf36..2a973c72 100644 --- a/BackEnd/Timeline/Models/Http/HttpTimelinePostCreateRequest.cs +++ b/BackEnd/Timeline/Models/Http/HttpTimelinePostCreateRequest.cs @@ -1,4 +1,5 @@ using System;
+using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using Timeline.Models.Validation;
@@ -7,10 +8,14 @@ namespace Timeline.Models.Http public class HttpTimelinePostCreateRequest
{
/// <summary>
- /// Content of the new post.
+ /// Data list of the new content.
/// </summary>
[Required]
- public HttpTimelinePostCreateRequestContent Content { get; set; } = default!;
+ [MinLength(1)]
+ [MaxLength(100)]
+#pragma warning disable CA2227
+ public List<HttpTimelinePostCreateRequestData> DataList { get; set; } = default!;
+#pragma warning restore CA2227
/// <summary>
/// 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
-{
- /// <summary>
- /// Content of post create request.
- /// </summary>
- public class HttpTimelinePostCreateRequestContent
- {
- /// <summary>
- /// Type of post content.
- /// </summary>
- [Required]
- [TimelinePostContentType]
- public string Type { get; set; } = default!;
- /// <summary>
- /// If post is of text type, this is the text.
- /// </summary>
- public string? Text { get; set; }
- /// <summary>
- /// If post is of image type, this is base64 of image data.
- /// </summary>
- public string? Data { get; set; }
- }
-}
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
+ {
+ /// <summary>
+ /// Mime type of the data.
+ /// </summary>
+ [Required]
+ public string ContentType { get; set; } = default!;
+
+ /// <summary>
+ /// Base64 of data.
+ /// </summary>
+ [Required]
+ public string Data { get; set; } = default!;
+ }
+}
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..5c46fa81 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,14 @@ namespace Timeline.Models.Mapper public async Task<HttpTimelinePost> 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))
- };
- }
+ _ = timelineName;
+ await _database.Entry(entity).Collection(p => p.DataList).LoadAsync();
await _database.Entry(entity).Reference(e => e.Author).LoadAsync();
- HttpUser? author = null;
+ List<HttpTimelinePostDataDigest> 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 +81,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/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 ca5e79e1..00000000 --- a/BackEnd/Timeline/Models/TimelinePostContentTypes.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Collections.Generic;
-
-namespace Timeline.Models
-{
- public static class TimelinePostContentTypes
- {
-#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 const string Text = "text";
- public const string Image = "image";
- }
-}
diff --git a/BackEnd/Timeline/Models/Validation/TimelinePostContentTypeValidator.cs b/BackEnd/Timeline/Models/Validation/TimelinePostContentTypeValidator.cs deleted file mode 100644 index 483cce06..00000000 --- a/BackEnd/Timeline/Models/Validation/TimelinePostContentTypeValidator.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System;
-
-namespace Timeline.Models.Validation
-{
- public class TimelinePostContentTypeValidator : StringSetValidator
- {
- public TimelinePostContentTypeValidator() : base(TimelinePostContentTypes.AllTypes) { }
- }
-
- [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter, AllowMultiple = false)]
- public class TimelinePostContentTypeAttribute : ValidateWithAttribute
- {
- public TimelinePostContentTypeAttribute() : base(typeof(TimelinePostContentTypeValidator))
- {
-
- }
- }
-}
diff --git a/BackEnd/Timeline/Program.cs b/BackEnd/Timeline/Program.cs index 75bf6154..19fa6e37 100644 --- a/BackEnd/Timeline/Program.cs +++ b/BackEnd/Timeline/Program.cs @@ -4,8 +4,10 @@ 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;
[assembly: NeutralResourcesLanguage("en")]
@@ -13,22 +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 env = host.Services.GetRequiredService<IWebHostEnvironment>();
-
var databaseBackupService = host.Services.GetRequiredService<IDatabaseBackupService>();
databaseBackupService.BackupNow();
- if (env.IsProduction())
+ using (var scope = host.Services.CreateScope())
{
- using (var scope = host.Services.CreateScope())
- {
- var databaseContext = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
- databaseContext.Database.Migrate();
- }
+ var databaseContext = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
+ await databaseContext.Database.MigrateAsync();
+ var customMigrationManager = scope.ServiceProvider.GetRequiredService<ICustomMigrationManager>();
+ 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/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 /// <exception cref="ArgumentException">Thrown when <paramref name="username"/> is of bad format.</exception>
/// <exception cref="UserNotExistException">Thrown when the user with given username does not exist.</exception>
Task<long> GetUserIdByUsername(string username);
+
+ /// <summary>
+ /// Get the username modified time of a user.
+ /// </summary>
+ /// <param name="userId">User id.</param>
+ /// <returns>The time.</returns>
+ /// <exception cref="UserNotExistException">Thrown when user does not exist.</exception>
+ Task<DateTime> GetUsernameLastModifiedTime(long userId);
}
public class BasicUserService : IBasicUserService
@@ -62,5 +70,26 @@ namespace Timeline.Services return entity.Id;
}
+
+ public async Task<DateTime> 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..b697630c 100644 --- a/BackEnd/Timeline/Services/DataManager.cs +++ b/BackEnd/Timeline/Services/DataManager.cs @@ -22,29 +22,30 @@ namespace Timeline.Services /// increases its ref count and returns a tag to the entry.
/// </summary>
/// <param name="data">The data. Can't be null.</param>
+ /// <param name="saveDatabaseChange">If true save database change. Otherwise it does not save database change.</param>
/// <returns>The tag of the created entry.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="data"/> is null.</exception>
- public Task<string> RetainEntry(byte[] data);
+ public Task<string> RetainEntry(byte[] data, bool saveDatabaseChange = true);
/// <summary>
/// Decrease the the ref count of the entry.
/// Remove it if ref count is zero.
/// </summary>
/// <param name="tag">The tag of the entry.</param>
+ /// <param name="saveDatabaseChange">If true save database change. Otherwise it does not save database change.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="tag"/> is null.</exception>
/// <remarks>
/// It's no-op if entry with tag does not exist.
/// </remarks>
- public Task FreeEntry(string tag);
+ public Task FreeEntry(string tag, bool saveDatabaseChange = true);
/// <summary>
- /// Retrieve the entry with given tag.
+ /// Retrieve the entry with given tag. If not exist, returns null.
/// </summary>
/// <param name="tag">The tag of the entry.</param>
- /// <returns>The data of the entry.</returns>
+ /// <returns>The data of the entry. If not exist, returns null.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="tag"/> is null.</exception>
- /// <exception cref="InvalidOperationException">Thrown when entry with given tag does not exist.</exception>
- public Task<byte[]> GetEntry(string tag);
+ public Task<byte[]?> GetEntry(string tag);
}
public class DataManager : IDataManager
@@ -58,7 +59,7 @@ namespace Timeline.Services _eTagGenerator = eTagGenerator;
}
- public async Task<string> RetainEntry(byte[] data)
+ public async Task<string> RetainEntry(byte[] data, bool saveDatabaseChange = true)
{
if (data == null)
throw new ArgumentNullException(nameof(data));
@@ -81,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));
@@ -102,21 +106,37 @@ namespace Timeline.Services {
entity.Ref -= 1;
}
- await _database.SaveChangesAsync();
+
+ if (saveDatabaseChange)
+ await _database.SaveChangesAsync();
}
}
- public async Task<byte[]> GetEntry(string tag)
+ public async Task<byte[]?> 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
+ {
+ /// <summary>
+ /// Try to get an entry and throw <see cref="DatabaseCorruptedException"/> if not exist.
+ /// </summary>
+ public static async Task<byte[]> 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/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/Migration/CustomMigrationManager.cs b/BackEnd/Timeline/Services/Migration/CustomMigrationManager.cs new file mode 100644 index 00000000..f6f156cc --- /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<ICustomMigration> _migrations;
+ private DatabaseContext _database;
+
+ private ILogger<CustomMigrationManager> _logger;
+
+ public CustomMigrationManager(IEnumerable<ICustomMigration> migrations, DatabaseContext database, ILogger<CustomMigrationManager> 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.LogWarning("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.LogWarning("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<ICustomMigrationManager, CustomMigrationManager>();
+ services.AddScoped<ICustomMigration, TimelinePostContentToDataMigration>();
+ 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/Services/TimelinePostCreateDataException.cs b/BackEnd/Timeline/Services/TimelinePostCreateDataException.cs new file mode 100644 index 00000000..10a09de7 --- /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($"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) { }
+
+ public long Index { get; }
+ }
+}
diff --git a/BackEnd/Timeline/Services/TimelinePostDataNotExistException.cs b/BackEnd/Timeline/Services/TimelinePostDataNotExistException.cs new file mode 100644 index 00000000..c70f5d9c --- /dev/null +++ b/BackEnd/Timeline/Services/TimelinePostDataNotExistException.cs @@ -0,0 +1,25 @@ +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) { }
+ 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 66ec8090..62bc43cc 100644 --- a/BackEnd/Timeline/Services/TimelinePostService.cs +++ b/BackEnd/Timeline/Services/TimelinePostService.cs @@ -1,108 +1,49 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
-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;
+using Timeline.Helpers.Cache;
using Timeline.Models;
using Timeline.Models.Validation;
using Timeline.Services.Exceptions;
-using static Timeline.Resources.Services.TimelineService;
namespace Timeline.Services
{
- public class PostData : ICacheableData
+ public class TimelinePostCreateRequestData
{
-#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 abstract class TimelinePostCreateRequestContent
- {
- public abstract string TypeName { get; }
- }
-
- public class TimelinePostCreateRequestTextContent : TimelinePostCreateRequestContent
- {
- private string _text;
-
- public TimelinePostCreateRequestTextContent(string text)
- {
- if (text is null)
- throw new ArgumentNullException(nameof(text));
-
- _text = text;
- }
-
- public override string TypeName => TimelinePostContentTypes.Text;
-
- public string Text
+ public TimelinePostCreateRequestData(string contentType, byte[] data)
{
- get => _text;
- set
- {
- if (value is null)
- throw new ArgumentNullException(nameof(value));
- _text = value;
- }
+ ContentType = contentType;
+ Data = data;
}
- }
-
- public class TimelinePostCreateRequestImageContent : TimelinePostCreateRequestContent
- {
- private byte[] _data;
-
- public TimelinePostCreateRequestImageContent(byte[] data)
- {
- if (data is null)
- throw new ArgumentNullException(nameof(data));
-
- _data = data;
- }
-
- public override string TypeName => TimelinePostContentTypes.Image;
+ public string ContentType { 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; }
/// <summary>If not set, current time is used.</summary>
public DateTime? Time { get; set; }
- public TimelinePostCreateRequestContent Content { get; set; }
+#pragma warning disable CA2227
+ public List<TimelinePostCreateRequestData> DataList { get; set; } = new List<TimelinePostCreateRequestData>();
+#pragma warning restore CA2227
}
public class TimelinePostPatchRequest
{
public string? Color { get; set; }
public DateTime? Time { get; set; }
- public TimelinePostCreateRequestContent? Content { get; set; }
}
public interface ITimelinePostService
@@ -122,34 +63,35 @@ namespace Timeline.Services /// </summary>
/// <param name="timelineId">The id of the timeline of the post.</param>
/// <param name="postId">The id of the post.</param>
- /// <param name="includeDelete">If true, return the entity even if it is deleted.</param>
+ /// <param name="includeDeleted">If true, return the entity even if it is deleted.</param>
/// <returns>The post.</returns>
/// <exception cref="TimelineNotExistException">Thrown when timeline does not exist.</exception>
/// <exception cref="TimelinePostNotExistException">Thrown when post of <paramref name="postId"/> does not exist or has been deleted.</exception>
- Task<TimelinePostEntity> GetPost(long timelineId, long postId, bool includeDelete = false);
+ Task<TimelinePostEntity> GetPost(long timelineId, long postId, bool includeDeleted = false);
/// <summary>
- /// Get the etag of data of a post.
+ /// Get the data digest of a post.
/// </summary>
- /// <param name="timelineId">The id of the timeline of the post.</param>
- /// <param name="postId">The id of the post.</param>
- /// <returns>The etag of the data.</returns>
+ /// <param name="timelineId">The timeline id.</param>
+ /// <param name="postId">The post id.</param>
+ /// <param name="dataIndex">The index of the data.</param>
+ /// <returns>The data digest.</returns>
/// <exception cref="TimelineNotExistException">Thrown when timeline does not exist.</exception>
/// <exception cref="TimelinePostNotExistException">Thrown when post of <paramref name="postId"/> does not exist or has been deleted.</exception>
- /// <exception cref="TimelinePostNoDataException">Thrown when post has no data.</exception>
- Task<string> GetPostDataETag(long timelineId, long postId);
+ /// <exception cref="TimelinePostDataNotExistException">Thrown when data of that index does not exist.</exception>
+ Task<ICacheableDataDigest> GetPostDataDigest(long timelineId, long postId, long dataIndex);
/// <summary>
/// Get the data of a post.
/// </summary>
- /// <param name="timelineId">The id of the timeline of the post.</param>
- /// <param name="postId">The id of the post.</param>
- /// <returns>The etag of the data.</returns>
+ /// <param name="timelineId">The timeline id.</param>
+ /// <param name="postId">The post id.</param>
+ /// <param name="dataIndex">The index of the data.</param>
+ /// <returns>The data.</returns>
/// <exception cref="TimelineNotExistException">Thrown when timeline does not exist.</exception>
/// <exception cref="TimelinePostNotExistException">Thrown when post of <paramref name="postId"/> does not exist or has been deleted.</exception>
- /// <exception cref="TimelinePostNoDataException">Thrown when post has no data.</exception>
- /// <seealso cref="GetPostDataETag(long, long)"/>
- Task<PostData> GetPostData(long timelineId, long postId);
+ /// <exception cref="TimelinePostDataNotExistException">Thrown when data of that index does not exist.</exception>
+ Task<ByteData> GetPostData(long timelineId, long postId, long dataIndex);
/// <summary>
/// Create a new post in timeline.
@@ -176,7 +118,6 @@ namespace Timeline.Services /// <exception cref="ArgumentException">Thrown when <paramref name="request"/> is of invalid format.</exception>
/// <exception cref="TimelineNotExistException">Thrown when timeline does not exist.</exception>
/// <exception cref="TimelinePostNotExistException">Thrown when post does not exist.</exception>
- /// <exception cref="ImageException">Thrown if data is not a image. Validated by <see cref="ImageValidator"/>.</exception>
Task<TimelinePostEntity> PatchPost(long timelineId, long postId, TimelinePostPatchRequest request);
/// <summary>
@@ -261,7 +202,7 @@ namespace Timeline.Services if (!includeDeleted)
{
- query = query.Where(p => p.Content != null);
+ query = query.Where(p => !p.Deleted);
}
if (modifiedSince.HasValue)
@@ -274,7 +215,7 @@ namespace Timeline.Services return await query.ToListAsync();
}
- public async Task<TimelinePostEntity> GetPost(long timelineId, long postId, bool includeDelete = false)
+ public async Task<TimelinePostEntity> GetPost(long timelineId, long postId, bool includeDeleted = false)
{
await CheckTimelineExistence(timelineId);
@@ -285,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);
}
@@ -293,99 +234,46 @@ namespace Timeline.Services return post;
}
- public async Task<string> GetPostDataETag(long timelineId, long postId)
+ public async Task<ICacheableDataDigest> 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 != TimelinePostContentTypes.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<PostData> GetPostData(long timelineId, long postId)
+ public async Task<ByteData> 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 != TimelinePostContentTypes.Image)
- throw new TimelinePostNoDataException(ExceptionGetDataNonImagePost);
-
- var tag = postEntity.Content;
-
- byte[] data;
+ var dataEntity = await _database.TimelinePostData.Where(d => d.PostId == postEntity.Id && d.Index == dataIndex).SingleOrDefaultAsync();
- try
- {
- data = await _dataManager.GetEntry(tag);
- }
- catch (InvalidOperationException e)
- {
- throw new DatabaseCorruptedException(ExceptionGetDataDataEntryNotExist, e);
- }
+ if (dataEntity is null)
+ throw new TimelinePostDataNotExistException(timelineId, postId, dataIndex);
- if (postEntity.ExtraContent == null)
- {
- _logger.LogWarning(LogGetDataNoFormat);
- var format = Image.DetectFormat(data);
- postEntity.ExtraContent = format.DefaultMimeType;
- await _database.SaveChangesAsync();
- }
+ var data = await _dataManager.GetEntryAndCheck(dataEntity.DataTag, $"Timeline {timelineId}, post {postId}, data {dataIndex} requires this data.");
- return new PostData
- {
- Data = data,
- Type = postEntity.ExtraContent,
- ETag = tag,
- LastModified = postEntity.LastUpdated
- };
- }
-
- private async Task SaveContent(TimelinePostEntity entity, TimelinePostCreateRequestContent content)
- {
- switch (content)
- {
- case TimelinePostCreateRequestTextContent c:
- entity.ContentType = c.TypeName;
- entity.Content = c.Text;
- break;
- case TimelinePostCreateRequestImageContent c:
- var imageFormat = await _imageValidator.Validate(c.Data);
- var imageFormatText = imageFormat.DefaultMimeType;
-
- var tag = await _dataManager.RetainEntry(c.Data);
-
- entity.ContentType = content.TypeName;
- 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 == TimelinePostContentTypes.Image)
- await _dataManager.FreeEntry(entity.Content);
- entity.Content = null;
+ return new ByteData(data, dataEntity.Kind);
}
public async Task<TimelinePostEntity> CreatePost(long timelineId, long authorId, TimelinePostCreateRequest request)
@@ -393,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);
@@ -421,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<string> dataTags = new List<string>();
+
+ 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();
@@ -452,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)
@@ -466,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;
}
@@ -490,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();
@@ -506,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<string>();
-
- foreach (var post in posts)
- {
- if (post.Content != null)
- {
- if (post.ContentType == TimelinePostContentTypes.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);
}
}
@@ -539,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);
@@ -549,7 +472,7 @@ namespace Timeline.Services return true;
}
- if (postEntity.Content == null && throwOnPostNotExist)
+ if (postEntity.Deleted && throwOnPostNotExist)
{
throw new TimelinePostNotExistException(timelineId, postId, true);
}
diff --git a/BackEnd/Timeline/Services/UserAvatarService.cs b/BackEnd/Timeline/Services/UserAvatarService.cs index b41c45fd..5a6d013e 100644 --- a/BackEnd/Timeline/Services/UserAvatarService.cs +++ b/BackEnd/Timeline/Services/UserAvatarService.cs @@ -2,34 +2,18 @@ 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;
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);
- }
- }
-
/// <summary>
/// Provider for default user avatar.
/// </summary>
@@ -39,42 +23,53 @@ namespace Timeline.Services public interface IDefaultUserAvatarProvider
{
/// <summary>
- /// Get the etag of default avatar.
+ /// Get the digest of default avatar.
/// </summary>
- /// <returns></returns>
- Task<string> GetDefaultAvatarETag();
+ /// <returns>The digest.</returns>
+ Task<ICacheableDataDigest> GetDefaultAvatarDigest();
/// <summary>
/// Get the default avatar.
/// </summary>
- Task<AvatarInfo> GetDefaultAvatar();
+ /// <returns>The avatar.</returns>
+ Task<ByteData> GetDefaultAvatar();
}
public interface IUserAvatarService
{
/// <summary>
- /// Get the etag of a user's avatar. Warning: This method does not check the user existence.
+ /// Get avatar digest of a user.
/// </summary>
- /// <param name="id">The id of the user to get avatar etag of.</param>
- /// <returns>The etag.</returns>
- Task<string> GetAvatarETag(long id);
+ /// <param name="userId">User id.</param>
+ /// <returns>The avatar digest.</returns>
+ /// <exception cref="UserNotExistException">Thrown when user does not exist.</exception>
+ Task<ICacheableDataDigest> GetAvatarDigest(long userId);
/// <summary>
- /// 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.
/// </summary>
- /// <param name="id">The id of the user to get avatar of.</param>
- /// <returns>The avatar info.</returns>
- Task<AvatarInfo> GetAvatar(long id);
+ /// <param name="userId">User id.</param>
+ /// <returns>The avatar.</returns>
+ /// <exception cref="UserNotExistException">Thrown when user does not exist.</exception>
+ Task<ByteData> GetAvatar(long userId);
/// <summary>
- /// Set avatar for a user. Warning: This method does not check the user existence.
+ /// Set avatar for a user.
/// </summary>
- /// <param name="id">The id of the user to set avatar for.</param>
- /// <param name="avatar">The avatar. Can be null to delete the saved avatar.</param>
- /// <returns>The etag of the avatar.</returns>
- /// <exception cref="ArgumentException">Thrown if any field in <paramref name="avatar"/> is null when <paramref name="avatar"/> is not null.</exception>
+ /// <param name="userId">User id.</param>
+ /// <param name="avatar">The new avatar data.</param>
+ /// <returns>The digest of the avatar.</returns>
+ /// <exception cref="ArgumentNullException">Thrown if <paramref name="avatar"/> is null.</exception>
+ /// <exception cref="UserNotExistException">Thrown when user does not exist.</exception>
/// <exception cref="ImageException">Thrown if avatar is of bad format.</exception>
- Task<string> SetAvatar(long id, Avatar? avatar);
+ Task<ICacheableDataDigest> SetAvatar(long userId, ByteData avatar);
+
+ /// <summary>
+ /// Remove avatar of a user.
+ /// </summary>
+ /// <param name="userId">User id.</param>
+ /// <exception cref="UserNotExistException">Thrown when user does not exist.</exception>
+ Task DeleteAvatar(long userId);
}
// TODO! : Make this configurable.
@@ -84,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)
{
@@ -97,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<string> GetDefaultAvatarETag()
+ public async Task<ICacheableDataDigest> GetDefaultAvatarDigest()
{
await CheckAndInit();
- return _cacheETag;
+ return _cacheDigest!;
}
- public async Task<AvatarInfo> GetDefaultAvatar()
+ public async Task<ByteData> 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<UserAvatarService> _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<UserAvatarService> logger,
DatabaseContext database,
+ IBasicUserService basicUserService,
IDefaultUserAvatarProvider defaultUserAvatarProvider,
IImageValidator imageValidator,
IDataManager dataManager,
@@ -151,106 +134,123 @@ namespace Timeline.Services {
_logger = logger;
_database = database;
+ _basicUserService = basicUserService;
_defaultUserAvatarProvider = defaultUserAvatarProvider;
_imageValidator = imageValidator;
_dataManager = dataManager;
_clock = clock;
}
- public async Task<string> GetAvatarETag(long id)
+ public async Task<ICacheableDataDigest> 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<AvatarInfo> GetAvatar(long id)
+ public async Task<ByteData> 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<string> SetAvatar(long id, Avatar? avatar)
+ public async Task<ICacheableDataDigest> 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));
- var avatarEntity = await _database.UserAvatars.Where(a => a.UserId == id).SingleOrDefaultAsync();
+ await _imageValidator.Validate(avatar.Data, avatar.ContentType, true);
- if (avatar == null)
+ await _basicUserService.ThrowIfUserNotExist(userId);
+
+ var entity = await _database.UserAvatars.Where(a => a.UserId == userId).SingleOrDefaultAsync();
+
+ await using var transaction = await _database.Database.BeginTransactionAsync();
+
+ 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();
}
}
diff --git a/BackEnd/Timeline/Startup.cs b/BackEnd/Timeline/Startup.cs index 0fab798b..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
@@ -61,7 +62,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");
@@ -86,7 +87,9 @@ namespace Timeline services.TryAddSingleton<IActionContextAccessor, ActionContextAccessor>();
services.AddSingleton<IPathProvider, PathProvider>();
+
services.AddSingleton<IDatabaseBackupService, DatabaseBackupService>();
+ services.AddCustomMigration();
services.AddAutoMapper(GetType().Assembly);
services.AddMappers();
|