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; using Timeline.Helpers; using Timeline.Models.Validation; 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; } } /// /// 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. /// Thrown if the is of bad format. /// Thrown if the user does not exist. Task GetAvatarETag(string username); /// /// Get avatar of a user. If the user has no avatar set, a default one is returned. /// /// The username of the user to get avatar of. /// The avatar info. /// Thrown if is null. /// Thrown if the is of bad format. /// 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. /// Thrown if any field in is null when is not null. /// Thrown if the is of bad format. /// Thrown if the user does not exist. /// Thrown if avatar is of bad format. Task SetAvatar(string username, 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!; [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1062:Validate arguments of public methods", Justification = "DI.")] 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 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 AvatarFormatException(avatar, AvatarFormatException.ErrorReason.UnmatchedFormat); if (image.Width != image.Height) throw new AvatarFormatException(avatar, AvatarFormatException.ErrorReason.BadSize); } catch (UnknownImageFormatException e) { throw new AvatarFormatException(avatar, AvatarFormatException.ErrorReason.CantDecode, 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; private readonly IClock _clock; public UserAvatarService( ILogger logger, DatabaseContext database, IDefaultUserAvatarProvider defaultUserAvatarProvider, IUserAvatarValidator avatarValidator, IETagGenerator eTagGenerator, IClock clock) { _logger = logger; _database = database; _defaultUserAvatarProvider = defaultUserAvatarProvider; _avatarValidator = avatarValidator; _eTagGenerator = eTagGenerator; _clock = clock; } 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 }).SingleOrDefaultAsync())?.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 avatarEntity = await _database.UserAvatars.Where(a => a.UserId == userId).Select(a => new { a.Type, a.Data, a.LastModified }).SingleOrDefaultAsync(); if (avatarEntity != null) { if (!LanguageHelper.AreSame(avatarEntity.Data == null, avatarEntity.Type == null)) { var message = Resources.Services.UserAvatarService.ExceptionDatabaseCorruptedDataAndTypeNotSame; _logger.LogCritical(message); throw new DatabaseCorruptedException(message); } if (avatarEntity.Data != null) { return new AvatarInfo { Avatar = new Avatar { Type = avatarEntity.Type!, Data = avatarEntity.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(string username, 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 userId = await DatabaseExtensions.CheckAndGetUser(_database.Users, username); var avatarEntity = await _database.UserAvatars.Where(a => a.UserId == userId).SingleOrDefaultAsync(); if (avatar == null) { if (avatarEntity == null || avatarEntity.Data == null) { return; } else { avatarEntity.Data = null; avatarEntity.Type = null; avatarEntity.ETag = null; avatarEntity.LastModified = _clock.GetCurrentTime(); await _database.SaveChangesAsync(); _logger.LogInformation(Resources.Services.UserAvatarService.LogUpdateEntity); } } else { await _avatarValidator.Validate(avatar); var create = avatarEntity == null; if (create) { avatarEntity = new UserAvatar(); } avatarEntity!.Type = avatar.Type; avatarEntity.Data = avatar.Data; avatarEntity.ETag = await _eTagGenerator.Generate(avatar.Data); avatarEntity.LastModified = _clock.GetCurrentTime(); avatarEntity.UserId = userId; if (create) { _database.UserAvatars.Add(avatarEntity); } await _database.SaveChangesAsync(); _logger.LogInformation(create ? Resources.Services.UserAvatarService.LogCreateEntity : Resources.Services.UserAvatarService.LogUpdateEntity); } } } public static class UserAvatarServiceCollectionExtensions { public static void AddUserAvatarService(this IServiceCollection services) { services.TryAddTransient(); services.AddScoped(); services.AddSingleton(); services.AddTransient(); } } }