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 // You should have received a copy of the GNU General Public License
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>. // along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
using System;
using System.IO;
using System.Threading.Tasks; using System.Threading.Tasks;
using Kyoo.Abstractions.Models; using Kyoo.Abstractions.Models;
@ -59,5 +61,19 @@ namespace Kyoo.Abstractions.Controllers
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
Task DeleteImages<T>(T item) Task DeleteImages<T>(T item)
where T : IThumbnails; 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/>. // along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
using System; using System;
using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Kyoo.Abstractions.Controllers; using Kyoo.Abstractions.Controllers;
@ -42,40 +43,13 @@ namespace Kyoo.Authentication.Views
[ApiController] [ApiController]
[Route("auth")] [Route("auth")]
[ApiDefinition("Authentication", Group = UsersGroup)] [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> /// <summary>
/// Create a new Forbidden result from an object. /// Create a new Forbidden result from an object.
/// </summary> /// </summary>
@ -100,15 +74,15 @@ namespace Kyoo.Authentication.Views
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
public async Task<ActionResult<JwtToken>> Login([FromBody] LoginRequest request) 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) new Filter<User>.Eq(nameof(Abstractions.Models.User.Username), request.Username)
); );
if (user == null || !BCryptNet.Verify(request.Password, user.Password)) if (user == null || !BCryptNet.Verify(request.Password, user.Password))
return Forbid(new RequestError("The user and password does not match.")); return Forbid(new RequestError("The user and password does not match."));
return new JwtToken( return new JwtToken(
_token.CreateAccessToken(user, out TimeSpan expireIn), tokenController.CreateAccessToken(user, out TimeSpan expireIn),
await _token.CreateRefreshToken(user), await tokenController.CreateRefreshToken(user),
expireIn expireIn
); );
} }
@ -130,13 +104,13 @@ namespace Kyoo.Authentication.Views
public async Task<ActionResult<JwtToken>> Register([FromBody] RegisterRequest request) public async Task<ActionResult<JwtToken>> Register([FromBody] RegisterRequest request)
{ {
User user = request.ToUser(); 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 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; user.Permissions = PermissionOption.Admin;
try try
{ {
await _users.Create(user); await users.Create(user);
} }
catch (DuplicatedItemException) catch (DuplicatedItemException)
{ {
@ -144,8 +118,8 @@ namespace Kyoo.Authentication.Views
} }
return new JwtToken( return new JwtToken(
_token.CreateAccessToken(user, out TimeSpan expireIn), tokenController.CreateAccessToken(user, out TimeSpan expireIn),
await _token.CreateRefreshToken(user), await tokenController.CreateRefreshToken(user),
expireIn expireIn
); );
} }
@ -167,11 +141,11 @@ namespace Kyoo.Authentication.Views
{ {
try try
{ {
Guid userId = _token.GetRefreshTokenUserID(token); Guid userId = tokenController.GetRefreshTokenUserID(token);
User user = await _users.Get(userId); User user = await users.Get(userId);
return new JwtToken( return new JwtToken(
_token.CreateAccessToken(user, out TimeSpan expireIn), tokenController.CreateAccessToken(user, out TimeSpan expireIn),
await _token.CreateRefreshToken(user), await tokenController.CreateRefreshToken(user),
expireIn expireIn
); );
} }
@ -200,10 +174,10 @@ namespace Kyoo.Authentication.Views
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
public async Task<ActionResult<User>> ResetPassword([FromBody] PasswordResetRequest request) 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)) if (!BCryptNet.Verify(request.OldPassword, user.Password))
return Forbid(new RequestError("The old password is invalid.")); return Forbid(new RequestError("The old password is invalid."));
return await _users.Patch( return await users.Patch(
user.Id, user.Id,
(user) => (user) =>
{ {
@ -232,7 +206,7 @@ namespace Kyoo.Authentication.Views
{ {
try try
{ {
return await _users.Get(User.GetIdOrThrow()); return await users.Get(User.GetIdOrThrow());
} }
catch (ItemNotFoundException) catch (ItemNotFoundException)
{ {
@ -260,7 +234,7 @@ namespace Kyoo.Authentication.Views
try try
{ {
user.Id = User.GetIdOrThrow(); user.Id = User.GetIdOrThrow();
return await _users.Edit(user); return await users.Edit(user);
} }
catch (ItemNotFoundException) catch (ItemNotFoundException)
{ {
@ -294,7 +268,7 @@ namespace Kyoo.Authentication.Views
throw new ArgumentException( throw new ArgumentException(
"Can't edit your password via a PATCH. Use /auth/password-reset" "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) catch (ItemNotFoundException)
{ {
@ -308,7 +282,6 @@ namespace Kyoo.Authentication.Views
/// <remarks> /// <remarks>
/// Delete the current account. /// Delete the current account.
/// </remarks> /// </remarks>
/// <returns>The currently authenticated user after modifications.</returns>
/// <response code="401">The user is not authenticated.</response> /// <response code="401">The user is not authenticated.</response>
/// <response code="403">The given access token is invalid.</response> /// <response code="403">The given access token is invalid.</response>
[HttpDelete("me")] [HttpDelete("me")]
@ -320,7 +293,7 @@ namespace Kyoo.Authentication.Views
{ {
try try
{ {
await _users.Delete(User.GetIdOrThrow()); await users.Delete(User.GetIdOrThrow());
return NoContent(); return NoContent();
} }
catch (ItemNotFoundException) catch (ItemNotFoundException)
@ -328,5 +301,47 @@ namespace Kyoo.Authentication.Views
return Forbid(new RequestError("Invalid token")); 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 Kyoo.Postgresql;
using Microsoft.EntityFrameworkCore; 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> /// <summary>
/// A repository for users. /// The database handle
/// </summary> /// </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> return await AddIncludes(_database.Users, include)
/// The database handle .Where(x => EF.Functions.ILike(x.Username, $"%{query}%"))
/// </summary> .Take(20)
private readonly DatabaseContext _database; .ToListAsync();
}
/// <summary> /// <inheritdoc />
/// Create a new <see cref="UserRepository"/> public override async Task<User> Create(User obj)
/// </summary> {
/// <param name="database">The database handle to use</param> await base.Create(obj);
/// <param name="thumbs">The thumbnail manager used to store images.</param> _database.Entry(obj).State = EntityState.Added;
public UserRepository(DatabaseContext database, IThumbnailsManager thumbs) if (obj.Logo != null)
: base(database, thumbs) _database.Entry(obj).Reference(x => x.Logo).TargetEntry!.State = EntityState.Added;
{ await _database.SaveChangesAsync(() => Get(obj.Slug));
_database = database; await IRepository<User>.OnResourceCreated(obj);
} return obj;
}
/// <inheritdoc /> /// <inheritdoc />
public override async Task<ICollection<User>> Search( public override async Task Delete(User obj)
string query, {
Include<User>? include = default _database.Entry(obj).State = EntityState.Deleted;
) await _database.SaveChangesAsync();
{ await base.Delete(obj);
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);
}
} }
} }

View File

@ -21,6 +21,8 @@ using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Net.Http; using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using Blurhash.SkiaSharp; using Blurhash.SkiaSharp;
using Kyoo.Abstractions.Controllers; using Kyoo.Abstractions.Controllers;
@ -34,29 +36,15 @@ namespace Kyoo.Core.Controllers
/// <summary> /// <summary>
/// Download images and retrieve the path of those images for a resource. /// Download images and retrieve the path of those images for a resource.
/// </summary> /// </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 = private static readonly Dictionary<string, TaskCompletionSource<object>> _downloading =
new(); 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) private static async Task _WriteTo(SKBitmap bitmap, string path, int quality)
{ {
SKData data = bitmap.Encode(SKEncodedImageFormat.Webp, quality); SKData data = bitmap.Encode(SKEncodedImageFormat.Webp, quality);
@ -71,16 +59,16 @@ namespace Kyoo.Core.Controllers
return; return;
try 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); HttpResponseMessage response = await client.GetAsync(image.Source);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
await using Stream reader = await response.Content.ReadAsStreamAsync(); await using Stream reader = await response.Content.ReadAsStreamAsync();
using SKCodec codec = SKCodec.Create(reader); using SKCodec codec = SKCodec.Create(reader);
if (codec == null) if (codec == null)
{ {
_logger.LogError("Unsupported codec for {What}", what); logger.LogError("Unsupported codec for {What}", what);
return; return;
} }
@ -122,7 +110,7 @@ namespace Kyoo.Core.Controllers
} }
catch (Exception ex) 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); File.Delete(image);
return Task.CompletedTask; 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);
}
} }
} }