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.Tasks; using Timeline.Entities; using Timeline.Helpers; using Timeline.Models; using Timeline.Services.Exceptions; using static Timeline.Resources.Services.TimelineService; namespace Timeline.Services { 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? } 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 the etag of data of a post. /// /// The id of the timeline of the post. /// The id of the post. /// The etag of the data. /// Thrown when timeline does not exist. /// Thrown when post of does not exist or has been deleted. /// Thrown when post has no data. Task GetPostDataETag(long timelineId, long postId); /// /// Get the data of a post. /// /// The id of the timeline of the post. /// The id of the post. /// The etag of the data. /// Thrown when timeline does not exist. /// Thrown when post of does not exist or has been deleted. /// Thrown when post has no data. /// Task GetPostData(long timelineId, long postId); /// /// Create a new text post in timeline. /// /// The id 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 is null. /// Thrown when timeline does not exist. /// Thrown if user of does not exist. Task CreateTextPost(long timelineId, long authorId, string text, DateTime? time); /// /// Create a new image post in timeline. /// /// The id 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 is null. /// Thrown when timeline does not exist. /// Thrown if user of does not exist. /// Thrown if data is not a image. Validated by . Task CreateImagePost(long timelineId, long authorId, byte[] imageData, DateTime? time); /// /// 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 IUserService _userService; private readonly IDataManager _dataManager; private readonly IImageValidator _imageValidator; private readonly IClock _clock; public TimelinePostService(ILogger logger, DatabaseContext database, IBasicTimelineService basicTimelineService, IUserService userService, IDataManager dataManager, IImageValidator imageValidator, IClock clock) { _logger = logger; _database = database; _basicTimelineService = basicTimelineService; _userService = userService; _dataManager = dataManager; _imageValidator = imageValidator; _clock = clock; } private async Task CheckTimelineExistence(long timelineId) { if (!await _basicTimelineService.CheckExistence(timelineId)) throw new TimelineNotExistException(timelineId); } 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.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); return await query.Include(p => p.Author!.Permissions).ToListAsync(); } public async Task GetPostDataETag(long timelineId, long postId) { await CheckTimelineExistence(timelineId); var postEntity = await _database.TimelinePosts.Where(p => p.TimelineId == timelineId && p.LocalId == postId).SingleOrDefaultAsync(); if (postEntity == null) throw new TimelinePostNotExistException(timelineId, postId, false); if (postEntity.Content == null) throw new TimelinePostNotExistException(timelineId, postId, true); if (postEntity.ContentType != TimelinePostContentTypes.Image) throw new TimelinePostNoDataException(ExceptionGetDataNonImagePost); var tag = postEntity.Content; return tag; } public async Task GetPostData(long timelineId, long postId) { await CheckTimelineExistence(timelineId); var postEntity = await _database.TimelinePosts.Where(p => p.TimelineId == timelineId && p.LocalId == postId).SingleOrDefaultAsync(); if (postEntity == null) throw new TimelinePostNotExistException(timelineId, postId, false); if (postEntity.Content == null) throw new TimelinePostNotExistException(timelineId, 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(long timelineId, long authorId, string text, DateTime? time) { if (text is null) throw new ArgumentNullException(nameof(text)); await CheckTimelineExistence(timelineId); time = time?.MyToUtc(); var timelineEntity = await _database.Timelines.Where(t => t.Id == timelineId).SingleAsync(); var author = await _userService.GetUser(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(); await _database.Entry(postEntity).Reference(p => p.Author).Query().Include(a => a.Permissions).LoadAsync(); return postEntity; } public async Task CreateImagePost(long timelineId, long authorId, byte[] data, DateTime? time) { if (data is null) throw new ArgumentNullException(nameof(data)); await CheckTimelineExistence(timelineId); time = time?.MyToUtc(); var timelineEntity = await _database.Timelines.Where(t => t.Id == timelineId).SingleAsync(); var author = await _userService.GetUser(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(); await _database.Entry(postEntity).Reference(p => p.Author).Query().Include(a => a.Permissions).LoadAsync(); return postEntity; } public async Task DeletePost(long timelineId, long postId) { await CheckTimelineExistence(timelineId); var post = await _database.TimelinePosts.Where(p => p.TimelineId == timelineId && p.LocalId == postId).SingleOrDefaultAsync(); if (post == null) throw new TimelinePostNotExistException(timelineId, postId, false); if (post.Content == null) throw new TimelinePostNotExistException(timelineId, postId, 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 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.Id == postId).Select(p => new { p.Content, p.AuthorId }).SingleOrDefaultAsync(); if (postEntity == null) { if (throwOnPostNotExist) throw new TimelinePostNotExistException(timelineId, postId, false); else return true; } if (postEntity.Content == null && throwOnPostNotExist) { throw new TimelinePostNotExistException(timelineId, postId, true); } return timelineEntity.OwnerId == modifierId || postEntity.AuthorId == modifierId; } } }