From a4a75188bd17e31b39a02511bbd6d628bab5c909 Mon Sep 17 00:00:00 2001 From: crupest Date: Sun, 25 Apr 2021 21:20:04 +0800 Subject: ... --- BackEnd/Timeline.Tests/Helpers/TestApplication.cs | 3 - BackEnd/Timeline.Tests/Helpers/TestDatabase.cs | 1 + .../IntegratedTests/IntegratedTestBase.cs | 2 +- .../IntegratedTests/TimelinePostTest.cs | 9 +- .../Timeline.Tests/IntegratedTests/TokenTest.cs | 2 +- .../IntegratedTests/UserPermissionTest.cs | 2 +- BackEnd/Timeline.Tests/PasswordGenerator.cs | 2 +- .../Timeline.Tests/Services/SearchServiceTest.cs | 4 +- BackEnd/Timeline.Tests/Services/ServiceTestBase.cs | 3 +- .../Services/UserDeleteServiceTest.cs | 4 +- .../Services/UserPermissionServiceTest.cs | 3 +- BackEnd/Timeline/Auth/MyAuthenticationHandler.cs | 2 +- .../Timeline/Auth/PermissionAuthorizeAttribute.cs | 2 +- BackEnd/Timeline/Auth/PrincipalExtensions.cs | 2 +- .../Controllers/BookmarkTimelineController.cs | 7 +- .../Controllers/ControllerAuthExtensions.cs | 2 +- .../Controllers/HighlightTimelineController.cs | 7 +- BackEnd/Timeline/Controllers/SearchController.cs | 5 +- BackEnd/Timeline/Controllers/TimelineController.cs | 5 +- .../Timeline/Controllers/TimelinePostController.cs | 9 +- BackEnd/Timeline/Controllers/TokenController.cs | 4 +- .../Timeline/Controllers/UserAvatarController.cs | 5 +- BackEnd/Timeline/Controllers/UserController.cs | 4 +- BackEnd/Timeline/Entities/UserEntity.cs | 3 +- .../CatchTimelineNotExistExceptionAttribute.cs | 3 +- ...chTimelinePostDataNotExistExceptionAttribute.cs | 2 +- .../CatchTimelinePostNotExistExceptionAttribute.cs | 2 +- .../Migrations/20200312112552_AddImagePost.cs | 1 - .../Timeline/Migrations/20210212141443_PostData.cs | 4 +- BackEnd/Timeline/Models/ByteData.cs | 4 +- .../Timeline/Models/Http/HttpAutoMapperProfile.cs | 3 +- .../Mapper/MapperServiceCollectionExtensions.cs | 13 - BackEnd/Timeline/Models/Mapper/TimelineMapper.cs | 157 ------- BackEnd/Timeline/Models/Mapper/UserMapper.cs | 47 -- BackEnd/Timeline/Program.cs | 5 - .../Resources/Services/Exceptions.Designer.cs | 45 -- .../Timeline/Resources/Services/Exceptions.resx | 15 - .../Services/Api/BookmarkTimelineService.cs | 205 +++++++++ .../Services/Api/HighlightTimelineService.cs | 194 +++++++++ BackEnd/Timeline/Services/Api/SearchService.cs | 104 +++++ BackEnd/Timeline/Services/BadPasswordException.cs | 27 -- BackEnd/Timeline/Services/BasicTimelineService.cs | 134 ------ BackEnd/Timeline/Services/BasicUserService.cs | 95 ---- .../Timeline/Services/BookmarkTimelineService.cs | 204 --------- BackEnd/Timeline/Services/Data/DataManager.cs | 138 ++++++ BackEnd/Timeline/Services/Data/ETagGenerator.cs | 45 ++ BackEnd/Timeline/Services/DataManager.cs | 138 ------ .../TimelinePostContentToDataMigration.cs | 1 + BackEnd/Timeline/Services/ETagGenerator.cs | 45 -- .../Services/EntityAlreadyExistException.cs | 32 ++ .../Timeline/Services/EntityNotExistException.cs | 27 ++ .../Services/Exceptions/EntityAlreadyExistError.cs | 63 --- .../Services/Exceptions/EntityNotExistError.cs | 55 --- .../Services/Exceptions/ExceptionMessageHelper.cs | 13 - .../Timeline/Services/Exceptions/ImageException.cs | 57 --- .../InvalidOperationOnRootUserException.cs | 16 - .../Exceptions/TimelineNotExistException.cs | 26 -- .../Exceptions/TimelinePostNotExistException.cs | 38 -- .../Services/Exceptions/UserNotExistException.cs | 40 -- .../Timeline/Services/HighlightTimelineService.cs | 193 -------- BackEnd/Timeline/Services/ImageValidator.cs | 54 --- .../Timeline/Services/Imaging/ImageException.cs | 58 +++ .../Timeline/Services/Imaging/ImageValidator.cs | 53 +++ .../Timeline/Services/Imaging/Resource.Designer.cs | 108 +++++ BackEnd/Timeline/Services/Imaging/Resource.resx | 135 ++++++ .../Mapper/MapperServiceCollectionExtensions.cs | 13 + BackEnd/Timeline/Services/Mapper/TimelineMapper.cs | 158 +++++++ BackEnd/Timeline/Services/Mapper/UserMapper.cs | 47 ++ BackEnd/Timeline/Services/MarkdownProcessor.cs | 52 --- .../Services/PasswordBadFormatException.cs | 27 -- BackEnd/Timeline/Services/PasswordService.cs | 224 ---------- BackEnd/Timeline/Services/Resource.Designer.cs | 81 ++++ BackEnd/Timeline/Services/Resource.resx | 126 ++++++ BackEnd/Timeline/Services/SearchService.cs | 104 ----- .../Services/Timeline/BasicTimelineService.cs | 134 ++++++ .../Services/Timeline/MarkdownProcessor.cs | 52 +++ .../Services/Timeline/Resource.Designer.cs | 117 +++++ BackEnd/Timeline/Services/Timeline/Resource.resx | 138 ++++++ .../Timeline/TimelineAlreadyExistException.cs | 24 + .../Services/Timeline/TimelineNotExistException.cs | 27 ++ .../Timeline/TimelinePostCreateDataException.cs | 16 + .../Timeline/TimelinePostDataNotExistException.cs | 25 ++ .../Timeline/TimelinePostNotExistException.cs | 36 ++ .../Services/Timeline/TimelinePostService.cs | 485 +++++++++++++++++++++ .../Timeline/Services/Timeline/TimelineService.cs | 452 +++++++++++++++++++ .../Services/TimelinePostCreateDataException.cs | 16 - .../Services/TimelinePostDataNotExistException.cs | 25 -- BackEnd/Timeline/Services/TimelinePostService.cs | 483 -------------------- BackEnd/Timeline/Services/TimelineService.cs | 453 ------------------- .../Timeline/Services/Token/UserTokenManager.cs | 2 +- .../Timeline/Services/User/BadPasswordException.cs | 25 ++ BackEnd/Timeline/Services/User/BasicUserService.cs | 94 ++++ .../User/InvalidOperationOnRootUserException.cs | 16 + .../Services/User/PasswordBadFormatException.cs | 26 ++ BackEnd/Timeline/Services/User/PasswordService.cs | 224 ++++++++++ .../Timeline/Services/User/Resource.Designer.cs | 117 +++++ BackEnd/Timeline/Services/User/Resource.resx | 138 ++++++ .../Services/User/UserAlreadyExistException.cs | 24 + .../Timeline/Services/User/UserAvatarService.cs | 266 +++++++++++ .../Services/User/UserCredentialService.cs | 101 +++++ .../Timeline/Services/User/UserDeleteService.cs | 70 +++ .../Services/User/UserNotExistException.cs | 37 ++ .../Services/User/UserPermissionService.cs | 240 ++++++++++ BackEnd/Timeline/Services/User/UserService.cs | 214 +++++++++ BackEnd/Timeline/Services/UserAvatarService.cs | 265 ----------- BackEnd/Timeline/Services/UserCredentialService.cs | 102 ----- BackEnd/Timeline/Services/UserDeleteService.cs | 73 ---- BackEnd/Timeline/Services/UserPermissionService.cs | 241 ---------- BackEnd/Timeline/Services/UserService.cs | 220 ---------- BackEnd/Timeline/Startup.cs | 8 +- BackEnd/Timeline/Timeline.csproj | 36 ++ 111 files changed, 4724 insertions(+), 3821 deletions(-) delete mode 100644 BackEnd/Timeline/Models/Mapper/MapperServiceCollectionExtensions.cs delete mode 100644 BackEnd/Timeline/Models/Mapper/TimelineMapper.cs delete mode 100644 BackEnd/Timeline/Models/Mapper/UserMapper.cs create mode 100644 BackEnd/Timeline/Services/Api/BookmarkTimelineService.cs create mode 100644 BackEnd/Timeline/Services/Api/HighlightTimelineService.cs create mode 100644 BackEnd/Timeline/Services/Api/SearchService.cs delete mode 100644 BackEnd/Timeline/Services/BadPasswordException.cs delete mode 100644 BackEnd/Timeline/Services/BasicTimelineService.cs delete mode 100644 BackEnd/Timeline/Services/BasicUserService.cs delete mode 100644 BackEnd/Timeline/Services/BookmarkTimelineService.cs create mode 100644 BackEnd/Timeline/Services/Data/DataManager.cs create mode 100644 BackEnd/Timeline/Services/Data/ETagGenerator.cs delete mode 100644 BackEnd/Timeline/Services/DataManager.cs delete mode 100644 BackEnd/Timeline/Services/ETagGenerator.cs create mode 100644 BackEnd/Timeline/Services/EntityAlreadyExistException.cs create mode 100644 BackEnd/Timeline/Services/EntityNotExistException.cs delete mode 100644 BackEnd/Timeline/Services/Exceptions/EntityAlreadyExistError.cs delete mode 100644 BackEnd/Timeline/Services/Exceptions/EntityNotExistError.cs delete mode 100644 BackEnd/Timeline/Services/Exceptions/ExceptionMessageHelper.cs delete mode 100644 BackEnd/Timeline/Services/Exceptions/ImageException.cs delete mode 100644 BackEnd/Timeline/Services/Exceptions/InvalidOperationOnRootUserException.cs delete mode 100644 BackEnd/Timeline/Services/Exceptions/TimelineNotExistException.cs delete mode 100644 BackEnd/Timeline/Services/Exceptions/TimelinePostNotExistException.cs delete mode 100644 BackEnd/Timeline/Services/Exceptions/UserNotExistException.cs delete mode 100644 BackEnd/Timeline/Services/HighlightTimelineService.cs delete mode 100644 BackEnd/Timeline/Services/ImageValidator.cs create mode 100644 BackEnd/Timeline/Services/Imaging/ImageException.cs create mode 100644 BackEnd/Timeline/Services/Imaging/ImageValidator.cs create mode 100644 BackEnd/Timeline/Services/Imaging/Resource.Designer.cs create mode 100644 BackEnd/Timeline/Services/Imaging/Resource.resx create mode 100644 BackEnd/Timeline/Services/Mapper/MapperServiceCollectionExtensions.cs create mode 100644 BackEnd/Timeline/Services/Mapper/TimelineMapper.cs create mode 100644 BackEnd/Timeline/Services/Mapper/UserMapper.cs delete mode 100644 BackEnd/Timeline/Services/MarkdownProcessor.cs delete mode 100644 BackEnd/Timeline/Services/PasswordBadFormatException.cs delete mode 100644 BackEnd/Timeline/Services/PasswordService.cs create mode 100644 BackEnd/Timeline/Services/Resource.Designer.cs create mode 100644 BackEnd/Timeline/Services/Resource.resx delete mode 100644 BackEnd/Timeline/Services/SearchService.cs create mode 100644 BackEnd/Timeline/Services/Timeline/BasicTimelineService.cs create mode 100644 BackEnd/Timeline/Services/Timeline/MarkdownProcessor.cs create mode 100644 BackEnd/Timeline/Services/Timeline/Resource.Designer.cs create mode 100644 BackEnd/Timeline/Services/Timeline/Resource.resx create mode 100644 BackEnd/Timeline/Services/Timeline/TimelineAlreadyExistException.cs create mode 100644 BackEnd/Timeline/Services/Timeline/TimelineNotExistException.cs create mode 100644 BackEnd/Timeline/Services/Timeline/TimelinePostCreateDataException.cs create mode 100644 BackEnd/Timeline/Services/Timeline/TimelinePostDataNotExistException.cs create mode 100644 BackEnd/Timeline/Services/Timeline/TimelinePostNotExistException.cs create mode 100644 BackEnd/Timeline/Services/Timeline/TimelinePostService.cs create mode 100644 BackEnd/Timeline/Services/Timeline/TimelineService.cs delete mode 100644 BackEnd/Timeline/Services/TimelinePostCreateDataException.cs delete mode 100644 BackEnd/Timeline/Services/TimelinePostDataNotExistException.cs delete mode 100644 BackEnd/Timeline/Services/TimelinePostService.cs delete mode 100644 BackEnd/Timeline/Services/TimelineService.cs create mode 100644 BackEnd/Timeline/Services/User/BadPasswordException.cs create mode 100644 BackEnd/Timeline/Services/User/BasicUserService.cs create mode 100644 BackEnd/Timeline/Services/User/InvalidOperationOnRootUserException.cs create mode 100644 BackEnd/Timeline/Services/User/PasswordBadFormatException.cs create mode 100644 BackEnd/Timeline/Services/User/PasswordService.cs create mode 100644 BackEnd/Timeline/Services/User/Resource.Designer.cs create mode 100644 BackEnd/Timeline/Services/User/Resource.resx create mode 100644 BackEnd/Timeline/Services/User/UserAlreadyExistException.cs create mode 100644 BackEnd/Timeline/Services/User/UserAvatarService.cs create mode 100644 BackEnd/Timeline/Services/User/UserCredentialService.cs create mode 100644 BackEnd/Timeline/Services/User/UserDeleteService.cs create mode 100644 BackEnd/Timeline/Services/User/UserNotExistException.cs create mode 100644 BackEnd/Timeline/Services/User/UserPermissionService.cs create mode 100644 BackEnd/Timeline/Services/User/UserService.cs delete mode 100644 BackEnd/Timeline/Services/UserAvatarService.cs delete mode 100644 BackEnd/Timeline/Services/UserCredentialService.cs delete mode 100644 BackEnd/Timeline/Services/UserDeleteService.cs delete mode 100644 BackEnd/Timeline/Services/UserPermissionService.cs delete mode 100644 BackEnd/Timeline/Services/UserService.cs (limited to 'BackEnd') diff --git a/BackEnd/Timeline.Tests/Helpers/TestApplication.cs b/BackEnd/Timeline.Tests/Helpers/TestApplication.cs index 037aa05e..e0db966c 100644 --- a/BackEnd/Timeline.Tests/Helpers/TestApplication.cs +++ b/BackEnd/Timeline.Tests/Helpers/TestApplication.cs @@ -1,14 +1,11 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using System.Collections.Generic; using System.IO; using System.Threading.Tasks; using Timeline.Configs; -using Timeline.Entities; using Xunit; namespace Timeline.Tests.Helpers diff --git a/BackEnd/Timeline.Tests/Helpers/TestDatabase.cs b/BackEnd/Timeline.Tests/Helpers/TestDatabase.cs index c3b61919..24f4a922 100644 --- a/BackEnd/Timeline.Tests/Helpers/TestDatabase.cs +++ b/BackEnd/Timeline.Tests/Helpers/TestDatabase.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging.Abstractions; using System.Threading.Tasks; using Timeline.Entities; using Timeline.Services; +using Timeline.Services.User; using Xunit; using Xunit.Abstractions; diff --git a/BackEnd/Timeline.Tests/IntegratedTests/IntegratedTestBase.cs b/BackEnd/Timeline.Tests/IntegratedTests/IntegratedTestBase.cs index 161e5d98..427881a0 100644 --- a/BackEnd/Timeline.Tests/IntegratedTests/IntegratedTestBase.cs +++ b/BackEnd/Timeline.Tests/IntegratedTests/IntegratedTestBase.cs @@ -5,7 +5,7 @@ using System.Collections.Generic; using System.Net.Http; using System.Threading.Tasks; using Timeline.Models.Http; -using Timeline.Services; +using Timeline.Services.User; using Timeline.Tests.Helpers; using Xunit; diff --git a/BackEnd/Timeline.Tests/IntegratedTests/TimelinePostTest.cs b/BackEnd/Timeline.Tests/IntegratedTests/TimelinePostTest.cs index 68a75898..c918f793 100644 --- a/BackEnd/Timeline.Tests/IntegratedTests/TimelinePostTest.cs +++ b/BackEnd/Timeline.Tests/IntegratedTests/TimelinePostTest.cs @@ -1,8 +1,12 @@ using FluentAssertions; +using SixLabors.ImageSharp.Formats.Gif; +using SixLabors.ImageSharp.Formats.Jpeg; +using SixLabors.ImageSharp.Formats.Png; using System; using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Net; using System.Text; using System.Threading.Tasks; using Timeline.Models; @@ -10,11 +14,6 @@ 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; -using System.Text.RegularExpressions; namespace Timeline.Tests.IntegratedTests { diff --git a/BackEnd/Timeline.Tests/IntegratedTests/TokenTest.cs b/BackEnd/Timeline.Tests/IntegratedTests/TokenTest.cs index fdf1af99..7206dab8 100644 --- a/BackEnd/Timeline.Tests/IntegratedTests/TokenTest.cs +++ b/BackEnd/Timeline.Tests/IntegratedTests/TokenTest.cs @@ -4,7 +4,7 @@ using System.Collections.Generic; using System.Net.Http; using System.Threading.Tasks; using Timeline.Models.Http; -using Timeline.Services; +using Timeline.Services.User; using Xunit; namespace Timeline.Tests.IntegratedTests diff --git a/BackEnd/Timeline.Tests/IntegratedTests/UserPermissionTest.cs b/BackEnd/Timeline.Tests/IntegratedTests/UserPermissionTest.cs index ba3d893e..32e25aaa 100644 --- a/BackEnd/Timeline.Tests/IntegratedTests/UserPermissionTest.cs +++ b/BackEnd/Timeline.Tests/IntegratedTests/UserPermissionTest.cs @@ -4,7 +4,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Timeline.Models.Http; -using Timeline.Services; +using Timeline.Services.User; using Xunit; namespace Timeline.Tests.IntegratedTests diff --git a/BackEnd/Timeline.Tests/PasswordGenerator.cs b/BackEnd/Timeline.Tests/PasswordGenerator.cs index 863439b5..3e154d70 100644 --- a/BackEnd/Timeline.Tests/PasswordGenerator.cs +++ b/BackEnd/Timeline.Tests/PasswordGenerator.cs @@ -1,4 +1,4 @@ -using Timeline.Services; +using Timeline.Services.User; using Xunit; using Xunit.Abstractions; diff --git a/BackEnd/Timeline.Tests/Services/SearchServiceTest.cs b/BackEnd/Timeline.Tests/Services/SearchServiceTest.cs index 968352c0..0aa412b2 100644 --- a/BackEnd/Timeline.Tests/Services/SearchServiceTest.cs +++ b/BackEnd/Timeline.Tests/Services/SearchServiceTest.cs @@ -1,6 +1,8 @@ using FluentAssertions; using System.Threading.Tasks; -using Timeline.Services; +using Timeline.Services.Api; +using Timeline.Services.Timeline; +using Timeline.Services.User; using Xunit; namespace Timeline.Tests.Services diff --git a/BackEnd/Timeline.Tests/Services/ServiceTestBase.cs b/BackEnd/Timeline.Tests/Services/ServiceTestBase.cs index 654116d0..0f4efe95 100644 --- a/BackEnd/Timeline.Tests/Services/ServiceTestBase.cs +++ b/BackEnd/Timeline.Tests/Services/ServiceTestBase.cs @@ -1,7 +1,8 @@ using Microsoft.Extensions.Logging.Abstractions; using System.Threading.Tasks; using Timeline.Entities; -using Timeline.Services; +using Timeline.Services.Timeline; +using Timeline.Services.User; using Timeline.Tests.Helpers; using Xunit; using Xunit.Abstractions; diff --git a/BackEnd/Timeline.Tests/Services/UserDeleteServiceTest.cs b/BackEnd/Timeline.Tests/Services/UserDeleteServiceTest.cs index 10014d2b..376c1092 100644 --- a/BackEnd/Timeline.Tests/Services/UserDeleteServiceTest.cs +++ b/BackEnd/Timeline.Tests/Services/UserDeleteServiceTest.cs @@ -2,8 +2,8 @@ using Microsoft.Extensions.Logging.Abstractions; using Moq; using System.Threading.Tasks; -using Timeline.Services; -using Timeline.Services.Exceptions; +using Timeline.Services.Timeline; +using Timeline.Services.User; using Xunit; namespace Timeline.Tests.Services diff --git a/BackEnd/Timeline.Tests/Services/UserPermissionServiceTest.cs b/BackEnd/Timeline.Tests/Services/UserPermissionServiceTest.cs index 0c43c025..aa92ff73 100644 --- a/BackEnd/Timeline.Tests/Services/UserPermissionServiceTest.cs +++ b/BackEnd/Timeline.Tests/Services/UserPermissionServiceTest.cs @@ -1,8 +1,7 @@ using FluentAssertions; using System; using System.Threading.Tasks; -using Timeline.Services; -using Timeline.Services.Exceptions; +using Timeline.Services.User; using Xunit; namespace Timeline.Tests.Services diff --git a/BackEnd/Timeline/Auth/MyAuthenticationHandler.cs b/BackEnd/Timeline/Auth/MyAuthenticationHandler.cs index fe27814a..affed7a5 100644 --- a/BackEnd/Timeline/Auth/MyAuthenticationHandler.cs +++ b/BackEnd/Timeline/Auth/MyAuthenticationHandler.cs @@ -13,8 +13,8 @@ using System.Text.Json; using System.Threading.Tasks; using Timeline.Models; using Timeline.Models.Http; -using Timeline.Services; using Timeline.Services.Token; +using Timeline.Services.User; namespace Timeline.Auth { diff --git a/BackEnd/Timeline/Auth/PermissionAuthorizeAttribute.cs b/BackEnd/Timeline/Auth/PermissionAuthorizeAttribute.cs index 3df8dee5..64d6f876 100644 --- a/BackEnd/Timeline/Auth/PermissionAuthorizeAttribute.cs +++ b/BackEnd/Timeline/Auth/PermissionAuthorizeAttribute.cs @@ -1,7 +1,7 @@ using Microsoft.AspNetCore.Authorization; using System; using System.Linq; -using Timeline.Services; +using Timeline.Services.User; namespace Timeline.Auth { diff --git a/BackEnd/Timeline/Auth/PrincipalExtensions.cs b/BackEnd/Timeline/Auth/PrincipalExtensions.cs index c0f9de14..605f66f6 100644 --- a/BackEnd/Timeline/Auth/PrincipalExtensions.cs +++ b/BackEnd/Timeline/Auth/PrincipalExtensions.cs @@ -1,6 +1,6 @@ using System; using System.Security.Claims; -using Timeline.Services; +using Timeline.Services.User; namespace Timeline.Auth { diff --git a/BackEnd/Timeline/Controllers/BookmarkTimelineController.cs b/BackEnd/Timeline/Controllers/BookmarkTimelineController.cs index e2a08dcb..80a06c17 100644 --- a/BackEnd/Timeline/Controllers/BookmarkTimelineController.cs +++ b/BackEnd/Timeline/Controllers/BookmarkTimelineController.cs @@ -4,10 +4,11 @@ using System.Collections.Generic; using System.Threading.Tasks; using Timeline.Entities; using Timeline.Models.Http; -using Timeline.Models.Mapper; using Timeline.Models.Validation; -using Timeline.Services; -using Timeline.Services.Exceptions; +using Timeline.Services.Api; +using Timeline.Services.Mapper; +using Timeline.Services.Timeline; +using Timeline.Services.User; namespace Timeline.Controllers { diff --git a/BackEnd/Timeline/Controllers/ControllerAuthExtensions.cs b/BackEnd/Timeline/Controllers/ControllerAuthExtensions.cs index 9096978d..9fcb727a 100644 --- a/BackEnd/Timeline/Controllers/ControllerAuthExtensions.cs +++ b/BackEnd/Timeline/Controllers/ControllerAuthExtensions.cs @@ -2,7 +2,7 @@ using System; using System.Security.Claims; using Timeline.Auth; -using Timeline.Services; +using Timeline.Services.User; using static Timeline.Resources.Controllers.ControllerAuthExtensions; namespace Timeline.Controllers diff --git a/BackEnd/Timeline/Controllers/HighlightTimelineController.cs b/BackEnd/Timeline/Controllers/HighlightTimelineController.cs index f582e74b..2f1f898e 100644 --- a/BackEnd/Timeline/Controllers/HighlightTimelineController.cs +++ b/BackEnd/Timeline/Controllers/HighlightTimelineController.cs @@ -4,10 +4,11 @@ using System.Threading.Tasks; using Timeline.Auth; using Timeline.Entities; using Timeline.Models.Http; -using Timeline.Models.Mapper; using Timeline.Models.Validation; -using Timeline.Services; -using Timeline.Services.Exceptions; +using Timeline.Services.Api; +using Timeline.Services.Mapper; +using Timeline.Services.Timeline; +using Timeline.Services.User; namespace Timeline.Controllers { diff --git a/BackEnd/Timeline/Controllers/SearchController.cs b/BackEnd/Timeline/Controllers/SearchController.cs index b2266c18..33e50e59 100644 --- a/BackEnd/Timeline/Controllers/SearchController.cs +++ b/BackEnd/Timeline/Controllers/SearchController.cs @@ -5,8 +5,9 @@ using System.Linq; using System.Threading.Tasks; using Timeline.Entities; using Timeline.Models.Http; -using Timeline.Models.Mapper; -using Timeline.Services; +using Timeline.Services.Api; +using Timeline.Services.Mapper; +using Timeline.Services.User; namespace Timeline.Controllers { diff --git a/BackEnd/Timeline/Controllers/TimelineController.cs b/BackEnd/Timeline/Controllers/TimelineController.cs index b20ab227..fc28daa4 100644 --- a/BackEnd/Timeline/Controllers/TimelineController.cs +++ b/BackEnd/Timeline/Controllers/TimelineController.cs @@ -9,10 +9,11 @@ using Timeline.Entities; using Timeline.Filters; using Timeline.Models; using Timeline.Models.Http; -using Timeline.Models.Mapper; using Timeline.Models.Validation; using Timeline.Services; -using Timeline.Services.Exceptions; +using Timeline.Services.Mapper; +using Timeline.Services.Timeline; +using Timeline.Services.User; namespace Timeline.Controllers { diff --git a/BackEnd/Timeline/Controllers/TimelinePostController.cs b/BackEnd/Timeline/Controllers/TimelinePostController.cs index 86c5c8cf..ef139201 100644 --- a/BackEnd/Timeline/Controllers/TimelinePostController.cs +++ b/BackEnd/Timeline/Controllers/TimelinePostController.cs @@ -3,16 +3,17 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using System; using System.Collections.Generic; -using System.Threading.Tasks; using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; +using Timeline.Entities; using Timeline.Filters; using Timeline.Helpers.Cache; using Timeline.Models; using Timeline.Models.Http; -using Timeline.Models.Mapper; using Timeline.Models.Validation; -using Timeline.Services; -using Timeline.Entities; +using Timeline.Services.Mapper; +using Timeline.Services.Timeline; +using Timeline.Services.User; namespace Timeline.Controllers { diff --git a/BackEnd/Timeline/Controllers/TokenController.cs b/BackEnd/Timeline/Controllers/TokenController.cs index 7df3891c..1de45754 100644 --- a/BackEnd/Timeline/Controllers/TokenController.cs +++ b/BackEnd/Timeline/Controllers/TokenController.cs @@ -7,10 +7,10 @@ using System.Globalization; using System.Threading.Tasks; using Timeline.Helpers; using Timeline.Models.Http; -using Timeline.Models.Mapper; using Timeline.Services; -using Timeline.Services.Exceptions; +using Timeline.Services.Mapper; using Timeline.Services.Token; +using Timeline.Services.User; using static Timeline.Resources.Controllers.TokenController; namespace Timeline.Controllers diff --git a/BackEnd/Timeline/Controllers/UserAvatarController.cs b/BackEnd/Timeline/Controllers/UserAvatarController.cs index fa13f0f6..158c342e 100644 --- a/BackEnd/Timeline/Controllers/UserAvatarController.cs +++ b/BackEnd/Timeline/Controllers/UserAvatarController.cs @@ -2,7 +2,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -using Microsoft.Net.Http.Headers; using System; using System.Threading.Tasks; using Timeline.Filters; @@ -11,8 +10,8 @@ using Timeline.Helpers.Cache; using Timeline.Models; using Timeline.Models.Http; using Timeline.Models.Validation; -using Timeline.Services; -using Timeline.Services.Exceptions; +using Timeline.Services.Imaging; +using Timeline.Services.User; using static Timeline.Resources.Controllers.UserAvatarController; namespace Timeline.Controllers diff --git a/BackEnd/Timeline/Controllers/UserController.cs b/BackEnd/Timeline/Controllers/UserController.cs index 76d6042f..95f65d25 100644 --- a/BackEnd/Timeline/Controllers/UserController.cs +++ b/BackEnd/Timeline/Controllers/UserController.cs @@ -8,10 +8,10 @@ using System.Threading.Tasks; using Timeline.Auth; using Timeline.Helpers; using Timeline.Models.Http; -using Timeline.Models.Mapper; using Timeline.Models.Validation; using Timeline.Services; -using Timeline.Services.Exceptions; +using Timeline.Services.Mapper; +using Timeline.Services.User; using static Timeline.Resources.Controllers.UserController; using static Timeline.Resources.Messages; diff --git a/BackEnd/Timeline/Entities/UserEntity.cs b/BackEnd/Timeline/Entities/UserEntity.cs index ad4d7db5..d8015ccf 100644 --- a/BackEnd/Timeline/Entities/UserEntity.cs +++ b/BackEnd/Timeline/Entities/UserEntity.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using Timeline.Services.User; namespace Timeline.Entities { @@ -40,7 +41,7 @@ namespace Timeline.Entities #pragma warning disable CA2227 // Collection properties should be read only /// - /// Do not use this directly. Get permissions with . + /// Do not use this directly. Get permissions with . /// [Obsolete("Use IUserPermissionService instead.")] public List Permissions { get; set; } = default!; diff --git a/BackEnd/Timeline/Filters/CatchTimelineNotExistExceptionAttribute.cs b/BackEnd/Timeline/Filters/CatchTimelineNotExistExceptionAttribute.cs index 857d1d2b..7a1352a3 100644 --- a/BackEnd/Timeline/Filters/CatchTimelineNotExistExceptionAttribute.cs +++ b/BackEnd/Timeline/Filters/CatchTimelineNotExistExceptionAttribute.cs @@ -2,7 +2,8 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Timeline.Models.Http; -using Timeline.Services.Exceptions; +using Timeline.Services.Timeline; +using Timeline.Services.User; namespace Timeline.Filters { diff --git a/BackEnd/Timeline/Filters/CatchTimelinePostDataNotExistExceptionAttribute.cs b/BackEnd/Timeline/Filters/CatchTimelinePostDataNotExistExceptionAttribute.cs index 8b5868aa..b4046c58 100644 --- a/BackEnd/Timeline/Filters/CatchTimelinePostDataNotExistExceptionAttribute.cs +++ b/BackEnd/Timeline/Filters/CatchTimelinePostDataNotExistExceptionAttribute.cs @@ -2,7 +2,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Timeline.Models.Http; -using Timeline.Services; +using Timeline.Services.Timeline; namespace Timeline.Filters { diff --git a/BackEnd/Timeline/Filters/CatchTimelinePostNotExistExceptionAttribute.cs b/BackEnd/Timeline/Filters/CatchTimelinePostNotExistExceptionAttribute.cs index ac3789c7..a288f890 100644 --- a/BackEnd/Timeline/Filters/CatchTimelinePostNotExistExceptionAttribute.cs +++ b/BackEnd/Timeline/Filters/CatchTimelinePostNotExistExceptionAttribute.cs @@ -2,7 +2,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Timeline.Models.Http; -using Timeline.Services.Exceptions; +using Timeline.Services.Timeline; namespace Timeline.Filters { diff --git a/BackEnd/Timeline/Migrations/20200312112552_AddImagePost.cs b/BackEnd/Timeline/Migrations/20200312112552_AddImagePost.cs index 7d9c6614..b7d50be3 100644 --- a/BackEnd/Timeline/Migrations/20200312112552_AddImagePost.cs +++ b/BackEnd/Timeline/Migrations/20200312112552_AddImagePost.cs @@ -1,5 +1,4 @@ using Microsoft.EntityFrameworkCore.Migrations; -using Timeline.Models; namespace Timeline.Migrations { diff --git a/BackEnd/Timeline/Migrations/20210212141443_PostData.cs b/BackEnd/Timeline/Migrations/20210212141443_PostData.cs index 5a0c6179..27e18045 100644 --- a/BackEnd/Timeline/Migrations/20210212141443_PostData.cs +++ b/BackEnd/Timeline/Migrations/20210212141443_PostData.cs @@ -1,5 +1,5 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; +using System; namespace Timeline.Migrations { diff --git a/BackEnd/Timeline/Models/ByteData.cs b/BackEnd/Timeline/Models/ByteData.cs index a1a0c238..b10771b0 100644 --- a/BackEnd/Timeline/Models/ByteData.cs +++ b/BackEnd/Timeline/Models/ByteData.cs @@ -1,5 +1,5 @@ -using System; -using NSwag.Annotations; +using NSwag.Annotations; +using System; namespace Timeline.Models { diff --git a/BackEnd/Timeline/Models/Http/HttpAutoMapperProfile.cs b/BackEnd/Timeline/Models/Http/HttpAutoMapperProfile.cs index 426379b8..50c20862 100644 --- a/BackEnd/Timeline/Models/Http/HttpAutoMapperProfile.cs +++ b/BackEnd/Timeline/Models/Http/HttpAutoMapperProfile.cs @@ -1,5 +1,6 @@ using AutoMapper; -using Timeline.Services; +using Timeline.Services.Timeline; +using Timeline.Services.User; namespace Timeline.Models.Http { diff --git a/BackEnd/Timeline/Models/Mapper/MapperServiceCollectionExtensions.cs b/BackEnd/Timeline/Models/Mapper/MapperServiceCollectionExtensions.cs deleted file mode 100644 index c87586d2..00000000 --- a/BackEnd/Timeline/Models/Mapper/MapperServiceCollectionExtensions.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; - -namespace Timeline.Models.Mapper -{ - public static class MapperServiceCollectionExtensions - { - public static void AddMappers(this IServiceCollection services) - { - services.AddScoped(); - services.AddScoped(); - } - } -} diff --git a/BackEnd/Timeline/Models/Mapper/TimelineMapper.cs b/BackEnd/Timeline/Models/Mapper/TimelineMapper.cs deleted file mode 100644 index e4304311..00000000 --- a/BackEnd/Timeline/Models/Mapper/TimelineMapper.cs +++ /dev/null @@ -1,157 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Timeline.Controllers; -using Timeline.Entities; -using Timeline.Models.Http; -using Timeline.Services; - -namespace Timeline.Models.Mapper -{ - public class TimelineMapper - { - private readonly DatabaseContext _database; - private readonly UserMapper _userMapper; - private readonly IHighlightTimelineService _highlightTimelineService; - private readonly IBookmarkTimelineService _bookmarkTimelineService; - private readonly ITimelineService _timelineService; - private readonly ITimelinePostService _timelinePostService; - - public TimelineMapper(DatabaseContext database, UserMapper userMapper, IHighlightTimelineService highlightTimelineService, IBookmarkTimelineService bookmarkTimelineService, ITimelineService timelineService, ITimelinePostService timelinePostService) - { - _database = database; - _userMapper = userMapper; - _highlightTimelineService = highlightTimelineService; - _bookmarkTimelineService = bookmarkTimelineService; - _timelineService = timelineService; - _timelinePostService = timelinePostService; - } - - public async Task MapToHttp(TimelineEntity entity, IUrlHelper urlHelper, long? userId, bool isAdministrator) - { - await _database.Entry(entity).Reference(e => e.Owner).LoadAsync(); - await _database.Entry(entity).Collection(e => e.Members).Query().Include(m => m.User).LoadAsync(); - - var timelineName = entity.Name is null ? "@" + entity.Owner.Username : entity.Name; - - bool manageable; - - if (userId is null) - { - manageable = false; - } - else if (isAdministrator) - { - manageable = true; - } - else - { - manageable = await _timelineService.HasManagePermission(entity.Id, userId.Value); - } - - bool postable; - if (userId is null) - { - postable = false; - } - else - { - postable = await _timelineService.IsMemberOf(entity.Id, userId.Value); - } - - return new HttpTimeline( - uniqueId: entity.UniqueId, - title: string.IsNullOrEmpty(entity.Title) ? timelineName : entity.Title, - name: timelineName, - nameLastModifed: entity.NameLastModified, - description: entity.Description ?? "", - owner: await _userMapper.MapToHttp(entity.Owner, urlHelper), - visibility: entity.Visibility, - members: await _userMapper.MapToHttp(entity.Members.Select(m => m.User).ToList(), urlHelper), - color: entity.Color, - createTime: entity.CreateTime, - lastModified: entity.LastModified, - isHighlight: await _highlightTimelineService.IsHighlightTimeline(entity.Id), - isBookmark: userId is not null && await _bookmarkTimelineService.IsBookmark(userId.Value, entity.Id, false, false), - manageable: manageable, - postable: postable, - links: new HttpTimelineLinks( - self: urlHelper.ActionLink(nameof(TimelineController.TimelineGet), nameof(TimelineController)[0..^nameof(Controller).Length], new { timeline = timelineName }), - posts: urlHelper.ActionLink(nameof(TimelinePostController.List), nameof(TimelinePostController)[0..^nameof(Controller).Length], new { timeline = timelineName }) - ) - ); - } - - public async Task> MapToHttp(List entities, IUrlHelper urlHelper, long? userId, bool isAdministrator) - { - var result = new List(); - foreach (var entity in entities) - { - result.Add(await MapToHttp(entity, urlHelper, userId, isAdministrator)); - } - return result; - } - - - public async Task MapToHttp(TimelinePostEntity entity, string timelineName, IUrlHelper urlHelper, long? userId, bool isAdministrator) - { - _ = timelineName; - - await _database.Entry(entity).Collection(p => p.DataList).LoadAsync(); - await _database.Entry(entity).Reference(e => e.Author).LoadAsync(); - - List dataDigestList = entity.DataList.OrderBy(d => d.Index).Select(d => new HttpTimelinePostDataDigest(d.Kind, $"\"{d.DataTag}\"", d.LastUpdated)).ToList(); - - HttpUser? author = null; - if (entity.Author is not null) - { - author = await _userMapper.MapToHttp(entity.Author, urlHelper); - } - - bool editable; - - if (userId is null) - { - editable = false; - } - else if (isAdministrator) - { - editable = true; - } - else - { - editable = await _timelinePostService.HasPostModifyPermission(entity.TimelineId, entity.LocalId, userId.Value); - } - - - return new HttpTimelinePost( - id: entity.LocalId, - dataList: dataDigestList, - time: entity.Time, - author: author, - color: entity.Color, - deleted: entity.Deleted, - lastUpdated: entity.LastUpdated, - timelineName: timelineName, - editable: editable - ); - } - - public async Task> MapToHttp(List entities, string timelineName, IUrlHelper urlHelper, long? userId, bool isAdministrator) - { - var result = new List(); - foreach (var entity in entities) - { - result.Add(await MapToHttp(entity, timelineName, urlHelper, userId, isAdministrator)); - } - return result; - } - - internal Task MapToHttp(TimelinePostEntity post, string timeline, IUrlHelper url) - { - throw new System.NotImplementedException(); - } - } -} diff --git a/BackEnd/Timeline/Models/Mapper/UserMapper.cs b/BackEnd/Timeline/Models/Mapper/UserMapper.cs deleted file mode 100644 index e6db4225..00000000 --- a/BackEnd/Timeline/Models/Mapper/UserMapper.cs +++ /dev/null @@ -1,47 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using System.Collections.Generic; -using System.Threading.Tasks; -using Timeline.Controllers; -using Timeline.Entities; -using Timeline.Models.Http; -using Timeline.Services; - -namespace Timeline.Models.Mapper -{ - public class UserMapper - { - private readonly DatabaseContext _database; - private readonly IUserPermissionService _userPermissionService; - - public UserMapper(DatabaseContext database, IUserPermissionService userPermissionService) - { - _database = database; - _userPermissionService = userPermissionService; - } - - public async Task MapToHttp(UserEntity entity, IUrlHelper urlHelper) - { - return new HttpUser( - uniqueId: entity.UniqueId, - username: entity.Username, - nickname: string.IsNullOrEmpty(entity.Nickname) ? entity.Username : entity.Nickname, - permissions: (await _userPermissionService.GetPermissionsOfUserAsync(entity.Id, false)).ToStringList(), - links: new HttpUserLinks( - self: urlHelper.ActionLink(nameof(UserController.Get), nameof(UserController)[0..^nameof(Controller).Length], new { entity.Username }), - avatar: urlHelper.ActionLink(nameof(UserAvatarController.Get), nameof(UserAvatarController)[0..^nameof(Controller).Length], new { entity.Username }), - timeline: urlHelper.ActionLink(nameof(TimelineController.TimelineGet), nameof(TimelineController)[0..^nameof(Controller).Length], new { timeline = "@" + entity.Username }) - ) - ); - } - - public async Task> MapToHttp(List entities, IUrlHelper urlHelper) - { - var result = new List(); - foreach (var entity in entities) - { - result.Add(await MapToHttp(entity, urlHelper)); - } - return result; - } - } -} diff --git a/BackEnd/Timeline/Program.cs b/BackEnd/Timeline/Program.cs index e16c27c5..82d0e0ac 100644 --- a/BackEnd/Timeline/Program.cs +++ b/BackEnd/Timeline/Program.cs @@ -1,13 +1,8 @@ using Microsoft.AspNetCore.Hosting; -using Microsoft.EntityFrameworkCore; 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.DatabaseManagement; [assembly: NeutralResourcesLanguage("en")] diff --git a/BackEnd/Timeline/Resources/Services/Exceptions.Designer.cs b/BackEnd/Timeline/Resources/Services/Exceptions.Designer.cs index 7f00d60d..da36a30b 100644 --- a/BackEnd/Timeline/Resources/Services/Exceptions.Designer.cs +++ b/BackEnd/Timeline/Resources/Services/Exceptions.Designer.cs @@ -96,51 +96,6 @@ namespace Timeline.Resources.Services { } } - /// - /// Looks up a localized string similar to Image is in valid because {0}.. - /// - internal static string ImageException { - get { - return ResourceManager.GetString("ImageException", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to image is not of required size. - /// - internal static string ImageExceptionBadSize { - get { - return ResourceManager.GetString("ImageExceptionBadSize", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to failed to decode image, see inner exception. - /// - internal static string ImageExceptionCantDecode { - get { - return ResourceManager.GetString("ImageExceptionCantDecode", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to unknown error. - /// - internal static string ImageExceptionUnknownError { - get { - return ResourceManager.GetString("ImageExceptionUnknownError", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to image's actual mime type is not the specified one. - /// - internal static string ImageExceptionUnmatchedFormat { - get { - return ResourceManager.GetString("ImageExceptionUnmatchedFormat", resourceCulture); - } - } - /// /// Looks up a localized string similar to The timeline has no data.. /// diff --git a/BackEnd/Timeline/Resources/Services/Exceptions.resx b/BackEnd/Timeline/Resources/Services/Exceptions.resx index d988b084..e2220913 100644 --- a/BackEnd/Timeline/Resources/Services/Exceptions.resx +++ b/BackEnd/Timeline/Resources/Services/Exceptions.resx @@ -141,21 +141,6 @@ The timeline has no data. - - Image is in valid because {0}. - - - image is not of required size - - - failed to decode image, see inner exception - - - unknown error - - - image's actual mime type is not the specified one - Request timeline id is "{0}". Request timeline post id is "{1}". The post does not exist because it is deleted. diff --git a/BackEnd/Timeline/Services/Api/BookmarkTimelineService.cs b/BackEnd/Timeline/Services/Api/BookmarkTimelineService.cs new file mode 100644 index 00000000..0d4cc0a6 --- /dev/null +++ b/BackEnd/Timeline/Services/Api/BookmarkTimelineService.cs @@ -0,0 +1,205 @@ +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Timeline.Entities; +using Timeline.Services.Timeline; +using Timeline.Services.User; + +namespace Timeline.Services.Api +{ + + [Serializable] + public class InvalidBookmarkException : Exception + { + public InvalidBookmarkException() { } + public InvalidBookmarkException(string message) : base(message) { } + public InvalidBookmarkException(string message, Exception inner) : base(message, inner) { } + protected InvalidBookmarkException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + } + + /// + /// Service interface that manages timeline bookmarks. + /// + public interface IBookmarkTimelineService + { + /// + /// Get bookmarks of a user. + /// + /// User id of bookmark owner. + /// Id of Bookmark timelines in order. + /// Thrown when user does not exist. + Task> GetBookmarks(long userId); + + /// + /// Check if a timeline is a bookmark. + /// + /// The user id. + /// Timeline id. + /// If true it will throw when user does not exist. + /// If true it will throw when timeline does not exist. + /// True if timeline is a bookmark. Otherwise false. + /// Throw if user does not exist and is true. + /// Thrown if timeline does not exist and is true. + Task IsBookmark(long userId, long timelineId, bool checkUserExistence = true, bool checkTimelineExistence = true); + + /// + /// Add a bookmark to tail to a user. + /// + /// User id of bookmark owner. + /// Timeline id. + /// True if timeline is added to bookmark. False if it already is. + /// Thrown when user does not exist. + /// Thrown when timeline does not exist. + Task AddBookmark(long userId, long timelineId); + + /// + /// Remove a bookmark from a user. + /// + /// User id of bookmark owner. + /// Timeline id. + /// True if deletion is performed. False if bookmark does not exist. + /// Thrown when user does not exist. + /// Thrown when timeline does not exist. + Task RemoveBookmark(long userId, long timelineId); + + /// + /// Move bookmark to a new position. + /// + /// User id of bookmark owner. + /// Timeline name. + /// New position. Starts at 1. + /// Thrown when user does not exist. + /// Thrown when timeline does not exist. + /// Thrown when the timeline is not a bookmark. + Task MoveBookmark(long userId, long timelineId, long newPosition); + } + + public class BookmarkTimelineService : IBookmarkTimelineService + { + private readonly DatabaseContext _database; + private readonly IBasicUserService _userService; + private readonly IBasicTimelineService _timelineService; + + public BookmarkTimelineService(DatabaseContext database, IBasicUserService userService, IBasicTimelineService timelineService) + { + _database = database; + _userService = userService; + _timelineService = timelineService; + } + + public async Task AddBookmark(long userId, long timelineId) + { + if (!await _userService.CheckUserExistence(userId)) + throw new UserNotExistException(userId); + + if (!await _timelineService.CheckExistence(timelineId)) + throw new TimelineNotExistException(timelineId); + + if (await _database.BookmarkTimelines.AnyAsync(t => t.TimelineId == timelineId && t.UserId == userId)) + return false; + + _database.BookmarkTimelines.Add(new BookmarkTimelineEntity + { + TimelineId = timelineId, + UserId = userId, + Rank = (await _database.BookmarkTimelines.CountAsync(t => t.UserId == userId)) + 1 + }); + + await _database.SaveChangesAsync(); + return true; + } + + public async Task> GetBookmarks(long userId) + { + if (!await _userService.CheckUserExistence(userId)) + throw new UserNotExistException(userId); + + var entities = await _database.BookmarkTimelines.Where(t => t.UserId == userId).OrderBy(t => t.Rank).Select(t => new { t.TimelineId }).ToListAsync(); + + return entities.Select(e => e.TimelineId).ToList(); + } + + public async Task IsBookmark(long userId, long timelineId, bool checkUserExistence = true, bool checkTimelineExistence = true) + { + if (checkUserExistence && !await _userService.CheckUserExistence(userId)) + throw new UserNotExistException(userId); + + if (checkTimelineExistence && !await _timelineService.CheckExistence(timelineId)) + throw new TimelineNotExistException(timelineId); + + return await _database.BookmarkTimelines.AnyAsync(b => b.TimelineId == timelineId && b.UserId == userId); + } + + public async Task MoveBookmark(long userId, long timelineId, long newPosition) + { + if (!await _userService.CheckUserExistence(userId)) + throw new UserNotExistException(userId); + + if (!await _timelineService.CheckExistence(timelineId)) + throw new TimelineNotExistException(timelineId); + + var entity = await _database.BookmarkTimelines.SingleOrDefaultAsync(t => t.TimelineId == timelineId && t.UserId == userId); + + if (entity == null) throw new InvalidBookmarkException("You can't move a non-bookmark timeline."); + + var oldPosition = entity.Rank; + + if (newPosition < 1) + { + newPosition = 1; + } + else + { + var totalCount = await _database.BookmarkTimelines.CountAsync(t => t.UserId == userId); + if (newPosition > totalCount) newPosition = totalCount; + } + + if (oldPosition == newPosition) return; + + await using var transaction = await _database.Database.BeginTransactionAsync(); + + if (newPosition > oldPosition) + { + await _database.Database.ExecuteSqlRawAsync("UPDATE `bookmark_timelines` SET `rank` = `rank` - 1 WHERE `rank` BETWEEN {0} AND {1} AND `user` = {2}", oldPosition + 1, newPosition, userId); + await _database.Database.ExecuteSqlRawAsync("UPDATE `bookmark_timelines` SET `rank` = {0} WHERE `id` = {1}", newPosition, entity.Id); + } + else + { + await _database.Database.ExecuteSqlRawAsync("UPDATE `bookmark_timelines` SET `rank` = `rank` + 1 WHERE `rank` BETWEEN {0} AND {1} AND `user` = {2}", newPosition, oldPosition - 1, userId); + await _database.Database.ExecuteSqlRawAsync("UPDATE `bookmark_timelines` SET `rank` = {0} WHERE `id` = {1}", newPosition, entity.Id); + } + + await transaction.CommitAsync(); + } + + public async Task RemoveBookmark(long userId, long timelineId) + { + if (!await _userService.CheckUserExistence(userId)) + throw new UserNotExistException(userId); + + if (!await _timelineService.CheckExistence(timelineId)) + throw new TimelineNotExistException(timelineId); + + var entity = await _database.BookmarkTimelines.SingleOrDefaultAsync(t => t.UserId == userId && t.TimelineId == timelineId); + + if (entity == null) return false; + + await using var transaction = await _database.Database.BeginTransactionAsync(); + + var rank = entity.Rank; + + _database.BookmarkTimelines.Remove(entity); + await _database.SaveChangesAsync(); + + await _database.Database.ExecuteSqlRawAsync("UPDATE `bookmark_timelines` SET `rank` = `rank` - 1 WHERE `rank` > {0}", rank); + + await transaction.CommitAsync(); + + return true; + } + } +} diff --git a/BackEnd/Timeline/Services/Api/HighlightTimelineService.cs b/BackEnd/Timeline/Services/Api/HighlightTimelineService.cs new file mode 100644 index 00000000..9ef8ea84 --- /dev/null +++ b/BackEnd/Timeline/Services/Api/HighlightTimelineService.cs @@ -0,0 +1,194 @@ +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Timeline.Entities; +using Timeline.Services.Timeline; +using Timeline.Services.User; + +namespace Timeline.Services.Api +{ + + [Serializable] + public class InvalidHighlightTimelineException : Exception + { + public InvalidHighlightTimelineException() { } + public InvalidHighlightTimelineException(string message) : base(message) { } + public InvalidHighlightTimelineException(string message, Exception inner) : base(message, inner) { } + protected InvalidHighlightTimelineException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + } + + /// + /// Service that controls highlight timeline. + /// + public interface IHighlightTimelineService + { + /// + /// Get all highlight timelines in order. + /// + /// Id list of all highlight timelines. + Task> GetHighlightTimelines(); + + /// + /// Check if a timeline is highlight timeline. + /// + /// Timeline id. + /// If true it will throw if timeline does not exist. + /// True if timeline is highlight. Otherwise false. + /// Thrown when timeline does not exist and is true. + Task IsHighlightTimeline(long timelineId, bool checkTimelineExistence = true); + + /// + /// Add a timeline to highlight list. + /// + /// The timeline id. + /// The user id of operator. + /// True if timeline is actually added to highligh. False if it already is. + /// Thrown when timeline with given id does not exist. + /// Thrown when user with given operator id does not exist. + Task AddHighlightTimeline(long timelineId, long? operatorId); + + /// + /// Remove a timeline from highlight list. + /// + /// The timeline id. + /// The user id of operator. + /// True if deletion is actually performed. Otherwise false (timeline was not in the list). + /// Thrown when timeline with given id does not exist. + /// Thrown when user with given operator id does not exist. + Task RemoveHighlightTimeline(long timelineId, long? operatorId); + + /// + /// Move a highlight timeline to a new position. + /// + /// The timeline name. + /// The new position. Starts at 1. + /// Thrown when timeline with given id does not exist. + /// Thrown when given timeline is not a highlight timeline. + /// + /// If is smaller than 1. Then move the timeline to head. + /// If is bigger than total count. Then move the timeline to tail. + /// + Task MoveHighlightTimeline(long timelineId, long newPosition); + } + + public class HighlightTimelineService : IHighlightTimelineService + { + private readonly DatabaseContext _database; + private readonly IBasicUserService _userService; + private readonly IBasicTimelineService _timelineService; + private readonly IClock _clock; + + public HighlightTimelineService(DatabaseContext database, IBasicUserService userService, IBasicTimelineService timelineService, IClock clock) + { + _database = database; + _userService = userService; + _timelineService = timelineService; + _clock = clock; + } + + public async Task AddHighlightTimeline(long timelineId, long? operatorId) + { + if (!await _timelineService.CheckExistence(timelineId)) + throw new TimelineNotExistException(timelineId); + + if (operatorId.HasValue && !await _userService.CheckUserExistence(operatorId.Value)) + { + throw new UserNotExistException(null, operatorId.Value, "User with given operator id does not exist.", null); + } + + var alreadyIs = await _database.HighlightTimelines.AnyAsync(t => t.TimelineId == timelineId); + + if (alreadyIs) return false; + + _database.HighlightTimelines.Add(new HighlightTimelineEntity { TimelineId = timelineId, OperatorId = operatorId, AddTime = _clock.GetCurrentTime(), Order = await _database.HighlightTimelines.CountAsync() + 1 }); + await _database.SaveChangesAsync(); + return true; + } + + public async Task> GetHighlightTimelines() + { + var entities = await _database.HighlightTimelines.OrderBy(t => t.Order).Select(t => new { t.TimelineId }).ToListAsync(); + + return entities.Select(e => e.TimelineId).ToList(); + } + + public async Task RemoveHighlightTimeline(long timelineId, long? operatorId) + { + if (!await _timelineService.CheckExistence(timelineId)) + throw new TimelineNotExistException(timelineId); + + if (operatorId.HasValue && !await _userService.CheckUserExistence(operatorId.Value)) + { + throw new UserNotExistException(null, operatorId.Value, "User with given operator id does not exist.", null); + } + + var entity = await _database.HighlightTimelines.SingleOrDefaultAsync(t => t.TimelineId == timelineId); + + if (entity == null) return false; + + await using var transaction = await _database.Database.BeginTransactionAsync(); + + var order = entity.Order; + + _database.HighlightTimelines.Remove(entity); + await _database.SaveChangesAsync(); + + await _database.Database.ExecuteSqlRawAsync("UPDATE highlight_timelines SET `order` = `order` - 1 WHERE `order` > {0}", order); + + await transaction.CommitAsync(); + + return true; + } + + public async Task MoveHighlightTimeline(long timelineId, long newPosition) + { + if (!await _timelineService.CheckExistence(timelineId)) + throw new TimelineNotExistException(timelineId); + + var entity = await _database.HighlightTimelines.SingleOrDefaultAsync(t => t.TimelineId == timelineId); + + if (entity == null) throw new InvalidHighlightTimelineException("You can't move a non-highlight timeline."); + + var oldPosition = entity.Order; + + if (newPosition < 1) + { + newPosition = 1; + } + else + { + var totalCount = await _database.HighlightTimelines.CountAsync(); + if (newPosition > totalCount) newPosition = totalCount; + } + + if (oldPosition == newPosition) return; + + await using var transaction = await _database.Database.BeginTransactionAsync(); + + if (newPosition > oldPosition) + { + await _database.Database.ExecuteSqlRawAsync("UPDATE highlight_timelines SET `order` = `order` - 1 WHERE `order` BETWEEN {0} AND {1}", oldPosition + 1, newPosition); + await _database.Database.ExecuteSqlRawAsync("UPDATE highlight_timelines SET `order` = {0} WHERE id = {1}", newPosition, entity.Id); + } + else + { + await _database.Database.ExecuteSqlRawAsync("UPDATE highlight_timelines SET `order` = `order` + 1 WHERE `order` BETWEEN {0} AND {1}", newPosition, oldPosition - 1); + await _database.Database.ExecuteSqlRawAsync("UPDATE highlight_timelines SET `order` = {0} WHERE id = {1}", newPosition, entity.Id); + } + + await transaction.CommitAsync(); + } + + public async Task IsHighlightTimeline(long timelineId, bool checkTimelineExistence = true) + { + if (checkTimelineExistence && !await _timelineService.CheckExistence(timelineId)) + throw new TimelineNotExistException(timelineId); + + return await _database.HighlightTimelines.AnyAsync(t => t.TimelineId == timelineId); + } + } +} diff --git a/BackEnd/Timeline/Services/Api/SearchService.cs b/BackEnd/Timeline/Services/Api/SearchService.cs new file mode 100644 index 00000000..eec5001f --- /dev/null +++ b/BackEnd/Timeline/Services/Api/SearchService.cs @@ -0,0 +1,104 @@ +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Timeline.Entities; + +namespace Timeline.Services.Api +{ + public class SearchResultItem + { + public SearchResultItem(TItem item, int score) + { + Item = item; + Score = score; + } + + public TItem Item { get; set; } = default!; + + /// + /// Bigger is better. + /// + public int Score { get; set; } + } + + public class SearchResult + { +#pragma warning disable CA2227 // Collection properties should be read only + public List> Items { get; set; } = new(); +#pragma warning restore CA2227 // Collection properties should be read only + } + + public interface ISearchService + { + /// + /// Search timelines whose name or title contains query string. + /// + /// String to contain. + /// Search results. + /// Thrown when is null. + /// Thrown when is empty. + /// + /// Implementation should promise high score is at first. + /// + Task> SearchTimeline(string query); + + /// + /// Search users whose username or nickname contains query string. + /// + /// String to contain. + /// Search results. + /// Thrown when is null. + /// Thrown when is empty. + /// + /// Implementation should promise high score is at first. + /// + Task> SearchUser(string query); + } + + public class SearchService : ISearchService + { + private readonly DatabaseContext _database; + + public SearchService(DatabaseContext database) + { + _database = database; + } + + public async Task> SearchTimeline(string query) + { + if (query is null) + throw new ArgumentNullException(nameof(query)); + if (query.Length == 0) + throw new ArgumentException("Query string can't be empty.", nameof(query)); + + var nameLikeTimelines = await _database.Timelines.Include(t => t.Owner).Where(t => t.Name == null ? t.Owner.Username.Contains(query) : t.Name.Contains(query)).ToListAsync(); + var titleLikeTimelines = await _database.Timelines.Where(t => t.Title != null && t.Title.Contains(query)).ToListAsync(); + + var searchResult = new SearchResult(); + searchResult.Items.AddRange(nameLikeTimelines.Select(t => new SearchResultItem(t, 2))); + searchResult.Items.AddRange(titleLikeTimelines.Select(t => new SearchResultItem(t, 1))); + + return searchResult; + } + + public async Task> SearchUser(string query) + { + if (query is null) + throw new ArgumentNullException(nameof(query)); + if (query.Length == 0) + throw new ArgumentException("Query string can't be empty.", nameof(query)); + + var usernameLikeUsers = await _database.Users.Where(u => u.Username.Contains(query)).ToListAsync(); + var nicknameLikeUsers = await _database.Users.Where(u => u.Nickname != null && u.Nickname.Contains(query)).ToListAsync(); + + var searchResult = new SearchResult(); + searchResult.Items.AddRange(usernameLikeUsers.Select(u => new SearchResultItem(u, 2))); + searchResult.Items.AddRange(nicknameLikeUsers.Select(u => new SearchResultItem(u, 1))); + + return searchResult; + + } + } +} diff --git a/BackEnd/Timeline/Services/BadPasswordException.cs b/BackEnd/Timeline/Services/BadPasswordException.cs deleted file mode 100644 index f609371d..00000000 --- a/BackEnd/Timeline/Services/BadPasswordException.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System; -using Timeline.Helpers; - -namespace Timeline.Services -{ - [Serializable] - public class BadPasswordException : Exception - { - public BadPasswordException() : base(Resources.Services.Exception.BadPasswordException) { } - public BadPasswordException(string message, Exception inner) : base(message, inner) { } - - public BadPasswordException(string badPassword) - : base(Log.Format(Resources.Services.Exception.BadPasswordException, ("Bad Password", badPassword))) - { - Password = badPassword; - } - - protected BadPasswordException( - System.Runtime.Serialization.SerializationInfo info, - System.Runtime.Serialization.StreamingContext context) : base(info, context) { } - - /// - /// The wrong password. - /// - public string? Password { get; set; } - } -} diff --git a/BackEnd/Timeline/Services/BasicTimelineService.cs b/BackEnd/Timeline/Services/BasicTimelineService.cs deleted file mode 100644 index be500135..00000000 --- a/BackEnd/Timeline/Services/BasicTimelineService.cs +++ /dev/null @@ -1,134 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Timeline.Entities; -using Timeline.Models; -using Timeline.Models.Validation; -using Timeline.Services.Exceptions; - -namespace Timeline.Services -{ - /// - /// This service provide some basic timeline functions, which should be used internally for other services. - /// - public interface IBasicTimelineService - { - /// - /// Check whether a timeline with given id exists without getting full info. - /// - /// The timeline id. - /// True if exist. Otherwise false. - Task CheckExistence(long id); - - /// - /// Get the timeline id by name. - /// - /// Timeline name. - /// Id of the timeline. - /// Thrown when is null. - /// Throw when is of bad format. - /// - /// Thrown when timeline with name does not exist. - /// If it is a personal timeline, then inner exception is . - /// - /// - /// If name is of personal timeline and the timeline does not exist, it will be created if user exists. - /// If the user does not exist, will be thrown with as inner exception. - /// - Task GetTimelineIdByName(string timelineName); - } - - - public class BasicTimelineService : IBasicTimelineService - { - private readonly DatabaseContext _database; - - private readonly IBasicUserService _basicUserService; - private readonly IClock _clock; - - private readonly GeneralTimelineNameValidator _generalTimelineNameValidator = new GeneralTimelineNameValidator(); - - public BasicTimelineService(DatabaseContext database, IBasicUserService basicUserService, IClock clock) - { - _database = database; - _basicUserService = basicUserService; - _clock = clock; - } - - protected TimelineEntity CreateNewTimelineEntity(string? name, long ownerId) - { - var currentTime = _clock.GetCurrentTime(); - - return new TimelineEntity - { - Name = name, - NameLastModified = currentTime, - OwnerId = ownerId, - Visibility = TimelineVisibility.Register, - CreateTime = currentTime, - LastModified = currentTime, - CurrentPostLocalId = 0, - Members = new List() - }; - } - - public async Task CheckExistence(long id) - { - return await _database.Timelines.AnyAsync(t => t.Id == id); - } - - public async Task GetTimelineIdByName(string timelineName) - { - if (timelineName == null) - throw new ArgumentNullException(nameof(timelineName)); - - if (!_generalTimelineNameValidator.Validate(timelineName, out var message)) - throw new ArgumentException(message); - - timelineName = TimelineHelper.ExtractTimelineName(timelineName, out var isPersonal); - - if (isPersonal) - { - long userId; - try - { - userId = await _basicUserService.GetUserIdByUsername(timelineName); - } - catch (UserNotExistException e) - { - throw new TimelineNotExistException(timelineName, e); - } - - var timelineEntity = await _database.Timelines.Where(t => t.OwnerId == userId && t.Name == null).Select(t => new { t.Id }).SingleOrDefaultAsync(); - - if (timelineEntity != null) - { - return timelineEntity.Id; - } - else - { - var newTimelineEntity = CreateNewTimelineEntity(null, userId); - _database.Timelines.Add(newTimelineEntity); - await _database.SaveChangesAsync(); - - return newTimelineEntity.Id; - } - } - else - { - var timelineEntity = await _database.Timelines.Where(t => t.Name == timelineName).Select(t => new { t.Id }).SingleOrDefaultAsync(); - - if (timelineEntity == null) - { - throw new TimelineNotExistException(timelineName); - } - else - { - return timelineEntity.Id; - } - } - } - } -} diff --git a/BackEnd/Timeline/Services/BasicUserService.cs b/BackEnd/Timeline/Services/BasicUserService.cs deleted file mode 100644 index de0829ee..00000000 --- a/BackEnd/Timeline/Services/BasicUserService.cs +++ /dev/null @@ -1,95 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using System; -using System.Linq; -using System.Threading.Tasks; -using Timeline.Entities; -using Timeline.Models.Validation; -using Timeline.Services.Exceptions; - -namespace Timeline.Services -{ - /// - /// This service provide some basic user features, which should be used internally for other services. - /// - public interface IBasicUserService - { - /// - /// Check if a user exists. - /// - /// The id of the user. - /// True if exists. Otherwise false. - Task CheckUserExistence(long id); - - /// - /// Get the user id of given username. - /// - /// Username of the user. - /// The id of the user. - /// Thrown when is null. - /// Thrown when is of bad format. - /// Thrown when the user with given username does not exist. - Task GetUserIdByUsername(string username); - - /// - /// Get the username modified time of a user. - /// - /// User id. - /// The time. - /// Thrown when user does not exist. - Task GetUsernameLastModifiedTime(long userId); - } - - public class BasicUserService : IBasicUserService - { - private readonly DatabaseContext _database; - - private readonly UsernameValidator _usernameValidator = new UsernameValidator(); - - public BasicUserService(DatabaseContext database) - { - _database = database; - } - - public async Task CheckUserExistence(long id) - { - return await _database.Users.AnyAsync(u => u.Id == id); - } - - public async Task GetUserIdByUsername(string username) - { - if (username == null) - throw new ArgumentNullException(nameof(username)); - - if (!_usernameValidator.Validate(username, out var message)) - throw new ArgumentException(message); - - var entity = await _database.Users.Where(user => user.Username == username).Select(u => new { u.Id }).SingleOrDefaultAsync(); - - if (entity == null) - throw new UserNotExistException(username); - - return entity.Id; - } - - public async Task GetUsernameLastModifiedTime(long userId) - { - var entity = await _database.Users.Where(u => u.Id == userId).Select(u => new { u.UsernameChangeTime }).SingleOrDefaultAsync(); - - if (entity is null) - throw new UserNotExistException(userId); - - return entity.UsernameChangeTime; - } - } - - public static class BasicUserServiceExtensions - { - public static async Task ThrowIfUserNotExist(this IBasicUserService service, long userId) - { - if (!await service.CheckUserExistence(userId)) - { - throw new UserNotExistException(userId); - } - } - } -} diff --git a/BackEnd/Timeline/Services/BookmarkTimelineService.cs b/BackEnd/Timeline/Services/BookmarkTimelineService.cs deleted file mode 100644 index 4930686e..00000000 --- a/BackEnd/Timeline/Services/BookmarkTimelineService.cs +++ /dev/null @@ -1,204 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Timeline.Entities; -using Timeline.Services.Exceptions; - -namespace Timeline.Services -{ - - [Serializable] - public class InvalidBookmarkException : Exception - { - public InvalidBookmarkException() { } - public InvalidBookmarkException(string message) : base(message) { } - public InvalidBookmarkException(string message, Exception inner) : base(message, inner) { } - protected InvalidBookmarkException( - System.Runtime.Serialization.SerializationInfo info, - System.Runtime.Serialization.StreamingContext context) : base(info, context) { } - } - - /// - /// Service interface that manages timeline bookmarks. - /// - public interface IBookmarkTimelineService - { - /// - /// Get bookmarks of a user. - /// - /// User id of bookmark owner. - /// Id of Bookmark timelines in order. - /// Thrown when user does not exist. - Task> GetBookmarks(long userId); - - /// - /// Check if a timeline is a bookmark. - /// - /// The user id. - /// Timeline id. - /// If true it will throw when user does not exist. - /// If true it will throw when timeline does not exist. - /// True if timeline is a bookmark. Otherwise false. - /// Throw if user does not exist and is true. - /// Thrown if timeline does not exist and is true. - Task IsBookmark(long userId, long timelineId, bool checkUserExistence = true, bool checkTimelineExistence = true); - - /// - /// Add a bookmark to tail to a user. - /// - /// User id of bookmark owner. - /// Timeline id. - /// True if timeline is added to bookmark. False if it already is. - /// Thrown when user does not exist. - /// Thrown when timeline does not exist. - Task AddBookmark(long userId, long timelineId); - - /// - /// Remove a bookmark from a user. - /// - /// User id of bookmark owner. - /// Timeline id. - /// True if deletion is performed. False if bookmark does not exist. - /// Thrown when user does not exist. - /// Thrown when timeline does not exist. - Task RemoveBookmark(long userId, long timelineId); - - /// - /// Move bookmark to a new position. - /// - /// User id of bookmark owner. - /// Timeline name. - /// New position. Starts at 1. - /// Thrown when user does not exist. - /// Thrown when timeline does not exist. - /// Thrown when the timeline is not a bookmark. - Task MoveBookmark(long userId, long timelineId, long newPosition); - } - - public class BookmarkTimelineService : IBookmarkTimelineService - { - private readonly DatabaseContext _database; - private readonly IBasicUserService _userService; - private readonly IBasicTimelineService _timelineService; - - public BookmarkTimelineService(DatabaseContext database, IBasicUserService userService, IBasicTimelineService timelineService) - { - _database = database; - _userService = userService; - _timelineService = timelineService; - } - - public async Task AddBookmark(long userId, long timelineId) - { - if (!await _userService.CheckUserExistence(userId)) - throw new UserNotExistException(userId); - - if (!await _timelineService.CheckExistence(timelineId)) - throw new TimelineNotExistException(timelineId); - - if (await _database.BookmarkTimelines.AnyAsync(t => t.TimelineId == timelineId && t.UserId == userId)) - return false; - - _database.BookmarkTimelines.Add(new BookmarkTimelineEntity - { - TimelineId = timelineId, - UserId = userId, - Rank = (await _database.BookmarkTimelines.CountAsync(t => t.UserId == userId)) + 1 - }); - - await _database.SaveChangesAsync(); - return true; - } - - public async Task> GetBookmarks(long userId) - { - if (!await _userService.CheckUserExistence(userId)) - throw new UserNotExistException(userId); - - var entities = await _database.BookmarkTimelines.Where(t => t.UserId == userId).OrderBy(t => t.Rank).Select(t => new { t.TimelineId }).ToListAsync(); - - return entities.Select(e => e.TimelineId).ToList(); - } - - public async Task IsBookmark(long userId, long timelineId, bool checkUserExistence = true, bool checkTimelineExistence = true) - { - if (checkUserExistence && !await _userService.CheckUserExistence(userId)) - throw new UserNotExistException(userId); - - if (checkTimelineExistence && !await _timelineService.CheckExistence(timelineId)) - throw new TimelineNotExistException(timelineId); - - return await _database.BookmarkTimelines.AnyAsync(b => b.TimelineId == timelineId && b.UserId == userId); - } - - public async Task MoveBookmark(long userId, long timelineId, long newPosition) - { - if (!await _userService.CheckUserExistence(userId)) - throw new UserNotExistException(userId); - - if (!await _timelineService.CheckExistence(timelineId)) - throw new TimelineNotExistException(timelineId); - - var entity = await _database.BookmarkTimelines.SingleOrDefaultAsync(t => t.TimelineId == timelineId && t.UserId == userId); - - if (entity == null) throw new InvalidBookmarkException("You can't move a non-bookmark timeline."); - - var oldPosition = entity.Rank; - - if (newPosition < 1) - { - newPosition = 1; - } - else - { - var totalCount = await _database.BookmarkTimelines.CountAsync(t => t.UserId == userId); - if (newPosition > totalCount) newPosition = totalCount; - } - - if (oldPosition == newPosition) return; - - await using var transaction = await _database.Database.BeginTransactionAsync(); - - if (newPosition > oldPosition) - { - await _database.Database.ExecuteSqlRawAsync("UPDATE `bookmark_timelines` SET `rank` = `rank` - 1 WHERE `rank` BETWEEN {0} AND {1} AND `user` = {2}", oldPosition + 1, newPosition, userId); - await _database.Database.ExecuteSqlRawAsync("UPDATE `bookmark_timelines` SET `rank` = {0} WHERE `id` = {1}", newPosition, entity.Id); - } - else - { - await _database.Database.ExecuteSqlRawAsync("UPDATE `bookmark_timelines` SET `rank` = `rank` + 1 WHERE `rank` BETWEEN {0} AND {1} AND `user` = {2}", newPosition, oldPosition - 1, userId); - await _database.Database.ExecuteSqlRawAsync("UPDATE `bookmark_timelines` SET `rank` = {0} WHERE `id` = {1}", newPosition, entity.Id); - } - - await transaction.CommitAsync(); - } - - public async Task RemoveBookmark(long userId, long timelineId) - { - if (!await _userService.CheckUserExistence(userId)) - throw new UserNotExistException(userId); - - if (!await _timelineService.CheckExistence(timelineId)) - throw new TimelineNotExistException(timelineId); - - var entity = await _database.BookmarkTimelines.SingleOrDefaultAsync(t => t.UserId == userId && t.TimelineId == timelineId); - - if (entity == null) return false; - - await using var transaction = await _database.Database.BeginTransactionAsync(); - - var rank = entity.Rank; - - _database.BookmarkTimelines.Remove(entity); - await _database.SaveChangesAsync(); - - await _database.Database.ExecuteSqlRawAsync("UPDATE `bookmark_timelines` SET `rank` = `rank` - 1 WHERE `rank` > {0}", rank); - - await transaction.CommitAsync(); - - return true; - } - } -} diff --git a/BackEnd/Timeline/Services/Data/DataManager.cs b/BackEnd/Timeline/Services/Data/DataManager.cs new file mode 100644 index 00000000..d9a4491d --- /dev/null +++ b/BackEnd/Timeline/Services/Data/DataManager.cs @@ -0,0 +1,138 @@ +using Microsoft.EntityFrameworkCore; +using System; +using System.Linq; +using System.Threading.Tasks; +using Timeline.Entities; + +namespace Timeline.Services.Data +{ + /// + /// A data manager controlling data. + /// + /// + /// Identical data will be saved as one copy and return the same tag. + /// Every data has a ref count. When data is retained, ref count increase. + /// When data is freed, ref count decease. If ref count is decreased + /// to 0, the data entry will be destroyed and no longer occupy space. + /// + public interface IDataManager + { + /// + /// Saves the data to a new entry if it does not exist, + /// increases its ref count and returns a tag to the entry. + /// + /// The data. Can't be null. + /// The tag of the created entry. + /// Thrown when is null. + public Task RetainEntry(byte[] data); + + /// + /// Decrease the the ref count of the entry. + /// Remove it if ref count is zero. + /// + /// The tag of the entry. + /// Thrown when is null. + /// + /// It's no-op if entry with tag does not exist. + /// + public Task FreeEntry(string tag); + + /// + /// Retrieve the entry with given tag. If not exist, returns null. + /// + /// The tag of the entry. + /// The data of the entry. If not exist, returns null. + /// Thrown when is null. + public Task GetEntry(string tag); + } + + public class DataManager : IDataManager + { + private readonly DatabaseContext _database; + private readonly IETagGenerator _eTagGenerator; + + public DataManager(DatabaseContext database, IETagGenerator eTagGenerator) + { + _database = database; + _eTagGenerator = eTagGenerator; + } + + public async Task RetainEntry(byte[] data) + { + if (data == null) + throw new ArgumentNullException(nameof(data)); + + var tag = await _eTagGenerator.Generate(data); + + var entity = await _database.Data.Where(d => d.Tag == tag).SingleOrDefaultAsync(); + + if (entity == null) + { + entity = new DataEntity + { + Tag = tag, + Data = data, + Ref = 1 + }; + _database.Data.Add(entity); + } + else + { + entity.Ref += 1; + } + + await _database.SaveChangesAsync(); + + return tag; + } + + public async Task FreeEntry(string tag) + { + if (tag == null) + throw new ArgumentNullException(nameof(tag)); + + var entity = await _database.Data.Where(d => d.Tag == tag).SingleOrDefaultAsync(); + + if (entity != null) + { + if (entity.Ref == 1) + { + _database.Data.Remove(entity); + } + else + { + entity.Ref -= 1; + } + + await _database.SaveChangesAsync(); + } + } + + public async Task GetEntry(string tag) + { + if (tag == null) + throw new ArgumentNullException(nameof(tag)); + + var entity = await _database.Data.Where(d => d.Tag == tag).Select(d => new { d.Data }).SingleOrDefaultAsync(); + + if (entity is null) + return null; + + return entity.Data; + } + } + + public static class DataManagerExtensions + { + /// + /// Try to get an entry and throw if not exist. + /// + public static async Task GetEntryAndCheck(this IDataManager dataManager, string tag, string notExistMessage) + { + var data = await dataManager.GetEntry(tag); + if (data is null) + throw new DatabaseCorruptedException($"Can't get data of tag {tag}. {notExistMessage}"); + return data; + } + } +} diff --git a/BackEnd/Timeline/Services/Data/ETagGenerator.cs b/BackEnd/Timeline/Services/Data/ETagGenerator.cs new file mode 100644 index 00000000..847c120b --- /dev/null +++ b/BackEnd/Timeline/Services/Data/ETagGenerator.cs @@ -0,0 +1,45 @@ +using System; +using System.Security.Cryptography; +using System.Threading.Tasks; + +namespace Timeline.Services.Data +{ + public interface IETagGenerator + { + /// + /// Generate a etag for given source. + /// + /// The source data. + /// The generated etag. + /// Thrown if is null. + Task Generate(byte[] source); + } + + public sealed class ETagGenerator : IETagGenerator, IDisposable + { + private readonly SHA1 _sha1; + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Security", "CA5350:Do Not Use Weak Cryptographic Algorithms", Justification = "Sha1 is enough ??? I don't know.")] + public ETagGenerator() + { + _sha1 = SHA1.Create(); + } + + public Task Generate(byte[] source) + { + if (source == null) + throw new ArgumentNullException(nameof(source)); + + return Task.Run(() => Convert.ToBase64String(_sha1.ComputeHash(source))); + } + + private bool _disposed; // To detect redundant calls + + public void Dispose() + { + if (_disposed) return; + _sha1.Dispose(); + _disposed = true; + } + } +} diff --git a/BackEnd/Timeline/Services/DataManager.cs b/BackEnd/Timeline/Services/DataManager.cs deleted file mode 100644 index b43b80f9..00000000 --- a/BackEnd/Timeline/Services/DataManager.cs +++ /dev/null @@ -1,138 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using System; -using System.Linq; -using System.Threading.Tasks; -using Timeline.Entities; - -namespace Timeline.Services -{ - /// - /// A data manager controlling data. - /// - /// - /// Identical data will be saved as one copy and return the same tag. - /// Every data has a ref count. When data is retained, ref count increase. - /// When data is freed, ref count decease. If ref count is decreased - /// to 0, the data entry will be destroyed and no longer occupy space. - /// - public interface IDataManager - { - /// - /// Saves the data to a new entry if it does not exist, - /// increases its ref count and returns a tag to the entry. - /// - /// The data. Can't be null. - /// The tag of the created entry. - /// Thrown when is null. - public Task RetainEntry(byte[] data); - - /// - /// Decrease the the ref count of the entry. - /// Remove it if ref count is zero. - /// - /// The tag of the entry. - /// Thrown when is null. - /// - /// It's no-op if entry with tag does not exist. - /// - public Task FreeEntry(string tag); - - /// - /// Retrieve the entry with given tag. If not exist, returns null. - /// - /// The tag of the entry. - /// The data of the entry. If not exist, returns null. - /// Thrown when is null. - public Task GetEntry(string tag); - } - - public class DataManager : IDataManager - { - private readonly DatabaseContext _database; - private readonly IETagGenerator _eTagGenerator; - - public DataManager(DatabaseContext database, IETagGenerator eTagGenerator) - { - _database = database; - _eTagGenerator = eTagGenerator; - } - - public async Task RetainEntry(byte[] data) - { - if (data == null) - throw new ArgumentNullException(nameof(data)); - - var tag = await _eTagGenerator.Generate(data); - - var entity = await _database.Data.Where(d => d.Tag == tag).SingleOrDefaultAsync(); - - if (entity == null) - { - entity = new DataEntity - { - Tag = tag, - Data = data, - Ref = 1 - }; - _database.Data.Add(entity); - } - else - { - entity.Ref += 1; - } - - await _database.SaveChangesAsync(); - - return tag; - } - - public async Task FreeEntry(string tag) - { - if (tag == null) - throw new ArgumentNullException(nameof(tag)); - - var entity = await _database.Data.Where(d => d.Tag == tag).SingleOrDefaultAsync(); - - if (entity != null) - { - if (entity.Ref == 1) - { - _database.Data.Remove(entity); - } - else - { - entity.Ref -= 1; - } - - await _database.SaveChangesAsync(); - } - } - - public async Task GetEntry(string tag) - { - if (tag == null) - throw new ArgumentNullException(nameof(tag)); - - var entity = await _database.Data.Where(d => d.Tag == tag).Select(d => new { d.Data }).SingleOrDefaultAsync(); - - if (entity is null) - return null; - - return entity.Data; - } - } - - public static class DataManagerExtensions - { - /// - /// Try to get an entry and throw if not exist. - /// - public static async Task GetEntryAndCheck(this IDataManager dataManager, string tag, string notExistMessage) - { - var data = await dataManager.GetEntry(tag); - if (data is null) - throw new DatabaseCorruptedException($"Can't get data of tag {tag}. {notExistMessage}"); - return data; - } - } -} diff --git a/BackEnd/Timeline/Services/DatabaseManagement/TimelinePostContentToDataMigration.cs b/BackEnd/Timeline/Services/DatabaseManagement/TimelinePostContentToDataMigration.cs index 605223f3..f9a3418b 100644 --- a/BackEnd/Timeline/Services/DatabaseManagement/TimelinePostContentToDataMigration.cs +++ b/BackEnd/Timeline/Services/DatabaseManagement/TimelinePostContentToDataMigration.cs @@ -5,6 +5,7 @@ using System.Threading; using System.Threading.Tasks; using Timeline.Entities; using Timeline.Models; +using Timeline.Services.Data; namespace Timeline.Services.DatabaseManagement { diff --git a/BackEnd/Timeline/Services/ETagGenerator.cs b/BackEnd/Timeline/Services/ETagGenerator.cs deleted file mode 100644 index 4493e903..00000000 --- a/BackEnd/Timeline/Services/ETagGenerator.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System; -using System.Security.Cryptography; -using System.Threading.Tasks; - -namespace Timeline.Services -{ - public interface IETagGenerator - { - /// - /// Generate a etag for given source. - /// - /// The source data. - /// The generated etag. - /// Thrown if is null. - Task Generate(byte[] source); - } - - public sealed class ETagGenerator : IETagGenerator, IDisposable - { - private readonly SHA1 _sha1; - - [System.Diagnostics.CodeAnalysis.SuppressMessage("Security", "CA5350:Do Not Use Weak Cryptographic Algorithms", Justification = "Sha1 is enough ??? I don't know.")] - public ETagGenerator() - { - _sha1 = SHA1.Create(); - } - - public Task Generate(byte[] source) - { - if (source == null) - throw new ArgumentNullException(nameof(source)); - - return Task.Run(() => Convert.ToBase64String(_sha1.ComputeHash(source))); - } - - private bool _disposed; // To detect redundant calls - - public void Dispose() - { - if (_disposed) return; - _sha1.Dispose(); - _disposed = true; - } - } -} diff --git a/BackEnd/Timeline/Services/EntityAlreadyExistException.cs b/BackEnd/Timeline/Services/EntityAlreadyExistException.cs new file mode 100644 index 00000000..2d3de368 --- /dev/null +++ b/BackEnd/Timeline/Services/EntityAlreadyExistException.cs @@ -0,0 +1,32 @@ +using System; + +namespace Timeline.Services +{ + /// + /// Thrown when an entity is already exists. + /// + /// + /// For example, want to create a timeline but a timeline with the same name already exists. + /// + [Serializable] + public class EntityAlreadyExistException : Exception + { + public EntityAlreadyExistException() : this(null, null, null, null) { } + public EntityAlreadyExistException(string? entityName) : this(entityName, null, null, null) { } + public EntityAlreadyExistException(string? entityName, Exception? inner) : this(entityName, null, null, inner) { } + public EntityAlreadyExistException(string? entityName, object? entity, Exception inner) : this(entityName, entity, null, inner) { } + public EntityAlreadyExistException(string? entityName, object? entity, string? message, Exception? inner) : base(message ?? Resource.ExceptionEntityAlreadyExist, inner) + { + EntityName = entityName; + Entity = entity; + } + + protected EntityAlreadyExistException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + + public string? EntityName { get; } + + public object? Entity { get; } + } +} diff --git a/BackEnd/Timeline/Services/EntityNotExistException.cs b/BackEnd/Timeline/Services/EntityNotExistException.cs new file mode 100644 index 00000000..39a4f545 --- /dev/null +++ b/BackEnd/Timeline/Services/EntityNotExistException.cs @@ -0,0 +1,27 @@ +using System; + +namespace Timeline.Services +{ + /// + /// Thrown when you want to get an entity that does not exist. + /// + /// + /// For example, you want to get a timeline with given name but it does not exist. + /// + [Serializable] + public class EntityNotExistException : Exception + { + public EntityNotExistException() : this(null, null) { } + public EntityNotExistException(string? entityName) : this(entityName, null) { } + public EntityNotExistException(string? entityName, Exception? inner) : this(entityName, null, inner) { } + public EntityNotExistException(string? entityName, string? message, Exception? inner) : base(message ?? Resource.ExceptionEntityNotExist, inner) + { + EntityName = entityName; + } + protected EntityNotExistException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + + public string? EntityName { get; } + } +} diff --git a/BackEnd/Timeline/Services/Exceptions/EntityAlreadyExistError.cs b/BackEnd/Timeline/Services/Exceptions/EntityAlreadyExistError.cs deleted file mode 100644 index 7db2e860..00000000 --- a/BackEnd/Timeline/Services/Exceptions/EntityAlreadyExistError.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System; -using System.Globalization; -using System.Text; - -namespace Timeline.Services.Exceptions -{ - /// - /// Thrown when an entity is already exists. - /// - /// - /// For example, want to create a timeline but a timeline with the same name already exists. - /// - [Serializable] - public class EntityAlreadyExistException : Exception - { - private readonly string? _entityName; - - public EntityAlreadyExistException() : this(null, null, null, null) { } - - public EntityAlreadyExistException(string? entityName) : this(entityName, null) { } - - public EntityAlreadyExistException(string? entityName, Exception? inner) : this(entityName, null, null, null, inner) { } - - public EntityAlreadyExistException(string? entityName, object? entity = null) : this(entityName, null, entity, null, null) { } - public EntityAlreadyExistException(Type? entityType, object? entity = null) : this(null, entityType, entity, null, null) { } - public EntityAlreadyExistException(string? entityName, Type? entityType, object? entity = null, string? message = null, Exception? inner = null) : base(MakeMessage(entityName, entityType, message), inner) - { - _entityName = entityName; - EntityType = entityType; - Entity = entity; - } - - private static string MakeMessage(string? entityName, Type? entityType, string? message) - { - string? name = entityName ?? (entityType?.Name); - - var result = new StringBuilder(); - - if (name == null) - result.Append(Resources.Services.Exceptions.EntityAlreadyExistErrorDefault); - else - result.AppendFormat(CultureInfo.InvariantCulture, Resources.Services.Exceptions.EntityAlreadyExistError, name); - - if (message != null) - { - result.Append(' '); - result.Append(message); - } - - return result.ToString(); - } - - protected EntityAlreadyExistException( - System.Runtime.Serialization.SerializationInfo info, - System.Runtime.Serialization.StreamingContext context) : base(info, context) { } - - public string? EntityName => _entityName ?? (EntityType?.Name); - - public Type? EntityType { get; } - - public object? Entity { get; } - } -} diff --git a/BackEnd/Timeline/Services/Exceptions/EntityNotExistError.cs b/BackEnd/Timeline/Services/Exceptions/EntityNotExistError.cs deleted file mode 100644 index e79496d3..00000000 --- a/BackEnd/Timeline/Services/Exceptions/EntityNotExistError.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System; -using System.Globalization; -using System.Text; - -namespace Timeline.Services.Exceptions -{ - /// - /// Thrown when you want to get an entity that does not exist. - /// - /// - /// For example, you want to get a timeline with given name but it does not exist. - /// - [Serializable] - public class EntityNotExistException : Exception - { - public EntityNotExistException() : this(null, null, null, null) { } - public EntityNotExistException(string? entityName) : this(entityName, null, null, null) { } - public EntityNotExistException(Type? entityType) : this(null, entityType, null, null) { } - public EntityNotExistException(string? entityName, Exception? inner) : this(entityName, null, null, inner) { } - public EntityNotExistException(Type? entityType, Exception? inner) : this(null, entityType, null, inner) { } - public EntityNotExistException(string? entityName, Type? entityType, string? message = null, Exception? inner = null) : base(MakeMessage(entityName, entityType, message), inner) - { - EntityName = entityName; - EntityType = entityType; - } - - private static string MakeMessage(string? entityName, Type? entityType, string? message) - { - string? name = entityName ?? (entityType?.Name); - - var result = new StringBuilder(); - - if (name == null) - result.Append(Resources.Services.Exceptions.EntityNotExistErrorDefault); - else - result.AppendFormat(CultureInfo.InvariantCulture, Resources.Services.Exceptions.EntityNotExistError, name); - - if (message != null) - { - result.Append(' '); - result.Append(message); - } - - return result.ToString(); - } - - protected EntityNotExistException( - System.Runtime.Serialization.SerializationInfo info, - System.Runtime.Serialization.StreamingContext context) : base(info, context) { } - - public string? EntityName { get; } - - public Type? EntityType { get; } - } -} diff --git a/BackEnd/Timeline/Services/Exceptions/ExceptionMessageHelper.cs b/BackEnd/Timeline/Services/Exceptions/ExceptionMessageHelper.cs deleted file mode 100644 index be3c42a4..00000000 --- a/BackEnd/Timeline/Services/Exceptions/ExceptionMessageHelper.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Timeline.Services.Exceptions -{ - public static class ExceptionMessageHelper - { - public static string AppendAdditionalMessage(this string origin, string? message) - { - if (message == null) - return origin; - else - return origin + " " + message; - } - } -} diff --git a/BackEnd/Timeline/Services/Exceptions/ImageException.cs b/BackEnd/Timeline/Services/Exceptions/ImageException.cs deleted file mode 100644 index 20dd48ae..00000000 --- a/BackEnd/Timeline/Services/Exceptions/ImageException.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System; -using System.Globalization; - -namespace Timeline.Services.Exceptions -{ - [Serializable] - public class ImageException : Exception - { - public enum ErrorReason - { - /// - /// Decoding image failed. - /// - CantDecode, - /// - /// Decoding succeeded but the real type is not the specified type. - /// - UnmatchedFormat, - /// - /// Image is not of required size. - /// - NotSquare, - /// - /// Other unknown errer. - /// - Unknown - } - - public ImageException() : this(null) { } - public ImageException(string? message) : this(message, null) { } - public ImageException(string? message, Exception? inner) : this(ErrorReason.Unknown, null, null, null, message, inner) { } - - public ImageException(ErrorReason error, byte[]? data, string? requestType = null, string? realType = null, string? message = null, Exception? inner = null) : base(MakeMessage(error).AppendAdditionalMessage(message), inner) { Error = error; ImageData = data; RequestType = requestType; RealType = realType; } - - protected ImageException( - System.Runtime.Serialization.SerializationInfo info, - System.Runtime.Serialization.StreamingContext context) : base(info, context) { } - - private static string MakeMessage(ErrorReason? reason) => - string.Format(CultureInfo.InvariantCulture, Resources.Services.Exceptions.ImageException, reason switch - { - ErrorReason.CantDecode => Resources.Services.Exceptions.ImageExceptionCantDecode, - ErrorReason.UnmatchedFormat => Resources.Services.Exceptions.ImageExceptionUnmatchedFormat, - ErrorReason.NotSquare => Resources.Services.Exceptions.ImageExceptionBadSize, - _ => Resources.Services.Exceptions.ImageExceptionUnknownError - }); - - public ErrorReason Error { get; } -#pragma warning disable CA1819 // Properties should not return arrays - public byte[]? ImageData { get; } -#pragma warning restore CA1819 // Properties should not return arrays - public string? RequestType { get; } - - // This field will be null if decoding failed. - public string? RealType { get; } - } -} diff --git a/BackEnd/Timeline/Services/Exceptions/InvalidOperationOnRootUserException.cs b/BackEnd/Timeline/Services/Exceptions/InvalidOperationOnRootUserException.cs deleted file mode 100644 index 2bcab316..00000000 --- a/BackEnd/Timeline/Services/Exceptions/InvalidOperationOnRootUserException.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; - -namespace Timeline.Services.Exceptions -{ - - [Serializable] - public class InvalidOperationOnRootUserException : InvalidOperationException - { - public InvalidOperationOnRootUserException() { } - public InvalidOperationOnRootUserException(string message) : base(message) { } - public InvalidOperationOnRootUserException(string message, Exception inner) : base(message, inner) { } - protected InvalidOperationOnRootUserException( - System.Runtime.Serialization.SerializationInfo info, - System.Runtime.Serialization.StreamingContext context) : base(info, context) { } - } -} diff --git a/BackEnd/Timeline/Services/Exceptions/TimelineNotExistException.cs b/BackEnd/Timeline/Services/Exceptions/TimelineNotExistException.cs deleted file mode 100644 index ef882ffe..00000000 --- a/BackEnd/Timeline/Services/Exceptions/TimelineNotExistException.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; -using System.Globalization; - -namespace Timeline.Services.Exceptions -{ - [Serializable] - public class TimelineNotExistException : EntityNotExistException - { - public TimelineNotExistException() : this((long?)null) { } - public TimelineNotExistException(long? id) : this(id, null) { } - public TimelineNotExistException(long? id, Exception? inner) : this(id, null, inner) { } - public TimelineNotExistException(long? id, string? message, Exception? inner) : base(EntityNames.Timeline, null, message, inner) { TimelineId = id; } - - public TimelineNotExistException(string? timelineName) : this(timelineName, null) { } - public TimelineNotExistException(string? timelineName, Exception? inner) : this(timelineName, null, inner) { } - public TimelineNotExistException(string? timelineName, string? message, Exception? inner = null) - : base(EntityNames.Timeline, null, string.Format(CultureInfo.InvariantCulture, Resources.Services.Exceptions.TimelineNotExistException, timelineName ?? "").AppendAdditionalMessage(message), inner) { TimelineName = timelineName; } - - protected TimelineNotExistException( - System.Runtime.Serialization.SerializationInfo info, - System.Runtime.Serialization.StreamingContext context) : base(info, context) { } - - public string? TimelineName { get; set; } - public long? TimelineId { get; set; } - } -} diff --git a/BackEnd/Timeline/Services/Exceptions/TimelinePostNotExistException.cs b/BackEnd/Timeline/Services/Exceptions/TimelinePostNotExistException.cs deleted file mode 100644 index 2a7b5b28..00000000 --- a/BackEnd/Timeline/Services/Exceptions/TimelinePostNotExistException.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System; -using System.Globalization; - -namespace Timeline.Services.Exceptions -{ - [Serializable] - public class TimelinePostNotExistException : EntityNotExistException - { - public TimelinePostNotExistException() : this(null, null, false, null, null) { } - [Obsolete("This has no meaning.")] - public TimelinePostNotExistException(string? message) : this(message, null) { } - [Obsolete("This has no meaning.")] - public TimelinePostNotExistException(string? message, Exception? inner) : this(null, null, false, message, inner) { } - protected TimelinePostNotExistException( - System.Runtime.Serialization.SerializationInfo info, - System.Runtime.Serialization.StreamingContext context) : base(info, context) { } - public TimelinePostNotExistException(long? timelineId, long? postId, bool isDelete, string? message = null, Exception? inner = null) - : base(EntityNames.TimelinePost, null, MakeMessage(timelineId, postId, isDelete).AppendAdditionalMessage(message), inner) - { - TimelineId = timelineId; - PostId = postId; - IsDelete = isDelete; - } - - private static string MakeMessage(long? timelineId, long? postId, bool isDelete) - { - return string.Format(CultureInfo.InvariantCulture, isDelete ? Resources.Services.Exceptions.TimelinePostNotExistExceptionDeleted : Resources.Services.Exceptions.TimelinePostNotExistException, timelineId, postId); - } - - public long? TimelineId { get; set; } - public long? PostId { get; set; } - - /// - /// True if the post is deleted. False if the post does not exist at all. - /// - public bool IsDelete { get; set; } - } -} diff --git a/BackEnd/Timeline/Services/Exceptions/UserNotExistException.cs b/BackEnd/Timeline/Services/Exceptions/UserNotExistException.cs deleted file mode 100644 index 7ef714df..00000000 --- a/BackEnd/Timeline/Services/Exceptions/UserNotExistException.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System; -using System.Globalization; - -namespace Timeline.Services.Exceptions -{ - /// - /// The user requested does not exist. - /// - [Serializable] - public class UserNotExistException : EntityNotExistException - { - public UserNotExistException() : this(null, null, null, null) { } - public UserNotExistException(string? username, Exception? inner) : this(username, null, null, inner) { } - - public UserNotExistException(string? username) : this(username, null, null, null) { } - - public UserNotExistException(long id) : this(null, id, null, null) { } - - public UserNotExistException(string? username, long? id, string? message, Exception? inner) : base(EntityNames.User, null, - string.Format(CultureInfo.InvariantCulture, Resources.Services.Exceptions.UserNotExistException, username ?? "", id).AppendAdditionalMessage(message), inner) - { - Username = username; - Id = id; - } - - protected UserNotExistException( - System.Runtime.Serialization.SerializationInfo info, - System.Runtime.Serialization.StreamingContext context) : base(info, context) { } - - /// - /// The username of the user that does not exist. - /// - public string? Username { get; set; } - - /// - /// The id of the user that does not exist. - /// - public long? Id { get; set; } - } -} diff --git a/BackEnd/Timeline/Services/HighlightTimelineService.cs b/BackEnd/Timeline/Services/HighlightTimelineService.cs deleted file mode 100644 index 557478c7..00000000 --- a/BackEnd/Timeline/Services/HighlightTimelineService.cs +++ /dev/null @@ -1,193 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Timeline.Entities; -using Timeline.Services.Exceptions; - -namespace Timeline.Services -{ - - [Serializable] - public class InvalidHighlightTimelineException : Exception - { - public InvalidHighlightTimelineException() { } - public InvalidHighlightTimelineException(string message) : base(message) { } - public InvalidHighlightTimelineException(string message, Exception inner) : base(message, inner) { } - protected InvalidHighlightTimelineException( - System.Runtime.Serialization.SerializationInfo info, - System.Runtime.Serialization.StreamingContext context) : base(info, context) { } - } - - /// - /// Service that controls highlight timeline. - /// - public interface IHighlightTimelineService - { - /// - /// Get all highlight timelines in order. - /// - /// Id list of all highlight timelines. - Task> GetHighlightTimelines(); - - /// - /// Check if a timeline is highlight timeline. - /// - /// Timeline id. - /// If true it will throw if timeline does not exist. - /// True if timeline is highlight. Otherwise false. - /// Thrown when timeline does not exist and is true. - Task IsHighlightTimeline(long timelineId, bool checkTimelineExistence = true); - - /// - /// Add a timeline to highlight list. - /// - /// The timeline id. - /// The user id of operator. - /// True if timeline is actually added to highligh. False if it already is. - /// Thrown when timeline with given id does not exist. - /// Thrown when user with given operator id does not exist. - Task AddHighlightTimeline(long timelineId, long? operatorId); - - /// - /// Remove a timeline from highlight list. - /// - /// The timeline id. - /// The user id of operator. - /// True if deletion is actually performed. Otherwise false (timeline was not in the list). - /// Thrown when timeline with given id does not exist. - /// Thrown when user with given operator id does not exist. - Task RemoveHighlightTimeline(long timelineId, long? operatorId); - - /// - /// Move a highlight timeline to a new position. - /// - /// The timeline name. - /// The new position. Starts at 1. - /// Thrown when timeline with given id does not exist. - /// Thrown when given timeline is not a highlight timeline. - /// - /// If is smaller than 1. Then move the timeline to head. - /// If is bigger than total count. Then move the timeline to tail. - /// - Task MoveHighlightTimeline(long timelineId, long newPosition); - } - - public class HighlightTimelineService : IHighlightTimelineService - { - private readonly DatabaseContext _database; - private readonly IBasicUserService _userService; - private readonly IBasicTimelineService _timelineService; - private readonly IClock _clock; - - public HighlightTimelineService(DatabaseContext database, IBasicUserService userService, IBasicTimelineService timelineService, IClock clock) - { - _database = database; - _userService = userService; - _timelineService = timelineService; - _clock = clock; - } - - public async Task AddHighlightTimeline(long timelineId, long? operatorId) - { - if (!await _timelineService.CheckExistence(timelineId)) - throw new TimelineNotExistException(timelineId); - - if (operatorId.HasValue && !await _userService.CheckUserExistence(operatorId.Value)) - { - throw new UserNotExistException(null, operatorId.Value, "User with given operator id does not exist.", null); - } - - var alreadyIs = await _database.HighlightTimelines.AnyAsync(t => t.TimelineId == timelineId); - - if (alreadyIs) return false; - - _database.HighlightTimelines.Add(new HighlightTimelineEntity { TimelineId = timelineId, OperatorId = operatorId, AddTime = _clock.GetCurrentTime(), Order = await _database.HighlightTimelines.CountAsync() + 1 }); - await _database.SaveChangesAsync(); - return true; - } - - public async Task> GetHighlightTimelines() - { - var entities = await _database.HighlightTimelines.OrderBy(t => t.Order).Select(t => new { t.TimelineId }).ToListAsync(); - - return entities.Select(e => e.TimelineId).ToList(); - } - - public async Task RemoveHighlightTimeline(long timelineId, long? operatorId) - { - if (!await _timelineService.CheckExistence(timelineId)) - throw new TimelineNotExistException(timelineId); - - if (operatorId.HasValue && !await _userService.CheckUserExistence(operatorId.Value)) - { - throw new UserNotExistException(null, operatorId.Value, "User with given operator id does not exist.", null); - } - - var entity = await _database.HighlightTimelines.SingleOrDefaultAsync(t => t.TimelineId == timelineId); - - if (entity == null) return false; - - await using var transaction = await _database.Database.BeginTransactionAsync(); - - var order = entity.Order; - - _database.HighlightTimelines.Remove(entity); - await _database.SaveChangesAsync(); - - await _database.Database.ExecuteSqlRawAsync("UPDATE highlight_timelines SET `order` = `order` - 1 WHERE `order` > {0}", order); - - await transaction.CommitAsync(); - - return true; - } - - public async Task MoveHighlightTimeline(long timelineId, long newPosition) - { - if (!await _timelineService.CheckExistence(timelineId)) - throw new TimelineNotExistException(timelineId); - - var entity = await _database.HighlightTimelines.SingleOrDefaultAsync(t => t.TimelineId == timelineId); - - if (entity == null) throw new InvalidHighlightTimelineException("You can't move a non-highlight timeline."); - - var oldPosition = entity.Order; - - if (newPosition < 1) - { - newPosition = 1; - } - else - { - var totalCount = await _database.HighlightTimelines.CountAsync(); - if (newPosition > totalCount) newPosition = totalCount; - } - - if (oldPosition == newPosition) return; - - await using var transaction = await _database.Database.BeginTransactionAsync(); - - if (newPosition > oldPosition) - { - await _database.Database.ExecuteSqlRawAsync("UPDATE highlight_timelines SET `order` = `order` - 1 WHERE `order` BETWEEN {0} AND {1}", oldPosition + 1, newPosition); - await _database.Database.ExecuteSqlRawAsync("UPDATE highlight_timelines SET `order` = {0} WHERE id = {1}", newPosition, entity.Id); - } - else - { - await _database.Database.ExecuteSqlRawAsync("UPDATE highlight_timelines SET `order` = `order` + 1 WHERE `order` BETWEEN {0} AND {1}", newPosition, oldPosition - 1); - await _database.Database.ExecuteSqlRawAsync("UPDATE highlight_timelines SET `order` = {0} WHERE id = {1}", newPosition, entity.Id); - } - - await transaction.CommitAsync(); - } - - public async Task IsHighlightTimeline(long timelineId, bool checkTimelineExistence = true) - { - if (checkTimelineExistence && !await _timelineService.CheckExistence(timelineId)) - throw new TimelineNotExistException(timelineId); - - return await _database.HighlightTimelines.AnyAsync(t => t.TimelineId == timelineId); - } - } -} diff --git a/BackEnd/Timeline/Services/ImageValidator.cs b/BackEnd/Timeline/Services/ImageValidator.cs deleted file mode 100644 index 59424a7c..00000000 --- a/BackEnd/Timeline/Services/ImageValidator.cs +++ /dev/null @@ -1,54 +0,0 @@ -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.Formats; -using System; -using System.Linq; -using System.Threading.Tasks; -using Timeline.Services.Exceptions; - -namespace Timeline.Services -{ - public interface IImageValidator - { - /// - /// Validate a image data. - /// - /// The data of the image. Can't be null. - /// If not null, the real image format will be check against the requested format and throw if not match. If null, then do not check. - /// If true, image must be square. - /// The format. - /// Thrown when is null. - /// Thrown when image data can't be decoded or real type does not match request type or image is not square when required. - Task Validate(byte[] data, string? requestType = null, bool square = false); - } - - public class ImageValidator : IImageValidator - { - public ImageValidator() - { - } - - public async Task Validate(byte[] data, string? requestType = null, bool square = false) - { - if (data == null) - throw new ArgumentNullException(nameof(data)); - - var format = await Task.Run(() => - { - try - { - using var image = Image.Load(data, out IImageFormat format); - if (requestType != null && !format.MimeTypes.Contains(requestType)) - throw new ImageException(ImageException.ErrorReason.UnmatchedFormat, data, requestType, format.DefaultMimeType); - if (square && image.Width != image.Height) - throw new ImageException(ImageException.ErrorReason.NotSquare, data, requestType, format.DefaultMimeType); - return format; - } - catch (UnknownImageFormatException e) - { - throw new ImageException(ImageException.ErrorReason.CantDecode, data, requestType, null, null, e); - } - }); - return format; - } - } -} diff --git a/BackEnd/Timeline/Services/Imaging/ImageException.cs b/BackEnd/Timeline/Services/Imaging/ImageException.cs new file mode 100644 index 00000000..926ecc0a --- /dev/null +++ b/BackEnd/Timeline/Services/Imaging/ImageException.cs @@ -0,0 +1,58 @@ +using System; +using System.Globalization; + +namespace Timeline.Services.Imaging +{ + [Serializable] + public class ImageException : Exception + { + public enum ErrorReason + { + /// + /// Decoding image failed. + /// + CantDecode, + /// + /// Decoding succeeded but the real type is not the specified type. + /// + UnmatchedFormat, + /// + /// Image is not of required size. + /// + NotSquare, + /// + /// Other unknown errer. + /// + Unknown + } + + public ImageException() : this(null) { } + public ImageException(string? message) : this(message, null) { } + public ImageException(string? message, Exception? inner) : this(ErrorReason.Unknown, null, null, null, message, inner) { } + + public ImageException(ErrorReason error, byte[]? data, string? requestType, string? realType, Exception? inner) : this(error, data, requestType, realType, null, inner) { } + public ImageException(ErrorReason error, byte[]? data, string? requestType = null, string? realType = null, string? message = null, Exception? inner = null) : base(message ?? MakeMessage(error), inner) { Error = error; ImageData = data; RequestType = requestType; RealType = realType; } + + protected ImageException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + + private static string MakeMessage(ErrorReason? reason) => + string.Format(CultureInfo.InvariantCulture, Resource.ExceptionImage, reason switch + { + ErrorReason.CantDecode => Resource.ExceptionImageReasonCantDecode, + ErrorReason.UnmatchedFormat => Resource.ExceptionImageReasonUnmatchedFormat, + ErrorReason.NotSquare => Resource.ExceptionImageReasonBadSize, + _ => Resource.ExceptionImageReasonUnknownError + }); + + public ErrorReason Error { get; } +#pragma warning disable CA1819 // Properties should not return arrays + public byte[]? ImageData { get; } +#pragma warning restore CA1819 // Properties should not return arrays + public string? RequestType { get; } + + // This field will be null if decoding failed. + public string? RealType { get; } + } +} diff --git a/BackEnd/Timeline/Services/Imaging/ImageValidator.cs b/BackEnd/Timeline/Services/Imaging/ImageValidator.cs new file mode 100644 index 00000000..b4ae68dc --- /dev/null +++ b/BackEnd/Timeline/Services/Imaging/ImageValidator.cs @@ -0,0 +1,53 @@ +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats; +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace Timeline.Services.Imaging +{ + public interface IImageValidator + { + /// + /// Validate a image data. + /// + /// The data of the image. Can't be null. + /// If not null, the real image format will be check against the requested format and throw if not match. If null, then do not check. + /// If true, image must be square. + /// The format. + /// Thrown when is null. + /// Thrown when image data can't be decoded or real type does not match request type or image is not square when required. + Task Validate(byte[] data, string? requestType = null, bool square = false); + } + + public class ImageValidator : IImageValidator + { + public ImageValidator() + { + } + + public async Task Validate(byte[] data, string? requestType = null, bool square = false) + { + if (data == null) + throw new ArgumentNullException(nameof(data)); + + var format = await Task.Run(() => + { + try + { + using var image = Image.Load(data, out IImageFormat format); + if (requestType != null && !format.MimeTypes.Contains(requestType)) + throw new ImageException(ImageException.ErrorReason.UnmatchedFormat, data, requestType, format.DefaultMimeType); + if (square && image.Width != image.Height) + throw new ImageException(ImageException.ErrorReason.NotSquare, data, requestType, format.DefaultMimeType); + return format; + } + catch (UnknownImageFormatException e) + { + throw new ImageException(ImageException.ErrorReason.CantDecode, data, requestType, null, null, e); + } + }); + return format; + } + } +} diff --git a/BackEnd/Timeline/Services/Imaging/Resource.Designer.cs b/BackEnd/Timeline/Services/Imaging/Resource.Designer.cs new file mode 100644 index 00000000..e9218208 --- /dev/null +++ b/BackEnd/Timeline/Services/Imaging/Resource.Designer.cs @@ -0,0 +1,108 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Timeline.Services.Imaging { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resource { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resource() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Timeline.Services.Imaging.Resource", typeof(Resource).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to Image is in valid because {0}.. + /// + internal static string ExceptionImage { + get { + return ResourceManager.GetString("ExceptionImage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to image is not of required size. + /// + internal static string ExceptionImageReasonBadSize { + get { + return ResourceManager.GetString("ExceptionImageReasonBadSize", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to failed to decode image, see inner exception. + /// + internal static string ExceptionImageReasonCantDecode { + get { + return ResourceManager.GetString("ExceptionImageReasonCantDecode", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to unknown error. + /// + internal static string ExceptionImageReasonUnknownError { + get { + return ResourceManager.GetString("ExceptionImageReasonUnknownError", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to image's actual mime type is not the specified one. + /// + internal static string ExceptionImageReasonUnmatchedFormat { + get { + return ResourceManager.GetString("ExceptionImageReasonUnmatchedFormat", resourceCulture); + } + } + } +} diff --git a/BackEnd/Timeline/Services/Imaging/Resource.resx b/BackEnd/Timeline/Services/Imaging/Resource.resx new file mode 100644 index 00000000..060e2a91 --- /dev/null +++ b/BackEnd/Timeline/Services/Imaging/Resource.resx @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Image is in valid because {0}. + + + image is not of required size + + + failed to decode image, see inner exception + + + unknown error + + + image's actual mime type is not the specified one + + \ No newline at end of file diff --git a/BackEnd/Timeline/Services/Mapper/MapperServiceCollectionExtensions.cs b/BackEnd/Timeline/Services/Mapper/MapperServiceCollectionExtensions.cs new file mode 100644 index 00000000..262b2f20 --- /dev/null +++ b/BackEnd/Timeline/Services/Mapper/MapperServiceCollectionExtensions.cs @@ -0,0 +1,13 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Timeline.Services.Mapper +{ + public static class MapperServiceCollectionExtensions + { + public static void AddMappers(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + } + } +} diff --git a/BackEnd/Timeline/Services/Mapper/TimelineMapper.cs b/BackEnd/Timeline/Services/Mapper/TimelineMapper.cs new file mode 100644 index 00000000..5d823a04 --- /dev/null +++ b/BackEnd/Timeline/Services/Mapper/TimelineMapper.cs @@ -0,0 +1,158 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Timeline.Controllers; +using Timeline.Entities; +using Timeline.Models.Http; +using Timeline.Services.Api; +using Timeline.Services.Timeline; + +namespace Timeline.Services.Mapper +{ + public class TimelineMapper + { + private readonly DatabaseContext _database; + private readonly UserMapper _userMapper; + private readonly IHighlightTimelineService _highlightTimelineService; + private readonly IBookmarkTimelineService _bookmarkTimelineService; + private readonly ITimelineService _timelineService; + private readonly ITimelinePostService _timelinePostService; + + public TimelineMapper(DatabaseContext database, UserMapper userMapper, IHighlightTimelineService highlightTimelineService, IBookmarkTimelineService bookmarkTimelineService, ITimelineService timelineService, ITimelinePostService timelinePostService) + { + _database = database; + _userMapper = userMapper; + _highlightTimelineService = highlightTimelineService; + _bookmarkTimelineService = bookmarkTimelineService; + _timelineService = timelineService; + _timelinePostService = timelinePostService; + } + + public async Task MapToHttp(TimelineEntity entity, IUrlHelper urlHelper, long? userId, bool isAdministrator) + { + await _database.Entry(entity).Reference(e => e.Owner).LoadAsync(); + await _database.Entry(entity).Collection(e => e.Members).Query().Include(m => m.User).LoadAsync(); + + var timelineName = entity.Name is null ? "@" + entity.Owner.Username : entity.Name; + + bool manageable; + + if (userId is null) + { + manageable = false; + } + else if (isAdministrator) + { + manageable = true; + } + else + { + manageable = await _timelineService.HasManagePermission(entity.Id, userId.Value); + } + + bool postable; + if (userId is null) + { + postable = false; + } + else + { + postable = await _timelineService.IsMemberOf(entity.Id, userId.Value); + } + + return new HttpTimeline( + uniqueId: entity.UniqueId, + title: string.IsNullOrEmpty(entity.Title) ? timelineName : entity.Title, + name: timelineName, + nameLastModifed: entity.NameLastModified, + description: entity.Description ?? "", + owner: await _userMapper.MapToHttp(entity.Owner, urlHelper), + visibility: entity.Visibility, + members: await _userMapper.MapToHttp(entity.Members.Select(m => m.User).ToList(), urlHelper), + color: entity.Color, + createTime: entity.CreateTime, + lastModified: entity.LastModified, + isHighlight: await _highlightTimelineService.IsHighlightTimeline(entity.Id), + isBookmark: userId is not null && await _bookmarkTimelineService.IsBookmark(userId.Value, entity.Id, false, false), + manageable: manageable, + postable: postable, + links: new HttpTimelineLinks( + self: urlHelper.ActionLink(nameof(TimelineController.TimelineGet), nameof(TimelineController)[0..^nameof(Controller).Length], new { timeline = timelineName }), + posts: urlHelper.ActionLink(nameof(TimelinePostController.List), nameof(TimelinePostController)[0..^nameof(Controller).Length], new { timeline = timelineName }) + ) + ); + } + + public async Task> MapToHttp(List entities, IUrlHelper urlHelper, long? userId, bool isAdministrator) + { + var result = new List(); + foreach (var entity in entities) + { + result.Add(await MapToHttp(entity, urlHelper, userId, isAdministrator)); + } + return result; + } + + + public async Task MapToHttp(TimelinePostEntity entity, string timelineName, IUrlHelper urlHelper, long? userId, bool isAdministrator) + { + _ = timelineName; + + await _database.Entry(entity).Collection(p => p.DataList).LoadAsync(); + await _database.Entry(entity).Reference(e => e.Author).LoadAsync(); + + List dataDigestList = entity.DataList.OrderBy(d => d.Index).Select(d => new HttpTimelinePostDataDigest(d.Kind, $"\"{d.DataTag}\"", d.LastUpdated)).ToList(); + + HttpUser? author = null; + if (entity.Author is not null) + { + author = await _userMapper.MapToHttp(entity.Author, urlHelper); + } + + bool editable; + + if (userId is null) + { + editable = false; + } + else if (isAdministrator) + { + editable = true; + } + else + { + editable = await _timelinePostService.HasPostModifyPermission(entity.TimelineId, entity.LocalId, userId.Value); + } + + + return new HttpTimelinePost( + id: entity.LocalId, + dataList: dataDigestList, + time: entity.Time, + author: author, + color: entity.Color, + deleted: entity.Deleted, + lastUpdated: entity.LastUpdated, + timelineName: timelineName, + editable: editable + ); + } + + public async Task> MapToHttp(List entities, string timelineName, IUrlHelper urlHelper, long? userId, bool isAdministrator) + { + var result = new List(); + foreach (var entity in entities) + { + result.Add(await MapToHttp(entity, timelineName, urlHelper, userId, isAdministrator)); + } + return result; + } + + internal Task MapToHttp(TimelinePostEntity post, string timeline, IUrlHelper url) + { + throw new System.NotImplementedException(); + } + } +} diff --git a/BackEnd/Timeline/Services/Mapper/UserMapper.cs b/BackEnd/Timeline/Services/Mapper/UserMapper.cs new file mode 100644 index 00000000..42f88d8a --- /dev/null +++ b/BackEnd/Timeline/Services/Mapper/UserMapper.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Mvc; +using System.Collections.Generic; +using System.Threading.Tasks; +using Timeline.Controllers; +using Timeline.Entities; +using Timeline.Models.Http; +using Timeline.Services.User; + +namespace Timeline.Services.Mapper +{ + public class UserMapper + { + private readonly DatabaseContext _database; + private readonly IUserPermissionService _userPermissionService; + + public UserMapper(DatabaseContext database, IUserPermissionService userPermissionService) + { + _database = database; + _userPermissionService = userPermissionService; + } + + public async Task MapToHttp(UserEntity entity, IUrlHelper urlHelper) + { + return new HttpUser( + uniqueId: entity.UniqueId, + username: entity.Username, + nickname: string.IsNullOrEmpty(entity.Nickname) ? entity.Username : entity.Nickname, + permissions: (await _userPermissionService.GetPermissionsOfUserAsync(entity.Id, false)).ToStringList(), + links: new HttpUserLinks( + self: urlHelper.ActionLink(nameof(UserController.Get), nameof(UserController)[0..^nameof(Controller).Length], new { entity.Username }), + avatar: urlHelper.ActionLink(nameof(UserAvatarController.Get), nameof(UserAvatarController)[0..^nameof(Controller).Length], new { entity.Username }), + timeline: urlHelper.ActionLink(nameof(TimelineController.TimelineGet), nameof(TimelineController)[0..^nameof(Controller).Length], new { timeline = "@" + entity.Username }) + ) + ); + } + + public async Task> MapToHttp(List entities, IUrlHelper urlHelper) + { + var result = new List(); + foreach (var entity in entities) + { + result.Add(await MapToHttp(entity, urlHelper)); + } + return result; + } + } +} diff --git a/BackEnd/Timeline/Services/MarkdownProcessor.cs b/BackEnd/Timeline/Services/MarkdownProcessor.cs deleted file mode 100644 index f34432cd..00000000 --- a/BackEnd/Timeline/Services/MarkdownProcessor.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Text; -using Markdig; -using Markdig.Renderers.Normalize; -using Markdig.Syntax; -using Markdig.Syntax.Inlines; -using Microsoft.AspNetCore.Mvc; -using Timeline.Controllers; - -namespace Timeline.Services -{ - public class MarkdownProcessor - { - public string Process(string text, Func urlGenerator) - { - MarkdownDocument markdown = Markdown.Parse(text); - foreach (var link in markdown.Descendants().Where(e => e is LinkInline).Cast()) - { - if (int.TryParse(link.Url, out var dataIndex)) - { - link.Url = urlGenerator(dataIndex); - } - } - - var writer = new StringWriter(); - NormalizeRenderer renderer = new NormalizeRenderer(writer); - renderer.Render(markdown); - - return writer.ToString(); - } - - /// Convert data url to true url with post id. - public string Process(string text, IUrlHelper url, string timeline, long post) - { - return Process( - text, - dataIndex => url.ActionLink( - nameof(TimelinePostController.DataGet), - nameof(TimelinePostController)[0..^nameof(Controller).Length], - new { timeline, post, data_index = dataIndex } - ) - ); - } - - public byte[] Process(byte[] data, IUrlHelper url, string timeline, long post) - { - return Encoding.UTF8.GetBytes(Process(Encoding.UTF8.GetString(data), url, timeline, post)); - } - } -} diff --git a/BackEnd/Timeline/Services/PasswordBadFormatException.cs b/BackEnd/Timeline/Services/PasswordBadFormatException.cs deleted file mode 100644 index 2029ebb4..00000000 --- a/BackEnd/Timeline/Services/PasswordBadFormatException.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System; - -namespace Timeline.Services -{ - - [Serializable] - public class PasswordBadFormatException : Exception - { - public PasswordBadFormatException() : base(Resources.Services.Exception.PasswordBadFormatException) { } - public PasswordBadFormatException(string message) : base(message) { } - public PasswordBadFormatException(string message, Exception inner) : base(message, inner) { } - - public PasswordBadFormatException(string password, string validationMessage) : this() - { - Password = password; - ValidationMessage = validationMessage; - } - - protected PasswordBadFormatException( - System.Runtime.Serialization.SerializationInfo info, - System.Runtime.Serialization.StreamingContext context) : base(info, context) { } - - public string Password { get; set; } = ""; - - public string ValidationMessage { get; set; } = ""; - } -} diff --git a/BackEnd/Timeline/Services/PasswordService.cs b/BackEnd/Timeline/Services/PasswordService.cs deleted file mode 100644 index 8114a520..00000000 --- a/BackEnd/Timeline/Services/PasswordService.cs +++ /dev/null @@ -1,224 +0,0 @@ -using Microsoft.AspNetCore.Cryptography.KeyDerivation; -using System; -using System.Runtime.CompilerServices; -using System.Security.Cryptography; - -namespace Timeline.Services -{ - /// - /// Hashed password is of bad format. - /// - /// - [Serializable] - public class HashedPasswordBadFromatException : Exception - { - private static string MakeMessage(string reason) - { - return Resources.Services.Exception.HashedPasswordBadFromatException + " Reason: " + reason; - } - - public HashedPasswordBadFromatException() : base(Resources.Services.Exception.HashedPasswordBadFromatException) { } - - public HashedPasswordBadFromatException(string message) : base(message) { } - public HashedPasswordBadFromatException(string message, Exception inner) : base(message, inner) { } - - public HashedPasswordBadFromatException(string hashedPassword, string reason) : base(MakeMessage(reason)) { HashedPassword = hashedPassword; } - public HashedPasswordBadFromatException(string hashedPassword, string reason, Exception inner) : base(MakeMessage(reason), inner) { HashedPassword = hashedPassword; } - protected HashedPasswordBadFromatException( - System.Runtime.Serialization.SerializationInfo info, - System.Runtime.Serialization.StreamingContext context) : base(info, context) { } - - public string? HashedPassword { get; set; } - } - - public interface IPasswordService - { - /// - /// Hash a password. - /// - /// The password to hash. - /// A hashed representation of the supplied . - /// Thrown when is null. - string HashPassword(string password); - - /// - /// Verify whether the password fits into the hashed one. - /// - /// Usually you only need to check the returned bool value. - /// Catching usually is not necessary. - /// Because if your program logic is right and always call - /// and in pair, this exception will never be thrown. - /// A thrown one usually means the data you saved is corupted, which is a critical problem. - /// - /// The hashed password. - /// The password supplied for comparison. - /// True indicating password is right. Otherwise false. - /// Thrown when or is null. - /// Thrown when the hashed password is of bad format. - bool VerifyPassword(string hashedPassword, string providedPassword); - } - - /// - /// Copied from https://github.com/aspnet/AspNetCore/blob/master/src/Identity/Extensions.Core/src/PasswordHasher.cs - /// Remove V2 format and unnecessary format version check. - /// Remove configuration options. - /// Remove user related parts. - /// Change the exceptions. - /// - public class PasswordService : IPasswordService - { - /* ======================= - * HASHED PASSWORD FORMATS - * ======================= - * - * Version 3: - * PBKDF2 with HMAC-SHA256, 128-bit salt, 256-bit subkey, 10000 iterations. - * Format: { 0x01, prf (UInt32), iter count (UInt32), salt length (UInt32), salt, subkey } - * (All UInt32s are stored big-endian.) - */ - - private readonly RandomNumberGenerator _rng = RandomNumberGenerator.Create(); - - public PasswordService() - { - } - - // Compares two byte arrays for equality. The method is specifically written so that the loop is not optimized. - [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)] - private static bool ByteArraysEqual(byte[] a, byte[] b) - { - if (a == null && b == null) - { - return true; - } - if (a == null || b == null || a.Length != b.Length) - { - return false; - } - var areSame = true; - for (var i = 0; i < a.Length; i++) - { - areSame &= (a[i] == b[i]); - } - return areSame; - } - - public string HashPassword(string password) - { - if (password == null) - throw new ArgumentNullException(nameof(password)); - return Convert.ToBase64String(HashPasswordV3(password, _rng)); - } - - private static byte[] HashPasswordV3(string password, RandomNumberGenerator rng) - { - return HashPasswordV3(password, rng, - prf: KeyDerivationPrf.HMACSHA256, - iterCount: 10000, - saltSize: 128 / 8, - numBytesRequested: 256 / 8); - } - - private static byte[] HashPasswordV3(string password, RandomNumberGenerator rng, KeyDerivationPrf prf, int iterCount, int saltSize, int numBytesRequested) - { - // Produce a version 3 (see comment above) text hash. - byte[] salt = new byte[saltSize]; - rng.GetBytes(salt); - byte[] subkey = KeyDerivation.Pbkdf2(password, salt, prf, iterCount, numBytesRequested); - - var outputBytes = new byte[13 + salt.Length + subkey.Length]; - outputBytes[0] = 0x01; // format marker - WriteNetworkByteOrder(outputBytes, 1, (uint)prf); - WriteNetworkByteOrder(outputBytes, 5, (uint)iterCount); - WriteNetworkByteOrder(outputBytes, 9, (uint)saltSize); - Buffer.BlockCopy(salt, 0, outputBytes, 13, salt.Length); - Buffer.BlockCopy(subkey, 0, outputBytes, 13 + saltSize, subkey.Length); - return outputBytes; - } - - public bool VerifyPassword(string hashedPassword, string providedPassword) - { - if (hashedPassword == null) - throw new ArgumentNullException(nameof(hashedPassword)); - if (providedPassword == null) - throw new ArgumentNullException(nameof(providedPassword)); - - byte[] decodedHashedPassword; - try - { - decodedHashedPassword = Convert.FromBase64String(hashedPassword); - } - catch (FormatException e) - { - throw new HashedPasswordBadFromatException(hashedPassword, Resources.Services.Exception.HashedPasswordBadFromatExceptionNotBase64, e); - } - - // read the format marker from the hashed password - if (decodedHashedPassword.Length == 0) - { - throw new HashedPasswordBadFromatException(hashedPassword, Resources.Services.Exception.HashedPasswordBadFromatExceptionNotLength0); - } - - return (decodedHashedPassword[0]) switch - { - 0x01 => VerifyHashedPasswordV3(decodedHashedPassword, providedPassword, hashedPassword), - _ => throw new HashedPasswordBadFromatException(hashedPassword, Resources.Services.Exception.HashedPasswordBadFromatExceptionNotUnknownMarker), - }; - } - - private static bool VerifyHashedPasswordV3(byte[] hashedPassword, string password, string hashedPasswordString) - { - try - { - // Read header information - KeyDerivationPrf prf = (KeyDerivationPrf)ReadNetworkByteOrder(hashedPassword, 1); - int iterCount = (int)ReadNetworkByteOrder(hashedPassword, 5); - int saltLength = (int)ReadNetworkByteOrder(hashedPassword, 9); - - // Read the salt: must be >= 128 bits - if (saltLength < 128 / 8) - { - throw new HashedPasswordBadFromatException(hashedPasswordString, Resources.Services.Exception.HashedPasswordBadFromatExceptionNotSaltTooShort); - } - byte[] salt = new byte[saltLength]; - Buffer.BlockCopy(hashedPassword, 13, salt, 0, salt.Length); - - // Read the subkey (the rest of the payload): must be >= 128 bits - int subkeyLength = hashedPassword.Length - 13 - salt.Length; - if (subkeyLength < 128 / 8) - { - throw new HashedPasswordBadFromatException(hashedPasswordString, Resources.Services.Exception.HashedPasswordBadFromatExceptionNotSubkeyTooShort); - } - byte[] expectedSubkey = new byte[subkeyLength]; - Buffer.BlockCopy(hashedPassword, 13 + salt.Length, expectedSubkey, 0, expectedSubkey.Length); - - // Hash the incoming password and verify it - byte[] actualSubkey = KeyDerivation.Pbkdf2(password, salt, prf, iterCount, subkeyLength); - return ByteArraysEqual(actualSubkey, expectedSubkey); - } - catch (Exception e) - { - // This should never occur except in the case of a malformed payload, where - // we might go off the end of the array. Regardless, a malformed payload - // implies verification failed. - throw new HashedPasswordBadFromatException(hashedPasswordString, Resources.Services.Exception.HashedPasswordBadFromatExceptionNotOthers, e); - } - } - - private static uint ReadNetworkByteOrder(byte[] buffer, int offset) - { - return ((uint)(buffer[offset + 0]) << 24) - | ((uint)(buffer[offset + 1]) << 16) - | ((uint)(buffer[offset + 2]) << 8) - | ((uint)(buffer[offset + 3])); - } - - private static void WriteNetworkByteOrder(byte[] buffer, int offset, uint value) - { - buffer[offset + 0] = (byte)(value >> 24); - buffer[offset + 1] = (byte)(value >> 16); - buffer[offset + 2] = (byte)(value >> 8); - buffer[offset + 3] = (byte)(value >> 0); - } - } -} diff --git a/BackEnd/Timeline/Services/Resource.Designer.cs b/BackEnd/Timeline/Services/Resource.Designer.cs new file mode 100644 index 00000000..def89b4f --- /dev/null +++ b/BackEnd/Timeline/Services/Resource.Designer.cs @@ -0,0 +1,81 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Timeline.Services { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resource { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resource() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Timeline.Services.Resource", typeof(Resource).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to A entity with given constraints already exists.. + /// + internal static string ExceptionEntityAlreadyExist { + get { + return ResourceManager.GetString("ExceptionEntityAlreadyExist", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Requested entity does not exist.. + /// + internal static string ExceptionEntityNotExist { + get { + return ResourceManager.GetString("ExceptionEntityNotExist", resourceCulture); + } + } + } +} diff --git a/BackEnd/Timeline/Services/Resource.resx b/BackEnd/Timeline/Services/Resource.resx new file mode 100644 index 00000000..526a2c29 --- /dev/null +++ b/BackEnd/Timeline/Services/Resource.resx @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + A entity with given constraints already exists. + + + Requested entity does not exist. + + \ No newline at end of file diff --git a/BackEnd/Timeline/Services/SearchService.cs b/BackEnd/Timeline/Services/SearchService.cs deleted file mode 100644 index 680ef9e3..00000000 --- a/BackEnd/Timeline/Services/SearchService.cs +++ /dev/null @@ -1,104 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Timeline.Entities; - -namespace Timeline.Services -{ - public class SearchResultItem - { - public SearchResultItem(TItem item, int score) - { - Item = item; - Score = score; - } - - public TItem Item { get; set; } = default!; - - /// - /// Bigger is better. - /// - public int Score { get; set; } - } - - public class SearchResult - { -#pragma warning disable CA2227 // Collection properties should be read only - public List> Items { get; set; } = new(); -#pragma warning restore CA2227 // Collection properties should be read only - } - - public interface ISearchService - { - /// - /// Search timelines whose name or title contains query string. - /// - /// String to contain. - /// Search results. - /// Thrown when is null. - /// Thrown when is empty. - /// - /// Implementation should promise high score is at first. - /// - Task> SearchTimeline(string query); - - /// - /// Search users whose username or nickname contains query string. - /// - /// String to contain. - /// Search results. - /// Thrown when is null. - /// Thrown when is empty. - /// - /// Implementation should promise high score is at first. - /// - Task> SearchUser(string query); - } - - public class SearchService : ISearchService - { - private readonly DatabaseContext _database; - - public SearchService(DatabaseContext database) - { - _database = database; - } - - public async Task> SearchTimeline(string query) - { - if (query is null) - throw new ArgumentNullException(nameof(query)); - if (query.Length == 0) - throw new ArgumentException("Query string can't be empty.", nameof(query)); - - var nameLikeTimelines = await _database.Timelines.Include(t => t.Owner).Where(t => t.Name == null ? t.Owner.Username.Contains(query) : t.Name.Contains(query)).ToListAsync(); - var titleLikeTimelines = await _database.Timelines.Where(t => t.Title != null && t.Title.Contains(query)).ToListAsync(); - - var searchResult = new SearchResult(); - searchResult.Items.AddRange(nameLikeTimelines.Select(t => new SearchResultItem(t, 2))); - searchResult.Items.AddRange(titleLikeTimelines.Select(t => new SearchResultItem(t, 1))); - - return searchResult; - } - - public async Task> SearchUser(string query) - { - if (query is null) - throw new ArgumentNullException(nameof(query)); - if (query.Length == 0) - throw new ArgumentException("Query string can't be empty.", nameof(query)); - - var usernameLikeUsers = await _database.Users.Where(u => u.Username.Contains(query)).ToListAsync(); - var nicknameLikeUsers = await _database.Users.Where(u => u.Nickname != null && u.Nickname.Contains(query)).ToListAsync(); - - var searchResult = new SearchResult(); - searchResult.Items.AddRange(usernameLikeUsers.Select(u => new SearchResultItem(u, 2))); - searchResult.Items.AddRange(nicknameLikeUsers.Select(u => new SearchResultItem(u, 1))); - - return searchResult; - - } - } -} diff --git a/BackEnd/Timeline/Services/Timeline/BasicTimelineService.cs b/BackEnd/Timeline/Services/Timeline/BasicTimelineService.cs new file mode 100644 index 00000000..f917b176 --- /dev/null +++ b/BackEnd/Timeline/Services/Timeline/BasicTimelineService.cs @@ -0,0 +1,134 @@ +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Timeline.Entities; +using Timeline.Models; +using Timeline.Models.Validation; +using Timeline.Services.User; + +namespace Timeline.Services.Timeline +{ + /// + /// This service provide some basic timeline functions, which should be used internally for other services. + /// + public interface IBasicTimelineService + { + /// + /// Check whether a timeline with given id exists without getting full info. + /// + /// The timeline id. + /// True if exist. Otherwise false. + Task CheckExistence(long id); + + /// + /// Get the timeline id by name. + /// + /// Timeline name. + /// Id of the timeline. + /// Thrown when is null. + /// Throw when is of bad format. + /// + /// Thrown when timeline with name does not exist. + /// If it is a personal timeline, then inner exception is . + /// + /// + /// If name is of personal timeline and the timeline does not exist, it will be created if user exists. + /// If the user does not exist, will be thrown with as inner exception. + /// + Task GetTimelineIdByName(string timelineName); + } + + + public class BasicTimelineService : IBasicTimelineService + { + private readonly DatabaseContext _database; + + private readonly IBasicUserService _basicUserService; + private readonly IClock _clock; + + private readonly GeneralTimelineNameValidator _generalTimelineNameValidator = new GeneralTimelineNameValidator(); + + public BasicTimelineService(DatabaseContext database, IBasicUserService basicUserService, IClock clock) + { + _database = database; + _basicUserService = basicUserService; + _clock = clock; + } + + protected TimelineEntity CreateNewTimelineEntity(string? name, long ownerId) + { + var currentTime = _clock.GetCurrentTime(); + + return new TimelineEntity + { + Name = name, + NameLastModified = currentTime, + OwnerId = ownerId, + Visibility = TimelineVisibility.Register, + CreateTime = currentTime, + LastModified = currentTime, + CurrentPostLocalId = 0, + Members = new List() + }; + } + + public async Task CheckExistence(long id) + { + return await _database.Timelines.AnyAsync(t => t.Id == id); + } + + public async Task GetTimelineIdByName(string timelineName) + { + if (timelineName == null) + throw new ArgumentNullException(nameof(timelineName)); + + if (!_generalTimelineNameValidator.Validate(timelineName, out var message)) + throw new ArgumentException(message); + + timelineName = TimelineHelper.ExtractTimelineName(timelineName, out var isPersonal); + + if (isPersonal) + { + long userId; + try + { + userId = await _basicUserService.GetUserIdByUsername(timelineName); + } + catch (UserNotExistException e) + { + throw new TimelineNotExistException(timelineName, e); + } + + var timelineEntity = await _database.Timelines.Where(t => t.OwnerId == userId && t.Name == null).Select(t => new { t.Id }).SingleOrDefaultAsync(); + + if (timelineEntity != null) + { + return timelineEntity.Id; + } + else + { + var newTimelineEntity = CreateNewTimelineEntity(null, userId); + _database.Timelines.Add(newTimelineEntity); + await _database.SaveChangesAsync(); + + return newTimelineEntity.Id; + } + } + else + { + var timelineEntity = await _database.Timelines.Where(t => t.Name == timelineName).Select(t => new { t.Id }).SingleOrDefaultAsync(); + + if (timelineEntity == null) + { + throw new TimelineNotExistException(timelineName); + } + else + { + return timelineEntity.Id; + } + } + } + } +} diff --git a/BackEnd/Timeline/Services/Timeline/MarkdownProcessor.cs b/BackEnd/Timeline/Services/Timeline/MarkdownProcessor.cs new file mode 100644 index 00000000..d338ba5e --- /dev/null +++ b/BackEnd/Timeline/Services/Timeline/MarkdownProcessor.cs @@ -0,0 +1,52 @@ +using Markdig; +using Markdig.Renderers.Normalize; +using Markdig.Syntax; +using Markdig.Syntax.Inlines; +using Microsoft.AspNetCore.Mvc; +using System; +using System.IO; +using System.Linq; +using System.Text; +using Timeline.Controllers; + +namespace Timeline.Services.Timeline +{ + public class MarkdownProcessor + { + public string Process(string text, Func urlGenerator) + { + MarkdownDocument markdown = Markdown.Parse(text); + foreach (var link in markdown.Descendants().Where(e => e is LinkInline).Cast()) + { + if (int.TryParse(link.Url, out var dataIndex)) + { + link.Url = urlGenerator(dataIndex); + } + } + + var writer = new StringWriter(); + NormalizeRenderer renderer = new NormalizeRenderer(writer); + renderer.Render(markdown); + + return writer.ToString(); + } + + /// Convert data url to true url with post id. + public string Process(string text, IUrlHelper url, string timeline, long post) + { + return Process( + text, + dataIndex => url.ActionLink( + nameof(TimelinePostController.DataGet), + nameof(TimelinePostController)[0..^nameof(Controller).Length], + new { timeline, post, data_index = dataIndex } + ) + ); + } + + public byte[] Process(byte[] data, IUrlHelper url, string timeline, long post) + { + return Encoding.UTF8.GetBytes(Process(Encoding.UTF8.GetString(data), url, timeline, post)); + } + } +} diff --git a/BackEnd/Timeline/Services/Timeline/Resource.Designer.cs b/BackEnd/Timeline/Services/Timeline/Resource.Designer.cs new file mode 100644 index 00000000..31fd6320 --- /dev/null +++ b/BackEnd/Timeline/Services/Timeline/Resource.Designer.cs @@ -0,0 +1,117 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Timeline.Services.Timeline { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resource { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resource() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Timeline.Services.Timeline.Resource", typeof(Resource).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to Timeline with given constraints already exist.. + /// + internal static string ExceptionTimelineAlreadyExist { + get { + return ResourceManager.GetString("ExceptionTimelineAlreadyExist", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Timeline name is of bad format. {0}. + /// + internal static string ExceptionTimelineNameBadFormat { + get { + return ResourceManager.GetString("ExceptionTimelineNameBadFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Requested timeline does not exist.. + /// + internal static string ExceptionTimelineNotExist { + get { + return ResourceManager.GetString("ExceptionTimelineNotExist", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Requested timeline post does not exist because {0}.. + /// + internal static string ExceptionTimelinePostNoExist { + get { + return ResourceManager.GetString("ExceptionTimelinePostNoExist", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to it is deleted. + /// + internal static string ExceptionTimelinePostNoExistReasonDeleted { + get { + return ResourceManager.GetString("ExceptionTimelinePostNoExistReasonDeleted", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to it has not been created. + /// + internal static string ExceptionTimelinePostNoExistReasonNotCreated { + get { + return ResourceManager.GetString("ExceptionTimelinePostNoExistReasonNotCreated", resourceCulture); + } + } + } +} diff --git a/BackEnd/Timeline/Services/Timeline/Resource.resx b/BackEnd/Timeline/Services/Timeline/Resource.resx new file mode 100644 index 00000000..7fd7b5c7 --- /dev/null +++ b/BackEnd/Timeline/Services/Timeline/Resource.resx @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Timeline with given constraints already exist. + + + Timeline name is of bad format. {0} + + + Requested timeline does not exist. + + + Requested timeline post does not exist because {0}. + + + it is deleted + + + it has not been created + + \ No newline at end of file diff --git a/BackEnd/Timeline/Services/Timeline/TimelineAlreadyExistException.cs b/BackEnd/Timeline/Services/Timeline/TimelineAlreadyExistException.cs new file mode 100644 index 00000000..11fc4ef8 --- /dev/null +++ b/BackEnd/Timeline/Services/Timeline/TimelineAlreadyExistException.cs @@ -0,0 +1,24 @@ +using System; + +namespace Timeline.Services.Timeline +{ + /// + /// The user requested does not exist. + /// + [Serializable] + public class TimelineAlreadyExistException : EntityAlreadyExistException + { + public TimelineAlreadyExistException() : this(null, null, null) { } + public TimelineAlreadyExistException(object? entity) : this(entity, null, null) { } + public TimelineAlreadyExistException(object? entity, Exception? inner) : this(entity, null, inner) { } + public TimelineAlreadyExistException(object? entity, string? message, Exception? inner) + : base(EntityNames.Timeline, entity, message ?? Resource.ExceptionTimelineAlreadyExist, inner) + { + + } + + protected TimelineAlreadyExistException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + } +} diff --git a/BackEnd/Timeline/Services/Timeline/TimelineNotExistException.cs b/BackEnd/Timeline/Services/Timeline/TimelineNotExistException.cs new file mode 100644 index 00000000..2cfbdedf --- /dev/null +++ b/BackEnd/Timeline/Services/Timeline/TimelineNotExistException.cs @@ -0,0 +1,27 @@ +using System; + +namespace Timeline.Services.Timeline +{ + [Serializable] + public class TimelineNotExistException : EntityNotExistException + { + public TimelineNotExistException() : this(null, null, null, null) { } + public TimelineNotExistException(long? id) : this(null, id, null, null) { } + public TimelineNotExistException(long? id, Exception? inner) : this(null, id, null, inner) { } + public TimelineNotExistException(string? timelineName) : this(timelineName, null, null, null) { } + public TimelineNotExistException(string? timelineName, Exception? inner) : this(timelineName, null, null, inner) { } + public TimelineNotExistException(string? timelineName, long? timelineId, string? message, Exception? inner = null) + : base(EntityNames.Timeline, message ?? Resource.ExceptionTimelineNotExist, inner) + { + TimelineId = timelineId; + TimelineName = timelineName; + } + + protected TimelineNotExistException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + + public string? TimelineName { get; set; } + public long? TimelineId { get; set; } + } +} diff --git a/BackEnd/Timeline/Services/Timeline/TimelinePostCreateDataException.cs b/BackEnd/Timeline/Services/Timeline/TimelinePostCreateDataException.cs new file mode 100644 index 00000000..669e8bc9 --- /dev/null +++ b/BackEnd/Timeline/Services/Timeline/TimelinePostCreateDataException.cs @@ -0,0 +1,16 @@ +namespace Timeline.Services.Timeline +{ + [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/Timeline/TimelinePostDataNotExistException.cs b/BackEnd/Timeline/Services/Timeline/TimelinePostDataNotExistException.cs new file mode 100644 index 00000000..177973a3 --- /dev/null +++ b/BackEnd/Timeline/Services/Timeline/TimelinePostDataNotExistException.cs @@ -0,0 +1,25 @@ +using System; + +namespace Timeline.Services.Timeline +{ + [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/Timeline/TimelinePostNotExistException.cs b/BackEnd/Timeline/Services/Timeline/TimelinePostNotExistException.cs new file mode 100644 index 00000000..e0e819aa --- /dev/null +++ b/BackEnd/Timeline/Services/Timeline/TimelinePostNotExistException.cs @@ -0,0 +1,36 @@ +using System; +using System.Globalization; + +namespace Timeline.Services.Timeline +{ + [Serializable] + public class TimelinePostNotExistException : EntityNotExistException + { + public TimelinePostNotExistException() : this(null, null, false, null, null) { } + public TimelinePostNotExistException(string? message) : this(message, null) { } + public TimelinePostNotExistException(string? message, Exception? inner) : this(null, null, false, message, inner) { } + protected TimelinePostNotExistException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + public TimelinePostNotExistException(long? timelineId, long? postId, bool isDelete, string? message = null, Exception? inner = null) + : base(EntityNames.TimelinePost, message ?? MakeMessage(isDelete), inner) + { + TimelineId = timelineId; + PostId = postId; + IsDelete = isDelete; + } + + private static string MakeMessage(bool isDelete) + { + return string.Format(CultureInfo.InvariantCulture, Resource.ExceptionTimelinePostNoExist, isDelete ? Resource.ExceptionTimelinePostNoExistReasonDeleted : Resource.ExceptionTimelinePostNoExistReasonNotCreated); + } + + public long? TimelineId { get; set; } + public long? PostId { get; set; } + + /// + /// True if the post is deleted. False if the post does not exist at all. + /// + public bool IsDelete { get; set; } + } +} diff --git a/BackEnd/Timeline/Services/Timeline/TimelinePostService.cs b/BackEnd/Timeline/Services/Timeline/TimelinePostService.cs new file mode 100644 index 00000000..073fffdf --- /dev/null +++ b/BackEnd/Timeline/Services/Timeline/TimelinePostService.cs @@ -0,0 +1,485 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +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.Data; +using Timeline.Services.Imaging; +using Timeline.Services.User; + +namespace Timeline.Services.Timeline +{ + public class TimelinePostCreateRequestData + { + public TimelinePostCreateRequestData(string contentType, byte[] data) + { + ContentType = contentType; + Data = data; + } + + public string ContentType { get; set; } +#pragma warning disable CA1819 // Properties should not return arrays + public byte[] Data { get; set; } +#pragma warning restore CA1819 // Properties should not return arrays + } + + public class TimelinePostCreateRequest + { + public string? Color { get; set; } + + /// If not set, current time is used. + public DateTime? Time { get; set; } + +#pragma warning disable CA2227 + public List DataList { get; set; } = new List(); +#pragma warning restore CA2227 + } + + public class TimelinePostPatchRequest + { + public string? Color { get; set; } + public DateTime? Time { get; set; } + } + + public interface ITimelinePostService + { + /// + /// Get all the posts in the timeline. + /// + /// The id of the timeline. + /// The time that posts have been modified since. + /// Whether include deleted posts. + /// A list of all posts. + /// Thrown when timeline does not exist. + Task> GetPosts(long timelineId, DateTime? modifiedSince = null, bool includeDeleted = false); + + /// + /// Get a post of a timeline. + /// + /// The id of the timeline of the post. + /// The id of the post. + /// If true, return the entity even if it is deleted. + /// The post. + /// Thrown when timeline does not exist. + /// Thrown when post of does not exist or has been deleted. + Task GetPost(long timelineId, long postId, bool includeDeleted = false); + + /// + /// Get the data digest of a post. + /// + /// The timeline id. + /// The post id. + /// The index of the data. + /// The data digest. + /// Thrown when timeline does not exist. + /// Thrown when post of does not exist or has been deleted. + /// Thrown when data of that index does not exist. + Task GetPostDataDigest(long timelineId, long postId, long dataIndex); + + /// + /// Get the data of a post. + /// + /// The timeline id. + /// The post id. + /// The index of the data. + /// The data. + /// Thrown when timeline does not exist. + /// Thrown when post of does not exist or has been deleted. + /// Thrown when data of that index does not exist. + Task GetPostData(long timelineId, long postId, long dataIndex); + + /// + /// Create a new post in timeline. + /// + /// The id of the timeline to create post against. + /// The author's user id. + /// Info about the post. + /// The entity of the created post. + /// Thrown when is null. + /// Thrown when is of invalid format. + /// Thrown when timeline does not exist. + /// Thrown if user of does not exist. + /// Thrown if data is not a image. Validated by . + Task CreatePost(long timelineId, long authorId, TimelinePostCreateRequest request); + + /// + /// Modify a post. Change its properties or replace its content. + /// + /// The timeline id. + /// The post id. + /// The request. + /// The entity of the patched post. + /// Thrown when is null. + /// Thrown when is of invalid format. + /// Thrown when timeline does not exist. + /// Thrown when post does not exist. + Task PatchPost(long timelineId, long postId, TimelinePostPatchRequest request); + + /// + /// Delete a post. + /// + /// The id of the timeline to delete post against. + /// The id of the post to delete. + /// Thrown when timeline does not exist. + /// Thrown when the post with given id does not exist or is deleted already. + /// + /// First use to check the permission. + /// + Task DeletePost(long timelineId, long postId); + + /// + /// Delete all posts of the given user. Used when delete a user. + /// + /// The id of the user. + Task DeleteAllPostsOfUser(long userId); + + /// + /// Verify whether a user has the permission to modify a post. + /// + /// The id of the timeline. + /// The id of the post. + /// The id of the user to check on. + /// True if you want it to throw . Default false. + /// True if can modify, false if can't modify. + /// Thrown when timeline does not exist. + /// Thrown when the post with given id does not exist or is deleted already and is true. + /// + /// Unless is true, this method should return true if the post does not exist. + /// If the post is deleted, its author info still exists, so it is checked as the post is not deleted unless is true. + /// This method does not check whether the user is administrator. + /// It only checks whether he is the author of the post or the owner of the timeline. + /// Return false when user with modifier id does not exist. + /// + Task HasPostModifyPermission(long timelineId, long postId, long modifierId, bool throwOnPostNotExist = false); + } + + public class TimelinePostService : ITimelinePostService + { + private readonly ILogger _logger; + private readonly DatabaseContext _database; + private readonly IBasicTimelineService _basicTimelineService; + private readonly IBasicUserService _basicUserService; + private readonly IDataManager _dataManager; + private readonly IImageValidator _imageValidator; + private readonly IClock _clock; + private readonly ColorValidator _colorValidator = new ColorValidator(); + + public TimelinePostService(ILogger logger, DatabaseContext database, IBasicTimelineService basicTimelineService, IBasicUserService basicUserService, IDataManager dataManager, IImageValidator imageValidator, IClock clock) + { + _logger = logger; + _database = database; + _basicTimelineService = basicTimelineService; + _basicUserService = basicUserService; + _dataManager = dataManager; + _imageValidator = imageValidator; + _clock = clock; + } + + private async Task CheckTimelineExistence(long timelineId) + { + if (!await _basicTimelineService.CheckExistence(timelineId)) + throw new TimelineNotExistException(timelineId); + } + + private async Task CheckUserExistence(long userId) + { + if (!await _basicUserService.CheckUserExistence(userId)) + throw new UserNotExistException(userId); + } + + public async Task> GetPosts(long timelineId, DateTime? modifiedSince = null, bool includeDeleted = false) + { + await CheckTimelineExistence(timelineId); + + modifiedSince = modifiedSince?.MyToUtc(); + + IQueryable query = _database.TimelinePosts.Where(p => p.TimelineId == timelineId); + + if (!includeDeleted) + { + query = query.Where(p => !p.Deleted); + } + + if (modifiedSince.HasValue) + { + query = query.Where(p => p.LastUpdated >= modifiedSince || (p.Author != null && p.Author.UsernameChangeTime >= modifiedSince)); + } + + query = query.OrderBy(p => p.Time); + + return await query.ToListAsync(); + } + + public async Task GetPost(long timelineId, long postId, bool includeDeleted = false) + { + await CheckTimelineExistence(timelineId); + + var post = await _database.TimelinePosts.Where(p => p.TimelineId == timelineId && p.LocalId == postId).SingleOrDefaultAsync(); + + if (post is null) + { + throw new TimelinePostNotExistException(timelineId, postId, false); + } + + if (!includeDeleted && post.Deleted) + { + throw new TimelinePostNotExistException(timelineId, postId, true); + } + + return post; + } + + public async Task GetPostDataDigest(long timelineId, long postId, long dataIndex) + { + await CheckTimelineExistence(timelineId); + + var postEntity = await _database.TimelinePosts.Where(p => p.TimelineId == timelineId && p.LocalId == postId).Select(p => new { p.Id, p.Deleted }).SingleOrDefaultAsync(); + + if (postEntity is null) + throw new TimelinePostNotExistException(timelineId, postId, false); + + if (postEntity.Deleted) + throw new TimelinePostNotExistException(timelineId, postId, true); + + var dataEntity = await _database.TimelinePostData.Where(d => d.PostId == postEntity.Id && d.Index == dataIndex).SingleOrDefaultAsync(); + + if (dataEntity is null) + throw new TimelinePostDataNotExistException(timelineId, postId, dataIndex); + + return new CacheableDataDigest(dataEntity.DataTag, dataEntity.LastUpdated); + } + + public async Task GetPostData(long timelineId, long postId, long dataIndex) + { + await CheckTimelineExistence(timelineId); + + var postEntity = await _database.TimelinePosts.Where(p => p.TimelineId == timelineId && p.LocalId == postId).Select(p => new { p.Id, p.Deleted }).SingleOrDefaultAsync(); + + if (postEntity is null) + throw new TimelinePostNotExistException(timelineId, postId, false); + + if (postEntity.Deleted) + throw new TimelinePostNotExistException(timelineId, postId, true); + + var dataEntity = await _database.TimelinePostData.Where(d => d.PostId == postEntity.Id && d.Index == dataIndex).SingleOrDefaultAsync(); + + if (dataEntity is null) + throw new TimelinePostDataNotExistException(timelineId, postId, dataIndex); + + var data = await _dataManager.GetEntryAndCheck(dataEntity.DataTag, $"Timeline {timelineId}, post {postId}, data {dataIndex} requires this data."); + + return new ByteData(data, dataEntity.Kind); + } + + public async Task CreatePost(long timelineId, long authorId, TimelinePostCreateRequest request) + { + if (request is null) + throw new ArgumentNullException(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); + await CheckUserExistence(authorId); + + var currentTime = _clock.GetCurrentTime(); + var finalTime = request.Time ?? currentTime; + + await using var transaction = await _database.Database.BeginTransactionAsync(); + + var postEntity = new TimelinePostEntity + { + AuthorId = authorId, + TimelineId = timelineId, + Time = finalTime, + LastUpdated = currentTime, + Color = request.Color + }; + + var timelineEntity = await _database.Timelines.Where(t => t.Id == timelineId).SingleAsync(); + timelineEntity.CurrentPostLocalId += 1; + postEntity.LocalId = timelineEntity.CurrentPostLocalId; + _database.TimelinePosts.Add(postEntity); + await _database.SaveChangesAsync(); + + List dataTags = new List(); + + for (int index = 0; index < request.DataList.Count; index++) + { + var data = request.DataList[index]; + + var tag = await _dataManager.RetainEntry(data.Data); + + _database.TimelinePostData.Add(new TimelinePostDataEntity + { + DataTag = tag, + Kind = data.ContentType, + Index = index, + PostId = postEntity.Id, + LastUpdated = currentTime, + }); + } + + await _database.SaveChangesAsync(); + + await transaction.CommitAsync(); + + return postEntity; + } + + public async Task PatchPost(long timelineId, long postId, TimelinePostPatchRequest request) + { + if (request is null) + throw new ArgumentNullException(nameof(request)); + + { + if (!_colorValidator.Validate(request.Color, out var message)) + throw new ArgumentException("Color is not valid.", nameof(request)); + } + + request.Time = request.Time?.MyToUtc(); + + await CheckTimelineExistence(timelineId); + + var entity = await _database.TimelinePosts.Where(p => p.TimelineId == timelineId && p.LocalId == postId).SingleOrDefaultAsync(); + + if (entity is null) + throw new TimelinePostNotExistException(timelineId, postId, false); + + if (entity.Deleted) + throw new TimelinePostNotExistException(timelineId, postId, true); + + if (request.Time.HasValue) + entity.Time = request.Time.Value; + + if (request.Color is not null) + entity.Color = request.Color; + + entity.LastUpdated = _clock.GetCurrentTime(); + + await _database.SaveChangesAsync(); + + return entity; + } + + public async Task DeletePost(long timelineId, long postId) + { + await CheckTimelineExistence(timelineId); + + var entity = await _database.TimelinePosts.Where(p => p.TimelineId == timelineId && p.LocalId == postId).SingleOrDefaultAsync(); + + if (entity == null) + throw new TimelinePostNotExistException(timelineId, postId, false); + + if (entity.Deleted) + throw new TimelinePostNotExistException(timelineId, postId, true); + + await using var transaction = await _database.Database.BeginTransactionAsync(); + + 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); + } + + _database.TimelinePostData.RemoveRange(dataEntities); + + await _database.SaveChangesAsync(); + + await transaction.CommitAsync(); + } + + public async Task DeleteAllPostsOfUser(long userId) + { + var postEntities = await _database.TimelinePosts.Where(p => p.AuthorId == userId).Select(p => new { p.TimelineId, p.LocalId }).ToListAsync(); + + foreach (var postEntity in postEntities) + { + await this.DeletePost(postEntity.TimelineId, postEntity.LocalId); + } + } + + public async Task HasPostModifyPermission(long timelineId, long postId, long modifierId, bool throwOnPostNotExist = false) + { + await CheckTimelineExistence(timelineId); + + var timelineEntity = await _database.Timelines.Where(t => t.Id == timelineId).Select(t => new { t.OwnerId }).SingleAsync(); + + var postEntity = await _database.TimelinePosts.Where(p => p.TimelineId == timelineId && p.LocalId == postId).Select(p => new { p.Deleted, p.AuthorId }).SingleOrDefaultAsync(); + + if (postEntity is null) + { + if (throwOnPostNotExist) + throw new TimelinePostNotExistException(timelineId, postId, false); + else + return true; + } + + if (postEntity.Deleted && throwOnPostNotExist) + { + throw new TimelinePostNotExistException(timelineId, postId, true); + } + + return timelineEntity.OwnerId == modifierId || postEntity.AuthorId == modifierId; + } + } +} diff --git a/BackEnd/Timeline/Services/Timeline/TimelineService.cs b/BackEnd/Timeline/Services/Timeline/TimelineService.cs new file mode 100644 index 00000000..342ce234 --- /dev/null +++ b/BackEnd/Timeline/Services/Timeline/TimelineService.cs @@ -0,0 +1,452 @@ +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Timeline.Entities; +using Timeline.Models; +using Timeline.Models.Validation; +using Timeline.Services.User; + +namespace Timeline.Services.Timeline +{ + public static class TimelineHelper + { + public static string ExtractTimelineName(string name, out bool isPersonal) + { + if (name.StartsWith("@", StringComparison.OrdinalIgnoreCase)) + { + isPersonal = true; + return name[1..]; + } + else + { + isPersonal = false; + return name; + } + } + } + + public enum TimelineUserRelationshipType + { + Own = 0b1, + Join = 0b10, + Default = Own | Join + } + + public class TimelineUserRelationship + { + public TimelineUserRelationship(TimelineUserRelationshipType type, long userId) + { + Type = type; + UserId = userId; + } + + public TimelineUserRelationshipType Type { get; set; } + public long UserId { get; set; } + } + + public class TimelineChangePropertyParams + { + public string? Name { get; set; } + public string? Title { get; set; } + public string? Description { get; set; } + public TimelineVisibility? Visibility { get; set; } + public string? Color { get; set; } + } + + /// + /// This define the interface of both personal timeline and ordinary timeline. + /// + public interface ITimelineService : IBasicTimelineService + { + /// + /// Get the timeline info. + /// + /// Id of timeline. + /// The timeline info. + /// Thrown when timeline does not exist. + Task GetTimeline(long id); + + /// + /// Set the properties of a timeline. + /// + /// The id of the timeline. + /// The new properties. Null member means not to change. + /// Thrown when is null. + /// Thrown when timeline with given id does not exist. + /// Thrown when a timeline with new name already exists. + Task ChangeProperty(long id, TimelineChangePropertyParams newProperties); + + /// + /// Add a member to timeline. + /// + /// Timeline id. + /// User id. + /// True if the memeber was added. False if it is already a member. + /// Thrown when timeline does not exist. + /// Thrown when the user does not exist. + Task AddMember(long timelineId, long userId); + + /// + /// Remove a member from timeline. + /// + /// Timeline id. + /// User id. + /// True if the memeber was removed. False if it was not a member before. + /// Thrown when timeline does not exist. + /// Thrown when the user does not exist. + Task RemoveMember(long timelineId, long userId); + + /// + /// Check whether a user can manage(change timeline info, member, ...) a timeline. + /// + /// The id of the timeline. + /// The id of the user to check on. + /// True if the user can manage the timeline, otherwise false. + /// Thrown when timeline does not exist. + /// + /// This method does not check whether visitor is administrator. + /// Return false if user with user id does not exist. + /// + Task HasManagePermission(long timelineId, long userId); + + /// + /// Verify whether a visitor has the permission to read a timeline. + /// + /// The id of the timeline. + /// The id of the user to check on. Null means visitor without account. + /// True if can read, false if can't read. + /// Thrown when timeline does not exist. + /// + /// This method does not check whether visitor is administrator. + /// Return false if user with visitor id does not exist. + /// + Task HasReadPermission(long timelineId, long? visitorId); + + /// + /// Verify whether a user is member of a timeline. + /// + /// The id of the timeline. + /// The id of user to check on. + /// True if it is a member, false if not. + /// Thrown when timeline does not exist. + /// + /// Timeline owner is also considered as a member. + /// Return false when user with user id does not exist. + /// + Task IsMemberOf(long timelineId, long userId); + + /// + /// Get all timelines including personal and ordinary timelines. + /// + /// Filter timelines related (own or is a member) to specific user. + /// Filter timelines with given visibility. If null or empty, all visibilities are returned. Duplicate value are ignored. + /// The list of timelines. + /// + /// If user with related user id does not exist, empty list will be returned. + /// + Task> GetTimelines(TimelineUserRelationship? relate = null, List? visibility = null); + + /// + /// Create a timeline. + /// + /// The name of the timeline. + /// The id of owner of the timeline. + /// The info of the new timeline. + /// Thrown when is null. + /// Thrown when timeline name is invalid. + /// Thrown when the timeline already exists. + /// Thrown when the owner user does not exist. + Task CreateTimeline(string timelineName, long ownerId); + + /// + /// Delete a timeline. + /// + /// The id of the timeline to delete. + /// Thrown when the timeline does not exist. + Task DeleteTimeline(long id); + } + + public class TimelineService : BasicTimelineService, ITimelineService + { + public TimelineService(DatabaseContext database, IBasicUserService userService, IClock clock) + : base(database, userService, clock) + { + _database = database; + _userService = userService; + _clock = clock; + } + + private readonly DatabaseContext _database; + + private readonly IBasicUserService _userService; + + private readonly IClock _clock; + + private readonly TimelineNameValidator _timelineNameValidator = new TimelineNameValidator(); + + private readonly ColorValidator _colorValidator = new ColorValidator(); + + private void ValidateTimelineName(string name, string paramName) + { + if (!_timelineNameValidator.Validate(name, out var message)) + { + throw new ArgumentException(string.Format(Resource.ExceptionTimelineNameBadFormat, message), paramName); + } + } + + public async Task GetTimeline(long id) + { + var entity = await _database.Timelines.Where(t => t.Id == id).SingleOrDefaultAsync(); + + if (entity is null) + throw new TimelineNotExistException(id); + + return entity; + } + + public async Task ChangeProperty(long id, TimelineChangePropertyParams newProperties) + { + if (newProperties is null) + throw new ArgumentNullException(nameof(newProperties)); + + if (newProperties.Name is not null) + ValidateTimelineName(newProperties.Name, nameof(newProperties)); + + if (newProperties.Color is not null) + { + var (result, message) = _colorValidator.Validate(newProperties.Color); + if (!result) + { + throw new ArgumentException(message, nameof(newProperties)); + } + } + + var entity = await _database.Timelines.Where(t => t.Id == id).SingleOrDefaultAsync(); + + if (entity is null) + throw new TimelineNotExistException(id); + + var changed = false; + var nameChanged = false; + + if (newProperties.Name is not null) + { + var conflict = await _database.Timelines.AnyAsync(t => t.Name == newProperties.Name); + + if (conflict) + throw new TimelineAlreadyExistException(); + + entity.Name = newProperties.Name; + + changed = true; + nameChanged = true; + } + + if (newProperties.Title != null) + { + changed = true; + entity.Title = newProperties.Title; + } + + if (newProperties.Description != null) + { + changed = true; + entity.Description = newProperties.Description; + } + + if (newProperties.Visibility.HasValue) + { + changed = true; + entity.Visibility = newProperties.Visibility.Value; + } + + if (newProperties.Color is not null) + { + changed = true; + entity.Color = newProperties.Color; + } + + if (changed) + { + var currentTime = _clock.GetCurrentTime(); + entity.LastModified = currentTime; + if (nameChanged) + entity.NameLastModified = currentTime; + } + + await _database.SaveChangesAsync(); + } + + public async Task AddMember(long timelineId, long userId) + { + if (!await CheckExistence(timelineId)) + throw new TimelineNotExistException(timelineId); + + if (!await _userService.CheckUserExistence(userId)) + throw new UserNotExistException(userId); + + if (await _database.TimelineMembers.AnyAsync(m => m.TimelineId == timelineId && m.UserId == userId)) + return false; + + + var entity = new TimelineMemberEntity { UserId = userId, TimelineId = timelineId }; + _database.TimelineMembers.Add(entity); + + var timelineEntity = await _database.Timelines.Where(t => t.Id == timelineId).SingleAsync(); + timelineEntity.LastModified = _clock.GetCurrentTime(); + + await _database.SaveChangesAsync(); + return true; + } + + public async Task RemoveMember(long timelineId, long userId) + { + if (!await CheckExistence(timelineId)) + throw new TimelineNotExistException(timelineId); + + if (!await _userService.CheckUserExistence(userId)) + throw new UserNotExistException(userId); + + var entity = await _database.TimelineMembers.SingleOrDefaultAsync(m => m.TimelineId == timelineId && m.UserId == userId); + if (entity is null) return false; + + _database.TimelineMembers.Remove(entity); + + var timelineEntity = await _database.Timelines.Where(t => t.Id == timelineId).SingleAsync(); + timelineEntity.LastModified = _clock.GetCurrentTime(); + + await _database.SaveChangesAsync(); + return true; + } + + public async Task HasManagePermission(long timelineId, long userId) + { + var entity = await _database.Timelines.Where(t => t.Id == timelineId).Select(t => new { t.OwnerId }).SingleOrDefaultAsync(); + + if (entity is null) + throw new TimelineNotExistException(timelineId); + + return entity.OwnerId == userId; + } + + public async Task HasReadPermission(long timelineId, long? visitorId) + { + var entity = await _database.Timelines.Where(t => t.Id == timelineId).Select(t => new { t.Visibility }).SingleOrDefaultAsync(); + + if (entity is null) + throw new TimelineNotExistException(timelineId); + + if (entity.Visibility == TimelineVisibility.Public) + return true; + + if (entity.Visibility == TimelineVisibility.Register && visitorId != null) + return true; + + if (visitorId == null) + { + return false; + } + else + { + var memberEntity = await _database.TimelineMembers.Where(m => m.UserId == visitorId && m.TimelineId == timelineId).SingleOrDefaultAsync(); + return memberEntity is not null; + } + } + + public async Task IsMemberOf(long timelineId, long userId) + { + var entity = await _database.Timelines.Where(t => t.Id == timelineId).Select(t => new { t.OwnerId }).SingleOrDefaultAsync(); + + if (entity is null) + throw new TimelineNotExistException(timelineId); + + if (userId == entity.OwnerId) + return true; + + return await _database.TimelineMembers.AnyAsync(m => m.TimelineId == timelineId && m.UserId == userId); + } + + public async Task> GetTimelines(TimelineUserRelationship? relate = null, List? visibility = null) + { + List entities; + + IQueryable ApplyTimelineVisibilityFilter(IQueryable query) + { + if (visibility != null && visibility.Count != 0) + { + return query.Where(t => visibility.Contains(t.Visibility)); + } + return query; + } + + bool allVisibilities = visibility == null || visibility.Count == 0; + + if (relate == null) + { + entities = await ApplyTimelineVisibilityFilter(_database.Timelines).ToListAsync(); + } + else + { + entities = new List(); + + if ((relate.Type & TimelineUserRelationshipType.Own) != 0) + { + entities.AddRange(await ApplyTimelineVisibilityFilter(_database.Timelines.Where(t => t.OwnerId == relate.UserId)).ToListAsync()); + } + + if ((relate.Type & TimelineUserRelationshipType.Join) != 0) + { + entities.AddRange(await ApplyTimelineVisibilityFilter(_database.TimelineMembers.Where(m => m.UserId == relate.UserId).Include(m => m.Timeline).Select(m => m.Timeline)).ToListAsync()); + } + } + + return entities; + } + + public async Task CreateTimeline(string name, long owner) + { + if (name == null) + throw new ArgumentNullException(nameof(name)); + + ValidateTimelineName(name, nameof(name)); + + var conflict = await _database.Timelines.AnyAsync(t => t.Name == name); + + if (conflict) + throw new TimelineAlreadyExistException(); + + var entity = CreateNewTimelineEntity(name, owner); + + _database.Timelines.Add(entity); + await _database.SaveChangesAsync(); + + return entity; + } + + public async Task DeleteTimeline(long id) + { + var entity = await _database.Timelines.Where(t => t.Id == id).SingleOrDefaultAsync(); + + if (entity is null) + throw new TimelineNotExistException(id); + + _database.Timelines.Remove(entity); + await _database.SaveChangesAsync(); + } + } + + public static class TimelineServiceExtensions + { + public static async Task> GetTimelineList(this ITimelineService service, IEnumerable ids) + { + var timelines = new List(); + foreach (var id in ids) + { + timelines.Add(await service.GetTimeline(id)); + } + return timelines; + } + } +} diff --git a/BackEnd/Timeline/Services/TimelinePostCreateDataException.cs b/BackEnd/Timeline/Services/TimelinePostCreateDataException.cs deleted file mode 100644 index 10a09de7..00000000 --- a/BackEnd/Timeline/Services/TimelinePostCreateDataException.cs +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index c70f5d9c..00000000 --- a/BackEnd/Timeline/Services/TimelinePostDataNotExistException.cs +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index f64c4c22..00000000 --- a/BackEnd/Timeline/Services/TimelinePostService.cs +++ /dev/null @@ -1,483 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -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; - -namespace Timeline.Services -{ - public class TimelinePostCreateRequestData - { - public TimelinePostCreateRequestData(string contentType, byte[] data) - { - ContentType = contentType; - Data = data; - } - - public string ContentType { get; set; } -#pragma warning disable CA1819 // Properties should not return arrays - public byte[] Data { get; set; } -#pragma warning restore CA1819 // Properties should not return arrays - } - - public class TimelinePostCreateRequest - { - public string? Color { get; set; } - - /// If not set, current time is used. - public DateTime? Time { get; set; } - -#pragma warning disable CA2227 - public List DataList { get; set; } = new List(); -#pragma warning restore CA2227 - } - - public class TimelinePostPatchRequest - { - public string? Color { get; set; } - public DateTime? Time { get; set; } - } - - public interface ITimelinePostService - { - /// - /// Get all the posts in the timeline. - /// - /// The id of the timeline. - /// The time that posts have been modified since. - /// Whether include deleted posts. - /// A list of all posts. - /// Thrown when timeline does not exist. - Task> GetPosts(long timelineId, DateTime? modifiedSince = null, bool includeDeleted = false); - - /// - /// Get a post of a timeline. - /// - /// The id of the timeline of the post. - /// The id of the post. - /// If true, return the entity even if it is deleted. - /// The post. - /// Thrown when timeline does not exist. - /// Thrown when post of does not exist or has been deleted. - Task GetPost(long timelineId, long postId, bool includeDeleted = false); - - /// - /// Get the data digest of a post. - /// - /// The timeline id. - /// The post id. - /// The index of the data. - /// The data digest. - /// Thrown when timeline does not exist. - /// Thrown when post of does not exist or has been deleted. - /// Thrown when data of that index does not exist. - Task GetPostDataDigest(long timelineId, long postId, long dataIndex); - - /// - /// Get the data of a post. - /// - /// The timeline id. - /// The post id. - /// The index of the data. - /// The data. - /// Thrown when timeline does not exist. - /// Thrown when post of does not exist or has been deleted. - /// Thrown when data of that index does not exist. - Task GetPostData(long timelineId, long postId, long dataIndex); - - /// - /// Create a new post in timeline. - /// - /// The id of the timeline to create post against. - /// The author's user id. - /// Info about the post. - /// The entity of the created post. - /// Thrown when is null. - /// Thrown when is of invalid format. - /// Thrown when timeline does not exist. - /// Thrown if user of does not exist. - /// Thrown if data is not a image. Validated by . - Task CreatePost(long timelineId, long authorId, TimelinePostCreateRequest request); - - /// - /// Modify a post. Change its properties or replace its content. - /// - /// The timeline id. - /// The post id. - /// The request. - /// The entity of the patched post. - /// Thrown when is null. - /// Thrown when is of invalid format. - /// Thrown when timeline does not exist. - /// Thrown when post does not exist. - Task PatchPost(long timelineId, long postId, TimelinePostPatchRequest request); - - /// - /// Delete a post. - /// - /// The id of the timeline to delete post against. - /// The id of the post to delete. - /// Thrown when timeline does not exist. - /// Thrown when the post with given id does not exist or is deleted already. - /// - /// First use to check the permission. - /// - Task DeletePost(long timelineId, long postId); - - /// - /// Delete all posts of the given user. Used when delete a user. - /// - /// The id of the user. - Task DeleteAllPostsOfUser(long userId); - - /// - /// Verify whether a user has the permission to modify a post. - /// - /// The id of the timeline. - /// The id of the post. - /// The id of the user to check on. - /// True if you want it to throw . Default false. - /// True if can modify, false if can't modify. - /// Thrown when timeline does not exist. - /// Thrown when the post with given id does not exist or is deleted already and is true. - /// - /// Unless is true, this method should return true if the post does not exist. - /// If the post is deleted, its author info still exists, so it is checked as the post is not deleted unless is true. - /// This method does not check whether the user is administrator. - /// It only checks whether he is the author of the post or the owner of the timeline. - /// Return false when user with modifier id does not exist. - /// - Task HasPostModifyPermission(long timelineId, long postId, long modifierId, bool throwOnPostNotExist = false); - } - - public class TimelinePostService : ITimelinePostService - { - private readonly ILogger _logger; - private readonly DatabaseContext _database; - private readonly IBasicTimelineService _basicTimelineService; - private readonly IBasicUserService _basicUserService; - private readonly IDataManager _dataManager; - private readonly IImageValidator _imageValidator; - private readonly IClock _clock; - private readonly ColorValidator _colorValidator = new ColorValidator(); - - public TimelinePostService(ILogger logger, DatabaseContext database, IBasicTimelineService basicTimelineService, IBasicUserService basicUserService, IDataManager dataManager, IImageValidator imageValidator, IClock clock) - { - _logger = logger; - _database = database; - _basicTimelineService = basicTimelineService; - _basicUserService = basicUserService; - _dataManager = dataManager; - _imageValidator = imageValidator; - _clock = clock; - } - - private async Task CheckTimelineExistence(long timelineId) - { - if (!await _basicTimelineService.CheckExistence(timelineId)) - throw new TimelineNotExistException(timelineId); - } - - private async Task CheckUserExistence(long userId) - { - if (!await _basicUserService.CheckUserExistence(userId)) - throw new UserNotExistException(userId); - } - - public async Task> GetPosts(long timelineId, DateTime? modifiedSince = null, bool includeDeleted = false) - { - await CheckTimelineExistence(timelineId); - - modifiedSince = modifiedSince?.MyToUtc(); - - IQueryable query = _database.TimelinePosts.Where(p => p.TimelineId == timelineId); - - if (!includeDeleted) - { - query = query.Where(p => !p.Deleted); - } - - if (modifiedSince.HasValue) - { - query = query.Where(p => p.LastUpdated >= modifiedSince || (p.Author != null && p.Author.UsernameChangeTime >= modifiedSince)); - } - - query = query.OrderBy(p => p.Time); - - return await query.ToListAsync(); - } - - public async Task GetPost(long timelineId, long postId, bool includeDeleted = false) - { - await CheckTimelineExistence(timelineId); - - var post = await _database.TimelinePosts.Where(p => p.TimelineId == timelineId && p.LocalId == postId).SingleOrDefaultAsync(); - - if (post is null) - { - throw new TimelinePostNotExistException(timelineId, postId, false); - } - - if (!includeDeleted && post.Deleted) - { - throw new TimelinePostNotExistException(timelineId, postId, true); - } - - return post; - } - - public async Task GetPostDataDigest(long timelineId, long postId, long dataIndex) - { - await CheckTimelineExistence(timelineId); - - var postEntity = await _database.TimelinePosts.Where(p => p.TimelineId == timelineId && p.LocalId == postId).Select(p => new { p.Id, p.Deleted }).SingleOrDefaultAsync(); - - if (postEntity is null) - throw new TimelinePostNotExistException(timelineId, postId, false); - - if (postEntity.Deleted) - throw new TimelinePostNotExistException(timelineId, postId, true); - - var dataEntity = await _database.TimelinePostData.Where(d => d.PostId == postEntity.Id && d.Index == dataIndex).SingleOrDefaultAsync(); - - if (dataEntity is null) - throw new TimelinePostDataNotExistException(timelineId, postId, dataIndex); - - return new CacheableDataDigest(dataEntity.DataTag, dataEntity.LastUpdated); - } - - public async Task GetPostData(long timelineId, long postId, long dataIndex) - { - await CheckTimelineExistence(timelineId); - - var postEntity = await _database.TimelinePosts.Where(p => p.TimelineId == timelineId && p.LocalId == postId).Select(p => new { p.Id, p.Deleted }).SingleOrDefaultAsync(); - - if (postEntity is null) - throw new TimelinePostNotExistException(timelineId, postId, false); - - if (postEntity.Deleted) - throw new TimelinePostNotExistException(timelineId, postId, true); - - var dataEntity = await _database.TimelinePostData.Where(d => d.PostId == postEntity.Id && d.Index == dataIndex).SingleOrDefaultAsync(); - - if (dataEntity is null) - throw new TimelinePostDataNotExistException(timelineId, postId, dataIndex); - - var data = await _dataManager.GetEntryAndCheck(dataEntity.DataTag, $"Timeline {timelineId}, post {postId}, data {dataIndex} requires this data."); - - return new ByteData(data, dataEntity.Kind); - } - - public async Task CreatePost(long timelineId, long authorId, TimelinePostCreateRequest request) - { - if (request is null) - throw new ArgumentNullException(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); - await CheckUserExistence(authorId); - - var currentTime = _clock.GetCurrentTime(); - var finalTime = request.Time ?? currentTime; - - await using var transaction = await _database.Database.BeginTransactionAsync(); - - var postEntity = new TimelinePostEntity - { - AuthorId = authorId, - TimelineId = timelineId, - Time = finalTime, - LastUpdated = currentTime, - Color = request.Color - }; - - var timelineEntity = await _database.Timelines.Where(t => t.Id == timelineId).SingleAsync(); - timelineEntity.CurrentPostLocalId += 1; - postEntity.LocalId = timelineEntity.CurrentPostLocalId; - _database.TimelinePosts.Add(postEntity); - await _database.SaveChangesAsync(); - - List dataTags = new List(); - - for (int index = 0; index < request.DataList.Count; index++) - { - var data = request.DataList[index]; - - var tag = await _dataManager.RetainEntry(data.Data); - - _database.TimelinePostData.Add(new TimelinePostDataEntity - { - DataTag = tag, - Kind = data.ContentType, - Index = index, - PostId = postEntity.Id, - LastUpdated = currentTime, - }); - } - - await _database.SaveChangesAsync(); - - await transaction.CommitAsync(); - - return postEntity; - } - - public async Task PatchPost(long timelineId, long postId, TimelinePostPatchRequest request) - { - if (request is null) - throw new ArgumentNullException(nameof(request)); - - { - if (!_colorValidator.Validate(request.Color, out var message)) - throw new ArgumentException("Color is not valid.", nameof(request)); - } - - request.Time = request.Time?.MyToUtc(); - - await CheckTimelineExistence(timelineId); - - var entity = await _database.TimelinePosts.Where(p => p.TimelineId == timelineId && p.LocalId == postId).SingleOrDefaultAsync(); - - if (entity is null) - throw new TimelinePostNotExistException(timelineId, postId, false); - - if (entity.Deleted) - throw new TimelinePostNotExistException(timelineId, postId, true); - - if (request.Time.HasValue) - entity.Time = request.Time.Value; - - if (request.Color is not null) - entity.Color = request.Color; - - entity.LastUpdated = _clock.GetCurrentTime(); - - await _database.SaveChangesAsync(); - - return entity; - } - - public async Task DeletePost(long timelineId, long postId) - { - await CheckTimelineExistence(timelineId); - - var entity = await _database.TimelinePosts.Where(p => p.TimelineId == timelineId && p.LocalId == postId).SingleOrDefaultAsync(); - - if (entity == null) - throw new TimelinePostNotExistException(timelineId, postId, false); - - if (entity.Deleted) - throw new TimelinePostNotExistException(timelineId, postId, true); - - await using var transaction = await _database.Database.BeginTransactionAsync(); - - 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); - } - - _database.TimelinePostData.RemoveRange(dataEntities); - - await _database.SaveChangesAsync(); - - await transaction.CommitAsync(); - } - - public async Task DeleteAllPostsOfUser(long userId) - { - var postEntities = await _database.TimelinePosts.Where(p => p.AuthorId == userId).Select(p => new { p.TimelineId, p.LocalId }).ToListAsync(); - - foreach (var postEntity in postEntities) - { - await this.DeletePost(postEntity.TimelineId, postEntity.LocalId); - } - } - - public async Task HasPostModifyPermission(long timelineId, long postId, long modifierId, bool throwOnPostNotExist = false) - { - await CheckTimelineExistence(timelineId); - - var timelineEntity = await _database.Timelines.Where(t => t.Id == timelineId).Select(t => new { t.OwnerId }).SingleAsync(); - - var postEntity = await _database.TimelinePosts.Where(p => p.TimelineId == timelineId && p.LocalId == postId).Select(p => new { p.Deleted, p.AuthorId }).SingleOrDefaultAsync(); - - if (postEntity is null) - { - if (throwOnPostNotExist) - throw new TimelinePostNotExistException(timelineId, postId, false); - else - return true; - } - - if (postEntity.Deleted && throwOnPostNotExist) - { - throw new TimelinePostNotExistException(timelineId, postId, true); - } - - return timelineEntity.OwnerId == modifierId || postEntity.AuthorId == modifierId; - } - } -} diff --git a/BackEnd/Timeline/Services/TimelineService.cs b/BackEnd/Timeline/Services/TimelineService.cs deleted file mode 100644 index bed1c99b..00000000 --- a/BackEnd/Timeline/Services/TimelineService.cs +++ /dev/null @@ -1,453 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Timeline.Entities; -using Timeline.Models; -using Timeline.Models.Validation; -using Timeline.Services.Exceptions; -using static Timeline.Resources.Services.TimelineService; - -namespace Timeline.Services -{ - public static class TimelineHelper - { - public static string ExtractTimelineName(string name, out bool isPersonal) - { - if (name.StartsWith("@", StringComparison.OrdinalIgnoreCase)) - { - isPersonal = true; - return name[1..]; - } - else - { - isPersonal = false; - return name; - } - } - } - - public enum TimelineUserRelationshipType - { - Own = 0b1, - Join = 0b10, - Default = Own | Join - } - - public class TimelineUserRelationship - { - public TimelineUserRelationship(TimelineUserRelationshipType type, long userId) - { - Type = type; - UserId = userId; - } - - public TimelineUserRelationshipType Type { get; set; } - public long UserId { get; set; } - } - - public class TimelineChangePropertyParams - { - public string? Name { get; set; } - public string? Title { get; set; } - public string? Description { get; set; } - public TimelineVisibility? Visibility { get; set; } - public string? Color { get; set; } - } - - /// - /// This define the interface of both personal timeline and ordinary timeline. - /// - public interface ITimelineService : IBasicTimelineService - { - /// - /// Get the timeline info. - /// - /// Id of timeline. - /// The timeline info. - /// Thrown when timeline does not exist. - Task GetTimeline(long id); - - /// - /// Set the properties of a timeline. - /// - /// The id of the timeline. - /// The new properties. Null member means not to change. - /// Thrown when is null. - /// Thrown when timeline with given id does not exist. - /// Thrown when a timeline with new name already exists. - Task ChangeProperty(long id, TimelineChangePropertyParams newProperties); - - /// - /// Add a member to timeline. - /// - /// Timeline id. - /// User id. - /// True if the memeber was added. False if it is already a member. - /// Thrown when timeline does not exist. - /// Thrown when the user does not exist. - Task AddMember(long timelineId, long userId); - - /// - /// Remove a member from timeline. - /// - /// Timeline id. - /// User id. - /// True if the memeber was removed. False if it was not a member before. - /// Thrown when timeline does not exist. - /// Thrown when the user does not exist. - Task RemoveMember(long timelineId, long userId); - - /// - /// Check whether a user can manage(change timeline info, member, ...) a timeline. - /// - /// The id of the timeline. - /// The id of the user to check on. - /// True if the user can manage the timeline, otherwise false. - /// Thrown when timeline does not exist. - /// - /// This method does not check whether visitor is administrator. - /// Return false if user with user id does not exist. - /// - Task HasManagePermission(long timelineId, long userId); - - /// - /// Verify whether a visitor has the permission to read a timeline. - /// - /// The id of the timeline. - /// The id of the user to check on. Null means visitor without account. - /// True if can read, false if can't read. - /// Thrown when timeline does not exist. - /// - /// This method does not check whether visitor is administrator. - /// Return false if user with visitor id does not exist. - /// - Task HasReadPermission(long timelineId, long? visitorId); - - /// - /// Verify whether a user is member of a timeline. - /// - /// The id of the timeline. - /// The id of user to check on. - /// True if it is a member, false if not. - /// Thrown when timeline does not exist. - /// - /// Timeline owner is also considered as a member. - /// Return false when user with user id does not exist. - /// - Task IsMemberOf(long timelineId, long userId); - - /// - /// Get all timelines including personal and ordinary timelines. - /// - /// Filter timelines related (own or is a member) to specific user. - /// Filter timelines with given visibility. If null or empty, all visibilities are returned. Duplicate value are ignored. - /// The list of timelines. - /// - /// If user with related user id does not exist, empty list will be returned. - /// - Task> GetTimelines(TimelineUserRelationship? relate = null, List? visibility = null); - - /// - /// Create a timeline. - /// - /// The name of the timeline. - /// The id of owner of the timeline. - /// The info of the new timeline. - /// Thrown when is null. - /// Thrown when timeline name is invalid. - /// Thrown when the timeline already exists. - /// Thrown when the owner user does not exist. - Task CreateTimeline(string timelineName, long ownerId); - - /// - /// Delete a timeline. - /// - /// The id of the timeline to delete. - /// Thrown when the timeline does not exist. - Task DeleteTimeline(long id); - } - - public class TimelineService : BasicTimelineService, ITimelineService - { - public TimelineService(DatabaseContext database, IBasicUserService userService, IClock clock) - : base(database, userService, clock) - { - _database = database; - _userService = userService; - _clock = clock; - } - - private readonly DatabaseContext _database; - - private readonly IBasicUserService _userService; - - private readonly IClock _clock; - - private readonly TimelineNameValidator _timelineNameValidator = new TimelineNameValidator(); - - private readonly ColorValidator _colorValidator = new ColorValidator(); - - private void ValidateTimelineName(string name, string paramName) - { - if (!_timelineNameValidator.Validate(name, out var message)) - { - throw new ArgumentException(ExceptionTimelineNameBadFormat.AppendAdditionalMessage(message), paramName); - } - } - - public async Task GetTimeline(long id) - { - var entity = await _database.Timelines.Where(t => t.Id == id).SingleOrDefaultAsync(); - - if (entity is null) - throw new TimelineNotExistException(id); - - return entity; - } - - public async Task ChangeProperty(long id, TimelineChangePropertyParams newProperties) - { - if (newProperties is null) - throw new ArgumentNullException(nameof(newProperties)); - - if (newProperties.Name is not null) - ValidateTimelineName(newProperties.Name, nameof(newProperties)); - - if (newProperties.Color is not null) - { - var (result, message) = _colorValidator.Validate(newProperties.Color); - if (!result) - { - throw new ArgumentException(message, nameof(newProperties)); - } - } - - var entity = await _database.Timelines.Where(t => t.Id == id).SingleOrDefaultAsync(); - - if (entity is null) - throw new TimelineNotExistException(id); - - var changed = false; - var nameChanged = false; - - if (newProperties.Name is not null) - { - var conflict = await _database.Timelines.AnyAsync(t => t.Name == newProperties.Name); - - if (conflict) - throw new EntityAlreadyExistException(EntityNames.Timeline, null, ExceptionTimelineNameConflict); - - entity.Name = newProperties.Name; - - changed = true; - nameChanged = true; - } - - if (newProperties.Title != null) - { - changed = true; - entity.Title = newProperties.Title; - } - - if (newProperties.Description != null) - { - changed = true; - entity.Description = newProperties.Description; - } - - if (newProperties.Visibility.HasValue) - { - changed = true; - entity.Visibility = newProperties.Visibility.Value; - } - - if (newProperties.Color is not null) - { - changed = true; - entity.Color = newProperties.Color; - } - - if (changed) - { - var currentTime = _clock.GetCurrentTime(); - entity.LastModified = currentTime; - if (nameChanged) - entity.NameLastModified = currentTime; - } - - await _database.SaveChangesAsync(); - } - - public async Task AddMember(long timelineId, long userId) - { - if (!await CheckExistence(timelineId)) - throw new TimelineNotExistException(timelineId); - - if (!await _userService.CheckUserExistence(userId)) - throw new UserNotExistException(userId); - - if (await _database.TimelineMembers.AnyAsync(m => m.TimelineId == timelineId && m.UserId == userId)) - return false; - - - var entity = new TimelineMemberEntity { UserId = userId, TimelineId = timelineId }; - _database.TimelineMembers.Add(entity); - - var timelineEntity = await _database.Timelines.Where(t => t.Id == timelineId).SingleAsync(); - timelineEntity.LastModified = _clock.GetCurrentTime(); - - await _database.SaveChangesAsync(); - return true; - } - - public async Task RemoveMember(long timelineId, long userId) - { - if (!await CheckExistence(timelineId)) - throw new TimelineNotExistException(timelineId); - - if (!await _userService.CheckUserExistence(userId)) - throw new UserNotExistException(userId); - - var entity = await _database.TimelineMembers.SingleOrDefaultAsync(m => m.TimelineId == timelineId && m.UserId == userId); - if (entity is null) return false; - - _database.TimelineMembers.Remove(entity); - - var timelineEntity = await _database.Timelines.Where(t => t.Id == timelineId).SingleAsync(); - timelineEntity.LastModified = _clock.GetCurrentTime(); - - await _database.SaveChangesAsync(); - return true; - } - - public async Task HasManagePermission(long timelineId, long userId) - { - var entity = await _database.Timelines.Where(t => t.Id == timelineId).Select(t => new { t.OwnerId }).SingleOrDefaultAsync(); - - if (entity is null) - throw new TimelineNotExistException(timelineId); - - return entity.OwnerId == userId; - } - - public async Task HasReadPermission(long timelineId, long? visitorId) - { - var entity = await _database.Timelines.Where(t => t.Id == timelineId).Select(t => new { t.Visibility }).SingleOrDefaultAsync(); - - if (entity is null) - throw new TimelineNotExistException(timelineId); - - if (entity.Visibility == TimelineVisibility.Public) - return true; - - if (entity.Visibility == TimelineVisibility.Register && visitorId != null) - return true; - - if (visitorId == null) - { - return false; - } - else - { - var memberEntity = await _database.TimelineMembers.Where(m => m.UserId == visitorId && m.TimelineId == timelineId).SingleOrDefaultAsync(); - return memberEntity is not null; - } - } - - public async Task IsMemberOf(long timelineId, long userId) - { - var entity = await _database.Timelines.Where(t => t.Id == timelineId).Select(t => new { t.OwnerId }).SingleOrDefaultAsync(); - - if (entity is null) - throw new TimelineNotExistException(timelineId); - - if (userId == entity.OwnerId) - return true; - - return await _database.TimelineMembers.AnyAsync(m => m.TimelineId == timelineId && m.UserId == userId); - } - - public async Task> GetTimelines(TimelineUserRelationship? relate = null, List? visibility = null) - { - List entities; - - IQueryable ApplyTimelineVisibilityFilter(IQueryable query) - { - if (visibility != null && visibility.Count != 0) - { - return query.Where(t => visibility.Contains(t.Visibility)); - } - return query; - } - - bool allVisibilities = visibility == null || visibility.Count == 0; - - if (relate == null) - { - entities = await ApplyTimelineVisibilityFilter(_database.Timelines).ToListAsync(); - } - else - { - entities = new List(); - - if ((relate.Type & TimelineUserRelationshipType.Own) != 0) - { - entities.AddRange(await ApplyTimelineVisibilityFilter(_database.Timelines.Where(t => t.OwnerId == relate.UserId)).ToListAsync()); - } - - if ((relate.Type & TimelineUserRelationshipType.Join) != 0) - { - entities.AddRange(await ApplyTimelineVisibilityFilter(_database.TimelineMembers.Where(m => m.UserId == relate.UserId).Include(m => m.Timeline).Select(m => m.Timeline)).ToListAsync()); - } - } - - return entities; - } - - public async Task CreateTimeline(string name, long owner) - { - if (name == null) - throw new ArgumentNullException(nameof(name)); - - ValidateTimelineName(name, nameof(name)); - - var conflict = await _database.Timelines.AnyAsync(t => t.Name == name); - - if (conflict) - throw new EntityAlreadyExistException(EntityNames.Timeline, null, ExceptionTimelineNameConflict); - - var entity = CreateNewTimelineEntity(name, owner); - - _database.Timelines.Add(entity); - await _database.SaveChangesAsync(); - - return entity; - } - - public async Task DeleteTimeline(long id) - { - var entity = await _database.Timelines.Where(t => t.Id == id).SingleOrDefaultAsync(); - - if (entity is null) - throw new TimelineNotExistException(id); - - _database.Timelines.Remove(entity); - await _database.SaveChangesAsync(); - } - } - - public static class TimelineServiceExtensions - { - public static async Task> GetTimelineList(this ITimelineService service, IEnumerable ids) - { - var timelines = new List(); - foreach (var id in ids) - { - timelines.Add(await service.GetTimeline(id)); - } - return timelines; - } - } -} diff --git a/BackEnd/Timeline/Services/Token/UserTokenManager.cs b/BackEnd/Timeline/Services/Token/UserTokenManager.cs index 00bc2cf7..4a5f08d2 100644 --- a/BackEnd/Timeline/Services/Token/UserTokenManager.cs +++ b/BackEnd/Timeline/Services/Token/UserTokenManager.cs @@ -5,7 +5,7 @@ using System.Threading.Tasks; using Timeline.Configs; using Timeline.Entities; using Timeline.Helpers; -using Timeline.Services.Exceptions; +using Timeline.Services.User; namespace Timeline.Services.Token { diff --git a/BackEnd/Timeline/Services/User/BadPasswordException.cs b/BackEnd/Timeline/Services/User/BadPasswordException.cs new file mode 100644 index 00000000..7302fbca --- /dev/null +++ b/BackEnd/Timeline/Services/User/BadPasswordException.cs @@ -0,0 +1,25 @@ +using System; + +namespace Timeline.Services.User +{ + [Serializable] + public class BadPasswordException : Exception + { + public BadPasswordException() : this(null, null, null) { } + public BadPasswordException(string? badPassword) : this(badPassword, null, null) { } + public BadPasswordException(string? badPassword, Exception? inner) : this(badPassword, null, inner) { } + public BadPasswordException(string? badPassword, string? message, Exception? inner) : base(message ?? Resource.ExceptionBadPassword, inner) + { + Password = badPassword; + } + + protected BadPasswordException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + + /// + /// The wrong password. + /// + public string? Password { get; set; } + } +} diff --git a/BackEnd/Timeline/Services/User/BasicUserService.cs b/BackEnd/Timeline/Services/User/BasicUserService.cs new file mode 100644 index 00000000..a3763ef6 --- /dev/null +++ b/BackEnd/Timeline/Services/User/BasicUserService.cs @@ -0,0 +1,94 @@ +using Microsoft.EntityFrameworkCore; +using System; +using System.Linq; +using System.Threading.Tasks; +using Timeline.Entities; +using Timeline.Models.Validation; + +namespace Timeline.Services.User +{ + /// + /// This service provide some basic user features, which should be used internally for other services. + /// + public interface IBasicUserService + { + /// + /// Check if a user exists. + /// + /// The id of the user. + /// True if exists. Otherwise false. + Task CheckUserExistence(long id); + + /// + /// Get the user id of given username. + /// + /// Username of the user. + /// The id of the user. + /// Thrown when is null. + /// Thrown when is of bad format. + /// Thrown when the user with given username does not exist. + Task GetUserIdByUsername(string username); + + /// + /// Get the username modified time of a user. + /// + /// User id. + /// The time. + /// Thrown when user does not exist. + Task GetUsernameLastModifiedTime(long userId); + } + + public class BasicUserService : IBasicUserService + { + private readonly DatabaseContext _database; + + private readonly UsernameValidator _usernameValidator = new UsernameValidator(); + + public BasicUserService(DatabaseContext database) + { + _database = database; + } + + public async Task CheckUserExistence(long id) + { + return await _database.Users.AnyAsync(u => u.Id == id); + } + + public async Task GetUserIdByUsername(string username) + { + if (username == null) + throw new ArgumentNullException(nameof(username)); + + if (!_usernameValidator.Validate(username, out var message)) + throw new ArgumentException(message); + + var entity = await _database.Users.Where(user => user.Username == username).Select(u => new { u.Id }).SingleOrDefaultAsync(); + + if (entity == null) + throw new UserNotExistException(username); + + return entity.Id; + } + + public async Task GetUsernameLastModifiedTime(long userId) + { + var entity = await _database.Users.Where(u => u.Id == userId).Select(u => new { u.UsernameChangeTime }).SingleOrDefaultAsync(); + + if (entity is null) + throw new UserNotExistException(userId); + + return entity.UsernameChangeTime; + } + } + + public static class BasicUserServiceExtensions + { + public static async Task ThrowIfUserNotExist(this IBasicUserService service, long userId) + { + if (!await service.CheckUserExistence(userId)) + { + throw new UserNotExistException(userId); + } + } + } +} diff --git a/BackEnd/Timeline/Services/User/InvalidOperationOnRootUserException.cs b/BackEnd/Timeline/Services/User/InvalidOperationOnRootUserException.cs new file mode 100644 index 00000000..c432febd --- /dev/null +++ b/BackEnd/Timeline/Services/User/InvalidOperationOnRootUserException.cs @@ -0,0 +1,16 @@ +using System; + +namespace Timeline.Services.User +{ + + [Serializable] + public class InvalidOperationOnRootUserException : InvalidOperationException + { + public InvalidOperationOnRootUserException() { } + public InvalidOperationOnRootUserException(string message) : base(message) { } + public InvalidOperationOnRootUserException(string message, Exception inner) : base(message, inner) { } + protected InvalidOperationOnRootUserException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + } +} diff --git a/BackEnd/Timeline/Services/User/PasswordBadFormatException.cs b/BackEnd/Timeline/Services/User/PasswordBadFormatException.cs new file mode 100644 index 00000000..b9d76017 --- /dev/null +++ b/BackEnd/Timeline/Services/User/PasswordBadFormatException.cs @@ -0,0 +1,26 @@ +using System; + +namespace Timeline.Services.User +{ + [Serializable] + public class PasswordBadFormatException : Exception + { + public PasswordBadFormatException() : base(Resources.Services.Exception.PasswordBadFormatException) { } + public PasswordBadFormatException(string message) : base(message) { } + public PasswordBadFormatException(string message, Exception inner) : base(message, inner) { } + + public PasswordBadFormatException(string password, string validationMessage) : this() + { + Password = password; + ValidationMessage = validationMessage; + } + + protected PasswordBadFormatException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + + public string Password { get; set; } = ""; + + public string ValidationMessage { get; set; } = ""; + } +} diff --git a/BackEnd/Timeline/Services/User/PasswordService.cs b/BackEnd/Timeline/Services/User/PasswordService.cs new file mode 100644 index 00000000..580471e1 --- /dev/null +++ b/BackEnd/Timeline/Services/User/PasswordService.cs @@ -0,0 +1,224 @@ +using Microsoft.AspNetCore.Cryptography.KeyDerivation; +using System; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; + +namespace Timeline.Services.User +{ + /// + /// Hashed password is of bad format. + /// + /// + [Serializable] + public class HashedPasswordBadFromatException : Exception + { + private static string MakeMessage(string reason) + { + return Resources.Services.Exception.HashedPasswordBadFromatException + " Reason: " + reason; + } + + public HashedPasswordBadFromatException() : base(Resources.Services.Exception.HashedPasswordBadFromatException) { } + + public HashedPasswordBadFromatException(string message) : base(message) { } + public HashedPasswordBadFromatException(string message, Exception inner) : base(message, inner) { } + + public HashedPasswordBadFromatException(string hashedPassword, string reason) : base(MakeMessage(reason)) { HashedPassword = hashedPassword; } + public HashedPasswordBadFromatException(string hashedPassword, string reason, Exception inner) : base(MakeMessage(reason), inner) { HashedPassword = hashedPassword; } + protected HashedPasswordBadFromatException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + + public string? HashedPassword { get; set; } + } + + public interface IPasswordService + { + /// + /// Hash a password. + /// + /// The password to hash. + /// A hashed representation of the supplied . + /// Thrown when is null. + string HashPassword(string password); + + /// + /// Verify whether the password fits into the hashed one. + /// + /// Usually you only need to check the returned bool value. + /// Catching usually is not necessary. + /// Because if your program logic is right and always call + /// and in pair, this exception will never be thrown. + /// A thrown one usually means the data you saved is corupted, which is a critical problem. + /// + /// The hashed password. + /// The password supplied for comparison. + /// True indicating password is right. Otherwise false. + /// Thrown when or is null. + /// Thrown when the hashed password is of bad format. + bool VerifyPassword(string hashedPassword, string providedPassword); + } + + /// + /// Copied from https://github.com/aspnet/AspNetCore/blob/master/src/Identity/Extensions.Core/src/PasswordHasher.cs + /// Remove V2 format and unnecessary format version check. + /// Remove configuration options. + /// Remove user related parts. + /// Change the exceptions. + /// + public class PasswordService : IPasswordService + { + /* ======================= + * HASHED PASSWORD FORMATS + * ======================= + * + * Version 3: + * PBKDF2 with HMAC-SHA256, 128-bit salt, 256-bit subkey, 10000 iterations. + * Format: { 0x01, prf (UInt32), iter count (UInt32), salt length (UInt32), salt, subkey } + * (All UInt32s are stored big-endian.) + */ + + private readonly RandomNumberGenerator _rng = RandomNumberGenerator.Create(); + + public PasswordService() + { + } + + // Compares two byte arrays for equality. The method is specifically written so that the loop is not optimized. + [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)] + private static bool ByteArraysEqual(byte[] a, byte[] b) + { + if (a == null && b == null) + { + return true; + } + if (a == null || b == null || a.Length != b.Length) + { + return false; + } + var areSame = true; + for (var i = 0; i < a.Length; i++) + { + areSame &= (a[i] == b[i]); + } + return areSame; + } + + public string HashPassword(string password) + { + if (password == null) + throw new ArgumentNullException(nameof(password)); + return Convert.ToBase64String(HashPasswordV3(password, _rng)); + } + + private static byte[] HashPasswordV3(string password, RandomNumberGenerator rng) + { + return HashPasswordV3(password, rng, + prf: KeyDerivationPrf.HMACSHA256, + iterCount: 10000, + saltSize: 128 / 8, + numBytesRequested: 256 / 8); + } + + private static byte[] HashPasswordV3(string password, RandomNumberGenerator rng, KeyDerivationPrf prf, int iterCount, int saltSize, int numBytesRequested) + { + // Produce a version 3 (see comment above) text hash. + byte[] salt = new byte[saltSize]; + rng.GetBytes(salt); + byte[] subkey = KeyDerivation.Pbkdf2(password, salt, prf, iterCount, numBytesRequested); + + var outputBytes = new byte[13 + salt.Length + subkey.Length]; + outputBytes[0] = 0x01; // format marker + WriteNetworkByteOrder(outputBytes, 1, (uint)prf); + WriteNetworkByteOrder(outputBytes, 5, (uint)iterCount); + WriteNetworkByteOrder(outputBytes, 9, (uint)saltSize); + Buffer.BlockCopy(salt, 0, outputBytes, 13, salt.Length); + Buffer.BlockCopy(subkey, 0, outputBytes, 13 + saltSize, subkey.Length); + return outputBytes; + } + + public bool VerifyPassword(string hashedPassword, string providedPassword) + { + if (hashedPassword == null) + throw new ArgumentNullException(nameof(hashedPassword)); + if (providedPassword == null) + throw new ArgumentNullException(nameof(providedPassword)); + + byte[] decodedHashedPassword; + try + { + decodedHashedPassword = Convert.FromBase64String(hashedPassword); + } + catch (FormatException e) + { + throw new HashedPasswordBadFromatException(hashedPassword, Resources.Services.Exception.HashedPasswordBadFromatExceptionNotBase64, e); + } + + // read the format marker from the hashed password + if (decodedHashedPassword.Length == 0) + { + throw new HashedPasswordBadFromatException(hashedPassword, Resources.Services.Exception.HashedPasswordBadFromatExceptionNotLength0); + } + + return (decodedHashedPassword[0]) switch + { + 0x01 => VerifyHashedPasswordV3(decodedHashedPassword, providedPassword, hashedPassword), + _ => throw new HashedPasswordBadFromatException(hashedPassword, Resources.Services.Exception.HashedPasswordBadFromatExceptionNotUnknownMarker), + }; + } + + private static bool VerifyHashedPasswordV3(byte[] hashedPassword, string password, string hashedPasswordString) + { + try + { + // Read header information + KeyDerivationPrf prf = (KeyDerivationPrf)ReadNetworkByteOrder(hashedPassword, 1); + int iterCount = (int)ReadNetworkByteOrder(hashedPassword, 5); + int saltLength = (int)ReadNetworkByteOrder(hashedPassword, 9); + + // Read the salt: must be >= 128 bits + if (saltLength < 128 / 8) + { + throw new HashedPasswordBadFromatException(hashedPasswordString, Resources.Services.Exception.HashedPasswordBadFromatExceptionNotSaltTooShort); + } + byte[] salt = new byte[saltLength]; + Buffer.BlockCopy(hashedPassword, 13, salt, 0, salt.Length); + + // Read the subkey (the rest of the payload): must be >= 128 bits + int subkeyLength = hashedPassword.Length - 13 - salt.Length; + if (subkeyLength < 128 / 8) + { + throw new HashedPasswordBadFromatException(hashedPasswordString, Resources.Services.Exception.HashedPasswordBadFromatExceptionNotSubkeyTooShort); + } + byte[] expectedSubkey = new byte[subkeyLength]; + Buffer.BlockCopy(hashedPassword, 13 + salt.Length, expectedSubkey, 0, expectedSubkey.Length); + + // Hash the incoming password and verify it + byte[] actualSubkey = KeyDerivation.Pbkdf2(password, salt, prf, iterCount, subkeyLength); + return ByteArraysEqual(actualSubkey, expectedSubkey); + } + catch (Exception e) + { + // This should never occur except in the case of a malformed payload, where + // we might go off the end of the array. Regardless, a malformed payload + // implies verification failed. + throw new HashedPasswordBadFromatException(hashedPasswordString, Resources.Services.Exception.HashedPasswordBadFromatExceptionNotOthers, e); + } + } + + private static uint ReadNetworkByteOrder(byte[] buffer, int offset) + { + return ((uint)(buffer[offset + 0]) << 24) + | ((uint)(buffer[offset + 1]) << 16) + | ((uint)(buffer[offset + 2]) << 8) + | ((uint)(buffer[offset + 3])); + } + + private static void WriteNetworkByteOrder(byte[] buffer, int offset, uint value) + { + buffer[offset + 0] = (byte)(value >> 24); + buffer[offset + 1] = (byte)(value >> 16); + buffer[offset + 2] = (byte)(value >> 8); + buffer[offset + 3] = (byte)(value >> 0); + } + } +} diff --git a/BackEnd/Timeline/Services/User/Resource.Designer.cs b/BackEnd/Timeline/Services/User/Resource.Designer.cs new file mode 100644 index 00000000..d64a7aab --- /dev/null +++ b/BackEnd/Timeline/Services/User/Resource.Designer.cs @@ -0,0 +1,117 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Timeline.Services.User { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resource { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resource() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Timeline.Services.User.Resource", typeof(Resource).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to Password is wrong.. + /// + internal static string ExceptionBadPassword { + get { + return ResourceManager.GetString("ExceptionBadPassword", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Nickname is of bad format. {0}. + /// + internal static string ExceptionNicknameBadFormat { + get { + return ResourceManager.GetString("ExceptionNicknameBadFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Password can't be empty.. + /// + internal static string ExceptionPasswordEmpty { + get { + return ResourceManager.GetString("ExceptionPasswordEmpty", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to User with given constraints already exists.. + /// + internal static string ExceptionUserAlreadyExist { + get { + return ResourceManager.GetString("ExceptionUserAlreadyExist", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Username is of bad format. {0}. + /// + internal static string ExceptionUsernameBadFormat { + get { + return ResourceManager.GetString("ExceptionUsernameBadFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Requested user does not exist.. + /// + internal static string ExceptionUserNotExist { + get { + return ResourceManager.GetString("ExceptionUserNotExist", resourceCulture); + } + } + } +} diff --git a/BackEnd/Timeline/Services/User/Resource.resx b/BackEnd/Timeline/Services/User/Resource.resx new file mode 100644 index 00000000..732cfefd --- /dev/null +++ b/BackEnd/Timeline/Services/User/Resource.resx @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Password is wrong. + + + Nickname is of bad format. {0} + + + Password can't be empty. + + + User with given constraints already exists. + + + Username is of bad format. {0} + + + Requested user does not exist. + + \ No newline at end of file diff --git a/BackEnd/Timeline/Services/User/UserAlreadyExistException.cs b/BackEnd/Timeline/Services/User/UserAlreadyExistException.cs new file mode 100644 index 00000000..e257af74 --- /dev/null +++ b/BackEnd/Timeline/Services/User/UserAlreadyExistException.cs @@ -0,0 +1,24 @@ +using System; + +namespace Timeline.Services.User +{ + /// + /// The user requested does not exist. + /// + [Serializable] + public class UserAlreadyExistException : EntityAlreadyExistException + { + public UserAlreadyExistException() : this(null, null, null) { } + public UserAlreadyExistException(object? entity) : this(entity, null, null) { } + public UserAlreadyExistException(object? entity, Exception? inner) : this(entity, null, inner) { } + public UserAlreadyExistException(object? entity, string? message, Exception? inner) + : base(EntityNames.User, entity, message ?? Resource.ExceptionUserAlreadyExist, inner) + { + + } + + protected UserAlreadyExistException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + } +} diff --git a/BackEnd/Timeline/Services/User/UserAvatarService.cs b/BackEnd/Timeline/Services/User/UserAvatarService.cs new file mode 100644 index 00000000..0a4b7438 --- /dev/null +++ b/BackEnd/Timeline/Services/User/UserAvatarService.cs @@ -0,0 +1,266 @@ +using Microsoft.AspNetCore.Hosting; +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.Cache; +using Timeline.Models; +using Timeline.Services.Data; +using Timeline.Services.Imaging; + +namespace Timeline.Services.User +{ + /// + /// Provider for default user avatar. + /// + /// + /// Mainly for unit tests. + /// + public interface IDefaultUserAvatarProvider + { + /// + /// Get the digest of default avatar. + /// + /// The digest. + Task GetDefaultAvatarDigest(); + + /// + /// Get the default avatar. + /// + /// The avatar. + Task GetDefaultAvatar(); + } + + public interface IUserAvatarService + { + /// + /// Get avatar digest of a user. + /// + /// User id. + /// The avatar digest. + /// Thrown when user does not exist. + Task GetAvatarDigest(long userId); + + /// + /// Get avatar of a user. If the user has no avatar set, a default one is returned. + /// + /// User id. + /// The avatar. + /// Thrown when user does not exist. + Task GetAvatar(long userId); + + /// + /// Set avatar for a user. + /// + /// User id. + /// The new avatar data. + /// The digest of the avatar. + /// Thrown if is null. + /// Thrown when user does not exist. + /// Thrown if avatar is of bad format. + Task SetAvatar(long userId, ByteData avatar); + + /// + /// Remove avatar of a user. + /// + /// User id. + /// Thrown when user does not exist. + Task DeleteAvatar(long userId); + } + + // TODO! : Make this configurable. + public class DefaultUserAvatarProvider : IDefaultUserAvatarProvider + { + private readonly IETagGenerator _eTagGenerator; + + private readonly string _avatarPath; + + private CacheableDataDigest? _cacheDigest; + private ByteData? _cacheData; + + public DefaultUserAvatarProvider(IWebHostEnvironment environment, IETagGenerator eTagGenerator) + { + _avatarPath = Path.Combine(environment.ContentRootPath, "default-avatar.png"); + _eTagGenerator = eTagGenerator; + } + + private async Task CheckAndInit() + { + var path = _avatarPath; + if (_cacheData == null || File.GetLastWriteTime(path) > _cacheDigest!.LastModified) + { + 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 GetDefaultAvatarDigest() + { + await CheckAndInit(); + return _cacheDigest!; + } + + public async Task GetDefaultAvatar() + { + await CheckAndInit(); + return _cacheData!; + } + } + + public class UserAvatarService : IUserAvatarService + { + private readonly ILogger _logger; + private readonly DatabaseContext _database; + private readonly IBasicUserService _basicUserService; + private readonly IDefaultUserAvatarProvider _defaultUserAvatarProvider; + private readonly IImageValidator _imageValidator; + private readonly IDataManager _dataManager; + private readonly IClock _clock; + + public UserAvatarService( + ILogger logger, + DatabaseContext database, + IBasicUserService basicUserService, + IDefaultUserAvatarProvider defaultUserAvatarProvider, + IImageValidator imageValidator, + IDataManager dataManager, + IClock clock) + { + _logger = logger; + _database = database; + _basicUserService = basicUserService; + _defaultUserAvatarProvider = defaultUserAvatarProvider; + _imageValidator = imageValidator; + _dataManager = dataManager; + _clock = clock; + } + + public async Task GetAvatarDigest(long userId) + { + 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 new CacheableDataDigest(entity.DataTag, new DateTime[] { usernameChangeTime, entity.LastModified }.Max()); + } + } + + public async Task GetAvatar(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 _defaultUserAvatarProvider.GetDefaultAvatar(); + } + + var data = await _dataManager.GetEntryAndCheck(entity.DataTag, $"This is required by avatar of {userId}."); + + if (entity.Type is null) + { + Image.Identify(data, out var format); + entity.Type = format.DefaultMimeType; + await _database.SaveChangesAsync(); + } + + return new ByteData(data, entity.Type); + } + + public async Task SetAvatar(long userId, ByteData avatar) + { + if (avatar is null) + throw new ArgumentNullException(nameof(avatar)); + + await _imageValidator.Validate(avatar.Data, avatar.ContentType, true); + + await _basicUserService.ThrowIfUserNotExist(userId); + + var entity = await _database.UserAvatars.Where(a => a.UserId == userId).SingleOrDefaultAsync(); + + await using var transaction = await _database.Database.BeginTransactionAsync(); + + var tag = await _dataManager.RetainEntry(avatar.Data); + + var now = _clock.GetCurrentTime(); + + if (entity is null) + { + var newEntity = new UserAvatarEntity + { + DataTag = tag, + Type = avatar.ContentType, + LastModified = now, + UserId = userId + }; + _database.Add(newEntity); + } + else + { + if (entity.DataTag is not null) + await _dataManager.FreeEntry(entity.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(); + } + } + + public static class UserAvatarServiceCollectionExtensions + { + public static void AddUserAvatarService(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + } + } +} diff --git a/BackEnd/Timeline/Services/User/UserCredentialService.cs b/BackEnd/Timeline/Services/User/UserCredentialService.cs new file mode 100644 index 00000000..6becc469 --- /dev/null +++ b/BackEnd/Timeline/Services/User/UserCredentialService.cs @@ -0,0 +1,101 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using System; +using System.Linq; +using System.Threading.Tasks; +using Timeline.Entities; +using Timeline.Helpers; +using Timeline.Models.Validation; + +namespace Timeline.Services.User +{ + public interface IUserCredentialService + { + /// + /// Try to verify the given username and password. + /// + /// The username of the user to verify. + /// The password of the user to verify. + /// User id. + /// Thrown when or is null. + /// Thrown when is of bad format or is empty. + /// Thrown when the user with given username does not exist. + /// Thrown when password is wrong. + Task VerifyCredential(string username, string password); + + /// + /// Try to change a user's password with old password. + /// + /// The id of user to change password of. + /// Old password. + /// New password. + /// Thrown if or is null. + /// Thrown if or is empty. + /// Thrown if the user with given username does not exist. + /// Thrown if the old password is wrong. + Task ChangePassword(long id, string oldPassword, string newPassword); + } + + public class UserCredentialService : IUserCredentialService + { + private readonly ILogger _logger; + private readonly DatabaseContext _database; + private readonly IPasswordService _passwordService; + + private readonly UsernameValidator _usernameValidator = new UsernameValidator(); + + public UserCredentialService(ILogger logger, DatabaseContext database, IPasswordService passwordService) + { + _logger = logger; + _database = database; + _passwordService = passwordService; + } + + public async Task VerifyCredential(string username, string password) + { + if (username == null) + throw new ArgumentNullException(nameof(username)); + if (password == null) + throw new ArgumentNullException(nameof(password)); + if (!_usernameValidator.Validate(username, out var message)) + throw new ArgumentException(message); + if (password.Length == 0) + throw new ArgumentException("Password can't be empty."); + + var entity = await _database.Users.Where(u => u.Username == username).Select(u => new { u.Id, u.Password }).SingleOrDefaultAsync(); + + if (entity == null) + throw new UserNotExistException(username); + + if (!_passwordService.VerifyPassword(entity.Password, password)) + throw new BadPasswordException(password); + + return entity.Id; + } + + public async Task ChangePassword(long id, string oldPassword, string newPassword) + { + if (oldPassword == null) + throw new ArgumentNullException(nameof(oldPassword)); + if (newPassword == null) + throw new ArgumentNullException(nameof(newPassword)); + if (oldPassword.Length == 0) + throw new ArgumentException("Old password can't be empty."); + if (newPassword.Length == 0) + throw new ArgumentException("New password can't be empty."); + + var entity = await _database.Users.Where(u => u.Id == id).SingleOrDefaultAsync(); + + if (entity == null) + throw new UserNotExistException(id); + + if (!_passwordService.VerifyPassword(entity.Password, oldPassword)) + throw new BadPasswordException(oldPassword); + + entity.Password = _passwordService.HashPassword(newPassword); + entity.Version += 1; + await _database.SaveChangesAsync(); + _logger.LogInformation(Log.Format(Resources.Services.UserService.LogDatabaseUpdate, ("Id", id), ("Operation", "Change password"))); + } + } +} diff --git a/BackEnd/Timeline/Services/User/UserDeleteService.cs b/BackEnd/Timeline/Services/User/UserDeleteService.cs new file mode 100644 index 00000000..8da4678a --- /dev/null +++ b/BackEnd/Timeline/Services/User/UserDeleteService.cs @@ -0,0 +1,70 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using System; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using Timeline.Entities; +using Timeline.Models.Validation; +using Timeline.Services.Timeline; + +namespace Timeline.Services.User +{ + public interface IUserDeleteService + { + /// + /// Delete a user of given username. + /// + /// Username of the user to delete. Can't be null. + /// True if user is deleted, false if user not exist. + /// Thrown if is null. + /// Thrown when is of bad format. + /// Thrown when deleting root user. + Task DeleteUser(string username); + } + + public class UserDeleteService : IUserDeleteService + { + private readonly ILogger _logger; + + private readonly DatabaseContext _databaseContext; + + private readonly ITimelinePostService _timelinePostService; + + private readonly UsernameValidator _usernameValidator = new UsernameValidator(); + + public UserDeleteService(ILogger logger, DatabaseContext databaseContext, ITimelinePostService timelinePostService) + { + _logger = logger; + _databaseContext = databaseContext; + _timelinePostService = timelinePostService; + } + + public async Task DeleteUser(string username) + { + if (username == null) + throw new ArgumentNullException(nameof(username)); + + if (!_usernameValidator.Validate(username, out var message)) + { + throw new ArgumentException(string.Format(CultureInfo.CurrentCulture, Resource.ExceptionUsernameBadFormat, message), nameof(username)); + } + + var user = await _databaseContext.Users.Where(u => u.Username == username).SingleOrDefaultAsync(); + if (user == null) + return false; + + if (user.Id == 1) + throw new InvalidOperationOnRootUserException("Can't delete root user."); + + await _timelinePostService.DeleteAllPostsOfUser(user.Id); + + _databaseContext.Users.Remove(user); + + await _databaseContext.SaveChangesAsync(); + + return true; + } + + } +} diff --git a/BackEnd/Timeline/Services/User/UserNotExistException.cs b/BackEnd/Timeline/Services/User/UserNotExistException.cs new file mode 100644 index 00000000..bc5d8d9e --- /dev/null +++ b/BackEnd/Timeline/Services/User/UserNotExistException.cs @@ -0,0 +1,37 @@ +using System; + +namespace Timeline.Services.User +{ + /// + /// The user requested does not exist. + /// + [Serializable] + public class UserNotExistException : EntityNotExistException + { + public UserNotExistException() : this(null, null, null, null) { } + public UserNotExistException(string? username) : this(username, null, null, null) { } + public UserNotExistException(string? username, Exception? inner) : this(username, null, null, inner) { } + public UserNotExistException(long id) : this(null, id, null, null) { } + public UserNotExistException(long id, Exception? inner) : this(null, id, null, inner) { } + public UserNotExistException(string? username, long? id, string? message, Exception? inner) + : base(EntityNames.User, message ?? Resource.ExceptionUserNotExist, inner) + { + Username = username; + Id = id; + } + + protected UserNotExistException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + + /// + /// The username of the user that does not exist. + /// + public string? Username { get; set; } + + /// + /// The id of the user that does not exist. + /// + public long? Id { get; set; } + } +} diff --git a/BackEnd/Timeline/Services/User/UserPermissionService.cs b/BackEnd/Timeline/Services/User/UserPermissionService.cs new file mode 100644 index 00000000..f292142d --- /dev/null +++ b/BackEnd/Timeline/Services/User/UserPermissionService.cs @@ -0,0 +1,240 @@ +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Timeline.Entities; + +namespace Timeline.Services.User +{ + public enum UserPermission + { + /// + /// This permission allows to manage user (creating, deleting or modifying). + /// + UserManagement, + /// + /// This permission allows to view and modify all timelines. + /// + AllTimelineManagement, + /// + /// This permission allow to add or remove highlight timelines. + /// + HighlightTimelineManagement + } + + /// + /// Represents a user's permissions. + /// + public class UserPermissions : IEnumerable, IEquatable + { + public static UserPermissions AllPermissions { get; } = new UserPermissions(Enum.GetValues()); + + /// + /// Create an instance containing given permissions. + /// + /// Permission list. + public UserPermissions(params UserPermission[] permissions) : this(permissions as IEnumerable) + { + + } + + /// + /// Create an instance containing given permissions. + /// + /// Permission list. + /// Thrown when is null. + public UserPermissions(IEnumerable permissions) + { + if (permissions == null) throw new ArgumentNullException(nameof(permissions)); + _permissions = new SortedSet(permissions); + } + + private readonly SortedSet _permissions = new(); + + /// + /// Check if a permission is contained in the list. + /// + /// The permission to check. + /// True if contains. Otherwise false. + public bool Contains(UserPermission permission) + { + return _permissions.Contains(permission); + } + + /// + /// To a serializable string list. + /// + /// A string list. + public List ToStringList() + { + return _permissions.Select(p => p.ToString()).ToList(); + } + + /// + /// Convert a string list to user permissions. + /// + /// The string list. + /// An instance. + /// Thrown when is null. + /// Thrown when there is unknown permission name. + public static UserPermissions FromStringList(IEnumerable list) + { + List permissions = new(); + + foreach (var value in list) + { + if (Enum.TryParse(value, false, out var result)) + { + permissions.Add(result); + } + else + { + throw new ArgumentException("Unknown permission name.", nameof(list)); + } + } + + return new UserPermissions(permissions); + } + + public IEnumerator GetEnumerator() + { + return _permissions.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return ((IEnumerable)_permissions).GetEnumerator(); + } + + public bool Equals(UserPermissions? other) + { + if (other == null) + return false; + + return _permissions.SequenceEqual(other._permissions); + } + + public override bool Equals(object? obj) + { + return Equals(obj as UserPermissions); + } + + public override int GetHashCode() + { + int result = 0; + foreach (var permission in Enum.GetValues()) + { + if (_permissions.Contains(permission)) + { + result += 1; + } + result <<= 1; + } + return result; + } + } + + public interface IUserPermissionService + { + /// + /// Get permissions of a user. + /// + /// The id of the user. + /// Whether check the user's existence. + /// The permission list. + /// Thrown when is true and user does not exist. + Task GetPermissionsOfUserAsync(long userId, bool checkUserExistence = true); + + /// + /// Add a permission to user. + /// + /// The id of the user. + /// The new permission. + /// Thrown when user does not exist. + /// Thrown when change root user's permission. + Task AddPermissionToUserAsync(long userId, UserPermission permission); + + /// + /// Remove a permission from user. + /// + /// The id of the user. + /// The permission. + /// Whether check the user's existence. + /// Thrown when is true and user does not exist. + /// Thrown when change root user's permission. + Task RemovePermissionFromUserAsync(long userId, UserPermission permission, bool checkUserExistence = true); + } + + public class UserPermissionService : IUserPermissionService + { + private readonly DatabaseContext _database; + + public UserPermissionService(DatabaseContext database) + { + _database = database; + } + + private async Task CheckUserExistence(long userId, bool checkUserExistence) + { + if (checkUserExistence) + { + var existence = await _database.Users.AnyAsync(u => u.Id == userId); + if (!existence) + { + throw new UserNotExistException(userId); + } + } + } + + public async Task GetPermissionsOfUserAsync(long userId, bool checkUserExistence = true) + { + if (userId == 1) // The init administrator account. + { + return UserPermissions.AllPermissions; + } + + await CheckUserExistence(userId, checkUserExistence); + + var permissionNameList = await _database.UserPermission.Where(e => e.UserId == userId).Select(e => e.Permission).ToListAsync(); + + return UserPermissions.FromStringList(permissionNameList); + } + + public async Task AddPermissionToUserAsync(long userId, UserPermission permission) + { + if (userId == 1) + throw new InvalidOperationOnRootUserException("Can't change root user's permission."); + + await CheckUserExistence(userId, true); + + var alreadyHas = await _database.UserPermission + .AnyAsync(e => e.UserId == userId && e.Permission == permission.ToString()); + + if (alreadyHas) return; + + _database.UserPermission.Add(new UserPermissionEntity { UserId = userId, Permission = permission.ToString() }); + + await _database.SaveChangesAsync(); + } + + public async Task RemovePermissionFromUserAsync(long userId, UserPermission permission, bool checkUserExistence = true) + { + if (userId == 1) + throw new InvalidOperationOnRootUserException("Can't change root user's permission."); + + await CheckUserExistence(userId, checkUserExistence); + + var entity = await _database.UserPermission + .Where(e => e.UserId == userId && e.Permission == permission.ToString()) + .SingleOrDefaultAsync(); + + if (entity == null) return; + + _database.UserPermission.Remove(entity); + + await _database.SaveChangesAsync(); + } + } +} diff --git a/BackEnd/Timeline/Services/User/UserService.cs b/BackEnd/Timeline/Services/User/UserService.cs new file mode 100644 index 00000000..bbbe15b0 --- /dev/null +++ b/BackEnd/Timeline/Services/User/UserService.cs @@ -0,0 +1,214 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using Timeline.Entities; +using Timeline.Models.Validation; + +namespace Timeline.Services.User +{ + /// + /// Null means not change. + /// + public class ModifyUserParams + { + public string? Username { get; set; } + public string? Password { get; set; } + public string? Nickname { get; set; } + } + + public interface IUserService : IBasicUserService + { + /// + /// Try to get a user by id. + /// + /// The id of the user. + /// The user info. + /// Thrown when the user with given id does not exist. + Task GetUser(long id); + + /// + /// List all users. + /// + /// The user info of users. + Task> GetUsers(); + + /// + /// Create a user with given info. + /// + /// The username of new user. + /// The password of new user. + /// The the new user. + /// Thrown when or is null. + /// Thrown when or is of bad format. + /// Thrown when a user with given username already exists. + Task CreateUser(string username, string password); + + /// + /// Modify a user. + /// + /// The id of the user. + /// The new information. + /// The new user info. + /// Thrown when some fields in is bad. + /// Thrown when user with given id does not exist. + /// + /// Version will increase if password is changed. + /// + Task ModifyUser(long id, ModifyUserParams? param); + } + + public class UserService : BasicUserService, IUserService + { + private readonly ILogger _logger; + private readonly IClock _clock; + + private readonly DatabaseContext _databaseContext; + + private readonly IPasswordService _passwordService; + + private readonly UsernameValidator _usernameValidator = new UsernameValidator(); + private readonly NicknameValidator _nicknameValidator = new NicknameValidator(); + + public UserService(ILogger logger, DatabaseContext databaseContext, IPasswordService passwordService, IClock clock) : base(databaseContext) + { + _logger = logger; + _databaseContext = databaseContext; + _passwordService = passwordService; + _clock = clock; + } + + private void CheckUsernameFormat(string username, string? paramName) + { + if (!_usernameValidator.Validate(username, out var message)) + { + throw new ArgumentException(string.Format(CultureInfo.CurrentCulture, Resource.ExceptionUsernameBadFormat, message), paramName); + } + } + + private static void CheckPasswordFormat(string password, string? paramName) + { + if (password.Length == 0) + { + throw new ArgumentException(Resource.ExceptionPasswordEmpty, paramName); + } + } + + private void CheckNicknameFormat(string nickname, string? paramName) + { + if (!_nicknameValidator.Validate(nickname, out var message)) + { + throw new ArgumentException(string.Format(CultureInfo.CurrentCulture, Resource.ExceptionNicknameBadFormat, message), paramName); + } + } + + private static void ThrowUsernameConflict(object? user) + { + throw new UserAlreadyExistException(user); + } + + public async Task GetUser(long id) + { + var user = await _databaseContext.Users.Where(u => u.Id == id).SingleOrDefaultAsync(); + + if (user == null) + throw new UserNotExistException(id); + + return user; + } + + public async Task> GetUsers() + { + return await _databaseContext.Users.ToListAsync(); + } + + public async Task CreateUser(string username, string password) + { + if (username == null) + throw new ArgumentNullException(nameof(username)); + if (password == null) + throw new ArgumentNullException(nameof(password)); + + CheckUsernameFormat(username, nameof(username)); + CheckPasswordFormat(password, nameof(password)); + + var conflict = await _databaseContext.Users.AnyAsync(u => u.Username == username); + if (conflict) + ThrowUsernameConflict(null); + + var newEntity = new UserEntity + { + Username = username, + Password = _passwordService.HashPassword(password), + Version = 1 + }; + _databaseContext.Users.Add(newEntity); + await _databaseContext.SaveChangesAsync(); + + return newEntity; + } + + public async Task ModifyUser(long id, ModifyUserParams? param) + { + if (param != null) + { + if (param.Username != null) + CheckUsernameFormat(param.Username, nameof(param)); + + if (param.Password != null) + CheckPasswordFormat(param.Password, nameof(param)); + + if (param.Nickname != null) + CheckNicknameFormat(param.Nickname, nameof(param)); + } + + var entity = await _databaseContext.Users.Where(u => u.Id == id).SingleOrDefaultAsync(); + if (entity == null) + throw new UserNotExistException(id); + + if (param != null) + { + var now = _clock.GetCurrentTime(); + bool updateLastModified = false; + + var username = param.Username; + if (username != null && username != entity.Username) + { + var conflict = await _databaseContext.Users.AnyAsync(u => u.Username == username); + if (conflict) + ThrowUsernameConflict(null); + + entity.Username = username; + entity.UsernameChangeTime = now; + updateLastModified = true; + } + + var password = param.Password; + if (password != null) + { + entity.Password = _passwordService.HashPassword(password); + entity.Version += 1; + } + + var nickname = param.Nickname; + if (nickname != null && nickname != entity.Nickname) + { + entity.Nickname = nickname; + updateLastModified = true; + } + + if (updateLastModified) + { + entity.LastModified = now; + } + + await _databaseContext.SaveChangesAsync(); + } + + return entity; + } + } +} diff --git a/BackEnd/Timeline/Services/UserAvatarService.cs b/BackEnd/Timeline/Services/UserAvatarService.cs deleted file mode 100644 index 5a6d013e..00000000 --- a/BackEnd/Timeline/Services/UserAvatarService.cs +++ /dev/null @@ -1,265 +0,0 @@ -using Microsoft.AspNetCore.Hosting; -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.Cache; -using Timeline.Models; -using Timeline.Services.Exceptions; - -namespace Timeline.Services -{ - /// - /// Provider for default user avatar. - /// - /// - /// Mainly for unit tests. - /// - public interface IDefaultUserAvatarProvider - { - /// - /// Get the digest of default avatar. - /// - /// The digest. - Task GetDefaultAvatarDigest(); - - /// - /// Get the default avatar. - /// - /// The avatar. - Task GetDefaultAvatar(); - } - - public interface IUserAvatarService - { - /// - /// Get avatar digest of a user. - /// - /// User id. - /// The avatar digest. - /// Thrown when user does not exist. - Task GetAvatarDigest(long userId); - - /// - /// Get avatar of a user. If the user has no avatar set, a default one is returned. - /// - /// User id. - /// The avatar. - /// Thrown when user does not exist. - Task GetAvatar(long userId); - - /// - /// Set avatar for a user. - /// - /// User id. - /// The new avatar data. - /// The digest of the avatar. - /// Thrown if is null. - /// Thrown when user does not exist. - /// Thrown if avatar is of bad format. - Task SetAvatar(long userId, ByteData avatar); - - /// - /// Remove avatar of a user. - /// - /// User id. - /// Thrown when user does not exist. - Task DeleteAvatar(long userId); - } - - // TODO! : Make this configurable. - public class DefaultUserAvatarProvider : IDefaultUserAvatarProvider - { - private readonly IETagGenerator _eTagGenerator; - - private readonly string _avatarPath; - - private CacheableDataDigest? _cacheDigest; - private ByteData? _cacheData; - - public DefaultUserAvatarProvider(IWebHostEnvironment environment, IETagGenerator eTagGenerator) - { - _avatarPath = Path.Combine(environment.ContentRootPath, "default-avatar.png"); - _eTagGenerator = eTagGenerator; - } - - private async Task CheckAndInit() - { - var path = _avatarPath; - if (_cacheData == null || File.GetLastWriteTime(path) > _cacheDigest!.LastModified) - { - 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 GetDefaultAvatarDigest() - { - await CheckAndInit(); - return _cacheDigest!; - } - - public async Task GetDefaultAvatar() - { - await CheckAndInit(); - return _cacheData!; - } - } - - public class UserAvatarService : IUserAvatarService - { - private readonly ILogger _logger; - private readonly DatabaseContext _database; - private readonly IBasicUserService _basicUserService; - private readonly IDefaultUserAvatarProvider _defaultUserAvatarProvider; - private readonly IImageValidator _imageValidator; - private readonly IDataManager _dataManager; - private readonly IClock _clock; - - public UserAvatarService( - ILogger logger, - DatabaseContext database, - IBasicUserService basicUserService, - IDefaultUserAvatarProvider defaultUserAvatarProvider, - IImageValidator imageValidator, - IDataManager dataManager, - IClock clock) - { - _logger = logger; - _database = database; - _basicUserService = basicUserService; - _defaultUserAvatarProvider = defaultUserAvatarProvider; - _imageValidator = imageValidator; - _dataManager = dataManager; - _clock = clock; - } - - public async Task GetAvatarDigest(long userId) - { - 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 new CacheableDataDigest(entity.DataTag, new DateTime[] { usernameChangeTime, entity.LastModified }.Max()); - } - } - - public async Task GetAvatar(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 _defaultUserAvatarProvider.GetDefaultAvatar(); - } - - var data = await _dataManager.GetEntryAndCheck(entity.DataTag, $"This is required by avatar of {userId}."); - - if (entity.Type is null) - { - Image.Identify(data, out var format); - entity.Type = format.DefaultMimeType; - await _database.SaveChangesAsync(); - } - - return new ByteData(data, entity.Type); - } - - public async Task SetAvatar(long userId, ByteData avatar) - { - if (avatar is null) - throw new ArgumentNullException(nameof(avatar)); - - await _imageValidator.Validate(avatar.Data, avatar.ContentType, true); - - await _basicUserService.ThrowIfUserNotExist(userId); - - var entity = await _database.UserAvatars.Where(a => a.UserId == userId).SingleOrDefaultAsync(); - - await using var transaction = await _database.Database.BeginTransactionAsync(); - - var tag = await _dataManager.RetainEntry(avatar.Data); - - var now = _clock.GetCurrentTime(); - - if (entity is null) - { - var newEntity = new UserAvatarEntity - { - DataTag = tag, - Type = avatar.ContentType, - LastModified = now, - UserId = userId - }; - _database.Add(newEntity); - } - else - { - if (entity.DataTag is not null) - await _dataManager.FreeEntry(entity.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(); - } - } - - public static class UserAvatarServiceCollectionExtensions - { - public static void AddUserAvatarService(this IServiceCollection services) - { - services.AddScoped(); - services.AddScoped(); - } - } -} diff --git a/BackEnd/Timeline/Services/UserCredentialService.cs b/BackEnd/Timeline/Services/UserCredentialService.cs deleted file mode 100644 index 8aeef9ef..00000000 --- a/BackEnd/Timeline/Services/UserCredentialService.cs +++ /dev/null @@ -1,102 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using System; -using System.Linq; -using System.Threading.Tasks; -using Timeline.Entities; -using Timeline.Helpers; -using Timeline.Models.Validation; -using Timeline.Services.Exceptions; - -namespace Timeline.Services -{ - public interface IUserCredentialService - { - /// - /// Try to verify the given username and password. - /// - /// The username of the user to verify. - /// The password of the user to verify. - /// User id. - /// Thrown when or is null. - /// Thrown when is of bad format or is empty. - /// Thrown when the user with given username does not exist. - /// Thrown when password is wrong. - Task VerifyCredential(string username, string password); - - /// - /// Try to change a user's password with old password. - /// - /// The id of user to change password of. - /// Old password. - /// New password. - /// Thrown if or is null. - /// Thrown if or is empty. - /// Thrown if the user with given username does not exist. - /// Thrown if the old password is wrong. - Task ChangePassword(long id, string oldPassword, string newPassword); - } - - public class UserCredentialService : IUserCredentialService - { - private readonly ILogger _logger; - private readonly DatabaseContext _database; - private readonly IPasswordService _passwordService; - - private readonly UsernameValidator _usernameValidator = new UsernameValidator(); - - public UserCredentialService(ILogger logger, DatabaseContext database, IPasswordService passwordService) - { - _logger = logger; - _database = database; - _passwordService = passwordService; - } - - public async Task VerifyCredential(string username, string password) - { - if (username == null) - throw new ArgumentNullException(nameof(username)); - if (password == null) - throw new ArgumentNullException(nameof(password)); - if (!_usernameValidator.Validate(username, out var message)) - throw new ArgumentException(message); - if (password.Length == 0) - throw new ArgumentException("Password can't be empty."); - - var entity = await _database.Users.Where(u => u.Username == username).Select(u => new { u.Id, u.Password }).SingleOrDefaultAsync(); - - if (entity == null) - throw new UserNotExistException(username); - - if (!_passwordService.VerifyPassword(entity.Password, password)) - throw new BadPasswordException(password); - - return entity.Id; - } - - public async Task ChangePassword(long id, string oldPassword, string newPassword) - { - if (oldPassword == null) - throw new ArgumentNullException(nameof(oldPassword)); - if (newPassword == null) - throw new ArgumentNullException(nameof(newPassword)); - if (oldPassword.Length == 0) - throw new ArgumentException("Old password can't be empty."); - if (newPassword.Length == 0) - throw new ArgumentException("New password can't be empty."); - - var entity = await _database.Users.Where(u => u.Id == id).SingleOrDefaultAsync(); - - if (entity == null) - throw new UserNotExistException(id); - - if (!_passwordService.VerifyPassword(entity.Password, oldPassword)) - throw new BadPasswordException(oldPassword); - - entity.Password = _passwordService.HashPassword(newPassword); - entity.Version += 1; - await _database.SaveChangesAsync(); - _logger.LogInformation(Log.Format(Resources.Services.UserService.LogDatabaseUpdate, ("Id", id), ("Operation", "Change password"))); - } - } -} diff --git a/BackEnd/Timeline/Services/UserDeleteService.cs b/BackEnd/Timeline/Services/UserDeleteService.cs deleted file mode 100644 index a4e77abc..00000000 --- a/BackEnd/Timeline/Services/UserDeleteService.cs +++ /dev/null @@ -1,73 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using System; -using System.Globalization; -using System.Linq; -using System.Threading.Tasks; -using Timeline.Entities; -using Timeline.Helpers; -using Timeline.Models.Validation; -using Timeline.Services.Exceptions; -using static Timeline.Resources.Services.UserService; - -namespace Timeline.Services -{ - public interface IUserDeleteService - { - /// - /// Delete a user of given username. - /// - /// Username of the user to delete. Can't be null. - /// True if user is deleted, false if user not exist. - /// Thrown if is null. - /// Thrown when is of bad format. - /// Thrown when deleting root user. - Task DeleteUser(string username); - } - - public class UserDeleteService : IUserDeleteService - { - private readonly ILogger _logger; - - private readonly DatabaseContext _databaseContext; - - private readonly ITimelinePostService _timelinePostService; - - private readonly UsernameValidator _usernameValidator = new UsernameValidator(); - - public UserDeleteService(ILogger logger, DatabaseContext databaseContext, ITimelinePostService timelinePostService) - { - _logger = logger; - _databaseContext = databaseContext; - _timelinePostService = timelinePostService; - } - - public async Task DeleteUser(string username) - { - if (username == null) - throw new ArgumentNullException(nameof(username)); - - if (!_usernameValidator.Validate(username, out var message)) - { - throw new ArgumentException(string.Format(CultureInfo.CurrentCulture, ExceptionUsernameBadFormat, message), nameof(username)); - } - - var user = await _databaseContext.Users.Where(u => u.Username == username).SingleOrDefaultAsync(); - if (user == null) - return false; - - if (user.Id == 1) - throw new InvalidOperationOnRootUserException("Can't delete root user."); - - await _timelinePostService.DeleteAllPostsOfUser(user.Id); - - _databaseContext.Users.Remove(user); - - await _databaseContext.SaveChangesAsync(); - _logger.LogInformation(Log.Format(LogDatabaseRemove, ("Id", user.Id), ("Username", user.Username))); - - return true; - } - - } -} diff --git a/BackEnd/Timeline/Services/UserPermissionService.cs b/BackEnd/Timeline/Services/UserPermissionService.cs deleted file mode 100644 index bd7cd6aa..00000000 --- a/BackEnd/Timeline/Services/UserPermissionService.cs +++ /dev/null @@ -1,241 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Timeline.Entities; -using Timeline.Services.Exceptions; - -namespace Timeline.Services -{ - public enum UserPermission - { - /// - /// This permission allows to manage user (creating, deleting or modifying). - /// - UserManagement, - /// - /// This permission allows to view and modify all timelines. - /// - AllTimelineManagement, - /// - /// This permission allow to add or remove highlight timelines. - /// - HighlightTimelineManagement - } - - /// - /// Represents a user's permissions. - /// - public class UserPermissions : IEnumerable, IEquatable - { - public static UserPermissions AllPermissions { get; } = new UserPermissions(Enum.GetValues()); - - /// - /// Create an instance containing given permissions. - /// - /// Permission list. - public UserPermissions(params UserPermission[] permissions) : this(permissions as IEnumerable) - { - - } - - /// - /// Create an instance containing given permissions. - /// - /// Permission list. - /// Thrown when is null. - public UserPermissions(IEnumerable permissions) - { - if (permissions == null) throw new ArgumentNullException(nameof(permissions)); - _permissions = new SortedSet(permissions); - } - - private readonly SortedSet _permissions = new(); - - /// - /// Check if a permission is contained in the list. - /// - /// The permission to check. - /// True if contains. Otherwise false. - public bool Contains(UserPermission permission) - { - return _permissions.Contains(permission); - } - - /// - /// To a serializable string list. - /// - /// A string list. - public List ToStringList() - { - return _permissions.Select(p => p.ToString()).ToList(); - } - - /// - /// Convert a string list to user permissions. - /// - /// The string list. - /// An instance. - /// Thrown when is null. - /// Thrown when there is unknown permission name. - public static UserPermissions FromStringList(IEnumerable list) - { - List permissions = new(); - - foreach (var value in list) - { - if (Enum.TryParse(value, false, out var result)) - { - permissions.Add(result); - } - else - { - throw new ArgumentException("Unknown permission name.", nameof(list)); - } - } - - return new UserPermissions(permissions); - } - - public IEnumerator GetEnumerator() - { - return _permissions.GetEnumerator(); - } - - IEnumerator IEnumerable.GetEnumerator() - { - return ((IEnumerable)_permissions).GetEnumerator(); - } - - public bool Equals(UserPermissions? other) - { - if (other == null) - return false; - - return _permissions.SequenceEqual(other._permissions); - } - - public override bool Equals(object? obj) - { - return Equals(obj as UserPermissions); - } - - public override int GetHashCode() - { - int result = 0; - foreach (var permission in Enum.GetValues()) - { - if (_permissions.Contains(permission)) - { - result += 1; - } - result <<= 1; - } - return result; - } - } - - public interface IUserPermissionService - { - /// - /// Get permissions of a user. - /// - /// The id of the user. - /// Whether check the user's existence. - /// The permission list. - /// Thrown when is true and user does not exist. - Task GetPermissionsOfUserAsync(long userId, bool checkUserExistence = true); - - /// - /// Add a permission to user. - /// - /// The id of the user. - /// The new permission. - /// Thrown when user does not exist. - /// Thrown when change root user's permission. - Task AddPermissionToUserAsync(long userId, UserPermission permission); - - /// - /// Remove a permission from user. - /// - /// The id of the user. - /// The permission. - /// Whether check the user's existence. - /// Thrown when is true and user does not exist. - /// Thrown when change root user's permission. - Task RemovePermissionFromUserAsync(long userId, UserPermission permission, bool checkUserExistence = true); - } - - public class UserPermissionService : IUserPermissionService - { - private readonly DatabaseContext _database; - - public UserPermissionService(DatabaseContext database) - { - _database = database; - } - - private async Task CheckUserExistence(long userId, bool checkUserExistence) - { - if (checkUserExistence) - { - var existence = await _database.Users.AnyAsync(u => u.Id == userId); - if (!existence) - { - throw new UserNotExistException(userId); - } - } - } - - public async Task GetPermissionsOfUserAsync(long userId, bool checkUserExistence = true) - { - if (userId == 1) // The init administrator account. - { - return UserPermissions.AllPermissions; - } - - await CheckUserExistence(userId, checkUserExistence); - - var permissionNameList = await _database.UserPermission.Where(e => e.UserId == userId).Select(e => e.Permission).ToListAsync(); - - return UserPermissions.FromStringList(permissionNameList); - } - - public async Task AddPermissionToUserAsync(long userId, UserPermission permission) - { - if (userId == 1) - throw new InvalidOperationOnRootUserException("Can't change root user's permission."); - - await CheckUserExistence(userId, true); - - var alreadyHas = await _database.UserPermission - .AnyAsync(e => e.UserId == userId && e.Permission == permission.ToString()); - - if (alreadyHas) return; - - _database.UserPermission.Add(new UserPermissionEntity { UserId = userId, Permission = permission.ToString() }); - - await _database.SaveChangesAsync(); - } - - public async Task RemovePermissionFromUserAsync(long userId, UserPermission permission, bool checkUserExistence = true) - { - if (userId == 1) - throw new InvalidOperationOnRootUserException("Can't change root user's permission."); - - await CheckUserExistence(userId, checkUserExistence); - - var entity = await _database.UserPermission - .Where(e => e.UserId == userId && e.Permission == permission.ToString()) - .SingleOrDefaultAsync(); - - if (entity == null) return; - - _database.UserPermission.Remove(entity); - - await _database.SaveChangesAsync(); - } - } -} diff --git a/BackEnd/Timeline/Services/UserService.cs b/BackEnd/Timeline/Services/UserService.cs deleted file mode 100644 index 288d208c..00000000 --- a/BackEnd/Timeline/Services/UserService.cs +++ /dev/null @@ -1,220 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Threading.Tasks; -using Timeline.Entities; -using Timeline.Helpers; -using Timeline.Models.Validation; -using Timeline.Services.Exceptions; -using static Timeline.Resources.Services.UserService; - -namespace Timeline.Services -{ - /// - /// Null means not change. - /// - public class ModifyUserParams - { - public string? Username { get; set; } - public string? Password { get; set; } - public string? Nickname { get; set; } - } - - public interface IUserService : IBasicUserService - { - /// - /// Try to get a user by id. - /// - /// The id of the user. - /// The user info. - /// Thrown when the user with given id does not exist. - Task GetUser(long id); - - /// - /// List all users. - /// - /// The user info of users. - Task> GetUsers(); - - /// - /// Create a user with given info. - /// - /// The username of new user. - /// The password of new user. - /// The the new user. - /// Thrown when or is null. - /// Thrown when or is of bad format. - /// Thrown when a user with given username already exists. - Task CreateUser(string username, string password); - - /// - /// Modify a user. - /// - /// The id of the user. - /// The new information. - /// The new user info. - /// Thrown when some fields in is bad. - /// Thrown when user with given id does not exist. - /// - /// Version will increase if password is changed. - /// - Task ModifyUser(long id, ModifyUserParams? param); - } - - public class UserService : BasicUserService, IUserService - { - private readonly ILogger _logger; - private readonly IClock _clock; - - private readonly DatabaseContext _databaseContext; - - private readonly IPasswordService _passwordService; - - private readonly UsernameValidator _usernameValidator = new UsernameValidator(); - private readonly NicknameValidator _nicknameValidator = new NicknameValidator(); - - public UserService(ILogger logger, DatabaseContext databaseContext, IPasswordService passwordService, IClock clock) : base(databaseContext) - { - _logger = logger; - _databaseContext = databaseContext; - _passwordService = passwordService; - _clock = clock; - } - - private void CheckUsernameFormat(string username, string? paramName) - { - if (!_usernameValidator.Validate(username, out var message)) - { - throw new ArgumentException(string.Format(CultureInfo.CurrentCulture, ExceptionUsernameBadFormat, message), paramName); - } - } - - private static void CheckPasswordFormat(string password, string? paramName) - { - if (password.Length == 0) - { - throw new ArgumentException(ExceptionPasswordEmpty, paramName); - } - } - - private void CheckNicknameFormat(string nickname, string? paramName) - { - if (!_nicknameValidator.Validate(nickname, out var message)) - { - throw new ArgumentException(string.Format(CultureInfo.CurrentCulture, ExceptionNicknameBadFormat, message), paramName); - } - } - - private static void ThrowUsernameConflict() - { - throw new EntityAlreadyExistException(EntityNames.User, ExceptionUsernameConflict); - } - - public async Task GetUser(long id) - { - var user = await _databaseContext.Users.Where(u => u.Id == id).SingleOrDefaultAsync(); - - if (user == null) - throw new UserNotExistException(id); - - return user; - } - - public async Task> GetUsers() - { - return await _databaseContext.Users.ToListAsync(); - } - - public async Task CreateUser(string username, string password) - { - if (username == null) - throw new ArgumentNullException(nameof(username)); - if (password == null) - throw new ArgumentNullException(nameof(password)); - - CheckUsernameFormat(username, nameof(username)); - CheckPasswordFormat(password, nameof(password)); - - var conflict = await _databaseContext.Users.AnyAsync(u => u.Username == username); - if (conflict) - ThrowUsernameConflict(); - - var newEntity = new UserEntity - { - Username = username, - Password = _passwordService.HashPassword(password), - Version = 1 - }; - _databaseContext.Users.Add(newEntity); - await _databaseContext.SaveChangesAsync(); - - _logger.LogInformation(Log.Format(LogDatabaseCreate, ("Id", newEntity.Id), ("Username", username))); - - return newEntity; - } - - public async Task ModifyUser(long id, ModifyUserParams? param) - { - if (param != null) - { - if (param.Username != null) - CheckUsernameFormat(param.Username, nameof(param)); - - if (param.Password != null) - CheckPasswordFormat(param.Password, nameof(param)); - - if (param.Nickname != null) - CheckNicknameFormat(param.Nickname, nameof(param)); - } - - var entity = await _databaseContext.Users.Where(u => u.Id == id).SingleOrDefaultAsync(); - if (entity == null) - throw new UserNotExistException(id); - - if (param != null) - { - var now = _clock.GetCurrentTime(); - bool updateLastModified = false; - - var username = param.Username; - if (username != null && username != entity.Username) - { - var conflict = await _databaseContext.Users.AnyAsync(u => u.Username == username); - if (conflict) - ThrowUsernameConflict(); - - entity.Username = username; - entity.UsernameChangeTime = now; - updateLastModified = true; - } - - var password = param.Password; - if (password != null) - { - entity.Password = _passwordService.HashPassword(password); - entity.Version += 1; - } - - var nickname = param.Nickname; - if (nickname != null && nickname != entity.Nickname) - { - entity.Nickname = nickname; - updateLastModified = true; - } - - if (updateLastModified) - { - entity.LastModified = now; - } - - await _databaseContext.SaveChangesAsync(); - _logger.LogInformation(LogDatabaseUpdate, ("Id", id)); - } - - return entity; - } - } -} diff --git a/BackEnd/Timeline/Startup.cs b/BackEnd/Timeline/Startup.cs index d4fffb51..0d7bc6b3 100644 --- a/BackEnd/Timeline/Startup.cs +++ b/BackEnd/Timeline/Startup.cs @@ -1,4 +1,3 @@ -using AutoMapper; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -18,11 +17,16 @@ using Timeline.Entities; using Timeline.Formatters; using Timeline.Helpers; using Timeline.Models.Converters; -using Timeline.Models.Mapper; using Timeline.Routes; using Timeline.Services; +using Timeline.Services.Api; +using Timeline.Services.Data; using Timeline.Services.DatabaseManagement; +using Timeline.Services.Imaging; +using Timeline.Services.Mapper; +using Timeline.Services.Timeline; using Timeline.Services.Token; +using Timeline.Services.User; using Timeline.Swagger; namespace Timeline diff --git a/BackEnd/Timeline/Timeline.csproj b/BackEnd/Timeline/Timeline.csproj index 08441c85..45e20df5 100644 --- a/BackEnd/Timeline/Timeline.csproj +++ b/BackEnd/Timeline/Timeline.csproj @@ -158,6 +158,26 @@ True UserTokenService.resx + + True + True + Resource.resx + + + True + True + Resource.resx + + + True + True + Resource.resx + + + True + True + Resource.resx + @@ -250,5 +270,21 @@ ResXFileCodeGenerator UserTokenService.Designer.cs + + ResXFileCodeGenerator + Resource.Designer.cs + + + ResXFileCodeGenerator + Resource.Designer.cs + + + ResXFileCodeGenerator + Resource.Designer.cs + + + ResXFileCodeGenerator + Resource.Designer.cs + \ No newline at end of file -- cgit v1.2.3