diff --git a/Kyoo.Authentication/AuthenticationModule.cs b/Kyoo.Authentication/AuthenticationModule.cs
new file mode 100644
index 00000000..9c7c3687
--- /dev/null
+++ b/Kyoo.Authentication/AuthenticationModule.cs
@@ -0,0 +1,170 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using IdentityServer4.Extensions;
+using IdentityServer4.Models;
+using IdentityServer4.Services;
+using Kyoo.Authentication.Models;
+using Kyoo.Authentication.Views;
+using Kyoo.Controllers;
+using Kyoo.Models.Permissions;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.FileProviders;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.IdentityModel.Logging;
+using SameSiteMode = Microsoft.AspNetCore.Http.SameSiteMode;
+
+namespace Kyoo.Authentication
+{
+ ///
+ /// A module that enable OpenID authentication for Kyoo.
+ ///
+ public class AuthenticationModule : IPlugin
+ {
+ ///
+ public string Slug => "auth";
+
+ ///
+ public string Name => "Authentication";
+
+ ///
+ public string Description => "Enable OpenID authentication for Kyoo.";
+
+ ///
+ public ICollection Provides => ArraySegment.Empty;
+
+ ///
+ public ICollection ConditionalProvides => ArraySegment.Empty;
+
+ ///
+ public ICollection Requires => new []
+ {
+ typeof(IUserRepository)
+ };
+
+
+ ///
+ /// The configuration to use.
+ ///
+ private readonly IConfiguration _configuration;
+
+ ///
+ /// A logger factory to allow IdentityServer to log things.
+ ///
+ private readonly ILoggerFactory _loggerFactory;
+
+ ///
+ /// The environment information to check if the app runs in debug mode
+ ///
+ private readonly IWebHostEnvironment _environment;
+
+
+ ///
+ /// Create a new authentication module instance and use the given configuration and environment.
+ ///
+ /// The configuration to use
+ /// The logger factory to allow IdentityServer to log things
+ /// The environment information to check if the app runs in debug mode
+ public AuthenticationModule(IConfiguration configuration,
+ ILoggerFactory loggerFactory,
+ IWebHostEnvironment environment)
+ {
+ _configuration = configuration;
+ _loggerFactory = loggerFactory;
+ _environment = environment;
+ }
+
+ ///
+ public void Configure(IServiceCollection services, ICollection availableTypes)
+ {
+ string publicUrl = _configuration.GetValue("publicUrl").TrimEnd('/');
+
+ if (_environment.IsDevelopment())
+ IdentityModelEventSource.ShowPII = true;
+
+ services.AddControllers();
+
+ // TODO handle direct-videos with bearers (probably add a cookie and a app.Use to translate that for videos)
+
+ // TODO Check if tokens should be stored.
+
+ services.Configure(_configuration.GetSection(PermissionOption.Path));
+ services.Configure(_configuration.GetSection(CertificateOption.Path));
+ services.Configure(_configuration.GetSection(AuthenticationOption.Path));
+
+
+ List clients = new();
+ _configuration.GetSection("authentication:clients").Bind(clients);
+ CertificateOption certificateOptions = new();
+ _configuration.GetSection(CertificateOption.Path).Bind(certificateOptions);
+
+ services.AddIdentityServer(options =>
+ {
+ options.IssuerUri = publicUrl;
+ options.UserInteraction.LoginUrl = $"{publicUrl}/login";
+ options.UserInteraction.ErrorUrl = $"{publicUrl}/error";
+ options.UserInteraction.LogoutUrl = $"{publicUrl}/logout";
+ })
+ .AddInMemoryIdentityResources(IdentityContext.GetIdentityResources())
+ .AddInMemoryApiScopes(IdentityContext.GetScopes())
+ .AddInMemoryApiResources(IdentityContext.GetApis())
+ .AddInMemoryClients(IdentityContext.GetClients().Concat(clients))
+ .AddProfileService()
+ .AddSigninKeys(certificateOptions);
+
+ services.AddAuthentication()
+ .AddJwtBearer(options =>
+ {
+ options.Authority = publicUrl;
+ options.Audience = "kyoo";
+ options.RequireHttpsMetadata = false;
+ });
+ services.AddSingleton();
+
+ DefaultCorsPolicyService cors = new(_loggerFactory.CreateLogger())
+ {
+ AllowedOrigins = {new Uri(publicUrl).GetLeftPart(UriPartial.Authority)}
+ };
+ services.AddSingleton(cors);
+ }
+
+ ///
+ public void ConfigureAspNet(IApplicationBuilder app)
+ {
+ app.UseCookiePolicy(new CookiePolicyOptions
+ {
+ MinimumSameSitePolicy = SameSiteMode.Strict
+ });
+ app.UseAuthentication();
+ app.Use((ctx, next) =>
+ {
+ ctx.SetIdentityServerOrigin(_configuration.GetValue("publicUrl").TrimEnd('/'));
+ return next();
+ });
+ app.UseIdentityServer();
+ app.UseAuthorization();
+
+ PhysicalFileProvider provider = new(Path.Combine(
+ Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!,
+ "login"));
+ app.UseDefaultFiles(new DefaultFilesOptions
+ {
+ RequestPath = new PathString("/login"),
+ FileProvider = provider,
+ RedirectToAppendTrailingSlash = true
+ });
+ app.UseStaticFiles(new StaticFileOptions
+ {
+ RequestPath = new PathString("/login"),
+ FileProvider = provider
+ });
+ }
+ }
+}
\ No newline at end of file
diff --git a/Kyoo.Authentication/Controllers/Certificates.cs b/Kyoo.Authentication/Controllers/Certificates.cs
new file mode 100644
index 00000000..3aaab9b8
--- /dev/null
+++ b/Kyoo.Authentication/Controllers/Certificates.cs
@@ -0,0 +1,120 @@
+using System;
+using System.IO;
+using System.Security.Cryptography.X509Certificates;
+using Kyoo.Authentication.Models;
+using Microsoft.Extensions.DependencyInjection;
+using Org.BouncyCastle.Asn1.X509;
+using Org.BouncyCastle.Crypto;
+using Org.BouncyCastle.Crypto.Generators;
+using Org.BouncyCastle.Crypto.Operators;
+using Org.BouncyCastle.Math;
+using Org.BouncyCastle.Pkcs;
+using Org.BouncyCastle.Security;
+using Org.BouncyCastle.Utilities;
+using Org.BouncyCastle.X509;
+using X509Certificate = Org.BouncyCastle.X509.X509Certificate;
+
+namespace Kyoo.Authentication
+{
+ ///
+ /// A class containing multiple extensions methods to manage certificates.
+ ///
+ public static class Certificates
+ {
+ ///
+ /// Add the certificate file to the identity server. If the certificate will expire soon, automatically renew it.
+ /// If no certificate exists, one is generated.
+ ///
+ /// The identity server that will be modified.
+ /// The certificate options
+ ///
+ public static IIdentityServerBuilder AddSigninKeys(this IIdentityServerBuilder builder,
+ CertificateOption options)
+ {
+ X509Certificate2 certificate = GetCertificate(options);
+ builder.AddSigningCredential(certificate);
+
+ if (certificate.NotAfter.AddDays(7) <= DateTime.UtcNow)
+ {
+ Console.WriteLine("Signin certificate will expire soon, renewing it.");
+ if (File.Exists(options.OldFile))
+ File.Delete(options.OldFile);
+ File.Move(options.File, options.OldFile);
+ builder.AddValidationKey(GenerateCertificate(options.File, options.Password));
+ }
+ else if (File.Exists(options.OldFile))
+ builder.AddValidationKey(GetExistingCredential(options.OldFile, options.Password));
+ return builder;
+ }
+
+ ///
+ /// Get or generate the sign-in certificate.
+ ///
+ /// The certificate options
+ /// A valid certificate
+ private static X509Certificate2 GetCertificate(CertificateOption options)
+ {
+ return File.Exists(options.File)
+ ? GetExistingCredential(options.File, options.Password)
+ : GenerateCertificate(options.File, options.Password);
+ }
+
+ ///
+ /// Load a certificate from a file
+ ///
+ /// The path of the certificate
+ /// The password of the certificate
+ /// The loaded certificate
+ private static X509Certificate2 GetExistingCredential(string file, string password)
+ {
+ return new(file, password,
+ X509KeyStorageFlags.MachineKeySet |
+ X509KeyStorageFlags.PersistKeySet |
+ X509KeyStorageFlags.Exportable
+ );
+ }
+
+ ///
+ /// Generate a new certificate key and put it in the file at .
+ ///
+ /// The path of the output file
+ /// The password of the new certificate
+ /// The generated certificate
+ private static X509Certificate2 GenerateCertificate(string file, string password)
+ {
+ SecureRandom random = new();
+
+ X509V3CertificateGenerator certificateGenerator = new();
+ certificateGenerator.SetSerialNumber(BigIntegers.CreateRandomInRange(BigInteger.One,
+ BigInteger.ValueOf(long.MaxValue), random));
+ certificateGenerator.SetIssuerDN(new X509Name($"C=NL, O=SDG, CN=Kyoo"));
+ certificateGenerator.SetSubjectDN(new X509Name($"C=NL, O=SDG, CN=Kyoo"));
+ certificateGenerator.SetNotBefore(DateTime.UtcNow.Date);
+ certificateGenerator.SetNotAfter(DateTime.UtcNow.Date.AddMonths(3));
+
+ KeyGenerationParameters keyGenerationParameters = new(random, 2048);
+ RsaKeyPairGenerator keyPairGenerator = new();
+ keyPairGenerator.Init(keyGenerationParameters);
+
+ AsymmetricCipherKeyPair subjectKeyPair = keyPairGenerator.GenerateKeyPair();
+ certificateGenerator.SetPublicKey(subjectKeyPair.Public);
+
+ const string signatureAlgorithm = "MD5WithRSA";
+ Asn1SignatureFactory signatureFactory = new(signatureAlgorithm, subjectKeyPair.Private);
+ X509Certificate bouncyCert = certificateGenerator.Generate(signatureFactory);
+
+ Pkcs12Store store = new Pkcs12StoreBuilder().Build();
+ store.SetKeyEntry("Kyoo_key", new AsymmetricKeyEntry(subjectKeyPair.Private), new []
+ {
+ new X509CertificateEntry(bouncyCert)
+ });
+
+ using MemoryStream pfxStream = new();
+ store.Save(pfxStream, password.ToCharArray(), random);
+ X509Certificate2 certificate = new(pfxStream.ToArray(), password, X509KeyStorageFlags.Exportable);
+ using FileStream fileStream = File.OpenWrite(file);
+ pfxStream.WriteTo(fileStream);
+ return certificate;
+ }
+ }
+}
\ No newline at end of file
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/Controllers/PremissionValidator.cs b/Kyoo.Authentication/Controllers/PremissionValidator.cs
new file mode 100644
index 00000000..dc60faa7
--- /dev/null
+++ b/Kyoo.Authentication/Controllers/PremissionValidator.cs
@@ -0,0 +1,145 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Kyoo.Authentication.Models;
+using Kyoo.Models.Permissions;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Filters;
+using Microsoft.Extensions.Options;
+
+namespace Kyoo.Authentication
+{
+ ///
+ /// A permission validator to validate permission with user Permission array
+ /// or the default array from the configurations if the user is not logged.
+ ///
+ public class PermissionValidatorFactory : IPermissionValidator
+ {
+ ///
+ /// The permissions options to retrieve default permissions.
+ ///
+ private readonly IOptionsMonitor _options;
+
+ ///
+ /// Create a new factory with the given options
+ ///
+ /// The option containing default values.
+ public PermissionValidatorFactory(IOptionsMonitor options)
+ {
+ _options = options;
+ }
+
+ ///
+ public IFilterMetadata Create(PermissionAttribute attribute)
+ {
+ return new PermissionValidator(attribute.Type, attribute.Kind, _options);
+ }
+
+ ///
+ public IFilterMetadata Create(PartialPermissionAttribute attribute)
+ {
+ return new PermissionValidator((object)attribute.Type ?? attribute.Kind, _options);
+ }
+
+ ///
+ /// The authorization filter used by
+ ///
+ private class PermissionValidator : IAsyncAuthorizationFilter
+ {
+ ///
+ /// The permission to validate
+ ///
+ private readonly string _permission;
+ ///
+ /// The kind of permission needed
+ ///
+ private readonly Kind? _kind;
+ ///
+ /// The permissions options to retrieve default permissions.
+ ///
+ private readonly IOptionsMonitor _options;
+
+ ///
+ /// Create a new permission validator with the given options
+ ///
+ /// The permission to validate
+ /// The kind of permission needed
+ /// The option containing default values.
+ public PermissionValidator(string permission, Kind kind, IOptionsMonitor options)
+ {
+ _permission = permission;
+ _kind = kind;
+ _options = options;
+ }
+
+ ///
+ /// Create a new permission validator with the given options
+ ///
+ /// The partial permission to validate
+ /// The option containing default values.
+ public PermissionValidator(object partialInfo, IOptionsMonitor options)
+ {
+ if (partialInfo is Kind kind)
+ _kind = kind;
+ else if (partialInfo is string perm)
+ _permission = perm;
+ else
+ throw new ArgumentException($"{nameof(partialInfo)} can only be a permission string or a kind.");
+ _options = options;
+ }
+
+
+ ///
+ public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
+ {
+ string permission = _permission;
+ Kind? kind = _kind;
+
+ if (permission == null || kind == null)
+ {
+ switch (context.HttpContext.Items["PermissionType"])
+ {
+ case string perm:
+ permission = perm;
+ break;
+ case Kind kin:
+ kind = kin;
+ break;
+ case null when kind != null:
+ context.HttpContext.Items["PermissionType"] = kind;
+ return;
+ case null when permission != null:
+ context.HttpContext.Items["PermissionType"] = permission;
+ return;
+ default:
+ throw new ArgumentException("Multiple non-matching partial permission attribute " +
+ "are not supported.");
+ }
+ if (permission == null || kind == null)
+ throw new ArgumentException("The permission type or kind is still missing after two partial " +
+ "permission attributes, this is unsupported.");
+ }
+
+ string permStr = $"{permission.ToLower()}.{kind.ToString()!.ToLower()}";
+ string overallStr = $"overall.{kind.ToString()!.ToLower()}";
+ AuthenticateResult res = await context.HttpContext.AuthenticateAsync(JwtBearerDefaults.AuthenticationScheme);
+ if (res.Succeeded)
+ {
+ ICollection permissions = res.Principal.GetPermissions();
+ if (permissions.All(x => x != permStr && x != overallStr))
+ context.Result = new StatusCodeResult(StatusCodes.Status403Forbidden);
+ }
+ else
+ {
+ ICollection permissions = _options.CurrentValue.Default ?? Array.Empty();
+ if (res.Failure != null || permissions.All(x => x != permStr && x != overallStr))
+ context.Result = new StatusCodeResult(StatusCodes.Status401Unauthorized);
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Kyoo.Authentication/Extensions.cs b/Kyoo.Authentication/Extensions.cs
new file mode 100644
index 00000000..718a7a44
--- /dev/null
+++ b/Kyoo.Authentication/Extensions.cs
@@ -0,0 +1,54 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Security.Claims;
+using IdentityModel;
+using IdentityServer4;
+using Kyoo.Models;
+
+namespace Kyoo.Authentication
+{
+ ///
+ /// Extension methods.
+ ///
+ public static class Extensions
+ {
+ ///
+ /// Get claims of an user.
+ ///
+ /// The user concerned
+ /// The list of claims the user has
+ public static ICollection GetClaims(this User user)
+ {
+ return new[]
+ {
+ new Claim(JwtClaimTypes.Subject, user.ID.ToString()),
+ new Claim(JwtClaimTypes.Name, user.Username),
+ new Claim(JwtClaimTypes.Picture, $"api/account/picture/{user.Slug}")
+ };
+ }
+
+ ///
+ /// Convert a user to an .
+ ///
+ /// The user to convert
+ /// The corresponding identity server user.
+ public static IdentityServerUser ToIdentityUser(this User user)
+ {
+ return new(user.ID.ToString())
+ {
+ DisplayName = user.Username,
+ AdditionalClaims = new[] {new Claim("permissions", string.Join(',', user.Permissions))}
+ };
+ }
+
+ ///
+ /// Get the permissions of an user.
+ ///
+ /// The user
+ /// The list of permissions
+ public static ICollection GetPermissions(this ClaimsPrincipal user)
+ {
+ return user.Claims.FirstOrDefault(x => x.Type == "permissions")?.Value.Split(',');
+ }
+ }
+}
\ No newline at end of file
diff --git a/Kyoo.Authentication/Kyoo.Authentication.csproj b/Kyoo.Authentication/Kyoo.Authentication.csproj
new file mode 100644
index 00000000..2cde6c7e
--- /dev/null
+++ b/Kyoo.Authentication/Kyoo.Authentication.csproj
@@ -0,0 +1,50 @@
+
+
+
+ net5.0
+ ../Kyoo/bin/$(Configuration)/$(TargetFramework)/plugins/authentication
+ false
+ false
+ false
+ false
+ true
+
+ SDG
+ Zoe Roux
+ https://github.com/AnonymusRaccoon/Kyoo
+ default
+ ../Kyoo.WebLogin/
+
+
+
+
+
+
+
+
+
+
+ all
+ false
+ runtime
+
+
+
+
+
+
+
+
+
+
+ login/%(LoginFiles.RecursiveDir)%(LoginFiles.Filename)%(LoginFiles.Extension)
+ PreserveNewest
+ true
+
+
+
+
+
+
+
+
diff --git a/Kyoo.Authentication/Models/DTO/AccountUpdateRequest.cs b/Kyoo.Authentication/Models/DTO/AccountUpdateRequest.cs
new file mode 100644
index 00000000..ac135799
--- /dev/null
+++ b/Kyoo.Authentication/Models/DTO/AccountUpdateRequest.cs
@@ -0,0 +1,28 @@
+using System.ComponentModel.DataAnnotations;
+using Microsoft.AspNetCore.Http;
+
+namespace Kyoo.Authentication.Models.DTO
+{
+ ///
+ /// A model only used on account update requests.
+ ///
+ public class AccountUpdateRequest
+ {
+ ///
+ /// The new email address of the user
+ ///
+ [EmailAddress(ErrorMessage = "The email is invalid.")]
+ public string Email { get; set; }
+
+ ///
+ /// The new username of the user.
+ ///
+ [MinLength(4, ErrorMessage = "The username must have at least 4 characters")]
+ public string Username { get; set; }
+
+ ///
+ /// The picture icon.
+ ///
+ public IFormFile Picture { get; set; }
+ }
+}
\ No newline at end of file
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/DTO/RegisterRequest.cs b/Kyoo.Authentication/Models/DTO/RegisterRequest.cs
new file mode 100644
index 00000000..ad556f6d
--- /dev/null
+++ b/Kyoo.Authentication/Models/DTO/RegisterRequest.cs
@@ -0,0 +1,47 @@
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using Kyoo.Models;
+
+namespace Kyoo.Authentication.Models.DTO
+{
+ ///
+ /// A model only used on register requests.
+ ///
+ public class RegisterRequest
+ {
+ ///
+ /// The user email address
+ ///
+ [EmailAddress(ErrorMessage = "The email must be a valid email address")]
+ public string Email { get; set; }
+
+ ///
+ /// The user's username.
+ ///
+ [MinLength(4, ErrorMessage = "The username must have at least {1} characters")]
+ public string Username { get; set; }
+
+ ///
+ /// The user's password.
+ ///
+ [MinLength(8, ErrorMessage = "The password must have at least {1} characters")]
+ public string Password { get; set; }
+
+
+ ///
+ /// Convert this register request to a new class.
+ ///
+ ///
+ public User ToUser()
+ {
+ return new()
+ {
+ Slug = Utility.ToSlug(Username),
+ Username = Username,
+ Password = Password,
+ Email = Email,
+ ExtraData = new Dictionary()
+ };
+ }
+ }
+}
\ No newline at end of file
diff --git a/Kyoo/Models/IdentityContext.cs b/Kyoo.Authentication/Models/IdentityContext.cs
similarity index 65%
rename from Kyoo/Models/IdentityContext.cs
rename to Kyoo.Authentication/Models/IdentityContext.cs
index c414efcc..e6ca3353 100644
--- a/Kyoo/Models/IdentityContext.cs
+++ b/Kyoo.Authentication/Models/IdentityContext.cs
@@ -2,10 +2,17 @@ using System.Collections.Generic;
using System.Linq;
using IdentityServer4.Models;
-namespace Kyoo
+namespace Kyoo.Authentication
{
+ ///
+ /// The hard coded context of the identity server.
+ ///
public static class IdentityContext
{
+ ///
+ /// The list of identity resources supported (email, profile and openid)
+ ///
+ /// The list of identity resources supported
public static IEnumerable GetIdentityResources()
{
return new List
@@ -16,6 +23,13 @@ namespace Kyoo
};
}
+ ///
+ /// The list of officially supported clients.
+ ///
+ ///
+ /// You can add custom clients in the settings.json file.
+ ///
+ /// The list of officially supported clients.
public static IEnumerable GetClients()
{
return new List
@@ -23,7 +37,7 @@ namespace Kyoo
new()
{
ClientId = "kyoo.webapp",
-
+
AccessTokenType = AccessTokenType.Jwt,
AllowedGrantTypes = GrantTypes.Code,
RequirePkce = true,
@@ -33,13 +47,17 @@ namespace Kyoo
AllowOfflineAccess = true,
RequireConsent = false,
- AllowedScopes = { "openid", "profile", "kyoo.read", "kyoo.write", "kyoo.play", "kyoo.download", "kyoo.admin" },
+ AllowedScopes = { "openid", "profile", "kyoo.read", "kyoo.write", "kyoo.play", "kyoo.admin" },
RedirectUris = { "/", "/silent.html" },
PostLogoutRedirectUris = { "/logout" }
}
};
}
+ ///
+ /// The list of scopes supported by the API.
+ ///
+ /// The list of scopes
public static IEnumerable GetScopes()
{
return new[]
@@ -60,11 +78,6 @@ namespace Kyoo
DisplayName = "Allow playback of movies and episodes."
},
new ApiScope
- {
- Name = "kyoo.download",
- DisplayName = "Allow downloading of episodes and movies from kyoo."
- },
- new ApiScope
{
Name = "kyoo.admin",
DisplayName = "Full access to the admin's API and the public API."
@@ -72,13 +85,16 @@ namespace Kyoo
};
}
+ ///
+ /// The list of APIs (this is used to create Audiences)
+ ///
+ /// The list of apis
public static IEnumerable GetApis()
{
return new[]
{
- new ApiResource
+ new ApiResource("kyoo", "Kyoo")
{
- Name = "Kyoo",
Scopes = GetScopes().Select(x => x.Name).ToArray()
}
};
diff --git a/Kyoo.Authentication/Models/Options/AuthenticationOption.cs b/Kyoo.Authentication/Models/Options/AuthenticationOption.cs
new file mode 100644
index 00000000..23e917aa
--- /dev/null
+++ b/Kyoo.Authentication/Models/Options/AuthenticationOption.cs
@@ -0,0 +1,28 @@
+namespace Kyoo.Authentication.Models
+{
+ ///
+ /// The main authentication options.
+ ///
+ public class AuthenticationOption
+ {
+ ///
+ /// 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/Options/CertificateOption.cs b/Kyoo.Authentication/Models/Options/CertificateOption.cs
new file mode 100644
index 00000000..93d2a878
--- /dev/null
+++ b/Kyoo.Authentication/Models/Options/CertificateOption.cs
@@ -0,0 +1,26 @@
+namespace Kyoo.Authentication.Models
+{
+ ///
+ /// A typed option model for the certificate
+ ///
+ public class CertificateOption
+ {
+ ///
+ /// The path to get this option from the root configuration.
+ ///
+ public const string Path = "authentication:certificate";
+
+ ///
+ /// The path of the certificate file.
+ ///
+ public string File { get; set; }
+ ///
+ /// The path of the old certificate file.
+ ///
+ public string OldFile { get; set; }
+ ///
+ /// The password of the certificates.
+ ///
+ public string Password { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/Kyoo.Authentication/Models/Options/PermissionOption.cs b/Kyoo.Authentication/Models/Options/PermissionOption.cs
new file mode 100644
index 00000000..8d6c698d
--- /dev/null
+++ b/Kyoo.Authentication/Models/Options/PermissionOption.cs
@@ -0,0 +1,23 @@
+namespace Kyoo.Authentication.Models
+{
+ ///
+ /// Permission options.
+ ///
+ public class PermissionOption
+ {
+ ///
+ /// The path to get this option from the root configuration.
+ ///
+ public const string Path = "authentication:permissions";
+
+ ///
+ /// The default permissions that will be given to a non-connected user.
+ ///
+ public string[] Default { get; set; }
+
+ ///
+ /// Permissions applied to a new user.
+ ///
+ 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
new file mode 100644
index 00000000..59d964f0
--- /dev/null
+++ b/Kyoo.Authentication/Views/AccountApi.cs
@@ -0,0 +1,225 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Security.Claims;
+using System.Threading.Tasks;
+using IdentityServer4.Extensions;
+using IdentityServer4.Models;
+using IdentityServer4.Services;
+using Kyoo.Authentication.Models;
+using Kyoo.Authentication.Models.DTO;
+using Kyoo.Controllers;
+using Kyoo.Models;
+using Kyoo.Models.Exceptions;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Options;
+
+namespace Kyoo.Authentication.Views
+{
+ ///
+ /// 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;
+ ///
+ /// A file manager to send profile pictures
+ ///
+ private readonly IFileManager _files;
+ ///
+ /// Options about authentication. Those options are monitored and reloads are supported.
+ ///
+ private readonly IOptions _options;
+
+
+ ///
+ /// Create a new handle to handle login/users requests.
+ ///
+ /// The user repository to create and manage users
+ /// A file manager to send profile pictures
+ /// Authentication options (this may be hot reloaded)
+ public AccountApi(IUserRepository users,
+ IFileManager files,
+ IOptions options)
+ {
+ _users = users;
+ _files = files;
+ _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.Value.Permissions.NewUser;
+ user.Password = PasswordUtils.HashPassword(user.Password);
+ user.ExtraData["otac"] = PasswordUtils.GenerateOTAC();
+ user.ExtraData["otac-expire"] = DateTime.Now.AddMinutes(1).ToString("s");
+ try
+ {
+ await _users.Create(user);
+ }
+ catch (DuplicatedItemException)
+ {
+ return Conflict(new {Errors = new {Duplicate = new[] {"A user with this name already exists"}}});
+ }
+
+ return Ok(new {Otac = 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)
+ {
+ User user = await _users.GetOrDefault(x => x.Username == login.Username);
+
+ if (user == null)
+ return Unauthorized();
+ if (!PasswordUtils.CheckPassword(login.Password, user.Password))
+ return Unauthorized();
+
+ await HttpContext.SignInAsync(user.ToIdentityUser(), 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)
+ {
+ // TODO once hstore (Dictionary accessor) are supported, use them.
+ // We retrieve all users, this is inefficient.
+ User user = (await _users.GetAll()).FirstOrDefault(x => x.ExtraData.GetValueOrDefault("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.ToIdentityUser(), StayLogged(otac.StayLoggedIn));
+ return Ok();
+ }
+
+ ///
+ /// Sign out an user
+ ///
+ [HttpGet("logout")]
+ [Authorize]
+ public async Task Logout()
+ {
+ await HttpContext.SignOutAsync();
+ return Ok();
+ }
+
+ ///
+ public async Task GetProfileDataAsync(ProfileDataRequestContext context)
+ {
+ User user = await _users.GetOrDefault(int.Parse(context.Subject.GetSubjectId()));
+ if (user == null)
+ return;
+ context.IssuedClaims.AddRange(user.GetClaims());
+ context.IssuedClaims.Add(new Claim("permissions", string.Join(',', user.Permissions)));
+ }
+
+ ///
+ public async Task IsActiveAsync(IsActiveContext context)
+ {
+ User user = await _users.GetOrDefault(int.Parse(context.Subject.GetSubjectId()));
+ context.IsActive = user != null;
+ }
+
+ ///
+ /// Get the user's profile picture.
+ ///
+ /// The user slug
+ /// The profile picture of the user or 404 if not found
+ [HttpGet("picture/{slug}")]
+ public async Task GetPicture(string slug)
+ {
+ User user = await _users.GetOrDefault(slug);
+ if (user == null)
+ return NotFound();
+ string path = Path.Combine(_options.Value.ProfilePicturePath, user.ID.ToString());
+ return _files.FileResult(path);
+ }
+
+ ///
+ /// Update profile information (email, username, profile picture...)
+ ///
+ /// The new information
+ /// The edited user
+ [HttpPut]
+ [Authorize]
+ public async Task> Update([FromForm] AccountUpdateRequest data)
+ {
+ User user = await _users.GetOrDefault(int.Parse(HttpContext.User.GetSubjectId()));
+
+ if (user == null)
+ return Unauthorized();
+ 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(_options.Value.ProfilePicturePath, user.ID.ToString());
+ await using Stream file = _files.NewFile(path);
+ await data.Picture.CopyToAsync(file);
+ }
+ return await _users.Edit(user, false);
+ }
+
+ ///
+ /// Get permissions for a non connected user.
+ ///
+ /// The list of permissions of a default user.
+ [HttpGet("permissions")]
+ public ActionResult> GetDefaultPermissions()
+ {
+ return _options.Value.Permissions.Default ?? Array.Empty();
+ }
+ }
+}
\ No newline at end of file
diff --git a/Kyoo.Common/Controllers/IFileManager.cs b/Kyoo.Common/Controllers/IFileManager.cs
index cc3c70bb..03f22e79 100644
--- a/Kyoo.Common/Controllers/IFileManager.cs
+++ b/Kyoo.Common/Controllers/IFileManager.cs
@@ -7,21 +7,88 @@ using Microsoft.AspNetCore.Mvc;
namespace Kyoo.Controllers
{
+ ///
+ /// A service to abstract the file system to allow custom file systems (like distant file systems or external providers)
+ ///
public interface IFileManager
{
- public IActionResult FileResult([CanBeNull] string path, bool rangeSupport = false);
-
- public StreamReader GetReader([NotNull] string path);
-
- public Task> ListFiles([NotNull] string path);
-
- public Task Exists([NotNull] string path);
// TODO find a way to handle Transmux/Transcode with this system.
+ ///
+ /// Used for http queries returning a file. This should be used to return local files
+ /// or proxy them from a distant server
+ ///
+ ///
+ /// If no file exists at the given path or if the path is null, a NotFoundResult is returned
+ /// to handle it gracefully.
+ ///
+ /// The path of the file.
+ ///
+ /// Should the file be downloaded at once or is the client allowed to request only part of the file
+ ///
+ ///
+ /// You can manually specify the content type of your file.
+ /// For example you can force a file to be returned as plain text using text/plain.
+ /// If the type is not specified, it will be deduced automatically (from the extension or by sniffing the file).
+ ///
+ /// An representing the file returned.
+ public IActionResult FileResult([CanBeNull] string path, bool rangeSupport = false, string type = null);
+
+ ///
+ /// Read a file present at . The reader can be used in an arbitrary context.
+ /// To return files from an http endpoint, use .
+ ///
+ /// The path of the file
+ /// If the file could not be found.
+ /// A reader to read the file.
+ public Stream GetReader([NotNull] string path);
+
+ ///
+ /// Create a new file at .
+ ///
+ /// The path of the new file.
+ /// A writer to write to the new file.
+ public Stream NewFile([NotNull] string path);
+
+ ///
+ /// List files in a directory.
+ ///
+ /// The path of the directory
+ /// A list of files's path.
+ public Task> ListFiles([NotNull] string path);
+
+ ///
+ /// Check if a file exists at the given path.
+ ///
+ /// The path to check
+ /// True if the path exists, false otherwise
+ public Task Exists([NotNull] string path);
+
+ ///
+ /// Get the extra directory of a show.
+ /// This method is in this system to allow a filesystem to use a different metadata policy for one.
+ /// It can be useful if the filesystem is readonly.
+ ///
+ /// The show to proceed
+ /// The extra directory of the show
public string GetExtraDirectory(Show show);
+ ///
+ /// Get the extra directory of a season.
+ /// This method is in this system to allow a filesystem to use a different metadata policy for one.
+ /// It can be useful if the filesystem is readonly.
+ ///
+ /// The season to proceed
+ /// The extra directory of the season
public string GetExtraDirectory(Season season);
+ ///
+ /// Get the extra directory of an episode.
+ /// This method is in this system to allow a filesystem to use a different metadata policy for one.
+ /// It can be useful if the filesystem is readonly.
+ ///
+ /// The episode to proceed
+ /// The extra directory of the episode
public string GetExtraDirectory(Episode episode);
}
}
\ No newline at end of file
diff --git a/Kyoo.Common/Controllers/ILibraryManager.cs b/Kyoo.Common/Controllers/ILibraryManager.cs
index d15987de..2cd0c909 100644
--- a/Kyoo.Common/Controllers/ILibraryManager.cs
+++ b/Kyoo.Common/Controllers/ILibraryManager.cs
@@ -18,7 +18,7 @@ namespace Kyoo.Controllers
/// Get the repository corresponding to the T item.
///
/// The type you want
- /// If the item is not found
+ /// If the item is not found
/// The repository corresponding
IRepository GetRepository() where T : class, IResource;
@@ -82,8 +82,9 @@ namespace Kyoo.Controllers
///
/// The id of the resource
/// The type of the resource
- /// If the item is not found
+ /// If the item is not found
/// The resource found
+ [ItemNotNull]
Task Get(int id) where T : class, IResource;
///
@@ -91,8 +92,9 @@ namespace Kyoo.Controllers
///
/// The slug of the resource
/// The type of the resource
- /// If the item is not found
+ /// If the item is not found
/// The resource found
+ [ItemNotNull]
Task Get(string slug) where T : class, IResource;
///
@@ -100,8 +102,9 @@ namespace Kyoo.Controllers
///
/// The filter function.
/// The type of the resource
- /// If the item is not found
+ /// If the item is not found
/// The first resource found that match the where function
+ [ItemNotNull]
Task Get(Expression> where) where T : class, IResource;
///
@@ -109,8 +112,9 @@ namespace Kyoo.Controllers
///
/// The id of the show
/// The season's number
- /// If the item is not found
+ /// If the item is not found
/// The season found
+ [ItemNotNull]
Task Get(int showID, int seasonNumber);
///
@@ -118,8 +122,9 @@ namespace Kyoo.Controllers
///
/// The slug of the show
/// The season's number
- /// If the item is not found
+ /// If the item is not found
/// The season found
+ [ItemNotNull]
Task Get(string showSlug, int seasonNumber);
///
@@ -128,8 +133,9 @@ namespace Kyoo.Controllers
/// The id of the show
/// The season's number
/// The episode's number
- /// If the item is not found
+ /// If the item is not found
/// The episode found
+ [ItemNotNull]
Task Get(int showID, int seasonNumber, int episodeNumber);
///
@@ -138,8 +144,9 @@ namespace Kyoo.Controllers
/// The slug of the show
/// The season's number
/// The episode's number
- /// If the item is not found
+ /// If the item is not found
/// The episode found
+ [ItemNotNull]
Task Get(string showSlug, int seasonNumber, int episodeNumber);
///
@@ -147,8 +154,9 @@ namespace Kyoo.Controllers
///
/// The slug of the track
/// The type (Video, Audio or Subtitle)
- /// If the item is not found
- /// The tracl found
+ /// If the item is not found
+ /// The track found
+ [ItemNotNull]
Task
/// The slug of the show
/// The season's number
- /// If the item is not found
+ /// If the item is not found
/// The season found
Task Get(string showSlug, int seasonNumber);
@@ -362,7 +361,7 @@ namespace Kyoo.Controllers
/// The id of the show
/// The season's number
/// The episode's number
- /// If the item is not found
+ /// If the item is not found
/// The episode found
Task Get(int showID, int seasonNumber, int episodeNumber);
///
@@ -371,7 +370,7 @@ namespace Kyoo.Controllers
/// The slug of the show
/// The season's number
/// The episode's number
- /// If the item is not found
+ /// If the item is not found
/// The episode found
Task Get(string showSlug, int seasonNumber, int episodeNumber);
@@ -397,7 +396,7 @@ namespace Kyoo.Controllers
///
/// The id of the show
/// The episode's absolute number (The episode number does not reset to 1 after the end of a season.
- /// If the item is not found
+ /// If the item is not found
/// The episode found
Task GetAbsolute(int showID, int absoluteNumber);
///
@@ -405,7 +404,7 @@ namespace Kyoo.Controllers
///
/// The slug of the show
/// The episode's absolute number (The episode number does not reset to 1 after the end of a season.
- /// If the item is not found
+ /// If the item is not found
/// The episode found
Task GetAbsolute(string showSlug, int absoluteNumber);
}
@@ -420,8 +419,8 @@ namespace Kyoo.Controllers
///
/// The slug of the track
/// The type (Video, Audio or Subtitle)
- /// If the item is not found
- /// The tracl found
+ /// If the item is not found
+ /// The track found
Task Get(string slug, StreamType type = StreamType.Unknown);
///
@@ -429,7 +428,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 +438,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 +455,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 +469,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 +481,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 +520,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 +546,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 +572,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 +598,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 +630,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 +641,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/Controllers/ITask.cs b/Kyoo.Common/Controllers/ITask.cs
new file mode 100644
index 00000000..75277dd2
--- /dev/null
+++ b/Kyoo.Common/Controllers/ITask.cs
@@ -0,0 +1,188 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Kyoo.Models.Attributes;
+
+namespace Kyoo.Controllers
+{
+ ///
+ /// A single task parameter. This struct contains metadata to display and utility functions to get them in the taks.
+ ///
+ /// This struct will be used to generate the swagger documentation of the task.
+ public record TaskParameter
+ {
+ ///
+ /// The name of this parameter.
+ ///
+ public string Name { get; init; }
+
+ ///
+ /// The description of this parameter.
+ ///
+ public string Description { get; init; }
+
+ ///
+ /// The type of this parameter.
+ ///
+ public Type Type { get; init; }
+
+ ///
+ /// Is this parameter required or can it be ignored?
+ ///
+ public bool IsRequired { get; init; }
+
+ ///
+ /// The default value of this object.
+ ///
+ public object DefaultValue { get; init; }
+
+ ///
+ /// The value of the parameter.
+ ///
+ private object Value { get; init; }
+
+ ///
+ /// Create a new task parameter.
+ ///
+ /// The name of the parameter
+ /// The description of the parameter
+ /// The type of the parameter.
+ /// A new task parameter.
+ public static TaskParameter Create(string name, string description)
+ {
+ return new()
+ {
+ Name = name,
+ Description = description,
+ Type = typeof(T)
+ };
+ }
+
+ ///
+ /// Create a parameter's value to give to a task.
+ ///
+ /// The name of the parameter
+ /// The value of the parameter. It's type will be used as parameter's type.
+ /// The type of the parameter
+ /// A TaskParameter that can be used as value.
+ public static TaskParameter CreateValue(string name, T value)
+ {
+ return new()
+ {
+ Name = name,
+ Type = typeof(T),
+ Value = value
+ };
+ }
+
+ ///
+ /// Create a parameter's value for the current parameter.
+ ///
+ /// The value to use
+ /// A new parameter's value for this current parameter
+ public TaskParameter CreateValue(object value)
+ {
+ return this with {Value = value};
+ }
+
+ ///
+ /// Get the value of this parameter. If the value is of the wrong type, it will be converted.
+ ///
+ /// The type of this parameter
+ /// The value of this parameter.
+ public T As()
+ {
+ return (T)Convert.ChangeType(Value, typeof(T));
+ }
+ }
+
+ ///
+ /// A parameters container implementing an indexer to allow simple usage of parameters.
+ ///
+ public class TaskParameters : List
+ {
+ ///
+ /// An indexer that return the parameter with the specified name.
+ ///
+ /// The name of the task (case sensitive)
+ public TaskParameter this[string name] => this.FirstOrDefault(x => x.Name == name);
+
+
+ ///
+ /// Create a new, empty,
+ ///
+ public TaskParameters() {}
+
+ ///
+ /// Create a with an initial parameters content
+ ///
+ /// The list of parameters
+ public TaskParameters(IEnumerable parameters)
+ {
+ AddRange(parameters);
+ }
+ }
+
+ ///
+ /// A common interface that tasks should implement.
+ ///
+ public interface ITask
+ {
+ ///
+ /// The slug of the task, used to start it.
+ ///
+ public string Slug { get; }
+
+ ///
+ /// The name of the task that will be displayed to the user.
+ ///
+ public string Name { get; }
+
+ ///
+ /// A quick description of what this task will do.
+ ///
+ public string Description { get; }
+
+ ///
+ /// An optional message to display to help the user.
+ ///
+ public string HelpMessage { get; }
+
+ ///
+ /// Should this task be automatically run at app startup?
+ ///
+ public bool RunOnStartup { get; }
+
+ ///
+ /// The priority of this task. Only used if is true.
+ /// It allow one to specify witch task will be started first as tasked are run on a Priority's descending order.
+ ///
+ public int Priority { get; }
+
+ ///
+ /// Start this task.
+ ///
+ /// The list of parameters.
+ /// A token to request the task's cancellation.
+ /// If this task is not cancelled quickly, it might be killed by the runner.
+ ///
+ /// Your task can have any service as a public field and use the ,
+ /// they will be set to an available service from the service container before calling this method.
+ ///
+ public Task Run(TaskParameters arguments, CancellationToken cancellationToken);
+
+ ///
+ /// The list of parameters
+ ///
+ /// All parameters that this task as. Every one of them will be given to the run function with a value.
+ public TaskParameters GetParameters();
+
+ ///
+ /// If this task is running, return the percentage of completion of this task or null if no percentage can be given.
+ ///
+ /// The percentage of completion of the task.
+ public int? Progress();
+ }
+}
\ No newline at end of file
diff --git a/Kyoo.Common/Controllers/ITaskManager.cs b/Kyoo.Common/Controllers/ITaskManager.cs
index 5c926eeb..392355d3 100644
--- a/Kyoo.Common/Controllers/ITaskManager.cs
+++ b/Kyoo.Common/Controllers/ITaskManager.cs
@@ -1,13 +1,34 @@
+using System;
using System.Collections.Generic;
-using Kyoo.Models;
+using Kyoo.Models.Exceptions;
namespace Kyoo.Controllers
{
+ ///
+ /// A service to handle long running tasks.
+ ///
+ /// The concurrent number of running tasks is implementation dependent.
public interface ITaskManager
{
- bool StartTask(string taskSlug, string arguments = null);
- ITask GetRunningTask();
- void ReloadTask();
- IEnumerable GetAllTasks();
+ ///
+ /// Start a new task (or queue it).
+ ///
+ /// The slug of the task to run
+ /// A list of arguments to pass to the task. An automatic conversion will be made if arguments to not fit.
+ /// If the number of arguments is invalid or if an argument can't be converted.
+ /// The task could not be found.
+ void StartTask(string taskSlug, Dictionary arguments = null);
+
+ ///
+ /// Get all currently running tasks
+ ///
+ /// A list of currently running tasks.
+ ICollection GetRunningTasks();
+
+ ///
+ /// Get all available tasks
+ ///
+ /// A list of every tasks that this instance know.
+ ICollection GetAllTasks();
}
}
\ No newline at end of file
diff --git a/Kyoo.Common/Controllers/IThumbnailsManager.cs b/Kyoo.Common/Controllers/IThumbnailsManager.cs
index 2282981a..ee31498a 100644
--- a/Kyoo.Common/Controllers/IThumbnailsManager.cs
+++ b/Kyoo.Common/Controllers/IThumbnailsManager.cs
@@ -1,5 +1,4 @@
using Kyoo.Models;
-using System.Collections.Generic;
using System.Threading.Tasks;
using JetBrains.Annotations;
diff --git a/Kyoo.Common/Controllers/Implementations/LibraryManager.cs b/Kyoo.Common/Controllers/Implementations/LibraryManager.cs
index 2fb16735..ce34f267 100644
--- a/Kyoo.Common/Controllers/Implementations/LibraryManager.cs
+++ b/Kyoo.Common/Controllers/Implementations/LibraryManager.cs
@@ -40,7 +40,7 @@ namespace Kyoo.Controllers
///
- /// Create a new instancce with every repository available.
+ /// Create a new instance with every repository available.
///
/// The list of repositories that this library manager should manage.
/// If a repository for every base type is not available, this instance won't be stable.
@@ -66,7 +66,7 @@ namespace Kyoo.Controllers
{
if (_repositories.FirstOrDefault(x => x.RepositoryType == typeof(T)) is IRepository ret)
return ret;
- throw new ItemNotFound();
+ throw new ItemNotFoundException($"No repository found for the type {typeof(T).Name}.");
}
///
diff --git a/Kyoo.Common/Kyoo.Common.csproj b/Kyoo.Common/Kyoo.Common.csproj
index 21d71761..349ef6a0 100644
--- a/Kyoo.Common/Kyoo.Common.csproj
+++ b/Kyoo.Common/Kyoo.Common.csproj
@@ -21,8 +21,10 @@
-
+
+
+
diff --git a/Kyoo.Common/MethodOfUtils.cs b/Kyoo.Common/MethodOfUtils.cs
new file mode 100644
index 00000000..5d051f69
--- /dev/null
+++ b/Kyoo.Common/MethodOfUtils.cs
@@ -0,0 +1,91 @@
+using System;
+using System.Reflection;
+
+namespace Kyoo
+{
+ ///
+ /// Static class containing MethodOf calls.
+ ///
+ public static class MethodOfUtils
+ {
+ ///
+ /// Get a MethodInfo from a direct method.
+ ///
+ /// The method (without any arguments or return value.
+ /// The of the given method
+ public static MethodInfo MethodOf(Action action)
+ {
+ return action.Method;
+ }
+
+ ///
+ /// Get a MethodInfo from a direct method.
+ ///
+ /// The method (without any arguments or return value.
+ /// The of the given method
+ public static MethodInfo MethodOf(Action action)
+ {
+ return action.Method;
+ }
+
+ ///
+ /// Get a MethodInfo from a direct method.
+ ///
+ /// The method (without any arguments or return value.
+ /// The of the given method
+ public static MethodInfo MethodOf(Action action)
+ {
+ return action.Method;
+ }
+
+ ///
+ /// Get a MethodInfo from a direct method.
+ ///
+ /// The method (without any arguments or return value.
+ /// The of the given method
+ public static MethodInfo MethodOf(Action action)
+ {
+ return action.Method;
+ }
+
+ ///
+ /// Get a MethodInfo from a direct method.
+ ///
+ /// The method (without any arguments or return value.
+ /// The of the given method
+ public static MethodInfo MethodOf(Func action)
+ {
+ return action.Method;
+ }
+
+ ///
+ /// Get a MethodInfo from a direct method.
+ ///
+ /// The method (without any arguments or return value.
+ /// The of the given method
+ public static MethodInfo MethodOf(Func action)
+ {
+ return action.Method;
+ }
+
+ ///
+ /// Get a MethodInfo from a direct method.
+ ///
+ /// The method (without any arguments or return value.
+ /// The of the given method
+ public static MethodInfo MethodOf(Func action)
+ {
+ return action.Method;
+ }
+
+ ///
+ /// Get a MethodInfo from a direct method.
+ ///
+ /// The method (without any arguments or return value.
+ /// The of the given method
+ public static MethodInfo MethodOf(Func action)
+ {
+ return action.Method;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Kyoo.Common/Models/Attributes/InjectedAttribute.cs b/Kyoo.Common/Models/Attributes/InjectedAttribute.cs
new file mode 100644
index 00000000..1e9a8ece
--- /dev/null
+++ b/Kyoo.Common/Models/Attributes/InjectedAttribute.cs
@@ -0,0 +1,16 @@
+using System;
+using JetBrains.Annotations;
+using Kyoo.Controllers;
+
+namespace Kyoo.Models.Attributes
+{
+ ///
+ /// An attribute to inform that the service will be injected automatically by a service provider.
+ ///
+ ///
+ /// It should only be used on and will be injected before calling
+ ///
+ [AttributeUsage(AttributeTargets.Property)]
+ [MeansImplicitUse(ImplicitUseKindFlags.Assign)]
+ public class InjectedAttribute : Attribute { }
+}
\ No newline at end of file
diff --git a/Kyoo.Common/Models/Attributes/MergeAttributes.cs b/Kyoo.Common/Models/Attributes/MergeAttributes.cs
index 399f5389..54d49d52 100644
--- a/Kyoo.Common/Models/Attributes/MergeAttributes.cs
+++ b/Kyoo.Common/Models/Attributes/MergeAttributes.cs
@@ -2,10 +2,21 @@ using System;
namespace Kyoo.Models.Attributes
{
- public class NotMergableAttribute : Attribute { }
+ ///
+ /// Specify that a property can't be merged.
+ ///
+ [AttributeUsage(AttributeTargets.Property)]
+ public class NotMergeableAttribute : Attribute { }
+ ///
+ /// An interface with a method called when this object is merged.
+ ///
public interface IOnMerge
{
+ ///
+ /// This function is called after the object has been merged.
+ ///
+ /// The object that has been merged with this.
void OnMerge(object merged);
}
}
\ No newline at end of file
diff --git a/Kyoo.Common/Models/Attributes/PermissionAttribute.cs b/Kyoo.Common/Models/Attributes/PermissionAttribute.cs
new file mode 100644
index 00000000..b34fb48b
--- /dev/null
+++ b/Kyoo.Common/Models/Attributes/PermissionAttribute.cs
@@ -0,0 +1,154 @@
+using System;
+using Microsoft.AspNetCore.Mvc.Filters;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Kyoo.Models.Permissions
+{
+ ///
+ /// The kind of permission needed.
+ ///
+ public enum Kind
+ {
+ Read,
+ Write,
+ Create,
+ Delete
+ }
+
+ ///
+ /// Specify permissions needed for the API.
+ ///
+ [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
+ public class PermissionAttribute : Attribute, IFilterFactory
+ {
+ ///
+ /// The needed permission as string.
+ ///
+ public string Type { get; }
+ ///
+ /// The needed permission kind.
+ ///
+ public Kind Kind { get; }
+
+ ///
+ /// Ask a permission to run an action.
+ ///
+ ///
+ /// The type of the action
+ /// (if the type ends with api, it will be removed. This allow you to use nameof(YourApi)).
+ ///
+ /// The kind of permission needed
+ public PermissionAttribute(string type, Kind permission)
+ {
+ if (type.EndsWith("API", StringComparison.OrdinalIgnoreCase))
+ type = type[..^3];
+ Type = type.ToLower();
+ Kind = permission;
+ }
+
+ ///
+ public IFilterMetadata CreateInstance(IServiceProvider serviceProvider)
+ {
+ return serviceProvider.GetRequiredService().Create(this);
+ }
+
+ ///
+ public bool IsReusable => true;
+
+ ///
+ /// Return this permission attribute as a string
+ ///
+ /// The string representation.
+ public string AsPermissionString()
+ {
+ return Type;
+ }
+ }
+
+ ///
+ /// Specify one part of a permissions needed for the API (the kind or the type).
+ ///
+ [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
+ public class PartialPermissionAttribute : Attribute, IFilterFactory
+ {
+ ///
+ /// The needed permission type.
+ ///
+ public string Type { get; }
+ ///
+ /// The needed permission kind.
+ ///
+ public Kind Kind { get; }
+
+ ///
+ /// Ask a permission to run an action.
+ ///
+ ///
+ /// With this attribute, you can only specify a type or a kind.
+ /// To have a valid permission attribute, you must specify the kind and the permission using two attributes.
+ /// Those attributes can be dispatched at different places (one on the class, one on the method for example).
+ /// If you don't put exactly two of those attributes, the permission attribute will be ill-formed and will
+ /// lead to unspecified behaviors.
+ ///
+ ///
+ /// The type of the action
+ /// (if the type ends with api, it will be removed. This allow you to use nameof(YourApi)).
+ ///
+ public PartialPermissionAttribute(string type)
+ {
+ if (type.EndsWith("API", StringComparison.OrdinalIgnoreCase))
+ type = type[..^3];
+ Type = type.ToLower();
+ }
+
+ ///
+ /// Ask a permission to run an action.
+ ///
+ ///
+ /// With this attribute, you can only specify a type or a kind.
+ /// To have a valid permission attribute, you must specify the kind and the permission using two attributes.
+ /// Those attributes can be dispatched at different places (one on the class, one on the method for example).
+ /// If you don't put exactly two of those attributes, the permission attribute will be ill-formed and will
+ /// lead to unspecified behaviors.
+ ///
+ /// The kind of permission needed
+ public PartialPermissionAttribute(Kind permission)
+ {
+ Kind = permission;
+ }
+
+ ///
+ public IFilterMetadata CreateInstance(IServiceProvider serviceProvider)
+ {
+ return serviceProvider.GetRequiredService().Create(this);
+ }
+
+ ///
+ public bool IsReusable => true;
+ }
+
+
+ ///
+ /// A service to validate permissions
+ ///
+ public interface IPermissionValidator
+ {
+ ///
+ /// Create an IAuthorizationFilter that will be used to validate permissions.
+ /// This can registered with any lifetime.
+ ///
+ /// The permission attribute to validate
+ /// An authorization filter used to validate the permission
+ IFilterMetadata Create(PermissionAttribute attribute);
+
+ ///
+ /// Create an IAuthorizationFilter that will be used to validate permissions.
+ /// This can registered with any lifetime.
+ ///
+ ///
+ /// A partial attribute to validate. See .
+ ///
+ /// An authorization filter used to validate the permission
+ IFilterMetadata Create(PartialPermissionAttribute attribute);
+ }
+}
\ No newline at end of file
diff --git a/Kyoo.Common/Models/Exceptions/DuplicatedItemException.cs b/Kyoo.Common/Models/Exceptions/DuplicatedItemException.cs
index 19be8a04..b0d26bf0 100644
--- a/Kyoo.Common/Models/Exceptions/DuplicatedItemException.cs
+++ b/Kyoo.Common/Models/Exceptions/DuplicatedItemException.cs
@@ -1,19 +1,36 @@
using System;
+using System.Runtime.Serialization;
namespace Kyoo.Models.Exceptions
{
+ ///
+ /// An exception raised when an item already exists in the database.
+ ///
+ [Serializable]
public class DuplicatedItemException : Exception
{
- public override string Message { get; }
-
+ ///
+ /// Create a new with the default message.
+ ///
public DuplicatedItemException()
- {
- Message = "Already exists in the databse.";
- }
+ : base("Already exists in the database.")
+ { }
+ ///
+ /// Create a new with a custom message.
+ ///
+ /// The message to use
public DuplicatedItemException(string message)
- {
- Message = message;
- }
+ : base(message)
+ { }
+
+ ///
+ /// The serialization constructor
+ ///
+ /// Serialization infos
+ /// The serialization context
+ protected DuplicatedItemException(SerializationInfo info, StreamingContext context)
+ : base(info, context)
+ { }
}
}
\ No newline at end of file
diff --git a/Kyoo.Common/Models/Exceptions/ItemNotFound.cs b/Kyoo.Common/Models/Exceptions/ItemNotFound.cs
deleted file mode 100644
index f23cf363..00000000
--- a/Kyoo.Common/Models/Exceptions/ItemNotFound.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-using System;
-
-namespace Kyoo.Models.Exceptions
-{
- public class ItemNotFound : Exception
- {
- public override string Message { get; }
-
- public ItemNotFound() {}
-
- public ItemNotFound(string message)
- {
- Message = message;
- }
- }
-}
\ No newline at end of file
diff --git a/Kyoo.Common/Models/Exceptions/ItemNotFoundException.cs b/Kyoo.Common/Models/Exceptions/ItemNotFoundException.cs
new file mode 100644
index 00000000..d05882b1
--- /dev/null
+++ b/Kyoo.Common/Models/Exceptions/ItemNotFoundException.cs
@@ -0,0 +1,34 @@
+using System;
+using System.Runtime.Serialization;
+
+namespace Kyoo.Models.Exceptions
+{
+ ///
+ /// An exception raised when an item could not be found.
+ ///
+ [Serializable]
+ public class ItemNotFoundException : Exception
+ {
+ ///
+ /// Create a default with no message.
+ ///
+ public ItemNotFoundException() {}
+
+ ///
+ /// Create a new with a message
+ ///
+ /// The message of the exception
+ public ItemNotFoundException(string message)
+ : base(message)
+ { }
+
+ ///
+ /// The serialization constructor
+ ///
+ /// Serialization infos
+ /// The serialization context
+ protected ItemNotFoundException(SerializationInfo info, StreamingContext context)
+ : base(info, context)
+ { }
+ }
+}
\ No newline at end of file
diff --git a/Kyoo.Common/Models/LibraryItem.cs b/Kyoo.Common/Models/LibraryItem.cs
index 6fe964d4..78f604f2 100644
--- a/Kyoo.Common/Models/LibraryItem.cs
+++ b/Kyoo.Common/Models/LibraryItem.cs
@@ -1,5 +1,6 @@
using System;
using System.Linq.Expressions;
+using JetBrains.Annotations;
using Kyoo.Models.Attributes;
namespace Kyoo.Models
@@ -22,7 +23,7 @@ namespace Kyoo.Models
public int? StartYear { get; set; }
public int? EndYear { get; set; }
[SerializeAs("{HOST}/api/{_type}/{Slug}/poster")] public string Poster { get; set; }
- private string _type => Type == ItemType.Collection ? "collection" : "show";
+ [UsedImplicitly] private string _type => Type == ItemType.Collection ? "collection" : "show";
public ItemType Type { get; set; }
public LibraryItem() {}
diff --git a/Kyoo.Common/Models/Link.cs b/Kyoo.Common/Models/Link.cs
index f504b5a0..2df85f1f 100644
--- a/Kyoo.Common/Models/Link.cs
+++ b/Kyoo.Common/Models/Link.cs
@@ -1,4 +1,5 @@
using System;
+using System.Diagnostics.CodeAnalysis;
using System.Linq.Expressions;
namespace Kyoo.Models
@@ -60,6 +61,7 @@ namespace Kyoo.Models
public Link() {}
+ [SuppressMessage("ReSharper", "VirtualMemberCallInConstructor")]
public Link(T1 first, T2 second, bool privateItems = false)
: base(first, second)
{
diff --git a/Kyoo.Common/Models/MetadataID.cs b/Kyoo.Common/Models/MetadataID.cs
index cc7985ac..d1752d50 100644
--- a/Kyoo.Common/Models/MetadataID.cs
+++ b/Kyoo.Common/Models/MetadataID.cs
@@ -22,14 +22,5 @@ namespace Kyoo.Models
public string DataID { get; set; }
public string Link { get; set; }
-
- public MetadataID() { }
-
- public MetadataID(Provider provider, string dataID, string link)
- {
- Provider = provider;
- DataID = dataID;
- Link = link;
- }
}
}
\ No newline at end of file
diff --git a/Kyoo.Common/Models/PeopleRole.cs b/Kyoo.Common/Models/PeopleRole.cs
index fe027682..be48abd1 100644
--- a/Kyoo.Common/Models/PeopleRole.cs
+++ b/Kyoo.Common/Models/PeopleRole.cs
@@ -1,4 +1,3 @@
-using System.Collections.Generic;
using Kyoo.Models.Attributes;
namespace Kyoo.Models
@@ -14,27 +13,5 @@ namespace Kyoo.Models
[SerializeIgnore] public virtual Show Show { get; set; }
public string Role { get; set; }
public string Type { get; set; }
-
- public PeopleRole() {}
-
- public PeopleRole(People people, Show show, string role, string type)
- {
- People = people;
- Show = show;
- Role = role;
- Type = type;
- }
-
- public PeopleRole(string slug,
- string name,
- string role,
- string type,
- string poster,
- IEnumerable externalIDs)
- {
- People = new People(slug, name, poster, externalIDs);
- Role = role;
- Type = type;
- }
}
}
\ No newline at end of file
diff --git a/Kyoo.Common/Models/Plugin.cs b/Kyoo.Common/Models/Plugin.cs
deleted file mode 100644
index 7947d542..00000000
--- a/Kyoo.Common/Models/Plugin.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-using System.Collections.Generic;
-
-namespace Kyoo.Models
-{
- public interface IPlugin
- {
- public string Name { get; }
- public ICollection Tasks { get; }
- }
-}
\ No newline at end of file
diff --git a/Kyoo.Common/Models/Resources/Episode.cs b/Kyoo.Common/Models/Resources/Episode.cs
index 27feefd3..29aeab06 100644
--- a/Kyoo.Common/Models/Resources/Episode.cs
+++ b/Kyoo.Common/Models/Resources/Episode.cs
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
-using System.Linq;
using Kyoo.Models.Attributes;
namespace Kyoo.Models
@@ -32,48 +31,6 @@ namespace Kyoo.Models
[EditableRelation] [LoadableRelation] public virtual ICollection Tracks { get; set; }
- public Episode() { }
-
- public Episode(int seasonNumber,
- int episodeNumber,
- int absoluteNumber,
- string title,
- string overview,
- DateTime? releaseDate,
- int runtime,
- string thumb,
- IEnumerable externalIDs)
- {
- SeasonNumber = seasonNumber;
- EpisodeNumber = episodeNumber;
- AbsoluteNumber = absoluteNumber;
- Title = title;
- Overview = overview;
- ReleaseDate = releaseDate;
- Runtime = runtime;
- Thumb = thumb;
- ExternalIDs = externalIDs?.ToArray();
- }
-
- public Episode(int showID,
- int seasonID,
- int seasonNumber,
- int episodeNumber,
- int absoluteNumber,
- string path,
- string title,
- string overview,
- DateTime? releaseDate,
- int runtime,
- string poster,
- IEnumerable externalIDs)
- : this(seasonNumber, episodeNumber, absoluteNumber, title, overview, releaseDate, runtime, poster, externalIDs)
- {
- ShowID = showID;
- SeasonID = seasonID;
- Path = path;
- }
-
public static string GetSlug(string showSlug, int seasonNumber, int episodeNumber, int absoluteNumber)
{
if (showSlug == null)
diff --git a/Kyoo.Common/Models/Resources/IResource.cs b/Kyoo.Common/Models/Resources/IResource.cs
index 297f3b1d..c4c4231b 100644
--- a/Kyoo.Common/Models/Resources/IResource.cs
+++ b/Kyoo.Common/Models/Resources/IResource.cs
@@ -1,30 +1,23 @@
-using System;
-using System.Collections.Generic;
-
namespace Kyoo.Models
{
+ ///
+ /// An interface to represent a resource that can be retrieved from the database.
+ ///
public interface IResource
{
+ ///
+ /// A unique ID for this type of resource. This can't be changed and duplicates are not allowed.
+ ///
public int ID { get; set; }
+
+ ///
+ /// A human-readable identifier that can be used instead of an ID.
+ /// A slug must be unique for a type of resource but it can be changed.
+ ///
+ ///
+ /// There is no setter for a slug since it can be computed from other fields.
+ /// For example, a season slug is {ShowSlug}-s{SeasonNumber}.
+ ///
public string Slug { get; }
}
-
- public class ResourceComparer : IEqualityComparer where T : IResource
- {
- public bool Equals(T x, T y)
- {
- if (ReferenceEquals(x, y))
- return true;
- if (ReferenceEquals(x, null))
- return false;
- if (ReferenceEquals(y, null))
- return false;
- return x.ID == y.ID || x.Slug == y.Slug;
- }
-
- public int GetHashCode(T obj)
- {
- return HashCode.Combine(obj.ID, obj.Slug);
- }
- }
}
\ No newline at end of file
diff --git a/Kyoo.Common/Models/Resources/Library.cs b/Kyoo.Common/Models/Resources/Library.cs
index 86cb324d..c8148544 100644
--- a/Kyoo.Common/Models/Resources/Library.cs
+++ b/Kyoo.Common/Models/Resources/Library.cs
@@ -1,5 +1,4 @@
using System.Collections.Generic;
-using System.Linq;
using Kyoo.Models.Attributes;
namespace Kyoo.Models
@@ -21,15 +20,5 @@ namespace Kyoo.Models
[SerializeIgnore] public virtual ICollection> ShowLinks { get; set; }
[SerializeIgnore] public virtual ICollection> CollectionLinks { get; set; }
#endif
-
- public Library() { }
-
- public Library(string slug, string name, IEnumerable paths, IEnumerable providers)
- {
- Slug = slug;
- Name = name;
- Paths = paths?.ToArray();
- Providers = providers?.ToArray();
- }
}
}
diff --git a/Kyoo.Common/Models/Resources/People.cs b/Kyoo.Common/Models/Resources/People.cs
index 2743cf3c..46b86143 100644
--- a/Kyoo.Common/Models/Resources/People.cs
+++ b/Kyoo.Common/Models/Resources/People.cs
@@ -1,5 +1,4 @@
using System.Collections.Generic;
-using System.Linq;
using Kyoo.Models.Attributes;
namespace Kyoo.Models
@@ -13,15 +12,5 @@ namespace Kyoo.Models
[EditableRelation] [LoadableRelation] public virtual ICollection ExternalIDs { get; set; }
[EditableRelation] [LoadableRelation] public virtual ICollection Roles { get; set; }
-
- public People() {}
-
- public People(string slug, string name, string poster, IEnumerable externalIDs)
- {
- Slug = slug;
- Name = name;
- Poster = poster;
- ExternalIDs = externalIDs?.ToArray();
- }
}
}
diff --git a/Kyoo.Common/Models/Resources/Season.cs b/Kyoo.Common/Models/Resources/Season.cs
index 827b24f7..b3f7ab27 100644
--- a/Kyoo.Common/Models/Resources/Season.cs
+++ b/Kyoo.Common/Models/Resources/Season.cs
@@ -1,5 +1,4 @@
using System.Collections.Generic;
-using System.Linq;
using Kyoo.Models.Attributes;
namespace Kyoo.Models
@@ -22,24 +21,5 @@ namespace Kyoo.Models
[EditableRelation] [LoadableRelation] public virtual ICollection ExternalIDs { get; set; }
[LoadableRelation] public virtual ICollection Episodes { get; set; }
-
- public Season() { }
-
- public Season(int showID,
- int seasonNumber,
- string title,
- string overview,
- int? year,
- string poster,
- IEnumerable externalIDs)
- {
- ShowID = showID;
- SeasonNumber = seasonNumber;
- Title = title;
- Overview = overview;
- Year = year;
- Poster = poster;
- ExternalIDs = externalIDs?.ToArray();
- }
}
}
diff --git a/Kyoo.Common/Models/Resources/Show.cs b/Kyoo.Common/Models/Resources/Show.cs
index 7b948b55..e7d14d79 100644
--- a/Kyoo.Common/Models/Resources/Show.cs
+++ b/Kyoo.Common/Models/Resources/Show.cs
@@ -42,62 +42,6 @@ namespace Kyoo.Models
[SerializeIgnore] public virtual ICollection> GenreLinks { get; set; }
#endif
-
- public Show() { }
-
- public Show(string slug,
- string title,
- IEnumerable aliases,
- string path, string overview,
- string trailerUrl,
- IEnumerable genres,
- Status? status,
- int? startYear,
- int? endYear,
- IEnumerable externalIDs)
- {
- Slug = slug;
- Title = title;
- Aliases = aliases?.ToArray();
- Path = path;
- Overview = overview;
- TrailerUrl = trailerUrl;
- Genres = genres?.ToArray();
- Status = status;
- StartYear = startYear;
- EndYear = endYear;
- ExternalIDs = externalIDs?.ToArray();
- }
-
- public Show(string slug,
- string title,
- IEnumerable aliases,
- string path,
- string overview,
- string trailerUrl,
- Status? status,
- int? startYear,
- int? endYear,
- string poster,
- string logo,
- string backdrop,
- IEnumerable externalIDs)
- {
- Slug = slug;
- Title = title;
- Aliases = aliases?.ToArray();
- Path = path;
- Overview = overview;
- TrailerUrl = trailerUrl;
- Status = status;
- StartYear = startYear;
- EndYear = endYear;
- Poster = poster;
- Logo = logo;
- Backdrop = backdrop;
- ExternalIDs = externalIDs?.ToArray();
- }
-
public string GetID(string provider)
{
return ExternalIDs?.FirstOrDefault(x => x.Provider.Name == provider)?.DataID;
diff --git a/Kyoo.Common/Models/Resources/User.cs b/Kyoo.Common/Models/Resources/User.cs
new file mode 100644
index 00000000..94afa240
--- /dev/null
+++ b/Kyoo.Common/Models/Resources/User.cs
@@ -0,0 +1,69 @@
+using System.Collections.Generic;
+
+namespace Kyoo.Models
+{
+ ///
+ /// A single user of the app.
+ ///
+ public class User : IResource
+ {
+ ///
+ public int ID { get; set; }
+
+ ///
+ public string Slug { get; set; }
+
+ ///
+ /// A username displayed to the user.
+ ///
+ public string Username { get; set; }
+
+ ///
+ /// The user email address.
+ ///
+ public string Email { get; set; }
+
+ ///
+ /// The user password (hashed, it can't be read like that). The hashing format is implementation defined.
+ ///
+ 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.
+ ///
+ public ICollection Watched { get; set; }
+
+ ///
+ /// The list of episodes the user is watching (stopped in progress or the next episode of the show)
+ ///
+ public ICollection CurrentlyWatching { get; set; }
+
+#if ENABLE_INTERNAL_LINKS
+ ///
+ /// Links between Users and Shows.
+ ///
+ public ICollection> ShowLinks { get; set; }
+#endif
+ }
+
+ ///
+ /// Metadata of episode currently watching by an user
+ ///
+ public class WatchedEpisode : Link
+ {
+ ///
+ /// 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/Models/Task.cs b/Kyoo.Common/Models/Task.cs
deleted file mode 100644
index 76cfbc52..00000000
--- a/Kyoo.Common/Models/Task.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Threading;
-using System.Threading.Tasks;
-
-namespace Kyoo.Models
-{
- public interface ITask
- {
- public string Slug { get; }
- public string Name { get; }
- public string Description { get; }
- public string HelpMessage { get; }
- public bool RunOnStartup { get; }
- public int Priority { get; }
- public Task Run(IServiceProvider serviceProvider, CancellationToken cancellationToken, string arguments = null);
- public Task> GetPossibleParameters();
- public int? Progress();
- }
-}
\ No newline at end of file
diff --git a/Kyoo.Common/Module.cs b/Kyoo.Common/Module.cs
new file mode 100644
index 00000000..c1c09165
--- /dev/null
+++ b/Kyoo.Common/Module.cs
@@ -0,0 +1,66 @@
+using System;
+using Kyoo.Controllers;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Kyoo
+{
+ ///
+ /// A static class with helper functions to setup external modules
+ ///
+ public static class Module
+ {
+ ///
+ /// Register a new task to the container.
+ ///
+ /// The container
+ /// The type of the task
+ /// The initial container.
+ public static IServiceCollection AddTask(this IServiceCollection services)
+ where T : class, ITask
+ {
+ services.AddSingleton();
+ return services;
+ }
+
+ ///
+ /// Register a new repository to the container.
+ ///
+ /// The container
+ /// The lifetime of the repository. The default is scoped.
+ /// The type of the repository.
+ ///
+ /// If your repository implements a special interface, please use
+ ///
+ /// The initial container.
+ public static IServiceCollection AddRepository(this IServiceCollection services,
+ ServiceLifetime lifetime = ServiceLifetime.Scoped)
+ where T : IBaseRepository
+ {
+ Type repository = Utility.GetGenericDefinition(typeof(T), typeof(IRepository<>));
+
+ if (repository != null)
+ services.Add(ServiceDescriptor.Describe(repository, typeof(T), lifetime));
+ services.Add(ServiceDescriptor.Describe(typeof(IBaseRepository), typeof(T), lifetime));
+ return services;
+ }
+
+ ///
+ /// Register a new repository with a custom mapping to the container.
+ ///
+ ///
+ /// The lifetime of the repository. The default is scoped.
+ /// The custom mapping you have for your repository.
+ /// The type of the repository.
+ ///
+ /// If your repository does not implements a special interface, please use
+ ///
+ /// The initial container.
+ public static IServiceCollection AddRepository(this IServiceCollection services,
+ ServiceLifetime lifetime = ServiceLifetime.Scoped)
+ where T2 : IBaseRepository, T
+ {
+ services.Add(ServiceDescriptor.Describe(typeof(T), typeof(T2), lifetime));
+ return services.AddRepository(lifetime);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Kyoo.Common/Utility.cs b/Kyoo.Common/Utility.cs
index 220b2003..6583f704 100644
--- a/Kyoo.Common/Utility.cs
+++ b/Kyoo.Common/Utility.cs
@@ -146,7 +146,7 @@ namespace Kyoo
}
///
- /// Set every fields of first to those of second. Ignore fields marked with the attribute
+ /// Set every fields of first to those of second. Ignore fields marked with the attribute
/// At the end, the OnMerge method of first will be called if first is a
///
/// The object to assign
@@ -158,7 +158,7 @@ namespace Kyoo
Type type = typeof(T);
IEnumerable properties = type.GetProperties()
.Where(x => x.CanRead && x.CanWrite
- && Attribute.GetCustomAttribute(x, typeof(NotMergableAttribute)) == null);
+ && Attribute.GetCustomAttribute(x, typeof(NotMergeableAttribute)) == null);
foreach (PropertyInfo property in properties)
{
@@ -191,7 +191,7 @@ namespace Kyoo
Type type = typeof(T);
IEnumerable properties = type.GetProperties()
.Where(x => x.CanRead && x.CanWrite
- && Attribute.GetCustomAttribute(x, typeof(NotMergableAttribute)) == null);
+ && Attribute.GetCustomAttribute(x, typeof(NotMergeableAttribute)) == null);
if (where != null)
properties = properties.Where(where);
@@ -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
@@ -232,7 +232,7 @@ namespace Kyoo
Type type = typeof(T);
IEnumerable properties = type.GetProperties()
.Where(x => x.CanRead && x.CanWrite
- && Attribute.GetCustomAttribute(x, typeof(NotMergableAttribute)) == null);
+ && Attribute.GetCustomAttribute(x, typeof(NotMergeableAttribute)) == null);
foreach (PropertyInfo property in properties)
{
@@ -529,9 +529,9 @@ namespace Kyoo
await action(i);
}
- private static MethodInfo GetMethod(Type type, BindingFlags flag, string name, Type[] generics, object[] args)
+ public static MethodInfo GetMethod(Type type, BindingFlags flag, string name, Type[] generics, object[] args)
{
- MethodInfo[] methods = type.GetMethods(flag | BindingFlags.Public | BindingFlags.NonPublic)
+ MethodInfo[] methods = type.GetMethods(flag | BindingFlags.Public)
.Where(x => x.Name == name)
.Where(x => x.GetGenericArguments().Length == generics.Length)
.Where(x => x.GetParameters().Length == args.Length)
@@ -712,70 +712,18 @@ namespace Kyoo
}, TaskContinuationOptions.ExecuteSynchronously);
}
- public static Expression> ResourceEquals(IResource obj)
- where T : IResource
+ ///
+ /// Get a friendly type name (supporting generics)
+ /// For example a list of string will be displayed as List<string> and not as List`1.
+ ///
+ /// The type to use
+ /// The friendly name of the type
+ public static string FriendlyName(this Type type)
{
- if (obj.ID > 0)
- return x => x.ID == obj.ID || x.Slug == obj.Slug;
- return x => x.Slug == obj.Slug;
- }
-
- public static Func ResourceEqualsFunc(IResource obj)
- where T : IResource
- {
- if (obj.ID > 0)
- return x => x.ID == obj.ID || x.Slug == obj.Slug;
- return x => x.Slug == obj.Slug;
- }
-
- public static bool ResourceEquals([CanBeNull] object first, [CanBeNull] object second)
- {
- if (ReferenceEquals(first, second))
- return true;
- if (first is IResource f && second is IResource s)
- return ResourceEquals(f, s);
- IEnumerable eno = first as IEnumerable;
- IEnumerable ens = second as IEnumerable;
- if (eno == null || ens == null)
- throw new ArgumentException("Arguments are not resources or lists of resources.");
- Type type = GetEnumerableType(eno);
- if (typeof(IResource).IsAssignableFrom(type))
- return ResourceEquals(eno.Cast(), ens.Cast());
- return RunGenericMethod(typeof(Enumerable), "SequenceEqual", type, first, second);
- }
-
- public static bool ResourceEquals([CanBeNull] T first, [CanBeNull] T second)
- where T : IResource
- {
- if (ReferenceEquals(first, second))
- return true;
- if (first == null || second == null)
- return false;
- return first.ID == second.ID || first.Slug == second.Slug;
- }
-
- public static bool ResourceEquals([CanBeNull] IEnumerable first, [CanBeNull] IEnumerable second)
- where T : IResource
- {
- if (ReferenceEquals(first, second))
- return true;
- if (first == null || second == null)
- return false;
- return first.SequenceEqual(second, new ResourceComparer());
- }
-
- public static bool LinkEquals([CanBeNull] T first, int? firstID, [CanBeNull] T second, int? secondID)
- where T : IResource
- {
- if (ResourceEquals(first, second))
- return true;
- if (first == null && second != null
- && firstID == second.ID)
- return true;
- if (first != null && second == null
- && first.ID == secondID)
- return true;
- return firstID == secondID;
+ if (!type.IsGenericType)
+ return type.Name;
+ string generics = string.Join(", ", type.GetGenericArguments().Select(x => x.FriendlyName()));
+ return $"{type.Name[..type.Name.IndexOf('`')]}<{generics}>";
}
}
}
\ No newline at end of file
diff --git a/Kyoo.CommonAPI/CrudApi.cs b/Kyoo.CommonAPI/CrudApi.cs
index 50dcb588..75a2758c 100644
--- a/Kyoo.CommonAPI/CrudApi.cs
+++ b/Kyoo.CommonAPI/CrudApi.cs
@@ -5,7 +5,7 @@ using System.Threading.Tasks;
using Kyoo.Controllers;
using Kyoo.Models;
using Kyoo.Models.Exceptions;
-using Microsoft.AspNetCore.Authorization;
+using Kyoo.Models.Permissions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
@@ -21,40 +21,32 @@ namespace Kyoo.CommonApi
public CrudApi(IRepository repository, IConfiguration configuration)
{
_repository = repository;
- BaseURL = configuration.GetValue("public_url").TrimEnd('/');
+ BaseURL = configuration.GetValue("publicUrl").TrimEnd('/');
}
[HttpGet("{id:int}")]
- [Authorize(Policy = "Read")]
+ [PartialPermission(Kind.Read)]
public virtual async Task> Get(int id)
{
- try
- {
- return await _repository.Get(id);
- }
- catch (ItemNotFound)
- {
+ T ret = await _repository.GetOrDefault(id);
+ if (ret == null)
return NotFound();
- }
+ return ret;
}
[HttpGet("{slug}")]
- [Authorize(Policy = "Read")]
+ [PartialPermission(Kind.Read)]
public virtual async Task> Get(string slug)
{
- try
- {
- return await _repository.Get(slug);
- }
- catch (ItemNotFound)
- {
+ T ret = await _repository.Get(slug);
+ if (ret == null)
return NotFound();
- }
+ return ret;
}
[HttpGet("count")]
- [Authorize(Policy = "Read")]
+ [PartialPermission(Kind.Read)]
public virtual async Task> GetCount([FromQuery] Dictionary where)
{
try
@@ -68,7 +60,7 @@ namespace Kyoo.CommonApi
}
[HttpGet]
- [Authorize(Policy = "Read")]
+ [PartialPermission(Kind.Read)]
public virtual async Task>> GetAll([FromQuery] string sortBy,
[FromQuery] int afterID,
[FromQuery] Dictionary where,
@@ -98,7 +90,7 @@ namespace Kyoo.CommonApi
}
[HttpPost]
- [Authorize(Policy = "Write")]
+ [PartialPermission(Kind.Create)]
public virtual async Task> Create([FromBody] T resource)
{
try
@@ -111,13 +103,13 @@ namespace Kyoo.CommonApi
}
catch (DuplicatedItemException)
{
- T existing = await _repository.Get(resource.Slug);
+ T existing = await _repository.GetOrDefault(resource.Slug);
return Conflict(existing);
}
}
[HttpPut]
- [Authorize(Policy = "Write")]
+ [PartialPermission(Kind.Write)]
public virtual async Task> Edit([FromQuery] bool resetOld, [FromBody] T resource)
{
try
@@ -129,14 +121,14 @@ namespace Kyoo.CommonApi
resource.ID = old.ID;
return await _repository.Edit(resource, resetOld);
}
- catch (ItemNotFound)
+ catch (ItemNotFoundException)
{
return NotFound();
}
}
[HttpPut("{id:int}")]
- [Authorize(Policy = "Write")]
+ [PartialPermission(Kind.Write)]
public virtual async Task> Edit(int id, [FromQuery] bool resetOld, [FromBody] T resource)
{
resource.ID = id;
@@ -144,14 +136,14 @@ namespace Kyoo.CommonApi
{
return await _repository.Edit(resource, resetOld);
}
- catch (ItemNotFound)
+ catch (ItemNotFoundException)
{
return NotFound();
}
}
[HttpPut("{slug}")]
- [Authorize(Policy = "Write")]
+ [PartialPermission(Kind.Write)]
public virtual async Task> Edit(string slug, [FromQuery] bool resetOld, [FromBody] T resource)
{
try
@@ -160,21 +152,21 @@ namespace Kyoo.CommonApi
resource.ID = old.ID;
return await _repository.Edit(resource, resetOld);
}
- catch (ItemNotFound)
+ catch (ItemNotFoundException)
{
return NotFound();
}
}
[HttpDelete("{id:int}")]
- [Authorize(Policy = "Write")]
+ [PartialPermission(Kind.Delete)]
public virtual async Task Delete(int id)
{
try
{
await _repository.Delete(id);
}
- catch (ItemNotFound)
+ catch (ItemNotFoundException)
{
return NotFound();
}
@@ -183,14 +175,14 @@ namespace Kyoo.CommonApi
}
[HttpDelete("{slug}")]
- [Authorize(Policy = "Write")]
+ [PartialPermission(Kind.Delete)]
public virtual async Task Delete(string slug)
{
try
{
await _repository.Delete(slug);
}
- catch (ItemNotFound)
+ catch (ItemNotFoundException)
{
return NotFound();
}
@@ -198,14 +190,14 @@ namespace Kyoo.CommonApi
return Ok();
}
- [Authorize(Policy = "Write")]
+ [PartialPermission(Kind.Delete)]
public virtual async Task Delete(Dictionary where)
{
try
{
await _repository.DeleteRange(ApiHelper.ParseWhere(where));
}
- catch (ItemNotFound)
+ catch (ItemNotFoundException)
{
return NotFound();
}
diff --git a/Kyoo/Models/DatabaseContext.cs b/Kyoo.CommonAPI/DatabaseContext.cs
similarity index 87%
rename from Kyoo/Models/DatabaseContext.cs
rename to Kyoo.CommonAPI/DatabaseContext.cs
index 577e9903..584230b9 100644
--- a/Kyoo/Models/DatabaseContext.cs
+++ b/Kyoo.CommonAPI/DatabaseContext.cs
@@ -1,5 +1,6 @@
using System;
using System.Linq;
+using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;
using Kyoo.Controllers;
@@ -7,17 +8,17 @@ using Kyoo.Models;
using Kyoo.Models.Exceptions;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
-using Npgsql;
namespace Kyoo
{
///
/// The database handle used for all local repositories.
+ /// This is an abstract class. It is meant to be implemented by plugins. This allow the core to be database agnostic.
///
///
/// It should not be used directly, to access the database use a or repositories.
///
- public class DatabaseContext : DbContext
+ public abstract class DatabaseContext : DbContext
{
///
/// All libraries of Kyoo. See .
@@ -63,11 +64,20 @@ namespace Kyoo
/// All metadataIDs (ExternalIDs) of Kyoo. See .
///
public DbSet MetadataIds { get; set; }
+ ///
+ /// The list of registered users.
+ ///
+ public DbSet Users { get; set; }
///
/// All people's role. See .
///
public DbSet PeopleRoles { get; set; }
+
+ ///
+ /// Episodes with a watch percentage. See
+ ///
+ public DbSet WatchedEpisodes { get; set; }
///
/// Get a generic link between two resource types.
@@ -82,32 +92,31 @@ namespace Kyoo
{
return Set>();
}
-
-
- ///
- /// A basic constructor that set default values (query tracker behaviors, mapping enums...)
- ///
- public DatabaseContext()
- {
- NpgsqlConnection.GlobalTypeMapper.MapEnum();
- NpgsqlConnection.GlobalTypeMapper.MapEnum();
- NpgsqlConnection.GlobalTypeMapper.MapEnum();
- ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
- ChangeTracker.LazyLoadingEnabled = false;
- }
///
- /// Create a new .
+ /// The default constructor
///
- /// Connection options to use (witch databse provider to use, connection strings...)
- public DatabaseContext(DbContextOptions options)
+ protected DatabaseContext() { }
+
+ ///
+ /// Create a new using specific options
+ ///
+ /// The options to use.
+ protected DatabaseContext(DbContextOptions options)
: base(options)
- {
- ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
- ChangeTracker.LazyLoadingEnabled = false;
- }
+ { }
+ ///
+ /// Set basic configurations (like preventing query tracking)
+ ///
+ /// An option builder to fill.
+ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
+ {
+ base.OnConfiguring(optionsBuilder);
+ optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
+ }
+
///
/// Set database parameters to support every types of Kyoo.
///
@@ -116,10 +125,6 @@ namespace Kyoo
{
base.OnModelCreating(modelBuilder);
- modelBuilder.HasPostgresEnum();
- modelBuilder.HasPostgresEnum();
- modelBuilder.HasPostgresEnum();
-
modelBuilder.Entity()
.Property(t => t.IsDefault)
.ValueGeneratedNever();
@@ -188,6 +193,17 @@ namespace Kyoo
.WithMany(x => x.ShowLinks),
y => y.HasKey(Link.PrimaryKey));
+ modelBuilder.Entity()
+ .HasMany(x => x.Watched)
+ .WithMany("users")
+ .UsingEntity>(
+ y => y
+ .HasOne(x => x.Second)
+ .WithMany(),
+ y => y
+ .HasOne(x => x.First)
+ .WithMany(x => x.ShowLinks),
+ y => y.HasKey(Link.PrimaryKey));
modelBuilder.Entity()
.HasOne(x => x.Show)
@@ -210,6 +226,9 @@ namespace Kyoo
.WithMany(x => x.MetadataLinks)
.OnDelete(DeleteBehavior.Cascade);
+ modelBuilder.Entity()
+ .HasKey(x => new {First = x.FirstID, Second = x.SecondID});
+
modelBuilder.Entity().Property(x => x.Slug).IsRequired();
modelBuilder.Entity().Property(x => x.Slug).IsRequired();
modelBuilder.Entity().Property(x => x.Slug).IsRequired();
@@ -217,6 +236,7 @@ namespace Kyoo
modelBuilder.Entity().Property(x => x.Slug).IsRequired();
modelBuilder.Entity().Property(x => x.Slug).IsRequired();
modelBuilder.Entity().Property(x => x.Slug).IsRequired();
+ modelBuilder.Entity().Property(x => x.Slug).IsRequired();
modelBuilder.Entity()
.HasIndex(x => x.Slug)
@@ -248,13 +268,16 @@ namespace Kyoo
modelBuilder.Entity()
.HasIndex(x => new {x.EpisodeID, x.Type, x.Language, x.TrackIndex, x.IsForced})
.IsUnique();
+ modelBuilder.Entity()
+ .HasIndex(x => x.Slug)
+ .IsUnique();
}
///
/// 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)
@@ -467,13 +490,9 @@ namespace Kyoo
///
/// Check if the exception is a duplicated exception.
///
- /// WARNING: this only works for PostgreSQL
/// The exception to check
/// True if the exception is a duplicate exception. False otherwise
- private static bool IsDuplicateException(Exception ex)
- {
- return ex.InnerException is PostgresException {SqlState: PostgresErrorCodes.UniqueViolation};
- }
+ protected abstract bool IsDuplicateException(Exception ex);
///
/// Delete every changes that are on this context.
@@ -486,5 +505,15 @@ namespace Kyoo
entry.State = EntityState.Detached;
}
}
+
+
+ ///
+ /// Perform a case insensitive like operation.
+ ///
+ /// An accessor to get the item that will be checked.
+ /// The second operator of the like format.
+ /// The type of the item to query
+ /// An expression representing the like query. It can directly be passed to a where call.
+ public abstract Expression> Like(Expression> query, string format);
}
}
\ No newline at end of file
diff --git a/Kyoo/Extensions.cs b/Kyoo.CommonAPI/Extensions.cs
similarity index 87%
rename from Kyoo/Extensions.cs
rename to Kyoo.CommonAPI/Extensions.cs
index 70a100d2..dfb2d4d8 100644
--- a/Kyoo/Extensions.cs
+++ b/Kyoo.CommonAPI/Extensions.cs
@@ -9,14 +9,15 @@ namespace Kyoo
public static class Extensions
{
///
- /// Get a connection string from the Configuration's section "Databse"
+ /// Get a connection string from the Configuration's section "Database"
///
/// The IConfiguration instance to load.
+ /// The database's name.
/// A parsed connection string
- public static string GetDatabaseConnection(this IConfiguration config)
+ public static string GetDatabaseConnection(this IConfiguration config, string database)
{
DbConnectionStringBuilder builder = new();
- IConfigurationSection section = config.GetSection("Database");
+ IConfigurationSection section = config.GetSection("Database").GetSection(database);
foreach (IConfigurationSection child in section.GetChildren())
builder[child.Key] = child.Value;
return builder.ConnectionString;
diff --git a/Kyoo.CommonAPI/Kyoo.CommonAPI.csproj b/Kyoo.CommonAPI/Kyoo.CommonAPI.csproj
index 3a2b6456..5288b814 100644
--- a/Kyoo.CommonAPI/Kyoo.CommonAPI.csproj
+++ b/Kyoo.CommonAPI/Kyoo.CommonAPI.csproj
@@ -12,10 +12,9 @@
-
-
+
+
-
diff --git a/Kyoo.CommonAPI/LocalRepository.cs b/Kyoo.CommonAPI/LocalRepository.cs
index c1e14a6e..7498fc14 100644
--- a/Kyoo.CommonAPI/LocalRepository.cs
+++ b/Kyoo.CommonAPI/LocalRepository.cs
@@ -46,13 +46,13 @@ namespace Kyoo.Controllers
/// Get a resource from it's ID and make the instance track it.
///
/// The ID of the resource
- /// If the item is not found
+ /// If the item is not found
/// The tracked resource with the given ID
protected virtual async Task GetWithTracking(int id)
{
T ret = await Database.Set().AsTracking().FirstOrDefaultAsync(x => x.ID == id);
if (ret == null)
- throw new ItemNotFound($"No {typeof(T).Name} found with the id {id}");
+ throw new ItemNotFoundException($"No {typeof(T).Name} found with the id {id}");
return ret;
}
@@ -61,7 +61,7 @@ namespace Kyoo.Controllers
{
T ret = await GetOrDefault(id);
if (ret == null)
- throw new ItemNotFound($"No {typeof(T).Name} found with the id {id}");
+ throw new ItemNotFoundException($"No {typeof(T).Name} found with the id {id}");
return ret;
}
@@ -70,7 +70,7 @@ namespace Kyoo.Controllers
{
T ret = await GetOrDefault(slug);
if (ret == null)
- throw new ItemNotFound($"No {typeof(T).Name} found with the slug {slug}");
+ throw new ItemNotFoundException($"No {typeof(T).Name} found with the slug {slug}");
return ret;
}
@@ -79,7 +79,7 @@ namespace Kyoo.Controllers
{
T ret = await GetOrDefault(where);
if (ret == null)
- throw new ItemNotFound($"No {typeof(T).Name} found with the given predicate.");
+ throw new ItemNotFoundException($"No {typeof(T).Name} found with the given predicate.");
return ret;
}
@@ -118,14 +118,14 @@ 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,
Sort sort = default,
Pagination limit = default)
{
- return ApplyFilters(query, Get, DefaultSort, where, sort, limit);
+ return ApplyFilters(query, GetOrDefault, DefaultSort, where, sort, limit);
}
///
@@ -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,
@@ -193,14 +193,14 @@ namespace Kyoo.Controllers
}
///
- public virtual async Task CreateIfNotExists(T obj, bool silentFail = false)
+ public virtual async Task CreateIfNotExists(T obj)
{
try
{
if (obj == null)
throw new ArgumentNullException(nameof(obj));
- T old = await Get(obj.Slug);
+ T old = await GetOrDefault(obj.Slug);
if (old != null)
return old;
@@ -208,13 +208,7 @@ namespace Kyoo.Controllers
}
catch (DuplicatedItemException)
{
- return await Get(obj.Slug);
- }
- catch
- {
- if (silentFail)
- return default;
- throw;
+ return await GetOrDefault(obj.Slug);
}
}
@@ -244,7 +238,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.Postgresql/Kyoo.Postgresql.csproj b/Kyoo.Postgresql/Kyoo.Postgresql.csproj
new file mode 100644
index 00000000..52c9041b
--- /dev/null
+++ b/Kyoo.Postgresql/Kyoo.Postgresql.csproj
@@ -0,0 +1,41 @@
+
+
+
+ net5.0
+
+ SDG
+ Zoe Roux
+ https://github.com/AnonymusRaccoon/Kyoo
+ default
+
+
+
+ ../Kyoo/bin/$(Configuration)/$(TargetFramework)/plugins/postgresql
+ false
+ false
+ false
+ false
+ true
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+ all
+ false
+ runtime
+
+
+ all
+ false
+ runtime
+
+
+
diff --git a/Kyoo/Models/DatabaseMigrations/Internal/20210420221509_Initial.Designer.cs b/Kyoo.Postgresql/Migrations/20210507203809_Initial.Designer.cs
similarity index 86%
rename from Kyoo/Models/DatabaseMigrations/Internal/20210420221509_Initial.Designer.cs
rename to Kyoo.Postgresql/Migrations/20210507203809_Initial.Designer.cs
index c27925c9..834321b2 100644
--- a/Kyoo/Models/DatabaseMigrations/Internal/20210420221509_Initial.Designer.cs
+++ b/Kyoo.Postgresql/Migrations/20210507203809_Initial.Designer.cs
@@ -1,16 +1,18 @@
//
using System;
-using Kyoo;
+using System.Collections.Generic;
+using Kyoo.Models;
+using Kyoo.Postgresql;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
-namespace Kyoo.Models.DatabaseMigrations.Internal
+namespace Kyoo.Postgresql.Migrations
{
- [DbContext(typeof(DatabaseContext))]
- [Migration("20210420221509_Initial")]
+ [DbContext(typeof(PostgresContext))]
+ [Migration("20210507203809_Initial")]
partial class Initial
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -21,7 +23,7 @@ namespace Kyoo.Models.DatabaseMigrations.Internal
.HasPostgresEnum(null, "status", new[] { "finished", "airing", "planned", "unknown" })
.HasPostgresEnum(null, "stream_type", new[] { "unknown", "video", "audio", "subtitle", "attachment" })
.HasAnnotation("Relational:MaxIdentifierLength", 63)
- .HasAnnotation("ProductVersion", "5.0.3")
+ .HasAnnotation("ProductVersion", "5.0.5")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
modelBuilder.Entity("Kyoo.Models.Collection", b =>
@@ -224,6 +226,21 @@ namespace Kyoo.Models.DatabaseMigrations.Internal
b.ToTable("Link");
});
+ modelBuilder.Entity("Kyoo.Models.Link", b =>
+ {
+ b.Property("FirstID")
+ .HasColumnType("integer");
+
+ b.Property("SecondID")
+ .HasColumnType("integer");
+
+ b.HasKey("FirstID", "SecondID");
+
+ b.HasIndex("SecondID");
+
+ b.ToTable("Link");
+ });
+
modelBuilder.Entity("Kyoo.Models.MetadataID", b =>
{
b.Property("ID")
@@ -419,8 +436,8 @@ namespace Kyoo.Models.DatabaseMigrations.Internal
b.Property("StartYear")
.HasColumnType("integer");
- b.Property("Status")
- .HasColumnType("integer");
+ b.Property("Status")
+ .HasColumnType("status");
b.Property("StudioID")
.HasColumnType("integer");
@@ -497,8 +514,8 @@ namespace Kyoo.Models.DatabaseMigrations.Internal
b.Property("TrackIndex")
.HasColumnType("integer");
- b.Property("Type")
- .HasColumnType("integer");
+ b.Property("Type")
+ .HasColumnType("stream_type");
b.HasKey("ID");
@@ -508,6 +525,58 @@ namespace Kyoo.Models.DatabaseMigrations.Internal
b.ToTable("Tracks");
});
+ modelBuilder.Entity("Kyoo.Models.User", b =>
+ {
+ b.Property("ID")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
+
+ b.Property("Email")
+ .HasColumnType("text");
+
+ b.Property>("ExtraData")
+ .HasColumnType("jsonb");
+
+ b.Property("Password")
+ .HasColumnType("text");
+
+ b.Property("Permissions")
+ .HasColumnType("text[]");
+
+ b.Property("Slug")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Username")
+ .HasColumnType("text");
+
+ b.HasKey("ID");
+
+ b.HasIndex("Slug")
+ .IsUnique();
+
+ b.ToTable("Users");
+ });
+
+ modelBuilder.Entity("Kyoo.Models.WatchedEpisode", b =>
+ {
+ b.Property("FirstID")
+ .HasColumnType("integer");
+
+ b.Property("SecondID")
+ .HasColumnType("integer");
+
+ b.Property("WatchedPercentage")
+ .HasColumnType("integer");
+
+ b.HasKey("FirstID", "SecondID");
+
+ b.HasIndex("SecondID");
+
+ b.ToTable("WatchedEpisodes");
+ });
+
modelBuilder.Entity("Kyoo.Models.Episode", b =>
{
b.HasOne("Kyoo.Models.Season", "Season")
@@ -620,6 +689,25 @@ namespace Kyoo.Models.DatabaseMigrations.Internal
b.Navigation("Second");
});
+ modelBuilder.Entity("Kyoo.Models.Link", b =>
+ {
+ b.HasOne("Kyoo.Models.User", "First")
+ .WithMany("ShowLinks")
+ .HasForeignKey("FirstID")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Kyoo.Models.Show", "Second")
+ .WithMany()
+ .HasForeignKey("SecondID")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("First");
+
+ b.Navigation("Second");
+ });
+
modelBuilder.Entity("Kyoo.Models.MetadataID", b =>
{
b.HasOne("Kyoo.Models.Episode", "Episode")
@@ -709,6 +797,25 @@ namespace Kyoo.Models.DatabaseMigrations.Internal
b.Navigation("Episode");
});
+ modelBuilder.Entity("Kyoo.Models.WatchedEpisode", b =>
+ {
+ b.HasOne("Kyoo.Models.User", "First")
+ .WithMany("CurrentlyWatching")
+ .HasForeignKey("FirstID")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Kyoo.Models.Episode", "Second")
+ .WithMany()
+ .HasForeignKey("SecondID")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("First");
+
+ b.Navigation("Second");
+ });
+
modelBuilder.Entity("Kyoo.Models.Collection", b =>
{
b.Navigation("LibraryLinks");
@@ -779,6 +886,13 @@ namespace Kyoo.Models.DatabaseMigrations.Internal
{
b.Navigation("Shows");
});
+
+ modelBuilder.Entity("Kyoo.Models.User", b =>
+ {
+ b.Navigation("CurrentlyWatching");
+
+ b.Navigation("ShowLinks");
+ });
#pragma warning restore 612, 618
}
}
diff --git a/Kyoo/Models/DatabaseMigrations/Internal/20210420221509_Initial.cs b/Kyoo.Postgresql/Migrations/20210507203809_Initial.cs
similarity index 86%
rename from Kyoo/Models/DatabaseMigrations/Internal/20210420221509_Initial.cs
rename to Kyoo.Postgresql/Migrations/20210507203809_Initial.cs
index 56051cb1..678e90ee 100644
--- a/Kyoo/Models/DatabaseMigrations/Internal/20210420221509_Initial.cs
+++ b/Kyoo.Postgresql/Migrations/20210507203809_Initial.cs
@@ -1,8 +1,10 @@
using System;
+using System.Collections.Generic;
+using Kyoo.Models;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
-namespace Kyoo.Models.DatabaseMigrations.Internal
+namespace Kyoo.Postgresql.Migrations
{
public partial class Initial : Migration
{
@@ -103,6 +105,24 @@ namespace Kyoo.Models.DatabaseMigrations.Internal
table.PrimaryKey("PK_Studios", x => x.ID);
});
+ migrationBuilder.CreateTable(
+ name: "Users",
+ columns: table => new
+ {
+ ID = table.Column(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ Slug = table.Column(type: "text", nullable: false),
+ Username = table.Column(type: "text", nullable: true),
+ Email = table.Column(type: "text", nullable: true),
+ Password = table.Column(type: "text", nullable: true),
+ Permissions = table.Column(type: "text[]", nullable: true),
+ ExtraData = table.Column>(type: "jsonb", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Users", x => x.ID);
+ });
+
migrationBuilder.CreateTable(
name: "Link",
columns: table => new
@@ -162,7 +182,7 @@ namespace Kyoo.Models.DatabaseMigrations.Internal
Aliases = table.Column(type: "text[]", nullable: true),
Path = table.Column(type: "text", nullable: true),
Overview = table.Column(type: "text", nullable: true),
- Status = table.Column(type: "integer", nullable: true),
+ Status = table.Column(type: "status", nullable: true),
TrailerUrl = table.Column(type: "text", nullable: true),
StartYear = table.Column(type: "integer", nullable: true),
EndYear = table.Column(type: "integer", nullable: true),
@@ -255,6 +275,30 @@ namespace Kyoo.Models.DatabaseMigrations.Internal
onDelete: ReferentialAction.Cascade);
});
+ migrationBuilder.CreateTable(
+ name: "Link",
+ columns: table => new
+ {
+ FirstID = table.Column(type: "integer", nullable: false),
+ SecondID = table.Column