Add user pp support with gravatar fallback

This commit is contained in:
Zoe Roux 2024-02-03 15:58:11 +01:00
parent c969908ff5
commit b43b6d6f75
4 changed files with 175 additions and 123 deletions

View File

@ -16,6 +16,8 @@
// You should have received a copy of the GNU General Public License
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
using System;
using System.IO;
using System.Threading.Tasks;
using Kyoo.Abstractions.Models;
@ -59,5 +61,19 @@ namespace Kyoo.Abstractions.Controllers
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
Task DeleteImages<T>(T item)
where T : IThumbnails;
/// <summary>
/// Set the user's profile picture
/// </summary>
/// <param name="userId">The id of the user. </param>
/// <returns>The byte stream of the image. Null if no image exist.</returns>
Task<Stream> GetUserImage(Guid userId);
/// <summary>
/// Set the user's profile picture
/// </summary>
/// <param name="userId">The id of the user. </param>
/// <param name="image">The byte stream of the image. Null to delete the image.</param>
Task SetUserImage(Guid userId, Stream? image);
}
}

View File

@ -17,6 +17,7 @@
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
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<User> users,
ITokenController tokenController,
IThumbnailsManager thumbs,
PermissionOption permissions
) : ControllerBase
{
/// <summary>
/// The repository to handle users.
/// </summary>
private readonly IRepository<User> _users;
/// <summary>
/// The token generator.
/// </summary>
private readonly ITokenController _token;
/// <summary>
/// The permisson options.
/// </summary>
private readonly PermissionOption _permissions;
/// <summary>
/// Create a new <see cref="AuthApi"/>.
/// </summary>
/// <param name="users">The repository used to check if the user exists.</param>
/// <param name="token">The token generator.</param>
/// <param name="permissions">The permission opitons.</param>
public AuthApi(
IRepository<User> users,
ITokenController token,
PermissionOption permissions
)
{
_users = users;
_token = token;
_permissions = permissions;
}
/// <summary>
/// Create a new Forbidden result from an object.
/// </summary>
@ -100,15 +74,15 @@ namespace Kyoo.Authentication.Views
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
public async Task<ActionResult<JwtToken>> Login([FromBody] LoginRequest request)
{
User? user = await _users.GetOrDefault(
User? user = await users.GetOrDefault(
new Filter<User>.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<ActionResult<JwtToken>> 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<ActionResult<User>> 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
/// <remarks>
/// Delete the current account.
/// </remarks>
/// <returns>The currently authenticated user after modifications.</returns>
/// <response code="401">The user is not authenticated.</response>
/// <response code="403">The given access token is invalid.</response>
[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"));
}
}
/// <summary>
/// Set profile picture
/// </summary>
/// <remarks>
/// Set your profile picture
/// </remarks>
/// <response code="401">The user is not authenticated.</response>
/// <response code="403">The given access token is invalid.</response>
[HttpPost("me/logo")]
[UserOnly]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
public async Task<ActionResult> SetProfilePicture(IFormFile picture)
{
if (picture == null || picture.Length == 0)
return BadRequest();
await thumbs.SetUserImage(User.GetIdOrThrow(), picture.OpenReadStream());
return NoContent();
}
/// <summary>
/// Get profile picture
/// </summary>
/// <remarks>
/// Get your profile picture
/// </remarks>
/// <response code="401">The user is not authenticated.</response>
/// <response code="403">The given access token is invalid.</response>
[HttpGet("me/logo")]
[UserOnly]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
public async Task<ActionResult> 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);
}
}
}

View File

@ -25,59 +25,53 @@ using Kyoo.Abstractions.Models.Utils;
using Kyoo.Postgresql;
using Microsoft.EntityFrameworkCore;
namespace Kyoo.Core.Controllers
namespace Kyoo.Core.Controllers;
/// <summary>
/// A repository for users.
/// </summary>
/// <remarks>
/// Create a new <see cref="UserRepository"/>
/// </remarks>
/// <param name="database">The database handle to use</param>
/// <param name="thumbs">The thumbnail manager used to store images.</param>
public class UserRepository(DatabaseContext database, IThumbnailsManager thumbs)
: LocalRepository<User>(database, thumbs)
{
/// <summary>
/// A repository for users.
/// The database handle
/// </summary>
public class UserRepository : LocalRepository<User>
private readonly DatabaseContext _database = database;
/// <inheritdoc />
public override async Task<ICollection<User>> Search(
string query,
Include<User>? include = default
)
{
/// <summary>
/// The database handle
/// </summary>
private readonly DatabaseContext _database;
return await AddIncludes(_database.Users, include)
.Where(x => EF.Functions.ILike(x.Username, $"%{query}%"))
.Take(20)
.ToListAsync();
}
/// <summary>
/// Create a new <see cref="UserRepository"/>
/// </summary>
/// <param name="database">The database handle to use</param>
/// <param name="thumbs">The thumbnail manager used to store images.</param>
public UserRepository(DatabaseContext database, IThumbnailsManager thumbs)
: base(database, thumbs)
{
_database = database;
}
/// <inheritdoc />
public override async Task<User> 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<User>.OnResourceCreated(obj);
return obj;
}
/// <inheritdoc />
public override async Task<ICollection<User>> Search(
string query,
Include<User>? include = default
)
{
return await AddIncludes(_database.Users, include)
.Where(x => EF.Functions.ILike(x.Username, $"%{query}%"))
.Take(20)
.ToListAsync();
}
/// <inheritdoc />
public override async Task<User> 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<User>.OnResourceCreated(obj);
return obj;
}
/// <inheritdoc />
public override async Task Delete(User obj)
{
_database.Entry(obj).State = EntityState.Deleted;
await _database.SaveChangesAsync();
await base.Delete(obj);
}
/// <inheritdoc />
public override async Task Delete(User obj)
{
_database.Entry(obj).State = EntityState.Deleted;
await _database.SaveChangesAsync();
await base.Delete(obj);
}
}

View File

@ -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
/// <summary>
/// Download images and retrieve the path of those images for a resource.
/// </summary>
public class ThumbnailsManager : IThumbnailsManager
public class ThumbnailsManager(
IHttpClientFactory clientFactory,
ILogger<ThumbnailsManager> logger,
Lazy<IRepository<User>> users
) : IThumbnailsManager
{
private static readonly Dictionary<string, TaskCompletionSource<object>> _downloading =
new();
private readonly ILogger<ThumbnailsManager> _logger;
private readonly IHttpClientFactory _clientFactory;
/// <summary>
/// Create a new <see cref="ThumbnailsManager"/>.
/// </summary>
/// <param name="clientFactory">Client factory</param>
/// <param name="logger">A logger to report errors</param>
public ThumbnailsManager(
IHttpClientFactory clientFactory,
ILogger<ThumbnailsManager> 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<Stream> 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);
}
}
}