From 05ccb4d8f1bbe3fb64e117136b4a89bcfb0b0b33 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/BadPasswordException.cs | 27 + BackEnd/Timeline/Services/Clock.cs | 29 + BackEnd/Timeline/Services/DataManager.cs | 122 ++ BackEnd/Timeline/Services/DatabaseBackupService.cs | 35 + .../Services/DatabaseCorruptedException.cs | 15 + BackEnd/Timeline/Services/ETagGenerator.cs | 45 + BackEnd/Timeline/Services/EntityNames.cs | 14 + .../Services/Exceptions/EntityAlreadyExistError.cs | 63 ++ .../Services/Exceptions/EntityNotExistError.cs | 55 + .../Services/Exceptions/ExceptionMessageHelper.cs | 13 + .../Timeline/Services/Exceptions/ImageException.cs | 57 + .../Exceptions/TimelineNotExistException.cs | 21 + .../Exceptions/TimelinePostNoDataException.cs | 15 + .../Exceptions/TimelinePostNotExistException.cs | 33 + .../Services/Exceptions/UserNotExistException.cs | 40 + BackEnd/Timeline/Services/ImageValidator.cs | 54 + .../Services/JwtUserTokenBadFormatException.cs | 48 + .../Services/PasswordBadFormatException.cs | 27 + BackEnd/Timeline/Services/PasswordService.cs | 224 ++++ BackEnd/Timeline/Services/PathProvider.cs | 42 + BackEnd/Timeline/Services/TimelineService.cs | 1166 ++++++++++++++++++++ BackEnd/Timeline/Services/UserAvatarService.cs | 265 +++++ BackEnd/Timeline/Services/UserDeleteService.cs | 69 ++ BackEnd/Timeline/Services/UserRoleConvert.cs | 43 + BackEnd/Timeline/Services/UserService.cs | 437 ++++++++ BackEnd/Timeline/Services/UserTokenException.cs | 68 ++ BackEnd/Timeline/Services/UserTokenManager.cs | 97 ++ BackEnd/Timeline/Services/UserTokenService.cs | 149 +++ 28 files changed, 3273 insertions(+) create mode 100644 BackEnd/Timeline/Services/BadPasswordException.cs create mode 100644 BackEnd/Timeline/Services/Clock.cs create mode 100644 BackEnd/Timeline/Services/DataManager.cs create mode 100644 BackEnd/Timeline/Services/DatabaseBackupService.cs create mode 100644 BackEnd/Timeline/Services/DatabaseCorruptedException.cs create mode 100644 BackEnd/Timeline/Services/ETagGenerator.cs create mode 100644 BackEnd/Timeline/Services/EntityNames.cs create mode 100644 BackEnd/Timeline/Services/Exceptions/EntityAlreadyExistError.cs create mode 100644 BackEnd/Timeline/Services/Exceptions/EntityNotExistError.cs create mode 100644 BackEnd/Timeline/Services/Exceptions/ExceptionMessageHelper.cs create mode 100644 BackEnd/Timeline/Services/Exceptions/ImageException.cs create mode 100644 BackEnd/Timeline/Services/Exceptions/TimelineNotExistException.cs create mode 100644 BackEnd/Timeline/Services/Exceptions/TimelinePostNoDataException.cs create mode 100644 BackEnd/Timeline/Services/Exceptions/TimelinePostNotExistException.cs create mode 100644 BackEnd/Timeline/Services/Exceptions/UserNotExistException.cs create mode 100644 BackEnd/Timeline/Services/ImageValidator.cs create mode 100644 BackEnd/Timeline/Services/JwtUserTokenBadFormatException.cs create mode 100644 BackEnd/Timeline/Services/PasswordBadFormatException.cs create mode 100644 BackEnd/Timeline/Services/PasswordService.cs create mode 100644 BackEnd/Timeline/Services/PathProvider.cs create mode 100644 BackEnd/Timeline/Services/TimelineService.cs create mode 100644 BackEnd/Timeline/Services/UserAvatarService.cs create mode 100644 BackEnd/Timeline/Services/UserDeleteService.cs create mode 100644 BackEnd/Timeline/Services/UserRoleConvert.cs create mode 100644 BackEnd/Timeline/Services/UserService.cs create mode 100644 BackEnd/Timeline/Services/UserTokenException.cs create mode 100644 BackEnd/Timeline/Services/UserTokenManager.cs create mode 100644 BackEnd/Timeline/Services/UserTokenService.cs (limited to 'BackEnd/Timeline/Services') diff --git a/BackEnd/Timeline/Services/BadPasswordException.cs b/BackEnd/Timeline/Services/BadPasswordException.cs new file mode 100644 index 00000000..f609371d --- /dev/null +++ b/BackEnd/Timeline/Services/BadPasswordException.cs @@ -0,0 +1,27 @@ +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/Clock.cs b/BackEnd/Timeline/Services/Clock.cs new file mode 100644 index 00000000..4395edcd --- /dev/null +++ b/BackEnd/Timeline/Services/Clock.cs @@ -0,0 +1,29 @@ +using System; + +namespace Timeline.Services +{ + /// + /// Convenient for unit test. + /// + public interface IClock + { + /// + /// Get current time. + /// + /// Current time. + DateTime GetCurrentTime(); + } + + public class Clock : IClock + { + public Clock() + { + + } + + public DateTime GetCurrentTime() + { + return DateTime.UtcNow; + } + } +} diff --git a/BackEnd/Timeline/Services/DataManager.cs b/BackEnd/Timeline/Services/DataManager.cs new file mode 100644 index 00000000..d447b0d5 --- /dev/null +++ b/BackEnd/Timeline/Services/DataManager.cs @@ -0,0 +1,122 @@ +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. + /// + /// The tag of the entry. + /// The data of the entry. + /// Thrown when is null. + /// Thrown when entry with given tag does not exist. + 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 == null) + throw new InvalidOperationException(Resources.Services.DataManager.ExceptionEntryNotExist); + + return entity.Data; + } + } +} diff --git a/BackEnd/Timeline/Services/DatabaseBackupService.cs b/BackEnd/Timeline/Services/DatabaseBackupService.cs new file mode 100644 index 00000000..a76b2a0d --- /dev/null +++ b/BackEnd/Timeline/Services/DatabaseBackupService.cs @@ -0,0 +1,35 @@ +using System.Globalization; +using System.IO; + +namespace Timeline.Services +{ + public interface IDatabaseBackupService + { + void BackupNow(); + } + + public class DatabaseBackupService : IDatabaseBackupService + { + private readonly IPathProvider _pathProvider; + private readonly IClock _clock; + + public DatabaseBackupService(IPathProvider pathProvider, IClock clock) + { + _pathProvider = pathProvider; + _clock = clock; + } + + public void BackupNow() + { + var databasePath = _pathProvider.GetDatabaseFilePath(); + if (File.Exists(databasePath)) + { + var backupDirPath = _pathProvider.GetDatabaseBackupDirectory(); + Directory.CreateDirectory(backupDirPath); + var fileName = _clock.GetCurrentTime().ToString("yyyy-MM-ddTHH-mm-ss", CultureInfo.InvariantCulture); + var path = Path.Combine(backupDirPath, fileName); + File.Copy(databasePath, path); + } + } + } +} diff --git a/BackEnd/Timeline/Services/DatabaseCorruptedException.cs b/BackEnd/Timeline/Services/DatabaseCorruptedException.cs new file mode 100644 index 00000000..9988e0ad --- /dev/null +++ b/BackEnd/Timeline/Services/DatabaseCorruptedException.cs @@ -0,0 +1,15 @@ +using System; + +namespace Timeline.Services +{ + [Serializable] + public class DatabaseCorruptedException : Exception + { + public DatabaseCorruptedException() { } + public DatabaseCorruptedException(string message) : base(message) { } + public DatabaseCorruptedException(string message, Exception inner) : base(message, inner) { } + protected DatabaseCorruptedException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + } +} diff --git a/BackEnd/Timeline/Services/ETagGenerator.cs b/BackEnd/Timeline/Services/ETagGenerator.cs new file mode 100644 index 00000000..4493e903 --- /dev/null +++ b/BackEnd/Timeline/Services/ETagGenerator.cs @@ -0,0 +1,45 @@ +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/EntityNames.cs b/BackEnd/Timeline/Services/EntityNames.cs new file mode 100644 index 00000000..0ce1de3b --- /dev/null +++ b/BackEnd/Timeline/Services/EntityNames.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Timeline.Services +{ + public static class EntityNames + { + public const string User = "User"; + public const string Timeline = "Timeline"; + public const string TimelinePost = "TimelinePost"; + } +} diff --git a/BackEnd/Timeline/Services/Exceptions/EntityAlreadyExistError.cs b/BackEnd/Timeline/Services/Exceptions/EntityAlreadyExistError.cs new file mode 100644 index 00000000..7db2e860 --- /dev/null +++ b/BackEnd/Timeline/Services/Exceptions/EntityAlreadyExistError.cs @@ -0,0 +1,63 @@ +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 new file mode 100644 index 00000000..e79496d3 --- /dev/null +++ b/BackEnd/Timeline/Services/Exceptions/EntityNotExistError.cs @@ -0,0 +1,55 @@ +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 new file mode 100644 index 00000000..be3c42a4 --- /dev/null +++ b/BackEnd/Timeline/Services/Exceptions/ExceptionMessageHelper.cs @@ -0,0 +1,13 @@ +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 new file mode 100644 index 00000000..20dd48ae --- /dev/null +++ b/BackEnd/Timeline/Services/Exceptions/ImageException.cs @@ -0,0 +1,57 @@ +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/TimelineNotExistException.cs b/BackEnd/Timeline/Services/Exceptions/TimelineNotExistException.cs new file mode 100644 index 00000000..70970b24 --- /dev/null +++ b/BackEnd/Timeline/Services/Exceptions/TimelineNotExistException.cs @@ -0,0 +1,21 @@ +using System; +using System.Globalization; + +namespace Timeline.Services.Exceptions +{ + [Serializable] + public class TimelineNotExistException : EntityNotExistException + { + public TimelineNotExistException() : this(null, null) { } + 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; } + } +} diff --git a/BackEnd/Timeline/Services/Exceptions/TimelinePostNoDataException.cs b/BackEnd/Timeline/Services/Exceptions/TimelinePostNoDataException.cs new file mode 100644 index 00000000..c4b6bf62 --- /dev/null +++ b/BackEnd/Timeline/Services/Exceptions/TimelinePostNoDataException.cs @@ -0,0 +1,15 @@ +using System; + +namespace Timeline.Services.Exceptions +{ + [Serializable] + public class TimelinePostNoDataException : Exception + { + public TimelinePostNoDataException() : this(null, null) { } + public TimelinePostNoDataException(string? message) : this(message, null) { } + public TimelinePostNoDataException(string? message, Exception? inner) : base(Resources.Services.Exceptions.TimelineNoDataException.AppendAdditionalMessage(message), inner) { } + protected TimelinePostNoDataException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + } +} diff --git a/BackEnd/Timeline/Services/Exceptions/TimelinePostNotExistException.cs b/BackEnd/Timeline/Services/Exceptions/TimelinePostNotExistException.cs new file mode 100644 index 00000000..f95dd410 --- /dev/null +++ b/BackEnd/Timeline/Services/Exceptions/TimelinePostNotExistException.cs @@ -0,0 +1,33 @@ +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(string? timelineName, long? id, bool isDelete, string? message = null, Exception? inner = null) : base(EntityNames.TimelinePost, null, MakeMessage(timelineName, id, isDelete).AppendAdditionalMessage(message), inner) { TimelineName = timelineName; Id = id; IsDelete = isDelete; } + + private static string MakeMessage(string? timelineName, long? id, bool isDelete) + { + return string.Format(CultureInfo.InvariantCulture, isDelete ? Resources.Services.Exceptions.TimelinePostNotExistExceptionDeleted : Resources.Services.Exceptions.TimelinePostNotExistException, timelineName ?? "", id); + } + + public string? TimelineName { get; set; } + public long? Id { 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 new file mode 100644 index 00000000..7ef714df --- /dev/null +++ b/BackEnd/Timeline/Services/Exceptions/UserNotExistException.cs @@ -0,0 +1,40 @@ +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/ImageValidator.cs b/BackEnd/Timeline/Services/ImageValidator.cs new file mode 100644 index 00000000..59424a7c --- /dev/null +++ b/BackEnd/Timeline/Services/ImageValidator.cs @@ -0,0 +1,54 @@ +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/JwtUserTokenBadFormatException.cs b/BackEnd/Timeline/Services/JwtUserTokenBadFormatException.cs new file mode 100644 index 00000000..c528c3e3 --- /dev/null +++ b/BackEnd/Timeline/Services/JwtUserTokenBadFormatException.cs @@ -0,0 +1,48 @@ +using System; +using System.Globalization; +using static Timeline.Resources.Services.Exception; + +namespace Timeline.Services +{ + [Serializable] + public class JwtUserTokenBadFormatException : UserTokenBadFormatException + { + public enum ErrorKind + { + NoIdClaim, + IdClaimBadFormat, + NoVersionClaim, + VersionClaimBadFormat, + Other + } + + public JwtUserTokenBadFormatException() : this("", ErrorKind.Other) { } + public JwtUserTokenBadFormatException(string message) : base(message) { } + public JwtUserTokenBadFormatException(string message, Exception inner) : base(message, inner) { } + + public JwtUserTokenBadFormatException(string token, ErrorKind type) : base(token, GetErrorMessage(type)) { ErrorType = type; } + public JwtUserTokenBadFormatException(string token, ErrorKind type, Exception inner) : base(token, GetErrorMessage(type), inner) { ErrorType = type; } + public JwtUserTokenBadFormatException(string token, ErrorKind type, string message, Exception inner) : base(token, message, inner) { ErrorType = type; } + protected JwtUserTokenBadFormatException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + + public ErrorKind ErrorType { get; set; } + + private static string GetErrorMessage(ErrorKind type) + { + var reason = type switch + { + ErrorKind.NoIdClaim => JwtUserTokenBadFormatExceptionIdMissing, + ErrorKind.IdClaimBadFormat => JwtUserTokenBadFormatExceptionIdBadFormat, + ErrorKind.NoVersionClaim => JwtUserTokenBadFormatExceptionVersionMissing, + ErrorKind.VersionClaimBadFormat => JwtUserTokenBadFormatExceptionVersionBadFormat, + ErrorKind.Other => JwtUserTokenBadFormatExceptionOthers, + _ => JwtUserTokenBadFormatExceptionUnknown + }; + + return string.Format(CultureInfo.CurrentCulture, + Resources.Services.Exception.JwtUserTokenBadFormatException, reason); + } + } +} diff --git a/BackEnd/Timeline/Services/PasswordBadFormatException.cs b/BackEnd/Timeline/Services/PasswordBadFormatException.cs new file mode 100644 index 00000000..2029ebb4 --- /dev/null +++ b/BackEnd/Timeline/Services/PasswordBadFormatException.cs @@ -0,0 +1,27 @@ +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 new file mode 100644 index 00000000..8114a520 --- /dev/null +++ b/BackEnd/Timeline/Services/PasswordService.cs @@ -0,0 +1,224 @@ +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/PathProvider.cs b/BackEnd/Timeline/Services/PathProvider.cs new file mode 100644 index 00000000..1baba5c0 --- /dev/null +++ b/BackEnd/Timeline/Services/PathProvider.cs @@ -0,0 +1,42 @@ +using Microsoft.Extensions.Configuration; +using System.IO; +using Timeline.Configs; + +namespace Timeline.Services +{ + public interface IPathProvider + { + public string GetWorkingDirectory(); + public string GetDatabaseFilePath(); + public string GetDatabaseBackupDirectory(); + } + + public class PathProvider : IPathProvider + { + private readonly IConfiguration _configuration; + + private readonly string _workingDirectory; + + + public PathProvider(IConfiguration configuration) + { + _configuration = configuration; + _workingDirectory = configuration.GetValue(ApplicationConfiguration.WorkDirKey) ?? ApplicationConfiguration.DefaultWorkDir; + } + + public string GetWorkingDirectory() + { + return _workingDirectory; + } + + public string GetDatabaseFilePath() + { + return Path.Combine(_workingDirectory, ApplicationConfiguration.DatabaseFileName); + } + + public string GetDatabaseBackupDirectory() + { + return Path.Combine(_workingDirectory, ApplicationConfiguration.DatabaseBackupDirectoryName); + } + } +} 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); + } + } +} diff --git a/BackEnd/Timeline/Services/UserAvatarService.cs b/BackEnd/Timeline/Services/UserAvatarService.cs new file mode 100644 index 00000000..b41c45fd --- /dev/null +++ b/BackEnd/Timeline/Services/UserAvatarService.cs @@ -0,0 +1,265 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Timeline.Entities; +using Timeline.Helpers; +using Timeline.Services.Exceptions; + +namespace Timeline.Services +{ + public class Avatar + { + public string Type { get; set; } = default!; + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1819:Properties should not return arrays", Justification = "DTO Object")] + public byte[] Data { get; set; } = default!; + } + + public class AvatarInfo + { + public Avatar Avatar { get; set; } = default!; + public DateTime LastModified { get; set; } + + public CacheableData ToCacheableData() + { + return new CacheableData(Avatar.Type, Avatar.Data, LastModified); + } + } + + /// + /// Provider for default user avatar. + /// + /// + /// Mainly for unit tests. + /// + public interface IDefaultUserAvatarProvider + { + /// + /// Get the etag of default avatar. + /// + /// + Task GetDefaultAvatarETag(); + + /// + /// Get the default avatar. + /// + Task GetDefaultAvatar(); + } + + public interface IUserAvatarService + { + /// + /// Get the etag of a user's avatar. Warning: This method does not check the user existence. + /// + /// The id of the user to get avatar etag of. + /// The etag. + Task GetAvatarETag(long id); + + /// + /// Get avatar of a user. If the user has no avatar set, a default one is returned. Warning: This method does not check the user existence. + /// + /// The id of the user to get avatar of. + /// The avatar info. + Task GetAvatar(long id); + + /// + /// Set avatar for a user. Warning: This method does not check the user existence. + /// + /// The id of the user to set avatar for. + /// The avatar. Can be null to delete the saved avatar. + /// The etag of the avatar. + /// Thrown if any field in is null when is not null. + /// Thrown if avatar is of bad format. + Task SetAvatar(long id, Avatar? avatar); + } + + // TODO! : Make this configurable. + public class DefaultUserAvatarProvider : IDefaultUserAvatarProvider + { + private readonly IETagGenerator _eTagGenerator; + + private readonly string _avatarPath; + + private byte[] _cacheData = default!; + private DateTime _cacheLastModified; + private string _cacheETag = default!; + + 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) > _cacheLastModified) + { + _cacheData = await File.ReadAllBytesAsync(path); + _cacheLastModified = File.GetLastWriteTime(path); + _cacheETag = await _eTagGenerator.Generate(_cacheData); + } + } + + public async Task GetDefaultAvatarETag() + { + await CheckAndInit(); + return _cacheETag; + } + + public async Task GetDefaultAvatar() + { + await CheckAndInit(); + return new AvatarInfo + { + Avatar = new Avatar + { + Type = "image/png", + Data = _cacheData + }, + LastModified = _cacheLastModified + }; + } + } + + public class UserAvatarService : IUserAvatarService + { + + private readonly ILogger _logger; + + private readonly DatabaseContext _database; + + private readonly IDefaultUserAvatarProvider _defaultUserAvatarProvider; + + private readonly IImageValidator _imageValidator; + + private readonly IDataManager _dataManager; + + private readonly IClock _clock; + + public UserAvatarService( + ILogger logger, + DatabaseContext database, + IDefaultUserAvatarProvider defaultUserAvatarProvider, + IImageValidator imageValidator, + IDataManager dataManager, + IClock clock) + { + _logger = logger; + _database = database; + _defaultUserAvatarProvider = defaultUserAvatarProvider; + _imageValidator = imageValidator; + _dataManager = dataManager; + _clock = clock; + } + + public async Task GetAvatarETag(long id) + { + var eTag = (await _database.UserAvatars.Where(a => a.UserId == id).Select(a => new { a.DataTag }).SingleOrDefaultAsync())?.DataTag; + if (eTag == null) + return await _defaultUserAvatarProvider.GetDefaultAvatarETag(); + else + return eTag; + } + + public async Task GetAvatar(long id) + { + var avatarEntity = await _database.UserAvatars.Where(a => a.UserId == id).Select(a => new { a.Type, a.DataTag, a.LastModified }).SingleOrDefaultAsync(); + + if (avatarEntity != null) + { + if (!LanguageHelper.AreSame(avatarEntity.DataTag == null, avatarEntity.Type == null)) + { + var message = Resources.Services.UserAvatarService.ExceptionDatabaseCorruptedDataAndTypeNotSame; + _logger.LogCritical(message); + throw new DatabaseCorruptedException(message); + } + + + if (avatarEntity.DataTag != null) + { + var data = await _dataManager.GetEntry(avatarEntity.DataTag); + return new AvatarInfo + { + Avatar = new Avatar + { + Type = avatarEntity.Type!, + Data = data + }, + LastModified = avatarEntity.LastModified + }; + } + } + var defaultAvatar = await _defaultUserAvatarProvider.GetDefaultAvatar(); + if (avatarEntity != null) + defaultAvatar.LastModified = defaultAvatar.LastModified > avatarEntity.LastModified ? defaultAvatar.LastModified : avatarEntity.LastModified; + return defaultAvatar; + } + + public async Task SetAvatar(long id, Avatar? avatar) + { + if (avatar != null) + { + if (avatar.Data == null) + throw new ArgumentException(Resources.Services.UserAvatarService.ExceptionAvatarDataNull, nameof(avatar)); + if (string.IsNullOrEmpty(avatar.Type)) + throw new ArgumentException(Resources.Services.UserAvatarService.ExceptionAvatarTypeNullOrEmpty, nameof(avatar)); + } + + var avatarEntity = await _database.UserAvatars.Where(a => a.UserId == id).SingleOrDefaultAsync(); + + if (avatar == null) + { + if (avatarEntity != null && avatarEntity.DataTag != null) + { + await _dataManager.FreeEntry(avatarEntity.DataTag); + avatarEntity.DataTag = null; + avatarEntity.Type = null; + avatarEntity.LastModified = _clock.GetCurrentTime(); + await _database.SaveChangesAsync(); + _logger.LogInformation(Resources.Services.UserAvatarService.LogUpdateEntity); + } + return await _defaultUserAvatarProvider.GetDefaultAvatarETag(); + } + else + { + await _imageValidator.Validate(avatar.Data, avatar.Type, true); + var tag = await _dataManager.RetainEntry(avatar.Data); + var oldTag = avatarEntity?.DataTag; + var create = avatarEntity == null; + if (avatarEntity == null) + { + avatarEntity = new UserAvatarEntity(); + _database.UserAvatars.Add(avatarEntity); + } + avatarEntity.DataTag = tag; + avatarEntity.Type = avatar.Type; + avatarEntity.LastModified = _clock.GetCurrentTime(); + avatarEntity.UserId = id; + await _database.SaveChangesAsync(); + _logger.LogInformation(create ? + Resources.Services.UserAvatarService.LogCreateEntity + : Resources.Services.UserAvatarService.LogUpdateEntity); + if (oldTag != null) + { + await _dataManager.FreeEntry(oldTag); + } + + return avatarEntity.DataTag; + } + } + } + + public static class UserAvatarServiceCollectionExtensions + { + public static void AddUserAvatarService(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + } + } +} diff --git a/BackEnd/Timeline/Services/UserDeleteService.cs b/BackEnd/Timeline/Services/UserDeleteService.cs new file mode 100644 index 00000000..845de573 --- /dev/null +++ b/BackEnd/Timeline/Services/UserDeleteService.cs @@ -0,0 +1,69 @@ +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 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. + Task DeleteUser(string username); + } + + public class UserDeleteService : IUserDeleteService + { + private readonly ILogger _logger; + + private readonly DatabaseContext _databaseContext; + + private readonly ITimelineService _timelineService; + + private readonly UsernameValidator _usernameValidator = new UsernameValidator(); + + public UserDeleteService(ILogger logger, DatabaseContext databaseContext, ITimelineService timelineService) + { + _logger = logger; + _databaseContext = databaseContext; + _timelineService = timelineService; + } + + 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; + + await _timelineService.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/UserRoleConvert.cs b/BackEnd/Timeline/Services/UserRoleConvert.cs new file mode 100644 index 00000000..f27ee1bb --- /dev/null +++ b/BackEnd/Timeline/Services/UserRoleConvert.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Timeline.Entities; + +namespace Timeline.Services +{ + public static class UserRoleConvert + { + public const string UserRole = UserRoles.User; + public const string AdminRole = UserRoles.Admin; + + public static string[] ToArray(bool administrator) + { + return administrator ? new string[] { UserRole, AdminRole } : new string[] { UserRole }; + } + + public static string[] ToArray(string s) + { + return s.Split(',').ToArray(); + } + + public static bool ToBool(IReadOnlyCollection roles) + { + return roles.Contains(AdminRole); + } + + public static string ToString(IReadOnlyCollection roles) + { + return string.Join(',', roles); + } + + public static string ToString(bool administrator) + { + return administrator ? UserRole + "," + AdminRole : UserRole; + } + + public static bool ToBool(string s) + { + return s.Contains("admin", StringComparison.InvariantCulture); + } + } +} diff --git a/BackEnd/Timeline/Services/UserService.cs b/BackEnd/Timeline/Services/UserService.cs new file mode 100644 index 00000000..821bc33d --- /dev/null +++ b/BackEnd/Timeline/Services/UserService.cs @@ -0,0 +1,437 @@ +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; +using Timeline.Models.Validation; +using Timeline.Services.Exceptions; +using static Timeline.Resources.Services.UserService; + +namespace Timeline.Services +{ + public interface IUserService + { + /// + /// Try to verify the given username and password. + /// + /// The username of the user to verify. + /// The password of the user to verify. + /// The user info and auth info. + /// 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 get a user by id. + /// + /// The id of the user. + /// The user info. + /// Thrown when the user with given id does not exist. + Task GetUserById(long id); + + /// + /// Get the user info of given username. + /// + /// Username of the user. + /// The info of the user. + /// Thrown when is null. + /// Thrown when is of bad format. + /// Thrown when the user with given username does not exist. + Task GetUserByUsername(string username); + + /// + /// 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); + + /// + /// List all users. + /// + /// The user info of users. + Task GetUsers(); + + /// + /// Create a user with given info. + /// + /// The info of new user. + /// The the new user. + /// Thrown when is null. + /// Thrown when some fields in is bad. + /// Thrown when a user with given username already exists. + /// + /// must not be null and must be a valid username. + /// must not be null or empty. + /// is false by default (null). + /// must be a valid nickname if set. It is empty by default. + /// Other fields are ignored. + /// + Task CreateUser(User info); + + /// + /// Modify a user's info. + /// + /// The id of the user. + /// The new info. May be null. + /// The new user info. + /// Thrown when some fields in is bad. + /// Thrown when user with given id does not exist. + /// + /// Only , , and will be used. + /// If null, then not change. + /// Other fields are ignored. + /// Version will increase if password is changed. + /// + /// must be a valid username if set. + /// can't be empty if set. + /// must be a valid nickname if set. + /// + /// + /// + Task ModifyUser(long id, User? info); + + /// + /// Modify a user's info. + /// + /// The username of the user. + /// The new info. May be null. + /// The new user info. + /// Thrown when is null. + /// Thrown when is of bad format or some fields in is bad. + /// Thrown when user with given id does not exist. + /// Thrown when user with the newusername already exist. + /// + /// Only , and will be used. + /// If null, then not change. + /// Other fields are ignored. + /// After modified, even if nothing is changed, version will increase. + /// + /// must be a valid username if set. + /// can't be empty if set. + /// must be a valid nickname if set. + /// + /// Note: Whether is set or not, version will increase and not set to the specified value if there is one. + /// + /// + Task ModifyUser(string username, User? info); + + /// + /// 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 UserService : 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) + { + _logger = logger; + _clock = clock; + _databaseContext = databaseContext; + _passwordService = passwordService; + } + + 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); + } + + private static User CreateUserFromEntity(UserEntity entity) + { + return new User + { + UniqueId = entity.UniqueId, + Username = entity.Username, + Administrator = UserRoleConvert.ToBool(entity.Roles), + Nickname = string.IsNullOrEmpty(entity.Nickname) ? entity.Username : entity.Nickname, + Id = entity.Id, + Version = entity.Version, + CreateTime = entity.CreateTime, + UsernameChangeTime = entity.UsernameChangeTime, + LastModified = entity.LastModified + }; + } + + public async Task VerifyCredential(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 entity = await _databaseContext.Users.Where(u => u.Username == username).SingleOrDefaultAsync(); + + if (entity == null) + throw new UserNotExistException(username); + + if (!_passwordService.VerifyPassword(entity.Password, password)) + throw new BadPasswordException(password); + + return CreateUserFromEntity(entity); + } + + public async Task GetUserById(long id) + { + var user = await _databaseContext.Users.Where(u => u.Id == id).SingleOrDefaultAsync(); + + if (user == null) + throw new UserNotExistException(id); + + return CreateUserFromEntity(user); + } + + public async Task GetUserByUsername(string username) + { + if (username == null) + throw new ArgumentNullException(nameof(username)); + + CheckUsernameFormat(username, nameof(username)); + + var entity = await _databaseContext.Users.Where(user => user.Username == username).SingleOrDefaultAsync(); + + if (entity == null) + throw new UserNotExistException(username); + + return CreateUserFromEntity(entity); + } + + public async Task GetUserIdByUsername(string username) + { + if (username == null) + throw new ArgumentNullException(nameof(username)); + + CheckUsernameFormat(username, nameof(username)); + + var entity = await _databaseContext.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 GetUsers() + { + var entities = await _databaseContext.Users.ToArrayAsync(); + return entities.Select(user => CreateUserFromEntity(user)).ToArray(); + } + + public async Task CreateUser(User info) + { + if (info == null) + throw new ArgumentNullException(nameof(info)); + + if (info.Username == null) + throw new ArgumentException(ExceptionUsernameNull, nameof(info)); + CheckUsernameFormat(info.Username, nameof(info)); + + if (info.Password == null) + throw new ArgumentException(ExceptionPasswordNull, nameof(info)); + CheckPasswordFormat(info.Password, nameof(info)); + + if (info.Nickname != null) + CheckNicknameFormat(info.Nickname, nameof(info)); + + var username = info.Username; + + var conflict = await _databaseContext.Users.AnyAsync(u => u.Username == username); + if (conflict) + ThrowUsernameConflict(); + + var administrator = info.Administrator ?? false; + var password = info.Password; + var nickname = info.Nickname; + + var newEntity = new UserEntity + { + Username = username, + Password = _passwordService.HashPassword(password), + Roles = UserRoleConvert.ToString(administrator), + Nickname = nickname, + Version = 1 + }; + _databaseContext.Users.Add(newEntity); + await _databaseContext.SaveChangesAsync(); + + _logger.LogInformation(Log.Format(LogDatabaseCreate, + ("Id", newEntity.Id), ("Username", username), ("Administrator", administrator))); + + return CreateUserFromEntity(newEntity); + } + + private void ValidateModifyUserInfo(User? info) + { + if (info != null) + { + if (info.Username != null) + CheckUsernameFormat(info.Username, nameof(info)); + + if (info.Password != null) + CheckPasswordFormat(info.Password, nameof(info)); + + if (info.Nickname != null) + CheckNicknameFormat(info.Nickname, nameof(info)); + } + } + + private async Task UpdateUserEntity(UserEntity entity, User? info) + { + if (info != null) + { + var now = _clock.GetCurrentTime(); + bool updateLastModified = false; + + var username = info.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 = info.Password; + if (password != null) + { + entity.Password = _passwordService.HashPassword(password); + entity.Version += 1; + } + + var administrator = info.Administrator; + if (administrator.HasValue && UserRoleConvert.ToBool(entity.Roles) != administrator) + { + entity.Roles = UserRoleConvert.ToString(administrator.Value); + updateLastModified = true; + } + + var nickname = info.Nickname; + if (nickname != null && nickname != entity.Nickname) + { + entity.Nickname = nickname; + updateLastModified = true; + } + + if (updateLastModified) + { + entity.LastModified = now; + } + } + } + + + public async Task ModifyUser(long id, User? info) + { + ValidateModifyUserInfo(info); + + var entity = await _databaseContext.Users.Where(u => u.Id == id).SingleOrDefaultAsync(); + if (entity == null) + throw new UserNotExistException(id); + + await UpdateUserEntity(entity, info); + + await _databaseContext.SaveChangesAsync(); + _logger.LogInformation(LogDatabaseUpdate, ("Id", id)); + + return CreateUserFromEntity(entity); + } + + public async Task ModifyUser(string username, User? info) + { + if (username == null) + throw new ArgumentNullException(nameof(username)); + CheckUsernameFormat(username, nameof(username)); + + ValidateModifyUserInfo(info); + + var entity = await _databaseContext.Users.Where(u => u.Username == username).SingleOrDefaultAsync(); + if (entity == null) + throw new UserNotExistException(username); + + await UpdateUserEntity(entity, info); + + await _databaseContext.SaveChangesAsync(); + _logger.LogInformation(LogDatabaseUpdate, ("Username", username)); + + return CreateUserFromEntity(entity); + } + + 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)); + CheckPasswordFormat(oldPassword, nameof(oldPassword)); + CheckPasswordFormat(newPassword, nameof(newPassword)); + + var entity = await _databaseContext.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 _databaseContext.SaveChangesAsync(); + _logger.LogInformation(Log.Format(LogDatabaseUpdate, ("Id", id), ("Operation", "Change password"))); + } + } +} diff --git a/BackEnd/Timeline/Services/UserTokenException.cs b/BackEnd/Timeline/Services/UserTokenException.cs new file mode 100644 index 00000000..d25fabb3 --- /dev/null +++ b/BackEnd/Timeline/Services/UserTokenException.cs @@ -0,0 +1,68 @@ +using System; + +namespace Timeline.Services +{ + + [Serializable] + public class UserTokenException : Exception + { + public UserTokenException() { } + public UserTokenException(string message) : base(message) { } + public UserTokenException(string message, Exception inner) : base(message, inner) { } + public UserTokenException(string token, string message) : base(message) { Token = token; } + public UserTokenException(string token, string message, Exception inner) : base(message, inner) { Token = token; } + protected UserTokenException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + + public string Token { get; private set; } = ""; + } + + + [Serializable] + public class UserTokenTimeExpireException : UserTokenException + { + public UserTokenTimeExpireException() : base(Resources.Services.Exception.UserTokenTimeExpireException) { } + public UserTokenTimeExpireException(string message) : base(message) { } + public UserTokenTimeExpireException(string message, Exception inner) : base(message, inner) { } + public UserTokenTimeExpireException(string token, DateTime expireTime, DateTime verifyTime) : base(token, Resources.Services.Exception.UserTokenTimeExpireException) { ExpireTime = expireTime; VerifyTime = verifyTime; } + public UserTokenTimeExpireException(string token, DateTime expireTime, DateTime verifyTime, Exception inner) : base(token, Resources.Services.Exception.UserTokenTimeExpireException, inner) { ExpireTime = expireTime; VerifyTime = verifyTime; } + protected UserTokenTimeExpireException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + + public DateTime ExpireTime { get; private set; } + + public DateTime VerifyTime { get; private set; } + } + + [Serializable] + public class UserTokenBadVersionException : UserTokenException + { + public UserTokenBadVersionException() : base(Resources.Services.Exception.UserTokenBadVersionException) { } + public UserTokenBadVersionException(string message) : base(message) { } + public UserTokenBadVersionException(string message, Exception inner) : base(message, inner) { } + public UserTokenBadVersionException(string token, long tokenVersion, long requiredVersion) : base(token, Resources.Services.Exception.UserTokenBadVersionException) { TokenVersion = tokenVersion; RequiredVersion = requiredVersion; } + public UserTokenBadVersionException(string token, long tokenVersion, long requiredVersion, Exception inner) : base(token, Resources.Services.Exception.UserTokenBadVersionException, inner) { TokenVersion = tokenVersion; RequiredVersion = requiredVersion; } + protected UserTokenBadVersionException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + + public long TokenVersion { get; set; } + + public long RequiredVersion { get; set; } + } + + [Serializable] + public class UserTokenBadFormatException : UserTokenException + { + public UserTokenBadFormatException() : base(Resources.Services.Exception.UserTokenBadFormatException) { } + public UserTokenBadFormatException(string token) : base(token, Resources.Services.Exception.UserTokenBadFormatException) { } + public UserTokenBadFormatException(string token, string message) : base(token, message) { } + public UserTokenBadFormatException(string token, Exception inner) : base(token, Resources.Services.Exception.UserTokenBadFormatException, inner) { } + public UserTokenBadFormatException(string token, string message, Exception inner) : base(token, message, inner) { } + protected UserTokenBadFormatException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + } +} diff --git a/BackEnd/Timeline/Services/UserTokenManager.cs b/BackEnd/Timeline/Services/UserTokenManager.cs new file mode 100644 index 00000000..813dae67 --- /dev/null +++ b/BackEnd/Timeline/Services/UserTokenManager.cs @@ -0,0 +1,97 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Threading.Tasks; +using Timeline.Helpers; +using Timeline.Models; +using Timeline.Services.Exceptions; + +namespace Timeline.Services +{ + public class UserTokenCreateResult + { + public string Token { get; set; } = default!; + public User User { get; set; } = default!; + } + + public interface IUserTokenManager + { + /// + /// Try to create a token for given username and password. + /// + /// The username. + /// The password. + /// The expire time of the token. + /// The created token and the user info. + /// Thrown when or is null. + /// Thrown when is of bad format. + /// Thrown when the user with does not exist. + /// Thrown when is wrong. + public Task CreateToken(string username, string password, DateTime? expireAt = null); + + /// + /// Verify a token and get the saved user info. This also check the database for existence of the user. + /// + /// The token. + /// The user stored in token. + /// Thrown when is null. + /// Thrown when the token is expired. + /// Thrown when the token is of bad version. + /// Thrown when the token is of bad format. + /// Thrown when the user specified by the token does not exist. Usually the user had been deleted after the token was issued. + public Task VerifyToken(string token); + } + + public class UserTokenManager : IUserTokenManager + { + private readonly ILogger _logger; + private readonly IUserService _userService; + private readonly IUserTokenService _userTokenService; + private readonly IClock _clock; + + public UserTokenManager(ILogger logger, IUserService userService, IUserTokenService userTokenService, IClock clock) + { + _logger = logger; + _userService = userService; + _userTokenService = userTokenService; + _clock = clock; + } + + public async Task CreateToken(string username, string password, DateTime? expireAt = null) + { + expireAt = expireAt?.MyToUtc(); + + if (username == null) + throw new ArgumentNullException(nameof(username)); + if (password == null) + throw new ArgumentNullException(nameof(password)); + + var user = await _userService.VerifyCredential(username, password); + var token = _userTokenService.GenerateToken(new UserTokenInfo { Id = user.Id!.Value, Version = user.Version!.Value, ExpireAt = expireAt }); + + return new UserTokenCreateResult { Token = token, User = user }; + } + + + public async Task VerifyToken(string token) + { + if (token == null) + throw new ArgumentNullException(nameof(token)); + + var tokenInfo = _userTokenService.VerifyToken(token); + + if (tokenInfo.ExpireAt.HasValue) + { + var currentTime = _clock.GetCurrentTime(); + if (tokenInfo.ExpireAt < currentTime) + throw new UserTokenTimeExpireException(token, tokenInfo.ExpireAt.Value, currentTime); + } + + var user = await _userService.GetUserById(tokenInfo.Id); + + if (tokenInfo.Version < user.Version) + throw new UserTokenBadVersionException(token, tokenInfo.Version, user.Version.Value); + + return user; + } + } +} diff --git a/BackEnd/Timeline/Services/UserTokenService.cs b/BackEnd/Timeline/Services/UserTokenService.cs new file mode 100644 index 00000000..86f3a0f7 --- /dev/null +++ b/BackEnd/Timeline/Services/UserTokenService.cs @@ -0,0 +1,149 @@ +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using System; +using System.Globalization; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Security.Claims; +using Timeline.Configs; +using Timeline.Entities; + +namespace Timeline.Services +{ + public class UserTokenInfo + { + public long Id { get; set; } + public long Version { get; set; } + public DateTime? ExpireAt { get; set; } + } + + public interface IUserTokenService + { + /// + /// Create a token for a given token info. + /// + /// The info to generate token. + /// Return the generated token. + /// Thrown when is null. + string GenerateToken(UserTokenInfo tokenInfo); + + /// + /// Verify a token and get the saved info. + /// + /// The token to verify. + /// The saved info in token. + /// Thrown when is null. + /// Thrown when the token is of bad format. + /// + /// If this method throw , it usually means the token is not created by this service. + /// + UserTokenInfo VerifyToken(string token); + } + + public class JwtUserTokenService : IUserTokenService + { + private const string VersionClaimType = "timeline_version"; + + private readonly IOptionsMonitor _jwtConfig; + private readonly IClock _clock; + + private readonly JwtSecurityTokenHandler _tokenHandler = new JwtSecurityTokenHandler(); + private SymmetricSecurityKey _tokenSecurityKey; + + public JwtUserTokenService(IOptionsMonitor jwtConfig, IClock clock, DatabaseContext database) + { + _jwtConfig = jwtConfig; + _clock = clock; + + var key = database.JwtToken.Select(t => t.Key).SingleOrDefault(); + + if (key == null) + { + throw new InvalidOperationException(Resources.Services.UserTokenService.JwtKeyNotExist); + } + + _tokenSecurityKey = new SymmetricSecurityKey(key); + } + + public string GenerateToken(UserTokenInfo tokenInfo) + { + if (tokenInfo == null) + throw new ArgumentNullException(nameof(tokenInfo)); + + var config = _jwtConfig.CurrentValue; + + var identity = new ClaimsIdentity(); + identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, tokenInfo.Id.ToString(CultureInfo.InvariantCulture.NumberFormat), ClaimValueTypes.Integer64)); + identity.AddClaim(new Claim(VersionClaimType, tokenInfo.Version.ToString(CultureInfo.InvariantCulture.NumberFormat), ClaimValueTypes.Integer64)); + + var tokenDescriptor = new SecurityTokenDescriptor() + { + Subject = identity, + Issuer = config.Issuer, + Audience = config.Audience, + SigningCredentials = new SigningCredentials(_tokenSecurityKey, SecurityAlgorithms.HmacSha384), + IssuedAt = _clock.GetCurrentTime(), + Expires = tokenInfo.ExpireAt.GetValueOrDefault(_clock.GetCurrentTime().AddSeconds(config.DefaultExpireOffset)), + NotBefore = _clock.GetCurrentTime() // I must explicitly set this or it will use the current time by default and mock is not work in which case test will not pass. + }; + + var token = _tokenHandler.CreateToken(tokenDescriptor); + var tokenString = _tokenHandler.WriteToken(token); + + return tokenString; + } + + + public UserTokenInfo VerifyToken(string token) + { + if (token == null) + throw new ArgumentNullException(nameof(token)); + + var config = _jwtConfig.CurrentValue; + try + { + var principal = _tokenHandler.ValidateToken(token, new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateIssuerSigningKey = true, + ValidateLifetime = false, + ValidIssuer = config.Issuer, + ValidAudience = config.Audience, + IssuerSigningKey = _tokenSecurityKey + }, out var t); + + var idClaim = principal.FindFirstValue(ClaimTypes.NameIdentifier); + if (idClaim == null) + throw new JwtUserTokenBadFormatException(token, JwtUserTokenBadFormatException.ErrorKind.NoIdClaim); + if (!long.TryParse(idClaim, out var id)) + throw new JwtUserTokenBadFormatException(token, JwtUserTokenBadFormatException.ErrorKind.IdClaimBadFormat); + + var versionClaim = principal.FindFirstValue(VersionClaimType); + if (versionClaim == null) + throw new JwtUserTokenBadFormatException(token, JwtUserTokenBadFormatException.ErrorKind.NoVersionClaim); + if (!long.TryParse(versionClaim, out var version)) + throw new JwtUserTokenBadFormatException(token, JwtUserTokenBadFormatException.ErrorKind.VersionClaimBadFormat); + + var decodedToken = (JwtSecurityToken)t; + var exp = decodedToken.Payload.Exp; + DateTime? expireAt = null; + if (exp.HasValue) + { + expireAt = EpochTime.DateTime(exp.Value); + } + + return new UserTokenInfo + { + Id = id, + Version = version, + ExpireAt = expireAt + }; + } + catch (Exception e) when (e is SecurityTokenException || e is ArgumentException) + { + throw new JwtUserTokenBadFormatException(token, JwtUserTokenBadFormatException.ErrorKind.Other, e); + } + } + } +} -- cgit v1.2.3