From ac769e656b122ff569c3f1534701b71e00fed586 Mon Sep 17 00:00:00 2001 From: crupest Date: Tue, 27 Oct 2020 19:21:35 +0800 Subject: Split front and back end. --- BackEnd/Timeline/Services/TimelineService.cs | 1166 ++++++++++++++++++++++++++ 1 file changed, 1166 insertions(+) create mode 100644 BackEnd/Timeline/Services/TimelineService.cs (limited to 'BackEnd/Timeline/Services/TimelineService.cs') diff --git a/BackEnd/Timeline/Services/TimelineService.cs b/BackEnd/Timeline/Services/TimelineService.cs new file mode 100644 index 00000000..4bcae596 --- /dev/null +++ b/BackEnd/Timeline/Services/TimelineService.cs @@ -0,0 +1,1166 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using SixLabors.ImageSharp; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Timeline.Entities; +using Timeline.Helpers; +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.Substring(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 PostData : ICacheableData + { +#pragma warning disable CA1819 // Properties should not return arrays + public byte[] Data { get; set; } = default!; +#pragma warning restore CA1819 // Properties should not return arrays + public string Type { get; set; } = default!; + public string ETag { get; set; } = default!; + public DateTime? LastModified { get; set; } // TODO: Why nullable? + } + + /// + /// This define the interface of both personal timeline and ordinary timeline. + /// + public interface ITimelineService + { + /// + /// Get the timeline last modified time (not include name change). + /// + /// The name of the timeline. + /// The timeline info. + /// 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 . + /// + Task GetTimelineLastModifiedTime(string timelineName); + + /// + /// Get the timeline unique id. + /// + /// The name of the timeline. + /// The timeline info. + /// 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 . + /// + Task GetTimelineUniqueId(string timelineName); + + /// + /// Get the timeline info. + /// + /// The name of the timeline. + /// The timeline info. + /// 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 . + /// + Task GetTimeline(string timelineName); + + /// + /// Set the properties of a timeline. + /// + /// The name of the timeline. + /// The new properties. Null member means not to change. + /// Thrown when or 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 . + /// + Task ChangeProperty(string timelineName, TimelineChangePropertyRequest newProperties); + + /// + /// Get all the posts in the timeline. + /// + /// The name of the timeline. + /// The time that posts have been modified since. + /// Whether include deleted posts. + /// A list of all posts. + /// 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 . + /// + Task> GetPosts(string timelineName, DateTime? modifiedSince = null, bool includeDeleted = false); + + /// + /// Get the etag of data of a post. + /// + /// The name of the timeline of the post. + /// The id of the post. + /// The etag of the data. + /// 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 . + /// + /// Thrown when post of does not exist or has been deleted. + /// Thrown when post has no data. + /// + Task GetPostDataETag(string timelineName, long postId); + + /// + /// Get the data of a post. + /// + /// The name of the timeline of the post. + /// The id of the post. + /// The etag of the data. + /// 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 . + /// + /// Thrown when post of does not exist or has been deleted. + /// Thrown when post has no data. + /// + Task GetPostData(string timelineName, long postId); + + /// + /// Create a new text post in timeline. + /// + /// The name of the timeline to create post against. + /// The author's user id. + /// The content text. + /// The time of the post. If null, then current time is used. + /// The info of the created post. + /// Thrown when or 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 . + /// + /// Thrown if user of does not exist. + Task CreateTextPost(string timelineName, long authorId, string text, DateTime? time); + + /// + /// Create a new image post in timeline. + /// + /// The name of the timeline to create post against. + /// The author's user id. + /// The image data. + /// The time of the post. If null, then use current time. + /// The info of the created post. + /// Thrown when or 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 . + /// + /// Thrown if user of does not exist. + /// Thrown if data is not a image. Validated by . + Task CreateImagePost(string timelineName, long authorId, byte[] imageData, DateTime? time); + + /// + /// Delete a post. + /// + /// The name of the timeline to delete post against. + /// The id of the post to delete. + /// 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 . + /// + /// Thrown when the post with given id does not exist or is deleted already. + /// + /// First use to check the permission. + /// + Task DeletePost(string timelineName, long postId); + + /// + /// Delete all posts of the given user. Used when delete a user. + /// + /// The id of the user. + Task DeleteAllPostsOfUser(long userId); + + /// + /// Change member of timeline. + /// + /// The name of the timeline. + /// A list of usernames of members to add. May be null. + /// A list of usernames of members to remove. May be null. + /// 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 . + /// + /// Thrown when names in or is not a valid username. + /// Thrown when one of the user to change does not exist. + /// + /// Operating on a username that is of bad format or does not exist always throws. + /// Add a user that already is a member has no effects. + /// Remove a user that is not a member also has not effects. + /// Add and remove an identical user results in no effects. + /// More than one same usernames are regarded as one. + /// + Task ChangeMember(string timelineName, IList? membersToAdd, IList? membersToRemove); + + /// + /// Check whether a user can manage(change timeline info, member, ...) a timeline. + /// + /// The name of the timeline. + /// The id of the user to check on. + /// True if the user can manage the timeline, otherwise false. + /// 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 . + /// + /// + /// This method does not check whether visitor is administrator. + /// Return false if user with user id does not exist. + /// + Task HasManagePermission(string timelineName, long userId); + + /// + /// Verify whether a visitor has the permission to read a timeline. + /// + /// The name 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 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 . + /// + /// + /// This method does not check whether visitor is administrator. + /// Return false if user with visitor id does not exist. + /// + Task HasReadPermission(string timelineName, long? visitorId); + + /// + /// Verify whether a user has the permission to modify a post. + /// + /// The name 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 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 . + /// + /// 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(string timelineName, long postId, long modifierId, bool throwOnPostNotExist = false); + + /// + /// Verify whether a user is member of a timeline. + /// + /// The name of the timeline. + /// The id of user to check on. + /// True if it is a member, false if not. + /// 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 . + /// + /// + /// Timeline owner is also considered as a member. + /// Return false when user with user id does not exist. + /// + Task IsMemberOf(string timelineName, 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 name of the timeline to delete. + /// Thrown when is null. + /// Thrown when timeline name is invalid. + /// Thrown when the timeline does not exist. + Task DeleteTimeline(string timelineName); + + /// + /// Change name of a timeline. + /// + /// The old timeline name. + /// The new timeline name. + /// The new timeline info. + /// Thrown when or is null. + /// Thrown when or is of invalid format. + /// Thrown when timeline does not exist. + /// Thrown when a timeline with new name already exists. + /// + /// You can only change name of general timeline. + /// + Task ChangeTimelineName(string oldTimelineName, string newTimelineName); + } + + public class TimelineService : ITimelineService + { + public TimelineService(ILogger logger, DatabaseContext database, IDataManager dataManager, IUserService userService, IImageValidator imageValidator, IClock clock) + { + _logger = logger; + _database = database; + _dataManager = dataManager; + _userService = userService; + _imageValidator = imageValidator; + _clock = clock; + } + + private readonly ILogger _logger; + + private readonly DatabaseContext _database; + + private readonly IDataManager _dataManager; + + private readonly IUserService _userService; + + private readonly IImageValidator _imageValidator; + + private readonly IClock _clock; + + private readonly UsernameValidator _usernameValidator = new UsernameValidator(); + + private readonly TimelineNameValidator _timelineNameValidator = new TimelineNameValidator(); + + private void ValidateTimelineName(string name, string paramName) + { + if (!_timelineNameValidator.Validate(name, out var message)) + { + throw new ArgumentException(ExceptionTimelineNameBadFormat.AppendAdditionalMessage(message), paramName); + } + } + + /// Remember to include Members when query. + private async Task MapTimelineFromEntity(TimelineEntity entity) + { + var owner = await _userService.GetUserById(entity.OwnerId); + + var members = new List(); + foreach (var memberEntity in entity.Members) + { + members.Add(await _userService.GetUserById(memberEntity.UserId)); + } + + var name = entity.Name ?? ("@" + owner.Username); + + return new Models.Timeline + { + UniqueID = entity.UniqueId, + Name = name, + NameLastModified = entity.NameLastModified, + Title = string.IsNullOrEmpty(entity.Title) ? name : entity.Title, + Description = entity.Description ?? "", + Owner = owner, + Visibility = entity.Visibility, + Members = members, + CreateTime = entity.CreateTime, + LastModified = entity.LastModified + }; + } + + private async Task MapTimelinePostFromEntity(TimelinePostEntity entity, string timelineName) + { + User? author = entity.AuthorId.HasValue ? await _userService.GetUserById(entity.AuthorId.Value) : null; + + ITimelinePostContent? content = null; + + if (entity.Content != null) + { + var type = entity.ContentType; + + content = type switch + { + TimelinePostContentTypes.Text => new TextTimelinePostContent(entity.Content), + TimelinePostContentTypes.Image => new ImageTimelinePostContent(entity.Content), + _ => throw new DatabaseCorruptedException(string.Format(CultureInfo.InvariantCulture, ExceptionDatabaseUnknownContentType, type)) + }; + } + + return new TimelinePost( + id: entity.LocalId, + author: author, + content: content, + time: entity.Time, + lastUpdated: entity.LastUpdated, + timelineName: timelineName + ); + } + + private 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() + }; + } + + + + // Get timeline id by name. If it is a personal timeline and it does not exist, it will be created. + // + // This method will check the name format and if it is invalid, ArgumentException is thrown. + // + // For personal timeline, if the user does not exist, TimelineNotExistException will be thrown with UserNotExistException as inner exception. + // For ordinary timeline, if the timeline does not exist, TimelineNotExistException will be thrown. + // + // It follows all timeline-related function common interface contracts. + private async Task FindTimelineId(string timelineName) + { + timelineName = TimelineHelper.ExtractTimelineName(timelineName, out var isPersonal); + + if (isPersonal) + { + long userId; + try + { + userId = await _userService.GetUserIdByUsername(timelineName); + } + catch (ArgumentException e) + { + throw new ArgumentException(ExceptionFindTimelineUsernameBadFormat, nameof(timelineName), e); + } + 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 + { + if (timelineName == null) + throw new ArgumentNullException(nameof(timelineName)); + + ValidateTimelineName(timelineName, nameof(timelineName)); + + 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; + } + } + } + + public async Task GetTimelineLastModifiedTime(string timelineName) + { + if (timelineName == null) + throw new ArgumentNullException(nameof(timelineName)); + + var timelineId = await FindTimelineId(timelineName); + + var timelineEntity = await _database.Timelines.Where(t => t.Id == timelineId).Select(t => new { t.LastModified }).SingleAsync(); + + return timelineEntity.LastModified; + } + + public async Task GetTimelineUniqueId(string timelineName) + { + if (timelineName == null) + throw new ArgumentNullException(nameof(timelineName)); + + var timelineId = await FindTimelineId(timelineName); + + var timelineEntity = await _database.Timelines.Where(t => t.Id == timelineId).Select(t => new { t.UniqueId }).SingleAsync(); + + return timelineEntity.UniqueId; + } + + public async Task GetTimeline(string timelineName) + { + if (timelineName == null) + throw new ArgumentNullException(nameof(timelineName)); + + var timelineId = await FindTimelineId(timelineName); + + var timelineEntity = await _database.Timelines.Where(t => t.Id == timelineId).Include(t => t.Members).SingleAsync(); + + return await MapTimelineFromEntity(timelineEntity); + } + + public async Task> GetPosts(string timelineName, DateTime? modifiedSince = null, bool includeDeleted = false) + { + modifiedSince = modifiedSince?.MyToUtc(); + + if (timelineName == null) + throw new ArgumentNullException(nameof(timelineName)); + + var timelineId = await FindTimelineId(timelineName); + IQueryable query = _database.TimelinePosts.Where(p => p.TimelineId == timelineId); + + if (!includeDeleted) + { + query = query.Where(p => p.Content != null); + } + + if (modifiedSince.HasValue) + { + query = query.Include(p => p.Author).Where(p => p.LastUpdated >= modifiedSince || (p.Author != null && p.Author.UsernameChangeTime >= modifiedSince)); + } + + query = query.OrderBy(p => p.Time); + + var postEntities = await query.ToListAsync(); + + var posts = new List(); + foreach (var entity in postEntities) + { + posts.Add(await MapTimelinePostFromEntity(entity, timelineName)); + } + return posts; + } + + public async Task GetPostDataETag(string timelineName, long postId) + { + if (timelineName == null) + throw new ArgumentNullException(nameof(timelineName)); + + var timelineId = await FindTimelineId(timelineName); + + var postEntity = await _database.TimelinePosts.Where(p => p.TimelineId == timelineId && p.LocalId == postId).SingleOrDefaultAsync(); + + if (postEntity == null) + throw new TimelinePostNotExistException(timelineName, postId, false); + + if (postEntity.Content == null) + throw new TimelinePostNotExistException(timelineName, postId, true); + + if (postEntity.ContentType != TimelinePostContentTypes.Image) + throw new TimelinePostNoDataException(ExceptionGetDataNonImagePost); + + var tag = postEntity.Content; + + return tag; + } + + public async Task GetPostData(string timelineName, long postId) + { + if (timelineName == null) + throw new ArgumentNullException(nameof(timelineName)); + + var timelineId = await FindTimelineId(timelineName); + var postEntity = await _database.TimelinePosts.Where(p => p.TimelineId == timelineId && p.LocalId == postId).SingleOrDefaultAsync(); + + if (postEntity == null) + throw new TimelinePostNotExistException(timelineName, postId, false); + + if (postEntity.Content == null) + throw new TimelinePostNotExistException(timelineName, postId, true); + + if (postEntity.ContentType != TimelinePostContentTypes.Image) + throw new TimelinePostNoDataException(ExceptionGetDataNonImagePost); + + var tag = postEntity.Content; + + byte[] data; + + try + { + data = await _dataManager.GetEntry(tag); + } + catch (InvalidOperationException e) + { + throw new DatabaseCorruptedException(ExceptionGetDataDataEntryNotExist, e); + } + + if (postEntity.ExtraContent == null) + { + _logger.LogWarning(LogGetDataNoFormat); + var format = Image.DetectFormat(data); + postEntity.ExtraContent = format.DefaultMimeType; + await _database.SaveChangesAsync(); + } + + return new PostData + { + Data = data, + Type = postEntity.ExtraContent, + ETag = tag, + LastModified = postEntity.LastUpdated + }; + } + + public async Task CreateTextPost(string timelineName, long authorId, string text, DateTime? time) + { + time = time?.MyToUtc(); + + if (timelineName == null) + throw new ArgumentNullException(nameof(timelineName)); + if (text == null) + throw new ArgumentNullException(nameof(text)); + + var timelineId = await FindTimelineId(timelineName); + var timelineEntity = await _database.Timelines.Where(t => t.Id == timelineId).SingleAsync(); + + var author = await _userService.GetUserById(authorId); + + var currentTime = _clock.GetCurrentTime(); + var finalTime = time ?? currentTime; + + timelineEntity.CurrentPostLocalId += 1; + + var postEntity = new TimelinePostEntity + { + LocalId = timelineEntity.CurrentPostLocalId, + ContentType = TimelinePostContentTypes.Text, + Content = text, + AuthorId = authorId, + TimelineId = timelineId, + Time = finalTime, + LastUpdated = currentTime + }; + _database.TimelinePosts.Add(postEntity); + await _database.SaveChangesAsync(); + + + return new TimelinePost( + id: postEntity.LocalId, + content: new TextTimelinePostContent(text), + time: finalTime, + author: author, + lastUpdated: currentTime, + timelineName: timelineName + ); + } + + public async Task CreateImagePost(string timelineName, long authorId, byte[] data, DateTime? time) + { + time = time?.MyToUtc(); + + if (timelineName == null) + throw new ArgumentNullException(nameof(timelineName)); + if (data == null) + throw new ArgumentNullException(nameof(data)); + + var timelineId = await FindTimelineId(timelineName); + var timelineEntity = await _database.Timelines.Where(t => t.Id == timelineId).SingleAsync(); + + var author = await _userService.GetUserById(authorId); + + var imageFormat = await _imageValidator.Validate(data); + + var imageFormatText = imageFormat.DefaultMimeType; + + var tag = await _dataManager.RetainEntry(data); + + var currentTime = _clock.GetCurrentTime(); + var finalTime = time ?? currentTime; + + timelineEntity.CurrentPostLocalId += 1; + + var postEntity = new TimelinePostEntity + { + LocalId = timelineEntity.CurrentPostLocalId, + ContentType = TimelinePostContentTypes.Image, + Content = tag, + ExtraContent = imageFormatText, + AuthorId = authorId, + TimelineId = timelineId, + Time = finalTime, + LastUpdated = currentTime + }; + _database.TimelinePosts.Add(postEntity); + await _database.SaveChangesAsync(); + + return new TimelinePost( + id: postEntity.LocalId, + content: new ImageTimelinePostContent(tag), + time: finalTime, + author: author, + lastUpdated: currentTime, + timelineName: timelineName + ); + } + + public async Task DeletePost(string timelineName, long id) + { + if (timelineName == null) + throw new ArgumentNullException(nameof(timelineName)); + + var timelineId = await FindTimelineId(timelineName); + + var post = await _database.TimelinePosts.Where(p => p.TimelineId == timelineId && p.LocalId == id).SingleOrDefaultAsync(); + + if (post == null) + throw new TimelinePostNotExistException(timelineName, id, false); + + if (post.Content == null) + throw new TimelinePostNotExistException(timelineName, id, true); + + string? dataTag = null; + + if (post.ContentType == TimelinePostContentTypes.Image) + { + dataTag = post.Content; + } + + post.Content = null; + post.LastUpdated = _clock.GetCurrentTime(); + + await _database.SaveChangesAsync(); + + if (dataTag != null) + { + await _dataManager.FreeEntry(dataTag); + } + } + + public async Task DeleteAllPostsOfUser(long userId) + { + var posts = await _database.TimelinePosts.Where(p => p.AuthorId == userId).ToListAsync(); + + var now = _clock.GetCurrentTime(); + + var dataTags = new List(); + + foreach (var post in posts) + { + if (post.Content != null) + { + if (post.ContentType == TimelinePostContentTypes.Image) + { + dataTags.Add(post.Content); + } + post.Content = null; + } + post.LastUpdated = now; + } + + await _database.SaveChangesAsync(); + + foreach (var dataTag in dataTags) + { + await _dataManager.FreeEntry(dataTag); + } + } + + public async Task ChangeProperty(string timelineName, TimelineChangePropertyRequest newProperties) + { + if (timelineName == null) + throw new ArgumentNullException(nameof(timelineName)); + if (newProperties == null) + throw new ArgumentNullException(nameof(newProperties)); + + var timelineId = await FindTimelineId(timelineName); + + var timelineEntity = await _database.Timelines.Where(t => t.Id == timelineId).SingleAsync(); + + var changed = false; + + if (newProperties.Title != null) + { + changed = true; + timelineEntity.Title = newProperties.Title; + } + + if (newProperties.Description != null) + { + changed = true; + timelineEntity.Description = newProperties.Description; + } + + if (newProperties.Visibility.HasValue) + { + changed = true; + timelineEntity.Visibility = newProperties.Visibility.Value; + } + + if (changed) + { + var currentTime = _clock.GetCurrentTime(); + timelineEntity.LastModified = currentTime; + } + + await _database.SaveChangesAsync(); + } + + public async Task ChangeMember(string timelineName, IList? add, IList? remove) + { + if (timelineName == null) + throw new ArgumentNullException(nameof(timelineName)); + + List? RemoveDuplicateAndCheckFormat(IList? list, string paramName) + { + if (list != null) + { + List result = new List(); + var count = list.Count; + for (var index = 0; index < count; index++) + { + var username = list[index]; + if (result.Contains(username)) + { + continue; + } + var (validationResult, message) = _usernameValidator.Validate(username); + if (!validationResult) + throw new ArgumentException(string.Format(CultureInfo.CurrentCulture, ExceptionChangeMemberUsernameBadFormat, index), nameof(paramName)); + result.Add(username); + } + return result; + } + else + { + return null; + } + } + var simplifiedAdd = RemoveDuplicateAndCheckFormat(add, nameof(add)); + var simplifiedRemove = RemoveDuplicateAndCheckFormat(remove, nameof(remove)); + + // remove those both in add and remove + if (simplifiedAdd != null && simplifiedRemove != null) + { + var usersToClean = simplifiedRemove.Where(u => simplifiedAdd.Contains(u)).ToList(); + foreach (var u in usersToClean) + { + simplifiedAdd.Remove(u); + simplifiedRemove.Remove(u); + } + + if (simplifiedAdd.Count == 0) + simplifiedAdd = null; + + if (simplifiedRemove.Count == 0) + simplifiedRemove = null; + } + + if (simplifiedAdd == null && simplifiedRemove == null) + return; + + var timelineId = await FindTimelineId(timelineName); + + async Task?> CheckExistenceAndGetId(List? list) + { + if (list == null) + return null; + + List result = new List(); + foreach (var username in list) + { + result.Add(await _userService.GetUserIdByUsername(username)); + } + return result; + } + var userIdsAdd = await CheckExistenceAndGetId(simplifiedAdd); + var userIdsRemove = await CheckExistenceAndGetId(simplifiedRemove); + + if (userIdsAdd != null) + { + var membersToAdd = userIdsAdd.Select(id => new TimelineMemberEntity { UserId = id, TimelineId = timelineId }).ToList(); + _database.TimelineMembers.AddRange(membersToAdd); + } + + if (userIdsRemove != null) + { + var membersToRemove = await _database.TimelineMembers.Where(m => m.TimelineId == timelineId && userIdsRemove.Contains(m.UserId)).ToListAsync(); + _database.TimelineMembers.RemoveRange(membersToRemove); + } + + var timelineEntity = await _database.Timelines.Where(t => t.Id == timelineId).SingleAsync(); + timelineEntity.LastModified = _clock.GetCurrentTime(); + + await _database.SaveChangesAsync(); + } + + public async Task HasManagePermission(string timelineName, long userId) + { + if (timelineName == null) + throw new ArgumentNullException(nameof(timelineName)); + + var timelineId = await FindTimelineId(timelineName); + var timelineEntity = await _database.Timelines.Where(t => t.Id == timelineId).Select(t => new { t.OwnerId }).SingleAsync(); + + return userId == timelineEntity.OwnerId; + } + + public async Task HasReadPermission(string timelineName, long? visitorId) + { + if (timelineName == null) + throw new ArgumentNullException(nameof(timelineName)); + + var timelineId = await FindTimelineId(timelineName); + var timelineEntity = await _database.Timelines.Where(t => t.Id == timelineId).Select(t => new { t.Visibility }).SingleAsync(); + + if (timelineEntity.Visibility == TimelineVisibility.Public) + return true; + + if (timelineEntity.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 != null; + } + } + + public async Task HasPostModifyPermission(string timelineName, long postId, long modifierId, bool throwOnPostNotExist = false) + { + if (timelineName == null) + throw new ArgumentNullException(nameof(timelineName)); + + var timelineId = await FindTimelineId(timelineName); + + var timelineEntity = await _database.Timelines.Where(t => t.Id == timelineId).Select(t => new { t.OwnerId }).SingleAsync(); + + var postEntity = await _database.TimelinePosts.Where(p => p.Id == postId).Select(p => new { p.Content, p.AuthorId }).SingleOrDefaultAsync(); + + if (postEntity == null) + { + if (throwOnPostNotExist) + throw new TimelinePostNotExistException(timelineName, postId, false); + else + return true; + } + + if (postEntity.Content == null && throwOnPostNotExist) + { + throw new TimelinePostNotExistException(timelineName, postId, true); + } + + return timelineEntity.OwnerId == modifierId || postEntity.AuthorId == modifierId; + } + + public async Task IsMemberOf(string timelineName, long userId) + { + if (timelineName == null) + throw new ArgumentNullException(nameof(timelineName)); + + var timelineId = await FindTimelineId(timelineName); + + var timelineEntity = await _database.Timelines.Where(t => t.Id == timelineId).Select(t => new { t.OwnerId }).SingleAsync(); + + if (userId == timelineEntity.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).Include(t => t.Members).ToListAsync(); + } + else + { + entities = new List(); + + if ((relate.Type & TimelineUserRelationshipType.Own) != 0) + { + entities.AddRange(await ApplyTimelineVisibilityFilter(_database.Timelines.Where(t => t.OwnerId == relate.UserId)).Include(t => t.Members).ToListAsync()); + } + + if ((relate.Type & TimelineUserRelationshipType.Join) != 0) + { + entities.AddRange(await ApplyTimelineVisibilityFilter(_database.TimelineMembers.Where(m => m.UserId == relate.UserId).Include(m => m.Timeline).ThenInclude(t => t.Members).Select(m => m.Timeline)).ToListAsync()); + } + } + + var result = new List(); + + foreach (var entity in entities) + { + result.Add(await MapTimelineFromEntity(entity)); + } + + return result; + } + + public async Task CreateTimeline(string name, long owner) + { + if (name == null) + throw new ArgumentNullException(nameof(name)); + + ValidateTimelineName(name, nameof(name)); + + var user = await _userService.GetUserById(owner); + + var conflict = await _database.Timelines.AnyAsync(t => t.Name == name); + + if (conflict) + throw new EntityAlreadyExistException(EntityNames.Timeline, null, ExceptionTimelineNameConflict); + + var newEntity = CreateNewTimelineEntity(name, user.Id!.Value); + + _database.Timelines.Add(newEntity); + await _database.SaveChangesAsync(); + + return await MapTimelineFromEntity(newEntity); + } + + public async Task DeleteTimeline(string name) + { + if (name == null) + throw new ArgumentNullException(nameof(name)); + + ValidateTimelineName(name, nameof(name)); + + var entity = await _database.Timelines.Where(t => t.Name == name).SingleOrDefaultAsync(); + + if (entity == null) + throw new TimelineNotExistException(name); + + _database.Timelines.Remove(entity); + await _database.SaveChangesAsync(); + } + + public async Task ChangeTimelineName(string oldTimelineName, string newTimelineName) + { + if (oldTimelineName == null) + throw new ArgumentNullException(nameof(oldTimelineName)); + if (newTimelineName == null) + throw new ArgumentNullException(nameof(newTimelineName)); + + ValidateTimelineName(oldTimelineName, nameof(oldTimelineName)); + ValidateTimelineName(newTimelineName, nameof(newTimelineName)); + + var entity = await _database.Timelines.Include(t => t.Members).Where(t => t.Name == oldTimelineName).SingleOrDefaultAsync(); + + if (entity == null) + throw new TimelineNotExistException(oldTimelineName); + + if (oldTimelineName == newTimelineName) + return await MapTimelineFromEntity(entity); + + var conflict = await _database.Timelines.AnyAsync(t => t.Name == newTimelineName); + + if (conflict) + throw new EntityAlreadyExistException(EntityNames.Timeline, null, ExceptionTimelineNameConflict); + + var now = _clock.GetCurrentTime(); + + entity.Name = newTimelineName; + entity.NameLastModified = now; + entity.LastModified = now; + + await _database.SaveChangesAsync(); + + return await MapTimelineFromEntity(entity); + } + } +} -- cgit v1.2.3