diff --git a/back/src/Kyoo.Abstractions/Controllers/IThumbnailsManager.cs b/back/src/Kyoo.Abstractions/Controllers/IThumbnailsManager.cs index d3cc15b5..1f7fc03c 100644 --- a/back/src/Kyoo.Abstractions/Controllers/IThumbnailsManager.cs +++ b/back/src/Kyoo.Abstractions/Controllers/IThumbnailsManager.cs @@ -16,6 +16,8 @@ // You should have received a copy of the GNU General Public License // along with Kyoo. If not, see . +using System; +using System.IO; using System.Threading.Tasks; using Kyoo.Abstractions.Models; @@ -59,5 +61,19 @@ namespace Kyoo.Abstractions.Controllers /// A representing the asynchronous operation. Task DeleteImages(T item) where T : IThumbnails; + + /// + /// Set the user's profile picture + /// + /// The id of the user. + /// The byte stream of the image. Null if no image exist. + Task GetUserImage(Guid userId); + + /// + /// Set the user's profile picture + /// + /// The id of the user. + /// The byte stream of the image. Null to delete the image. + Task SetUserImage(Guid userId, Stream? image); } } diff --git a/back/src/Kyoo.Authentication/Views/AuthApi.cs b/back/src/Kyoo.Authentication/Views/AuthApi.cs index a0c76663..2464f810 100644 --- a/back/src/Kyoo.Authentication/Views/AuthApi.cs +++ b/back/src/Kyoo.Authentication/Views/AuthApi.cs @@ -17,6 +17,7 @@ // along with Kyoo. If not, see . using System; +using System.IO; using System.Linq; using System.Threading.Tasks; using Kyoo.Abstractions.Controllers; @@ -42,40 +43,13 @@ namespace Kyoo.Authentication.Views [ApiController] [Route("auth")] [ApiDefinition("Authentication", Group = UsersGroup)] - public class AuthApi : ControllerBase + public class AuthApi( + IRepository users, + ITokenController tokenController, + IThumbnailsManager thumbs, + PermissionOption permissions + ) : ControllerBase { - /// - /// The repository to handle users. - /// - private readonly IRepository _users; - - /// - /// The token generator. - /// - private readonly ITokenController _token; - - /// - /// The permisson options. - /// - private readonly PermissionOption _permissions; - - /// - /// Create a new . - /// - /// The repository used to check if the user exists. - /// The token generator. - /// The permission opitons. - public AuthApi( - IRepository users, - ITokenController token, - PermissionOption permissions - ) - { - _users = users; - _token = token; - _permissions = permissions; - } - /// /// Create a new Forbidden result from an object. /// @@ -100,15 +74,15 @@ namespace Kyoo.Authentication.Views [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] public async Task> Login([FromBody] LoginRequest request) { - User? user = await _users.GetOrDefault( + User? user = await users.GetOrDefault( new Filter.Eq(nameof(Abstractions.Models.User.Username), request.Username) ); if (user == null || !BCryptNet.Verify(request.Password, user.Password)) return Forbid(new RequestError("The user and password does not match.")); return new JwtToken( - _token.CreateAccessToken(user, out TimeSpan expireIn), - await _token.CreateRefreshToken(user), + tokenController.CreateAccessToken(user, out TimeSpan expireIn), + await tokenController.CreateRefreshToken(user), expireIn ); } @@ -130,13 +104,13 @@ namespace Kyoo.Authentication.Views public async Task> Register([FromBody] RegisterRequest request) { User user = request.ToUser(); - user.Permissions = _permissions.NewUser; + user.Permissions = permissions.NewUser; // If no users exists, the new one will be an admin. Give it every permissions. - if ((await _users.GetAll(limit: new Pagination(1))).Any()) + if ((await users.GetAll(limit: new Pagination(1))).Any()) user.Permissions = PermissionOption.Admin; try { - await _users.Create(user); + await users.Create(user); } catch (DuplicatedItemException) { @@ -144,8 +118,8 @@ namespace Kyoo.Authentication.Views } return new JwtToken( - _token.CreateAccessToken(user, out TimeSpan expireIn), - await _token.CreateRefreshToken(user), + tokenController.CreateAccessToken(user, out TimeSpan expireIn), + await tokenController.CreateRefreshToken(user), expireIn ); } @@ -167,11 +141,11 @@ namespace Kyoo.Authentication.Views { try { - Guid userId = _token.GetRefreshTokenUserID(token); - User user = await _users.Get(userId); + Guid userId = tokenController.GetRefreshTokenUserID(token); + User user = await users.Get(userId); return new JwtToken( - _token.CreateAccessToken(user, out TimeSpan expireIn), - await _token.CreateRefreshToken(user), + tokenController.CreateAccessToken(user, out TimeSpan expireIn), + await tokenController.CreateRefreshToken(user), expireIn ); } @@ -200,10 +174,10 @@ namespace Kyoo.Authentication.Views [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] public async Task> ResetPassword([FromBody] PasswordResetRequest request) { - User user = await _users.Get(User.GetIdOrThrow()); + User user = await users.Get(User.GetIdOrThrow()); if (!BCryptNet.Verify(request.OldPassword, user.Password)) return Forbid(new RequestError("The old password is invalid.")); - return await _users.Patch( + return await users.Patch( user.Id, (user) => { @@ -232,7 +206,7 @@ namespace Kyoo.Authentication.Views { try { - return await _users.Get(User.GetIdOrThrow()); + return await users.Get(User.GetIdOrThrow()); } catch (ItemNotFoundException) { @@ -260,7 +234,7 @@ namespace Kyoo.Authentication.Views try { user.Id = User.GetIdOrThrow(); - return await _users.Edit(user); + return await users.Edit(user); } catch (ItemNotFoundException) { @@ -294,7 +268,7 @@ namespace Kyoo.Authentication.Views throw new ArgumentException( "Can't edit your password via a PATCH. Use /auth/password-reset" ); - return await _users.Patch(userId, patch.Apply); + return await users.Patch(userId, patch.Apply); } catch (ItemNotFoundException) { @@ -308,7 +282,6 @@ namespace Kyoo.Authentication.Views /// /// Delete the current account. /// - /// The currently authenticated user after modifications. /// The user is not authenticated. /// The given access token is invalid. [HttpDelete("me")] @@ -320,7 +293,7 @@ namespace Kyoo.Authentication.Views { try { - await _users.Delete(User.GetIdOrThrow()); + await users.Delete(User.GetIdOrThrow()); return NoContent(); } catch (ItemNotFoundException) @@ -328,5 +301,47 @@ namespace Kyoo.Authentication.Views return Forbid(new RequestError("Invalid token")); } } + + /// + /// Set profile picture + /// + /// + /// Set your profile picture + /// + /// The user is not authenticated. + /// The given access token is invalid. + [HttpPost("me/logo")] + [UserOnly] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] + public async Task SetProfilePicture(IFormFile picture) + { + if (picture == null || picture.Length == 0) + return BadRequest(); + await thumbs.SetUserImage(User.GetIdOrThrow(), picture.OpenReadStream()); + return NoContent(); + } + + /// + /// Get profile picture + /// + /// + /// Get your profile picture + /// + /// The user is not authenticated. + /// The given access token is invalid. + [HttpGet("me/logo")] + [UserOnly] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] + public async Task GetProfilePicture() + { + Stream img = await thumbs.GetUserImage(User.GetIdOrThrow()); + // Allow clients to cache the image for 6 month. + Response.Headers.Add("Cache-Control", $"public, max-age={60 * 60 * 24 * 31 * 6}"); + return File(img, "image/webp", true); + } } } diff --git a/back/src/Kyoo.Core/Controllers/Repositories/UserRepository.cs b/back/src/Kyoo.Core/Controllers/Repositories/UserRepository.cs index dc40ed92..571cbab3 100644 --- a/back/src/Kyoo.Core/Controllers/Repositories/UserRepository.cs +++ b/back/src/Kyoo.Core/Controllers/Repositories/UserRepository.cs @@ -25,59 +25,53 @@ using Kyoo.Abstractions.Models.Utils; using Kyoo.Postgresql; using Microsoft.EntityFrameworkCore; -namespace Kyoo.Core.Controllers +namespace Kyoo.Core.Controllers; + +/// +/// A repository for users. +/// +/// +/// Create a new +/// +/// The database handle to use +/// The thumbnail manager used to store images. +public class UserRepository(DatabaseContext database, IThumbnailsManager thumbs) + : LocalRepository(database, thumbs) { /// - /// A repository for users. + /// The database handle /// - public class UserRepository : LocalRepository + private readonly DatabaseContext _database = database; + + /// + public override async Task> Search( + string query, + Include? include = default + ) { - /// - /// The database handle - /// - private readonly DatabaseContext _database; + return await AddIncludes(_database.Users, include) + .Where(x => EF.Functions.ILike(x.Username, $"%{query}%")) + .Take(20) + .ToListAsync(); + } - /// - /// Create a new - /// - /// The database handle to use - /// The thumbnail manager used to store images. - public UserRepository(DatabaseContext database, IThumbnailsManager thumbs) - : base(database, thumbs) - { - _database = database; - } + /// + public override async Task Create(User obj) + { + await base.Create(obj); + _database.Entry(obj).State = EntityState.Added; + if (obj.Logo != null) + _database.Entry(obj).Reference(x => x.Logo).TargetEntry!.State = EntityState.Added; + await _database.SaveChangesAsync(() => Get(obj.Slug)); + await IRepository.OnResourceCreated(obj); + return obj; + } - /// - public override async Task> Search( - string query, - Include? include = default - ) - { - return await AddIncludes(_database.Users, include) - .Where(x => EF.Functions.ILike(x.Username, $"%{query}%")) - .Take(20) - .ToListAsync(); - } - - /// - public override async Task Create(User obj) - { - await base.Create(obj); - _database.Entry(obj).State = EntityState.Added; - if (obj.Logo != null) - _database.Entry(obj).Reference(x => x.Logo).TargetEntry!.State = EntityState.Added; - await _database.SaveChangesAsync(() => Get(obj.Slug)); - await IRepository.OnResourceCreated(obj); - return obj; - } - - /// - public override async Task Delete(User obj) - { - _database.Entry(obj).State = EntityState.Deleted; - await _database.SaveChangesAsync(); - await base.Delete(obj); - } + /// + public override async Task Delete(User obj) + { + _database.Entry(obj).State = EntityState.Deleted; + await _database.SaveChangesAsync(); + await base.Delete(obj); } } diff --git a/back/src/Kyoo.Core/Controllers/ThumbnailsManager.cs b/back/src/Kyoo.Core/Controllers/ThumbnailsManager.cs index 7355cb39..fe0bda25 100644 --- a/back/src/Kyoo.Core/Controllers/ThumbnailsManager.cs +++ b/back/src/Kyoo.Core/Controllers/ThumbnailsManager.cs @@ -21,6 +21,8 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Http; +using System.Security.Cryptography; +using System.Text; using System.Threading.Tasks; using Blurhash.SkiaSharp; using Kyoo.Abstractions.Controllers; @@ -34,29 +36,15 @@ namespace Kyoo.Core.Controllers /// /// Download images and retrieve the path of those images for a resource. /// - public class ThumbnailsManager : IThumbnailsManager + public class ThumbnailsManager( + IHttpClientFactory clientFactory, + ILogger logger, + Lazy> users + ) : IThumbnailsManager { private static readonly Dictionary> _downloading = new(); - private readonly ILogger _logger; - - private readonly IHttpClientFactory _clientFactory; - - /// - /// Create a new . - /// - /// Client factory - /// A logger to report errors - public ThumbnailsManager( - IHttpClientFactory clientFactory, - ILogger logger - ) - { - _clientFactory = clientFactory; - _logger = logger; - } - private static async Task _WriteTo(SKBitmap bitmap, string path, int quality) { SKData data = bitmap.Encode(SKEncodedImageFormat.Webp, quality); @@ -71,16 +59,16 @@ namespace Kyoo.Core.Controllers return; try { - _logger.LogInformation("Downloading image {What}", what); + logger.LogInformation("Downloading image {What}", what); - HttpClient client = _clientFactory.CreateClient(); + HttpClient client = clientFactory.CreateClient(); HttpResponseMessage response = await client.GetAsync(image.Source); response.EnsureSuccessStatusCode(); await using Stream reader = await response.Content.ReadAsStreamAsync(); using SKCodec codec = SKCodec.Create(reader); if (codec == null) { - _logger.LogError("Unsupported codec for {What}", what); + logger.LogError("Unsupported codec for {What}", what); return; } @@ -122,7 +110,7 @@ namespace Kyoo.Core.Controllers } catch (Exception ex) { - _logger.LogError(ex, "{What} could not be downloaded", what); + logger.LogError(ex, "{What} could not be downloaded", what); } } @@ -225,5 +213,44 @@ namespace Kyoo.Core.Controllers File.Delete(image); return Task.CompletedTask; } + + public async Task GetUserImage(Guid userId) + { + try + { + await using FileStream img = File.Open( + $"/metadata/user/${userId}.webp", + FileMode.Open + ); + return img; + } + catch (FileNotFoundException) { } + + User user = await users.Value.Get(userId); + if (user.Email == null) throw new ItemNotFoundException(); + using MD5 md5 = MD5.Create(); + string hash = Convert.ToHexString(md5.ComputeHash(Encoding.ASCII.GetBytes(user.Email))); + try + { + HttpClient client = clientFactory.CreateClient(); + HttpResponseMessage response = await client.GetAsync($"https://www.gravatar.com/avatar/{hash}.jpg?d=404&s=250"); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStreamAsync(); + } + catch + { + throw new ItemNotFoundException(); + } + } + + public async Task SetUserImage(Guid userId, Stream? image) + { + using SKCodec codec = SKCodec.Create(image); + SKImageInfo info = codec.Info; + info.ColorType = SKColorType.Rgba8888; + using SKBitmap original = SKBitmap.Decode(codec, info); + using SKBitmap ret = original.Resize(new SKSizeI(250, 250), SKFilterQuality.High); + await _WriteTo(ret, $"/metadata/user/${userId}.webp", 75); + } } }