From 36a5a7c81569bbc4fa76b77e9823767d951944b4 Mon Sep 17 00:00:00 2001 From: 杨宇千 Date: Sun, 18 Aug 2019 18:07:50 +0800 Subject: Add avatar service. --- Timeline/Services/UserAvatarService.cs | 181 +++++++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 Timeline/Services/UserAvatarService.cs (limited to 'Timeline/Services/UserAvatarService.cs') diff --git a/Timeline/Services/UserAvatarService.cs b/Timeline/Services/UserAvatarService.cs new file mode 100644 index 00000000..21153575 --- /dev/null +++ b/Timeline/Services/UserAvatarService.cs @@ -0,0 +1,181 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +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; } + } + + /// + /// Thrown when avatar is of bad format. + /// + [Serializable] + public class AvatarDataException : Exception + { + public AvatarDataException(Avatar avatar, string message) : base(message) { Avatar = avatar; } + public AvatarDataException(Avatar avatar, string message, Exception inner) : base(message, inner) { Avatar = avatar; } + protected AvatarDataException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + + public Avatar Avatar { get; set; } + } + + /// + /// Provider for default user avatar. + /// + /// + /// Mainly for unit tests. + /// + public interface IDefaultUserAvatarProvider + { + /// + /// Get the default avatar. + /// + Task GetDefaultAvatar(); + } + + public interface IUserAvatarService + { + /// + /// 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. + /// 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 IHostingEnvironment _environment; + + public DefaultUserAvatarProvider(IHostingEnvironment environment) + { + _environment = environment; + } + + public async Task GetDefaultAvatar() + { + return new Avatar + { + Type = "image/png", + Data = await File.ReadAllBytesAsync(Path.Combine(_environment.ContentRootPath, "default-avatar.png")) + }; + } + } + + public class UserAvatarService : IUserAvatarService + { + + private readonly ILogger _logger; + + private readonly DatabaseContext _database; + + private readonly IDefaultUserAvatarProvider _defaultUserAvatarProvider; + + public UserAvatarService(ILogger logger, DatabaseContext database, IDefaultUserAvatarProvider defaultUserAvatarProvider) + { + _logger = logger; + _database = database; + _defaultUserAvatarProvider = defaultUserAvatarProvider; + } + + public async Task GetAvatar(string username) + { + if (string.IsNullOrEmpty(username)) + throw new ArgumentException("Username is null or empty.", nameof(username)); + + var user = await _database.Users.Where(u => u.Name == username).SingleOrDefaultAsync(); + if (user == null) + throw new UserNotExistException(username); + + await _database.Entry(user).Reference(u => u.Avatar).LoadAsync(); + var avatar = user.Avatar; + + if (avatar == null) + { + return await _defaultUserAvatarProvider.GetDefaultAvatar(); + } + else + { + return new Avatar + { + Type = avatar.Type, + Data = avatar.Data + }; + } + } + + public async Task SetAvatar(string username, Avatar avatar) + { + if (string.IsNullOrEmpty(username)) + throw new ArgumentException("Username is null or empty.", nameof(username)); + + 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 user = await _database.Users.Where(u => u.Name == username).SingleOrDefaultAsync(); + if (user == null) + throw new UserNotExistException(username); + + await _database.Entry(user).Reference(u => u.Avatar).LoadAsync(); + var avatarEntity = user.Avatar; + + if (avatar == null) + { + if (avatarEntity == null) + return; + else + { + _database.UserAvatars.Remove(avatarEntity); + await _database.SaveChangesAsync(); + } + } + else + { + // TODO: Use image library to check the format to prohibit bad data. + if (avatarEntity == null) + { + user.Avatar = new UserAvatar + { + Type = avatar.Type, + Data = avatar.Data + }; + } + else + { + avatarEntity.Type = avatar.Type; + avatarEntity.Data = avatar.Data; + } + await _database.SaveChangesAsync(); + } + } + } +} -- cgit v1.2.3 From 47b26e8b2884a2e4c23dfeffa0ff8b0620aaaaa1 Mon Sep 17 00:00:00 2001 From: 杨宇千 Date: Sun, 18 Aug 2019 19:15:44 +0800 Subject: Add avatar controller. --- Timeline/Controllers/UserAvatarController.cs | 70 ++++++++++++++++++++++++++++ Timeline/Services/UserAvatarService.cs | 10 ++++ Timeline/Startup.cs | 2 + 3 files changed, 82 insertions(+) create mode 100644 Timeline/Controllers/UserAvatarController.cs (limited to 'Timeline/Services/UserAvatarService.cs') diff --git a/Timeline/Controllers/UserAvatarController.cs b/Timeline/Controllers/UserAvatarController.cs new file mode 100644 index 00000000..f61fd54a --- /dev/null +++ b/Timeline/Controllers/UserAvatarController.cs @@ -0,0 +1,70 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using System; +using System.Threading.Tasks; +using Timeline.Models.Http; +using Timeline.Services; + +namespace Timeline.Controllers +{ + [ApiController] + public class UserAvatarController : Controller + { + public static class ErrorCodes + { + public const int Get_UserNotExist = -1001; + + public const int Put_UserNotExist = -2001; + } + + private readonly ILogger _logger; + + private readonly IUserAvatarService _service; + + public UserAvatarController(ILogger logger, IUserAvatarService service) + { + _logger = logger; + _service = service; + } + + [HttpGet("users/{username}/avatar")] + public async Task Get(string username) + { + try + { + var avatar = await _service.GetAvatar(username); + return File(avatar.Data, avatar.Type); + } + catch (UserNotExistException) + { + _logger.LogInformation($"Attempt to get a avatar of a non-existent user failed. Username: {username} ."); + return NotFound(new CommonResponse(ErrorCodes.Get_UserNotExist, "User does not exist.")); + } + } + + [HttpPut("users/{username}/avatar")] + [Consumes("image/png", "image/jpeg", "image/gif", "image/webp")] + public async Task Put(string username) + { + try + { + var data = new byte[Convert.ToInt32(Request.ContentLength)]; + await Request.Body.ReadAsync(data, 0, data.Length); + + await _service.SetAvatar(username, new Avatar + { + Data = data, + Type = Request.ContentType + }); + + _logger.LogInformation($"Succeed to put a avatar of a user. Username: {username} . Mime Type: {Request.ContentType} ."); + return Ok(); + } + catch (UserNotExistException) + { + _logger.LogInformation($"Attempt to put a avatar of a non-existent user failed. Username: {username} ."); + return BadRequest(new CommonResponse(ErrorCodes.Put_UserNotExist, "User does not exist.")); + } + } + } +} diff --git a/Timeline/Services/UserAvatarService.cs b/Timeline/Services/UserAvatarService.cs index 21153575..4f11978c 100644 --- a/Timeline/Services/UserAvatarService.cs +++ b/Timeline/Services/UserAvatarService.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using System; using System.IO; @@ -178,4 +179,13 @@ namespace Timeline.Services } } } + + public static class UserAvatarServiceCollectionExtensions + { + public static void AddUserAvatarService(this IServiceCollection services) + { + services.AddScoped(); + services.AddSingleton(); + } + } } diff --git a/Timeline/Startup.cs b/Timeline/Startup.cs index 414bc705..afc06d9b 100644 --- a/Timeline/Startup.cs +++ b/Timeline/Startup.cs @@ -58,6 +58,8 @@ namespace Timeline services.AddTransient(); services.AddTransient(); + services.AddUserAvatarService(); + var databaseConfig = Configuration.GetSection(nameof(DatabaseConfig)).Get(); services.AddDbContext(options => -- cgit v1.2.3 From ee113b27d276524f29560f00e944851bf5bdf6a8 Mon Sep 17 00:00:00 2001 From: 杨宇千 Date: Mon, 19 Aug 2019 00:01:09 +0800 Subject: Add validator. --- Timeline.Tests/UserAvatarServiceTest.cs | 14 ++++++++--- Timeline/Services/UserAvatarService.cs | 44 +++++++++++++++++++++++++++++++-- Timeline/Timeline.csproj | 1 + nuget.config | 7 +++--- 4 files changed, 58 insertions(+), 8 deletions(-) (limited to 'Timeline/Services/UserAvatarService.cs') diff --git a/Timeline.Tests/UserAvatarServiceTest.cs b/Timeline.Tests/UserAvatarServiceTest.cs index a8e0562b..d767958a 100644 --- a/Timeline.Tests/UserAvatarServiceTest.cs +++ b/Timeline.Tests/UserAvatarServiceTest.cs @@ -24,7 +24,15 @@ namespace Timeline.Tests } } - public class UserAvatarServiceTest : IDisposable, IClassFixture + public class MockUserAvatarValidator : IUserAvatarValidator + { + public Task<(bool, string)> Validate(Avatar avatar) + { + return Task.FromResult((true, "Validate succeed.")); + } + } + + public class UserAvatarServiceTest : IDisposable, IClassFixture, IClassFixture { private static Avatar MockAvatar { get; } = new Avatar { @@ -45,14 +53,14 @@ namespace Timeline.Tests private readonly UserAvatarService _service; - public UserAvatarServiceTest(ITestOutputHelper outputHelper, MockDefaultUserAvatarProvider mockDefaultUserAvatarProvider) + public UserAvatarServiceTest(ITestOutputHelper outputHelper, MockDefaultUserAvatarProvider mockDefaultUserAvatarProvider, MockUserAvatarValidator mockUserAvatarValidator) { _mockDefaultUserAvatarProvider = mockDefaultUserAvatarProvider; _loggerFactory = MyTestLoggerFactory.Create(outputHelper); _database = new TestDatabase(); - _service = new UserAvatarService(_loggerFactory.CreateLogger(), _database.DatabaseContext, _mockDefaultUserAvatarProvider); + _service = new UserAvatarService(_loggerFactory.CreateLogger(), _database.DatabaseContext, _mockDefaultUserAvatarProvider, mockUserAvatarValidator); } public void Dispose() diff --git a/Timeline/Services/UserAvatarService.cs b/Timeline/Services/UserAvatarService.cs index 4f11978c..2a73cde5 100644 --- a/Timeline/Services/UserAvatarService.cs +++ b/Timeline/Services/UserAvatarService.cs @@ -2,6 +2,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats; using System; using System.IO; using System.Linq; @@ -45,6 +47,11 @@ namespace Timeline.Services Task GetDefaultAvatar(); } + public interface IUserAvatarValidator + { + Task<(bool valid, string message)> Validate(Avatar avatar); + } + public interface IUserAvatarService { /// @@ -87,6 +94,31 @@ namespace Timeline.Services } } + public class UserAvatarValidator : IUserAvatarValidator + { + public Task<(bool valid, string message)> Validate(Avatar avatar) + { + return Task.Run(() => + { + try + { + using (var image = Image.Load(avatar.Data, out IImageFormat format)) + { + if (!format.MimeTypes.Contains(avatar.Type)) + return (false, "Image's actual mime type is not the specified one."); + if (image.Width != image.Height) + return (false, "Image is not a square, aka width is not equal to height."); + } + return (true, "A good avatar."); + } + catch (UnknownImageFormatException e) + { + return (false, $"Failed to decode image. Exception: {e} ."); + } + }); + } + } + public class UserAvatarService : IUserAvatarService { @@ -95,12 +127,14 @@ namespace Timeline.Services private readonly DatabaseContext _database; private readonly IDefaultUserAvatarProvider _defaultUserAvatarProvider; + private readonly IUserAvatarValidator _avatarValidator; - public UserAvatarService(ILogger logger, DatabaseContext database, IDefaultUserAvatarProvider defaultUserAvatarProvider) + public UserAvatarService(ILogger logger, DatabaseContext database, IDefaultUserAvatarProvider defaultUserAvatarProvider, IUserAvatarValidator avatarValidator) { _logger = logger; _database = database; _defaultUserAvatarProvider = defaultUserAvatarProvider; + _avatarValidator = avatarValidator; } public async Task GetAvatar(string username) @@ -157,11 +191,15 @@ namespace Timeline.Services { _database.UserAvatars.Remove(avatarEntity); await _database.SaveChangesAsync(); + _logger.LogInformation("Removed an entry in user_avatars."); } } else { - // TODO: Use image library to check the format to prohibit bad data. + (bool valid, string message) = await _avatarValidator.Validate(avatar); + if (!valid) + throw new AvatarDataException(avatar, $"Failed to validate image. {message}"); + if (avatarEntity == null) { user.Avatar = new UserAvatar @@ -176,6 +214,7 @@ namespace Timeline.Services avatarEntity.Data = avatar.Data; } await _database.SaveChangesAsync(); + _logger.LogInformation("Added or modified an entry in user_avatars."); } } } @@ -186,6 +225,7 @@ namespace Timeline.Services { services.AddScoped(); services.AddSingleton(); + services.AddSingleton(); } } } diff --git a/Timeline/Timeline.csproj b/Timeline/Timeline.csproj index 29ff3354..3855e0d1 100644 --- a/Timeline/Timeline.csproj +++ b/Timeline/Timeline.csproj @@ -12,5 +12,6 @@ + diff --git a/nuget.config b/nuget.config index 22519a86..e1fc7cfe 100644 --- a/nuget.config +++ b/nuget.config @@ -1,8 +1,9 @@ - + - - + + + -- cgit v1.2.3 From 4bbaaf45c64da897c4832aceeeec2549cfbd901a Mon Sep 17 00:00:00 2001 From: 杨宇千 Date: Mon, 19 Aug 2019 00:21:38 +0800 Subject: Improve avatar validation. --- Timeline/Controllers/UserAvatarController.cs | 36 ++++++++++++++++++++---- Timeline/Services/UserAvatarService.cs | 41 ++++++++++++++++++++-------- 2 files changed, 60 insertions(+), 17 deletions(-) (limited to 'Timeline/Services/UserAvatarService.cs') diff --git a/Timeline/Controllers/UserAvatarController.cs b/Timeline/Controllers/UserAvatarController.cs index 6dc767df..710ca764 100644 --- a/Timeline/Controllers/UserAvatarController.cs +++ b/Timeline/Controllers/UserAvatarController.cs @@ -19,9 +19,28 @@ namespace Timeline.Controllers public const int Put_UserNotExist = -2001; public const int Put_Forbid = -2002; + public const int Put_BadFormat_CantDecode = -2011; + public const int Put_BadFormat_UnmatchedFormat = -2012; + public const int Put_BadFormat_BadSize = -2013; public const int Delete_UserNotExist = -3001; public const int Delete_Forbid = -3002; + + + public static int From(AvatarDataException.ErrorReason error) + { + switch (error) + { + case AvatarDataException.ErrorReason.CantDecode: + return Put_BadFormat_CantDecode; + case AvatarDataException.ErrorReason.UnmatchedFormat: + return Put_BadFormat_UnmatchedFormat; + case AvatarDataException.ErrorReason.BadSize: + return Put_BadFormat_BadSize; + default: + throw new Exception("Unknown AvatarDataException.ErrorReason value."); + } + } } private readonly ILogger _logger; @@ -43,9 +62,9 @@ namespace Timeline.Controllers var avatar = await _service.GetAvatar(username); return File(avatar.Data, avatar.Type); } - catch (UserNotExistException) + catch (UserNotExistException e) { - _logger.LogInformation($"Attempt to get a avatar of a non-existent user failed. Username: {username} ."); + _logger.LogInformation(e, $"Attempt to get a avatar of a non-existent user failed. Username: {username} ."); return NotFound(new CommonResponse(ErrorCodes.Get_UserNotExist, "User does not exist.")); } } @@ -76,11 +95,16 @@ namespace Timeline.Controllers _logger.LogInformation($"Succeed to put a avatar of a user. Username: {username} ; Mime Type: {Request.ContentType} ."); return Ok(); } - catch (UserNotExistException) + catch (UserNotExistException e) { - _logger.LogInformation($"Attempt to put a avatar of a non-existent user failed. Username: {username} ."); + _logger.LogInformation(e, $"Attempt to put a avatar of a non-existent user failed. Username: {username} ."); return BadRequest(new CommonResponse(ErrorCodes.Put_UserNotExist, "User does not exist.")); } + catch (AvatarDataException e) + { + _logger.LogInformation(e, $"Attempt to put a avatar of a bad format failed. Username: {username} ."); + return BadRequest(new CommonResponse(ErrorCodes.From(e.Error), "Bad format.")); + } } [HttpDelete("users/{username}/avatar")] @@ -101,9 +125,9 @@ namespace Timeline.Controllers _logger.LogInformation($"Succeed to delete a avatar of a user. Username: {username} ."); return Ok(); } - catch (UserNotExistException) + catch (UserNotExistException e) { - _logger.LogInformation($"Attempt to delete a avatar of a non-existent user failed. Username: {username} ."); + _logger.LogInformation(e, $"Attempt to delete a avatar of a non-existent user failed. Username: {username} ."); return BadRequest(new CommonResponse(ErrorCodes.Delete_UserNotExist, "User does not exist.")); } } diff --git a/Timeline/Services/UserAvatarService.cs b/Timeline/Services/UserAvatarService.cs index 2a73cde5..dd0e5e7c 100644 --- a/Timeline/Services/UserAvatarService.cs +++ b/Timeline/Services/UserAvatarService.cs @@ -24,12 +24,29 @@ namespace Timeline.Services [Serializable] public class AvatarDataException : Exception { - public AvatarDataException(Avatar avatar, string message) : base(message) { Avatar = avatar; } - public AvatarDataException(Avatar avatar, string message, Exception inner) : base(message, inner) { Avatar = avatar; } + 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; } } @@ -49,7 +66,12 @@ namespace Timeline.Services public interface IUserAvatarValidator { - Task<(bool valid, string message)> Validate(Avatar avatar); + /// + /// Validate a avatar's format and size info. + /// + /// The avatar to validate. + /// Thrown when validation failed. + Task Validate(Avatar avatar); } public interface IUserAvatarService @@ -96,7 +118,7 @@ namespace Timeline.Services public class UserAvatarValidator : IUserAvatarValidator { - public Task<(bool valid, string message)> Validate(Avatar avatar) + public Task Validate(Avatar avatar) { return Task.Run(() => { @@ -105,15 +127,14 @@ namespace Timeline.Services using (var image = Image.Load(avatar.Data, out IImageFormat format)) { if (!format.MimeTypes.Contains(avatar.Type)) - return (false, "Image's actual mime type is not the specified one."); + throw new AvatarDataException(avatar, AvatarDataException.ErrorReason.UnmatchedFormat, "Image's actual mime type is not the specified one."); if (image.Width != image.Height) - return (false, "Image is not a square, aka width is not equal to height."); + throw new AvatarDataException(avatar, AvatarDataException.ErrorReason.BadSize, "Image is not a square, aka, width is not equal to height."); } - return (true, "A good avatar."); } catch (UnknownImageFormatException e) { - return (false, $"Failed to decode image. Exception: {e} ."); + throw new AvatarDataException(avatar, AvatarDataException.ErrorReason.CantDecode, "Failed to decode image. See inner exception.", e); } }); } @@ -196,9 +217,7 @@ namespace Timeline.Services } else { - (bool valid, string message) = await _avatarValidator.Validate(avatar); - if (!valid) - throw new AvatarDataException(avatar, $"Failed to validate image. {message}"); + await _avatarValidator.Validate(avatar); if (avatarEntity == null) { -- cgit v1.2.3