diff --git a/Kyoo.Authentication/Controllers/PasswordUtils.cs b/Kyoo.Authentication/Controllers/PasswordUtils.cs new file mode 100644 index 00000000..d28aaa99 --- /dev/null +++ b/Kyoo.Authentication/Controllers/PasswordUtils.cs @@ -0,0 +1,54 @@ +using System; +using System.Linq; +using System.Security.Cryptography; +using IdentityModel; + +namespace Kyoo.Authentication +{ + public static class PasswordUtils + { + /// + /// Generate an OneTimeAccessCode. + /// + /// A new otac. + public static string GenerateOTAC() + { + return CryptoRandom.CreateUniqueId(); + } + + /// + /// Hash a password to store it has a verification only. + /// + /// The password to hash + /// The hashed password + public static string HashPassword(string password) + { + byte[] salt = new byte[16]; + new RNGCryptoServiceProvider().GetBytes(salt); + Rfc2898DeriveBytes pbkdf2 = new(password, salt, 100000); + byte[] hash = pbkdf2.GetBytes(20); + byte[] hashBytes = new byte[36]; + Array.Copy(salt, 0, hashBytes, 0, 16); + Array.Copy(hash, 0, hashBytes, 16, 20); + return Convert.ToBase64String(hashBytes); + } + + /// + /// Check if a password is the same as a valid hashed password. + /// + /// The password to check + /// + /// The valid hashed password. This password must be hashed via . + /// + /// True if the password is valid, false otherwise. + public static bool CheckPassword(string password, string validPassword) + { + byte[] validHash = Convert.FromBase64String(validPassword); + byte[] salt = new byte[16]; + Array.Copy(validHash, 0, salt, 0, 16); + Rfc2898DeriveBytes pbkdf2 = new(password, salt, 100000); + byte[] hash = pbkdf2.GetBytes(20); + return hash.SequenceEqual(validHash.Skip(16)); + } + } +} \ No newline at end of file diff --git a/Kyoo.Authentication/Extensions.cs b/Kyoo.Authentication/Extensions.cs new file mode 100644 index 00000000..b844ce5f --- /dev/null +++ b/Kyoo.Authentication/Extensions.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.Security.Claims; +using IdentityModel; +using Kyoo.Models; + +namespace Kyoo.Authentication +{ + /// + /// Extension methods. + /// + public static class Extensions + { + public static ClaimsPrincipal ToPrincipal(this User user) + { + List claims = new() + { + new Claim(JwtClaimTypes.Subject, user.ID.ToString()), + new Claim(JwtClaimTypes.Name, user.Username), + new Claim(JwtClaimTypes.Picture, $"api/account/picture/{user.Slug}") + }; + + ClaimsIdentity id = new (claims); + return new ClaimsPrincipal(id); + } + } +} \ No newline at end of file diff --git a/Kyoo.Authentication/Kyoo.Authentication.csproj b/Kyoo.Authentication/Kyoo.Authentication.csproj index 7bb0a7fb..b334a943 100644 --- a/Kyoo.Authentication/Kyoo.Authentication.csproj +++ b/Kyoo.Authentication/Kyoo.Authentication.csproj @@ -19,6 +19,7 @@ + diff --git a/Kyoo.Authentication/Models/DTO/LoginRequest.cs b/Kyoo.Authentication/Models/DTO/LoginRequest.cs new file mode 100644 index 00000000..9bee4e04 --- /dev/null +++ b/Kyoo.Authentication/Models/DTO/LoginRequest.cs @@ -0,0 +1,28 @@ +namespace Kyoo.Authentication.Models.DTO +{ + /// + /// A model only used on login requests. + /// + public class LoginRequest + { + /// + /// The user's username. + /// + public string Username { get; set; } + + /// + /// The user's password. + /// + public string Password { get; set; } + + /// + /// Should the user stay logged in? If true a cookie will be put. + /// + public bool StayLoggedIn { get; set; } + + /// + /// The return url of the login flow. + /// + public string ReturnURL { get; set; } + } +} \ No newline at end of file diff --git a/Kyoo.Authentication/Models/DTO/OtacRequest.cs b/Kyoo.Authentication/Models/DTO/OtacRequest.cs new file mode 100644 index 00000000..0c007f78 --- /dev/null +++ b/Kyoo.Authentication/Models/DTO/OtacRequest.cs @@ -0,0 +1,18 @@ +namespace Kyoo.Authentication.Models.DTO +{ + /// + /// A model to represent an otac request + /// + public class OtacRequest + { + /// + /// The One Time Access Code + /// + public string Otac { get; set; } + + /// + /// Should the user stay logged + /// + public bool StayLoggedIn { get; set; } + } +} \ No newline at end of file diff --git a/Kyoo.Authentication/Models/Options/AuthenticationOptions.cs b/Kyoo.Authentication/Models/Options/AuthenticationOptions.cs new file mode 100644 index 00000000..f35e22a3 --- /dev/null +++ b/Kyoo.Authentication/Models/Options/AuthenticationOptions.cs @@ -0,0 +1,28 @@ +namespace Kyoo.Authentication.Models +{ + /// + /// The main authentication options. + /// + public class AuthenticationOptions + { + /// + /// The path to get this option from the root configuration. + /// + public const string Path = "authentication"; + + /// + /// The options for certificates + /// + public CertificateOption Certificate { get; set; } + + /// + /// Options for permissions + /// + public PermissionOption Permissions { get; set; } + + /// + /// Root path of user's profile pictures. + /// + public string ProfilePicturePath { get; set; } + } +} \ No newline at end of file diff --git a/Kyoo.Authentication/Models/CertificateOption.cs b/Kyoo.Authentication/Models/Options/CertificateOption.cs similarity index 100% rename from Kyoo.Authentication/Models/CertificateOption.cs rename to Kyoo.Authentication/Models/Options/CertificateOption.cs diff --git a/Kyoo.Authentication/Models/PermissionOption.cs b/Kyoo.Authentication/Models/Options/PermissionOption.cs similarity index 83% rename from Kyoo.Authentication/Models/PermissionOption.cs rename to Kyoo.Authentication/Models/Options/PermissionOption.cs index 772f3fb5..0a26f89e 100644 --- a/Kyoo.Authentication/Models/PermissionOption.cs +++ b/Kyoo.Authentication/Models/Options/PermissionOption.cs @@ -15,11 +15,11 @@ namespace Kyoo.Authentication.Models /// /// The default permissions that will be given to a non-connected user. /// - public ICollection Default { get; set; } + public string[] Default { get; set; } /// /// Permissions applied to a new user. /// - public ICollection NewUser { get; set; } + public string[] NewUser { get; set; } } } \ No newline at end of file diff --git a/Kyoo.Authentication/Views/AccountApi.cs b/Kyoo.Authentication/Views/AccountApi.cs index de47d57d..94cae0ad 100644 --- a/Kyoo.Authentication/Views/AccountApi.cs +++ b/Kyoo.Authentication/Views/AccountApi.cs @@ -1,183 +1,218 @@ -// using System; -// using System.Collections.Generic; -// using System.IO; -// using System.Linq; -// using System.Security.Claims; -// using System.Threading.Tasks; -// using IdentityServer4.Models; -// using IdentityServer4.Services; -// using Kyoo.Authentication.Models.DTO; -// using Kyoo.Models; -// using Microsoft.AspNetCore.Authorization; -// using Microsoft.AspNetCore.Http; -// using Microsoft.AspNetCore.Identity; -// using Microsoft.AspNetCore.Mvc; -// using Microsoft.Extensions.Configuration; -// using SignInResult = Microsoft.AspNetCore.Identity.SignInResult; -// -// namespace Kyoo.Authentication.Views -// { -// public class LoginRequest -// { -// public string Username { get; set; } -// public string Password { get; set; } -// public bool StayLoggedIn { get; set; } -// } -// -// public class OtacRequest -// { -// public string Otac { get; set; } -// public bool StayLoggedIn { get; set; } -// } -// -// public class AccountData -// { -// [FromForm(Name = "email")] -// public string Email { get; set; } -// [FromForm(Name = "username")] -// public string Username { get; set; } -// [FromForm(Name = "picture")] -// public IFormFile Picture { get; set; } -// } -// -// -// /// -// /// The class responsible for login, logout, permissions and claims of a user. -// /// -// [Route("api/account")] -// [Route("api/accounts")] -// [ApiController] -// public class AccountApi : Controller, IProfileService -// { -// private readonly UserManager _userManager; -// private readonly SignInManager _signInManager; -// private readonly IConfiguration _configuration; -// private readonly string _picturePath; -// -// // TODO find how SignInManager & UserManager are implement and check if they can be used or not. -// public AccountApi(UserManager userManager, -// SignInManager signInManager, -// IConfiguration configuration) -// { -// _userManager = userManager; -// _signInManager = signInManager; -// _picturePath = configuration.GetValue("profilePicturePath"); -// _configuration = configuration; -// if (!Path.IsPathRooted(_picturePath)) -// _picturePath = Path.GetFullPath(_picturePath); -// } -// -// [HttpPost("register")] -// public async Task Register([FromBody] RegisterRequest request) -// { -// User user = request.ToUser(); -// IdentityResult result = await _userManager.CreateAsync(user, user.Password); -// if (!result.Succeeded) -// return BadRequest(result.Errors); -// string otac = account.GenerateOTAC(TimeSpan.FromMinutes(1)); -// await _userManager.UpdateAsync(account); -// await _userManager.AddClaimAsync(account, new Claim( -// "permissions", -// _configuration.GetValue("newUserPermissions"))); -// return Ok(new {otac}); -// } -// -// [HttpPost("login")] -// public async Task Login([FromBody] LoginRequest login) -// { -// if (!ModelState.IsValid) -// return BadRequest(login); -// SignInResult result = await _signInManager -// .PasswordSignInAsync(login.Username, login.Password, login.StayLoggedIn, false); -// if (result.Succeeded) -// return Ok(); -// return BadRequest(new [] { new {code = "InvalidCredentials", description = "Invalid username/password"}}); -// } -// -// [HttpPost("otac-login")] -// public async Task OtacLogin([FromBody] OtacRequest otac) -// { -// if (!ModelState.IsValid) -// return BadRequest(otac); -// User user = _userManager.Users.FirstOrDefault(x => x.OTAC == otac.Otac); -// if (user == null) -// return BadRequest(new [] { new {code = "InvalidOTAC", description = "No user was found for this OTAC."}}); -// if (user.OTACExpires <= DateTime.UtcNow) -// return BadRequest(new [] { new {code = "ExpiredOTAC", description = "The OTAC has expired. Try to login with your password."}}); -// await _signInManager.SignInAsync(user, otac.StayLoggedIn); -// return Ok(); -// } -// -// [HttpGet("logout")] -// [Authorize] -// public async Task Logout() -// { -// await _signInManager.SignOutAsync(); -// return Ok(); -// } -// -// public async Task GetProfileDataAsync(ProfileDataRequestContext context) -// { -// User user = await _userManager.GetUserAsync(context.Subject); -// if (user != null) -// { -// List claims = new() -// { -// new Claim("email", user.Email), -// new Claim("username", user.UserName), -// new Claim("picture", $"api/account/picture/{user.UserName}") -// }; -// -// Claim perms = (await _userManager.GetClaimsAsync(user)).FirstOrDefault(x => x.Type == "permissions"); -// if (perms != null) -// claims.Add(perms); -// -// context.IssuedClaims.AddRange(claims); -// } -// } -// -// public async Task IsActiveAsync(IsActiveContext context) -// { -// User user = await _userManager.GetUserAsync(context.Subject); -// context.IsActive = user != null; -// } -// -// [HttpGet("picture/{username}")] -// public async Task GetPicture(string username) -// { -// User user = await _userManager.FindByNameAsync(username); -// if (user == null) -// return BadRequest(); -// string path = Path.Combine(_picturePath, user.Id); -// if (!System.IO.File.Exists(path)) -// return NotFound(); -// return new PhysicalFileResult(path, "image/png"); -// } -// -// [HttpPost("update")] -// [Authorize] -// public async Task Update([FromForm] AccountData data) -// { -// User user = await _userManager.GetUserAsync(HttpContext.User); -// -// if (!string.IsNullOrEmpty(data.Email)) -// user.Email = data.Email; -// if (!string.IsNullOrEmpty(data.Username)) -// user.UserName = data.Username; -// if (data.Picture?.Length > 0) -// { -// string path = Path.Combine(_picturePath, user.Id); -// await using FileStream file = System.IO.File.Create(path); -// await data.Picture.CopyToAsync(file); -// } -// await _userManager.UpdateAsync(user); -// return Ok(); -// } -// -// [HttpGet("default-permissions")] -// public ActionResult> GetDefaultPermissions() -// { -// return _configuration.GetValue("defaultPermissions").Split(","); -// } -// } -// } \ No newline at end of file +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Security.Claims; +using System.Threading.Tasks; +using IdentityModel; +using IdentityServer4.Models; +using IdentityServer4.Services; +using Kyoo.Authentication.Models.DTO; +using Kyoo.Controllers; +using Kyoo.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using AuthenticationOptions = Kyoo.Authentication.Models.AuthenticationOptions; + +namespace Kyoo.Authentication.Views +{ + public class AccountData + { + [FromForm(Name = "email")] + public string Email { get; set; } + [FromForm(Name = "username")] + public string Username { get; set; } + [FromForm(Name = "picture")] + public IFormFile Picture { get; set; } + } + + + /// + /// The class responsible for login, logout, permissions and claims of a user. + /// + [Route("api/account")] + [Route("api/accounts")] + [ApiController] + public class AccountApi : Controller, IProfileService + { + /// + /// The repository to handle users. + /// + private readonly IUserRepository _users; + /// + /// The identity server interaction service to login users. + /// + private readonly IIdentityServerInteractionService _interaction; + /// + /// Options about authentication. Those options are monitored and reloads are supported. + /// + private readonly IOptionsMonitor _options; + + + /// + /// Create a new handle to handle login/users requests. + /// + /// The user repository to create and manage users + /// The identity server interaction service to login users. + /// Authentication options (this may be hot reloaded) + public AccountApi(IUserRepository users, + IIdentityServerInteractionService interaction, + IOptionsMonitor options) + { + _users = users; + _interaction = interaction; + _options = options; + } + + + /// + /// Register a new user and return a OTAC to connect to it. + /// + /// The DTO register request + /// A OTAC to connect to this new account + [HttpPost("register")] + public async Task> Register([FromBody] RegisterRequest request) + { + User user = request.ToUser(); + user.Permissions = _options.CurrentValue.Permissions.NewUser; + user.Password = PasswordUtils.HashPassword(user.Password); + user.ExtraData["otac"] = PasswordUtils.GenerateOTAC(); + user.ExtraData["otac-expire"] = DateTime.Now.AddMinutes(1).ToString("s"); + await _users.Create(user); + return user.ExtraData["otac"]; + } + + /// + /// Return an authentication properties based on a stay login property + /// + /// Should the user stay logged + /// Authentication properties based on a stay login + private static AuthenticationProperties StayLogged(bool stayLogged) + { + if (!stayLogged) + return null; + return new AuthenticationProperties + { + IsPersistent = true, + ExpiresUtc = DateTimeOffset.UtcNow.AddMonths(1) + }; + } + + /// + /// Login the user. + /// + /// The DTO login request + [HttpPost("login")] + public async Task Login([FromBody] LoginRequest login) + { + AuthorizationRequest context = await _interaction.GetAuthorizationContextAsync(login.ReturnURL); + User user = await _users.Get(x => x.Username == login.Username); + + if (context == null || user == null) + return Unauthorized(); + if (!PasswordUtils.CheckPassword(login.Password, user.Password)) + return Unauthorized(); + + await HttpContext.SignInAsync(user.ID.ToString(), user.ToPrincipal(), StayLogged(login.StayLoggedIn)); + return Ok(new { RedirectUrl = login.ReturnURL, IsOk = true }); + } + + /// + /// Use a OTAC to login a user. + /// + /// The OTAC request + [HttpPost("otac-login")] + public async Task OtacLogin([FromBody] OtacRequest otac) + { + User user = await _users.Get(x => x.ExtraData["OTAC"] == otac.Otac); + if (user == null) + return Unauthorized(); + if (DateTime.ParseExact(user.ExtraData["otac-expire"], "s", CultureInfo.InvariantCulture) <= DateTime.UtcNow) + return BadRequest(new + { + code = "ExpiredOTAC", description = "The OTAC has expired. Try to login with your password." + }); + await HttpContext.SignInAsync(user.ID.ToString(), user.ToPrincipal(), StayLogged(otac.StayLoggedIn)); + return Ok(); + } + + /// + /// Sign out an user + /// + [HttpGet("logout")] + [Authorize] + public async Task Logout() + { + await HttpContext.SignOutAsync(); + return Ok(); + } + + // TODO check with the extension method + public async Task GetProfileDataAsync(ProfileDataRequestContext context) + { + User user = await _userManager.GetUserAsync(context.Subject); + if (user != null) + { + List claims = new() + { + new Claim(JwtClaimTypes.Email, user.Email), + new Claim(JwtClaimTypes.Name, user.Username), + new Claim(JwtClaimTypes.Picture, $"api/account/picture/{user.Slug}") + }; + + Claim perms = (await _userManager.GetClaimsAsync(user)).FirstOrDefault(x => x.Type == "permissions"); + if (perms != null) + claims.Add(perms); + + context.IssuedClaims.AddRange(claims); + } + } + + public async Task IsActiveAsync(IsActiveContext context) + { + User user = await _userManager.GetUserAsync(context.Subject); + context.IsActive = user != null; + } + + [HttpGet("picture/{username}")] + public async Task GetPicture(string username) + { + User user = await _userManager.FindByNameAsync(username); + if (user == null) + return BadRequest(); + string path = Path.Combine(_picturePath, user.Id); + if (!System.IO.File.Exists(path)) + return NotFound(); + return new PhysicalFileResult(path, "image/png"); + } + + [HttpPost("update")] + [Authorize] + public async Task Update([FromForm] AccountData data) + { + User user = await _userManager.GetUserAsync(HttpContext.User); + + if (!string.IsNullOrEmpty(data.Email)) + user.Email = data.Email; + if (!string.IsNullOrEmpty(data.Username)) + user.UserName = data.Username; + if (data.Picture?.Length > 0) + { + string path = Path.Combine(_picturePath, user.Id); + await using FileStream file = System.IO.File.Create(path); + await data.Picture.CopyToAsync(file); + } + await _userManager.UpdateAsync(user); + return Ok(); + } + + [HttpGet("default-permissions")] + public ActionResult> GetDefaultPermissions() + { + return _configuration.GetValue("defaultPermissions").Split(","); + } + } +} \ No newline at end of file diff --git a/Kyoo.Common/Controllers/IRepository.cs b/Kyoo.Common/Controllers/IRepository.cs index bc5ed0bc..4e8b16a3 100644 --- a/Kyoo.Common/Controllers/IRepository.cs +++ b/Kyoo.Common/Controllers/IRepository.cs @@ -11,7 +11,7 @@ using Kyoo.Models.Exceptions; namespace Kyoo.Controllers { /// - /// Informations about the pagination. How many items should be displayed and where to start. + /// Information about the pagination. How many items should be displayed and where to start. /// public readonly struct Pagination { @@ -44,7 +44,7 @@ namespace Kyoo.Controllers } /// - /// Informations about how a query should be sorted. What factor should decide the sort and in which order. + /// Information about how a query should be sorted. What factor should decide the sort and in which order. /// /// For witch type this sort applies public readonly struct Sort @@ -54,7 +54,7 @@ namespace Kyoo.Controllers /// public Expression> Key { get; } /// - /// If this is set to true, items will be sorted in descend order else, they will be sorted in ascendent order. + /// If this is set to true, items will be sorted in descend order else, they will be sorted in ascendant order. /// public bool Descendant { get; } @@ -175,7 +175,7 @@ namespace Kyoo.Controllers /// Get every resources that match all filters /// /// A filter predicate - /// Sort informations about the query (sort by, sort order) + /// Sort information about the query (sort by, sort order) /// How pagination should be done (where to start and how many to return) /// A list of resources that match every filters Task> GetAll(Expression> where = null, @@ -205,24 +205,24 @@ namespace Kyoo.Controllers /// Create a new resource. /// /// The item to register - /// The resource registers and completed by database's informations (related items & so on) + /// The resource registers and completed by database's information (related items & so on) Task Create([NotNull] T obj); /// /// Create a new resource if it does not exist already. If it does, the existing value is returned instead. /// /// The object to create - /// Allow issues to occurs in this method. Every issue is catched and ignored. + /// Allow issues to occurs in this method. Every issue is caught and ignored. /// The newly created item or the existing value if it existed. Task CreateIfNotExists([NotNull] T obj, bool silentFail = false); /// /// Edit a resource /// - /// The resourcce to edit, it's ID can't change. + /// The resource to edit, it's ID can't change. /// Should old properties of the resource be discarded or should null values considered as not changed? /// If the item is not found - /// The resource edited and completed by database's informations (related items & so on) + /// The resource edited and completed by database's information (related items & so on) Task Edit([NotNull] T edited, bool resetOld); /// @@ -259,25 +259,25 @@ namespace Kyoo.Controllers /// /// Delete a list of resources. /// - /// One or multiple resources's id + /// One or multiple resource's id /// If the item is not found Task DeleteRange(params int[] ids) => DeleteRange(ids.AsEnumerable()); /// /// Delete a list of resources. /// - /// An enumearble of resources's id + /// An enumerable of resource's id /// If the item is not found Task DeleteRange(IEnumerable ids); /// /// Delete a list of resources. /// - /// One or multiple resources's slug + /// One or multiple resource's slug /// If the item is not found Task DeleteRange(params string[] slugs) => DeleteRange(slugs.AsEnumerable()); /// /// Delete a list of resources. /// - /// An enumerable of resources's slug + /// An enumerable of resource's slug /// If the item is not found Task DeleteRange(IEnumerable slugs); /// @@ -294,7 +294,7 @@ namespace Kyoo.Controllers public interface IShowRepository : IRepository { /// - /// Link a show to a collection and/or a library. The given show is now part of thoses containers. + /// Link a show to a collection and/or a library. The given show is now part of those containers. /// If both a library and a collection are given, the collection is added to the library too. /// /// The ID of the show @@ -421,7 +421,7 @@ namespace Kyoo.Controllers /// The slug of the track /// The type (Video, Audio or Subtitle) /// If the item is not found - /// The tracl found + /// The track found Task Get(string slug, StreamType type = StreamType.Unknown); /// @@ -429,7 +429,7 @@ namespace Kyoo.Controllers /// /// The slug of the track /// The type (Video, Audio or Subtitle) - /// The tracl found + /// The track found Task GetOrDefault(string slug, StreamType type = StreamType.Unknown); } @@ -439,16 +439,16 @@ namespace Kyoo.Controllers public interface ILibraryRepository : IRepository { } /// - /// A repository to handle library items (A wrapper arround shows and collections). + /// A repository to handle library items (A wrapper around shows and collections). /// public interface ILibraryItemRepository : IRepository { /// - /// Get items (A wrapper arround shows or collections) from a library. + /// Get items (A wrapper around shows or collections) from a library. /// /// The ID of the library /// A filter function - /// Sort informations (sort order & sort by) + /// Sort information (sort order & sort by) /// How many items to return and where to start /// A list of items that match every filters public Task> GetFromLibrary(int id, @@ -456,7 +456,7 @@ namespace Kyoo.Controllers Sort sort = default, Pagination limit = default); /// - /// Get items (A wrapper arround shows or collections) from a library. + /// Get items (A wrapper around shows or collections) from a library. /// /// The ID of the library /// A filter function @@ -470,11 +470,11 @@ namespace Kyoo.Controllers ) => GetFromLibrary(id, where, new Sort(sort), limit); /// - /// Get items (A wrapper arround shows or collections) from a library. + /// Get items (A wrapper around shows or collections) from a library. /// /// The slug of the library /// A filter function - /// Sort informations (sort order & sort by) + /// Sort information (sort order & sort by) /// How many items to return and where to start /// A list of items that match every filters public Task> GetFromLibrary(string slug, @@ -482,7 +482,7 @@ namespace Kyoo.Controllers Sort sort = default, Pagination limit = default); /// - /// Get items (A wrapper arround shows or collections) from a library. + /// Get items (A wrapper around shows or collections) from a library. /// /// The slug of the library /// A filter function @@ -521,7 +521,7 @@ namespace Kyoo.Controllers /// /// The ID of the show /// A filter function - /// Sort informations (sort order & sort by) + /// Sort information (sort order & sort by) /// How many items to return and where to start /// A list of items that match every filters Task> GetFromShow(int showID, @@ -547,7 +547,7 @@ namespace Kyoo.Controllers /// /// The slug of the show /// A filter function - /// Sort informations (sort order & sort by) + /// Sort information (sort order & sort by) /// How many items to return and where to start /// A list of items that match every filters Task> GetFromShow(string showSlug, @@ -573,7 +573,7 @@ namespace Kyoo.Controllers /// /// The id of the person /// A filter function - /// Sort informations (sort order & sort by) + /// Sort information (sort order & sort by) /// How many items to return and where to start /// A list of items that match every filters Task> GetFromPeople(int id, @@ -599,7 +599,7 @@ namespace Kyoo.Controllers /// /// The slug of the person /// A filter function - /// Sort informations (sort order & sort by) + /// Sort information (sort order & sort by) /// How many items to return and where to start /// A list of items that match every filters Task> GetFromPeople(string slug, @@ -631,7 +631,7 @@ namespace Kyoo.Controllers /// /// A predicate to add arbitrary filter /// Sort information (sort order & sort by) - /// Paginations information (where to start and how many to get) + /// Pagination information (where to start and how many to get) /// A filtered list of external ids. Task> GetMetadataID(Expression> where = null, Sort sort = default, @@ -642,11 +642,16 @@ namespace Kyoo.Controllers /// /// A predicate to add arbitrary filter /// A sort by expression - /// Paginations information (where to start and how many to get) + /// Pagination information (where to start and how many to get) /// A filtered list of external ids. Task> GetMetadataID([Optional] Expression> where, Expression> sort, Pagination limit = default ) => GetMetadataID(where, new Sort(sort), limit); } + + /// + /// A repository to handle users. + /// + public interface IUserRepository : IRepository {} } diff --git a/Kyoo.Common/Models/Resources/User.cs b/Kyoo.Common/Models/Resources/User.cs index a4367c11..da85af0b 100644 --- a/Kyoo.Common/Models/Resources/User.cs +++ b/Kyoo.Common/Models/Resources/User.cs @@ -28,6 +28,16 @@ namespace Kyoo.Models /// public string Password { get; set; } + /// + /// The list of permissions of the user. The format of this is implementation dependent. + /// + public string[] Permissions { get; set; } + + /// + /// Arbitrary extra data that can be used by specific authentication implementations. + /// + public Dictionary ExtraData { get; set; } + /// /// The list of shows the user has finished. /// @@ -36,6 +46,18 @@ namespace Kyoo.Models /// /// The list of episodes the user is watching (stopped in progress or the next episode of the show) /// - public ICollection<(Episode episode, int watchedPercentage)> CurrentlyWatching { get; set; } + public ICollection CurrentlyWatching { get; set; } + +#if ENABLE_INTERNAL_LINKS + /// + /// Links between Users and Shows. + /// + public ICollection> ShowLinks { get; set; } + + /// + /// Links between Users and WatchedEpisodes. + /// + public ICollection> EpisodeLinks { get; set; } +#endif } } \ No newline at end of file diff --git a/Kyoo.Common/Models/WatchedEpisode.cs b/Kyoo.Common/Models/WatchedEpisode.cs new file mode 100644 index 00000000..631c7572 --- /dev/null +++ b/Kyoo.Common/Models/WatchedEpisode.cs @@ -0,0 +1,30 @@ +using Kyoo.Models.Attributes; + +namespace Kyoo.Models +{ + /// + /// Metadata of episode currently watching by an user + /// + public class WatchedEpisode : IResource + { + /// + [SerializeIgnore] public int ID + { + get => Episode.ID; + set => Episode.ID = value; + } + + /// + [SerializeIgnore] public string Slug => Episode.Slug; + + /// + /// The episode currently watched + /// + public Episode Episode { get; set; } + + /// + /// Where the player has stopped watching the episode (-1 if not started, else between 0 and 100). + /// + public int WatchedPercentage { get; set; } + } +} \ No newline at end of file diff --git a/Kyoo.Common/Utility.cs b/Kyoo.Common/Utility.cs index ff44761a..6583f704 100644 --- a/Kyoo.Common/Utility.cs +++ b/Kyoo.Common/Utility.cs @@ -215,7 +215,7 @@ namespace Kyoo /// /// An advanced function. /// This will set missing values of to the corresponding values of . - /// Enumerables will be merged (concatened). + /// Enumerable will be merged (concatenated). /// At the end, the OnMerge method of first will be called if first is a . /// /// The object to complete diff --git a/Kyoo.CommonAPI/DatabaseContext.cs b/Kyoo.CommonAPI/DatabaseContext.cs index a795723e..4a4b416b 100644 --- a/Kyoo.CommonAPI/DatabaseContext.cs +++ b/Kyoo.CommonAPI/DatabaseContext.cs @@ -67,7 +67,7 @@ namespace Kyoo /// /// The list of registered users. /// - // public DbSet Users { get; set; } + public DbSet Users { get; set; } /// /// All people's role. See . @@ -254,7 +254,7 @@ namespace Kyoo /// Return a new or an in cache temporary object wih the same ID as the one given /// /// If a resource with the same ID is found in the database, it will be used. - /// will be used overwise + /// will be used otherwise /// The type of the resource /// A resource that is now tracked by this context. public T GetTemporaryObject(T model) diff --git a/Kyoo.CommonAPI/LocalRepository.cs b/Kyoo.CommonAPI/LocalRepository.cs index 32b47bd2..ee01b089 100644 --- a/Kyoo.CommonAPI/LocalRepository.cs +++ b/Kyoo.CommonAPI/LocalRepository.cs @@ -118,7 +118,7 @@ namespace Kyoo.Controllers /// The base query to filter. /// An expression to filter based on arbitrary conditions /// The sort settings (sort order & sort by) - /// Paginations information (where to start and how many to get) + /// Pagination information (where to start and how many to get) /// The filtered query protected Task> ApplyFilters(IQueryable query, Expression> where = null, @@ -137,7 +137,7 @@ namespace Kyoo.Controllers /// The base query to filter. /// An expression to filter based on arbitrary conditions /// The sort settings (sort order & sort by) - /// Paginations information (where to start and how many to get) + /// Pagination information (where to start and how many to get) /// The filtered query protected async Task> ApplyFilters(IQueryable query, Func> get, @@ -244,7 +244,7 @@ namespace Kyoo.Controllers } /// - /// An overridable method to edit relatiosn of a resource. + /// An overridable method to edit relation of a resource. /// /// The non edited resource /// The new version of . This item will be saved on the databse and replace diff --git a/Kyoo/Controllers/ProviderManager.cs b/Kyoo/Controllers/ProviderManager.cs index d025c31c..61f41593 100644 --- a/Kyoo/Controllers/ProviderManager.cs +++ b/Kyoo/Controllers/ProviderManager.cs @@ -33,7 +33,7 @@ namespace Kyoo.Controllers } catch (Exception ex) { await Console.Error.WriteLineAsync( - $"The provider {provider.Provider.Name} coudln't work for {what}. Exception: {ex.Message}"); + $"The provider {provider.Provider.Name} could not work for {what}. Exception: {ex.Message}"); } } return ret; diff --git a/Kyoo/Controllers/Repositories/UserRepository.cs b/Kyoo/Controllers/Repositories/UserRepository.cs new file mode 100644 index 00000000..97bce050 --- /dev/null +++ b/Kyoo/Controllers/Repositories/UserRepository.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; +using Kyoo.Models; +using Microsoft.EntityFrameworkCore; + +namespace Kyoo.Controllers +{ + /// + /// A repository for users. + /// + public class UserRepository : LocalRepository, IUserRepository + { + /// + /// The database handle + /// + private readonly DatabaseContext _database; + + /// + protected override Expression> DefaultSort => x => x.Username; + + + /// + /// Create a new + /// + /// The database handle to use + public UserRepository(DatabaseContext database) + : base(database) + { + _database = database; + } + + /// + public override async Task> Search(string query) + { + return await _database.Users + .Where(_database.Like(x => x.Username, $"%{query}%")) + .OrderBy(DefaultSort) + .Take(20) + .ToListAsync(); + } + + /// + public override async Task Delete(User obj) + { + if (obj == null) + throw new ArgumentNullException(nameof(obj)); + + _database.Entry(obj).State = EntityState.Deleted; + await _database.SaveChangesAsync(); + } + } +} \ No newline at end of file