mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-05-24 02:02:36 -04:00
Add user pp support with gravatar fallback
This commit is contained in:
parent
c969908ff5
commit
b43b6d6f75
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user