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, false);
_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, false);
}
_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.Id == 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;
}
}
}