using Microsoft.AspNetCore.Hosting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Formats; using System; using System.IO; using System.Linq; using System.Threading.Tasks; using Timeline.Entities; namespace Timeline.Services { public class Avatar { public string Type { get; set; } public byte[] Data { get; set; } } public class AvatarInfo { public Avatar Avatar { get; set; } public DateTime LastModified { get; set; } } /// /// Thrown when avatar is of bad format. /// [Serializable] public class AvatarDataException : Exception { public enum ErrorReason { /// /// Decoding image failed. /// CantDecode, /// /// Decoding succeeded but the real type is not the specified type. /// UnmatchedFormat, /// /// Image is not a square. /// BadSize } public AvatarDataException(Avatar avatar, ErrorReason error, string message) : base(message) { Avatar = avatar; Error = error; } public AvatarDataException(Avatar avatar, ErrorReason error, string message, Exception inner) : base(message, inner) { Avatar = avatar; Error = error; } protected AvatarDataException( System.Runtime.Serialization.SerializationInfo info, System.Runtime.Serialization.StreamingContext context) : base(info, context) { } public ErrorReason Error { get; set; } public Avatar Avatar { get; set; } } /// /// 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 IUserAvatarValidator { /// /// Validate a avatar's format and size info. /// /// The avatar to validate. /// Thrown when validation failed. Task Validate(Avatar avatar); } public interface IUserAvatarService { /// /// Get the etag of a user's avatar. /// /// The username of the user to get avatar etag of. /// The etag. /// Thrown if is null or empty. /// Thrown if the user does not exist. Task GetAvatarETag(string username); /// /// Get avatar of a user. If the user has no avatar, a default one is returned. /// /// The username of the user to get avatar of. /// The avatar info. /// Thrown if is null or empty. /// Thrown if the user does not exist. Task GetAvatar(string username); /// /// Set avatar for a user. /// /// The username of the user to set avatar for. /// The avatar. Can be null to delete the saved avatar. /// Throw if is null or empty. /// Or thrown if is not null but is null or empty or is null. /// Thrown if the user does not exist. /// Thrown if avatar is of bad format. Task SetAvatar(string username, Avatar avatar); } public class DefaultUserAvatarProvider : IDefaultUserAvatarProvider { private readonly IWebHostEnvironment _environment; private readonly IETagGenerator _eTagGenerator; private byte[] _cacheData; private DateTime _cacheLastModified; private string _cacheETag; public DefaultUserAvatarProvider(IWebHostEnvironment environment, IETagGenerator eTagGenerator) { _environment = environment; _eTagGenerator = eTagGenerator; } private async Task CheckAndInit() { if (_cacheData != null) return; var path = Path.Combine(_environment.ContentRootPath, "default-avatar.png"); _cacheData = await File.ReadAllBytesAsync(path); _cacheLastModified = File.GetLastWriteTime(path); _cacheETag = _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 UserAvatarValidator : IUserAvatarValidator { public Task Validate(Avatar avatar) { return Task.Run(() => { try { using (var image = Image.Load(avatar.Data, out IImageFormat format)) { if (!format.MimeTypes.Contains(avatar.Type)) throw new AvatarDataException(avatar, AvatarDataException.ErrorReason.UnmatchedFormat, "Image's actual mime type is not the specified one."); if (image.Width != image.Height) throw new AvatarDataException(avatar, AvatarDataException.ErrorReason.BadSize, "Image is not a square, aka, width is not equal to height."); } } catch (UnknownImageFormatException e) { throw new AvatarDataException(avatar, AvatarDataException.ErrorReason.CantDecode, "Failed to decode image. See inner exception.", e); } }); } } public class UserAvatarService : IUserAvatarService { private readonly ILogger _logger; private readonly DatabaseContext _database; private readonly IDefaultUserAvatarProvider _defaultUserAvatarProvider; private readonly IUserAvatarValidator _avatarValidator; private readonly IETagGenerator _eTagGenerator; public UserAvatarService( ILogger logger, DatabaseContext database, IDefaultUserAvatarProvider defaultUserAvatarProvider, IUserAvatarValidator avatarValidator, IETagGenerator eTagGenerator) { _logger = logger; _database = database; _defaultUserAvatarProvider = defaultUserAvatarProvider; _avatarValidator = avatarValidator; _eTagGenerator = eTagGenerator; } public async Task GetAvatarETag(string username) { var userId = await DatabaseExtensions.CheckAndGetUser(_database.Users, username); var eTag = (await _database.UserAvatars.Where(a => a.UserId == userId).Select(a => new { a.ETag }).SingleAsync()).ETag; if (eTag == null) return await _defaultUserAvatarProvider.GetDefaultAvatarETag(); else return eTag; } public async Task GetAvatar(string username) { var userId = await DatabaseExtensions.CheckAndGetUser(_database.Users, username); var avatar = await _database.UserAvatars.Where(a => a.UserId == userId).Select(a => new { a.Type, a.Data, a.LastModified }).SingleAsync(); if ((avatar.Type == null) != (avatar.Data == null)) { _logger.LogCritical("Database corupted! One of type and data of a avatar is null but the other is not."); throw new DatabaseCorruptedException(); } if (avatar.Data == null) { var defaultAvatar = await _defaultUserAvatarProvider.GetDefaultAvatar(); defaultAvatar.LastModified = defaultAvatar.LastModified > avatar.LastModified ? defaultAvatar.LastModified : avatar.LastModified; return defaultAvatar; } else { return new AvatarInfo { Avatar = new Avatar { Type = avatar.Type, Data = avatar.Data }, LastModified = avatar.LastModified }; } } public async Task SetAvatar(string username, Avatar avatar) { if (avatar != null) { if (string.IsNullOrEmpty(avatar.Type)) throw new ArgumentException("Type of avatar is null or empty.", nameof(avatar)); if (avatar.Data == null) throw new ArgumentException("Data of avatar is null.", nameof(avatar)); } var userId = await DatabaseExtensions.CheckAndGetUser(_database.Users, username); var avatarEntity = await _database.UserAvatars.Where(a => a.UserId == userId).SingleAsync(); if (avatar == null) { if (avatarEntity.Data == null) return; else { avatarEntity.Data = null; avatarEntity.Type = null; avatarEntity.ETag = null; avatarEntity.LastModified = DateTime.Now; await _database.SaveChangesAsync(); _logger.LogInformation("Updated an entry in user_avatars."); } } else { await _avatarValidator.Validate(avatar); avatarEntity.Type = avatar.Type; avatarEntity.Data = avatar.Data; avatarEntity.ETag = _eTagGenerator.Generate(avatar.Data); avatarEntity.LastModified = DateTime.Now; await _database.SaveChangesAsync(); _logger.LogInformation("Updated an entry in user_avatars."); } } } public static class UserAvatarServiceCollectionExtensions { public static void AddUserAvatarService(this IServiceCollection services) { services.TryAddTransient(); services.AddScoped(); services.AddSingleton(); services.AddSingleton(); } } }