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);
+ }
}
}