Account Email Support (#1000)

* Moved the Server Settings out into a button on nav header

* Refactored Mange Users page to the new design (skeleton). Implemented skeleton code for Invite User.

* Hashed out more of the code, but need to move all the email code to a Kavita controlled API server due to password credentials.

* Cleaned up some warnings

* When no user exists for an api key in Plugin controller, throw 401.

* Hooked in the ability to check if the Kavita instance can be accessed externally so we can determine if the user can invite or not.

* Hooked up some logic if the user's server isn't accessible, then default to old flow

* Basic flow is working for confirm email. Needs validation, error handling, etc.

* Refactored Password validation to account service

* Cleaned up the code in confirm-email to work much better.

* Refactored the login page to have a container functionality, so we can reuse the styles on multiple pages (registration pages). Hooked up the code for confirm email.

* Messy code, but making progress. Refactored Register to be used only for first time user registration. Added a new register component to handle first time flow only.

* Invite works much better, still needs a bit of work for non-accessible server setup. Started work on underlying manage users page to meet new design.

* Changed (you) to a star to indicate who you're logged in as.

* Inviting a user is now working and tested fully.

* Removed the register member component as we now have invite and confirm components.

* Editing a user is now working. Username change and Role/Library access from within one screen. Email changing is on hold.

* Cleaned up code for edit user and disabled email field for now.

* Cleaned up the code to indicate changing a user's email is not possible.

* Implemented a migration for existing accounts so they can validate their emails and still login.

* Change url for email server

* Implemented the ability to resend an email confirmation code (or regenerate for non accessible servers). Fixed an overflow on the confirm dialog.

* Took care of some code cleanup

* Removed 3 db calls from cover refresh and some misc cleanup

* Fixed a broken test
This commit is contained in:
Joseph Milazzo 2022-01-30 14:45:57 -08:00 committed by GitHub
parent 6e6b72a5b5
commit efb527035d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
109 changed files with 2041 additions and 407 deletions

View file

@ -1,4 +1,7 @@
namespace API.Constants
using System.Collections.Generic;
using System.Collections.Immutable;
namespace API.Constants
{
/// <summary>
/// Role-based Security
@ -17,5 +20,10 @@
/// Used to give a user ability to download files from the server
/// </summary>
public const string DownloadRole = "Download";
public static readonly ImmutableArray<string> ValidRoles = new ImmutableArray<string>()
{
AdminRole, PlebRole, DownloadRole
};
}
}

View file

@ -1,20 +1,30 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Reflection;
using System.Threading.Tasks;
using System.Web;
using API.Constants;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Account;
using API.DTOs.Email;
using API.Entities;
using API.Errors;
using API.Extensions;
using API.Services;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Flurl.Util;
using Kavita.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace API.Controllers
@ -31,13 +41,15 @@ namespace API.Controllers
private readonly ILogger<AccountController> _logger;
private readonly IMapper _mapper;
private readonly IAccountService _accountService;
private readonly IEmailService _emailService;
private readonly IHostEnvironment _environment;
/// <inheritdoc />
public AccountController(UserManager<AppUser> userManager,
SignInManager<AppUser> signInManager,
ITokenService tokenService, IUnitOfWork unitOfWork,
ILogger<AccountController> logger,
IMapper mapper, IAccountService accountService)
IMapper mapper, IAccountService accountService, IEmailService emailService, IHostEnvironment environment)
{
_userManager = userManager;
_signInManager = signInManager;
@ -46,6 +58,8 @@ namespace API.Controllers
_logger = logger;
_mapper = mapper;
_accountService = accountService;
_emailService = emailService;
_environment = environment;
}
/// <summary>
@ -73,71 +87,71 @@ namespace API.Controllers
}
/// <summary>
/// Register a new user on the server
/// Register the first user (admin) on the server. Will not do anything if an admin is already confirmed
/// </summary>
/// <param name="registerDto"></param>
/// <returns></returns>
[HttpPost("register")]
public async Task<ActionResult<UserDto>> Register(RegisterDto registerDto)
public async Task<ActionResult<UserDto>> RegisterFirstUser(RegisterDto registerDto)
{
var admins = await _userManager.GetUsersInRoleAsync("Admin");
if (admins.Count > 0) return BadRequest("Not allowed");
try
{
if (await _userManager.Users.AnyAsync(x => x.NormalizedUserName == registerDto.Username.ToUpper()))
var usernameValidation = await _accountService.ValidateUsername(registerDto.Username);
if (usernameValidation.Any())
{
return BadRequest("Username is taken.");
return BadRequest(usernameValidation);
}
// If we are registering an admin account, ensure there are no existing admins or user registering is an admin
if (registerDto.IsAdmin)
var user = new AppUser()
{
var firstTimeFlow = !(await _userManager.GetUsersInRoleAsync("Admin")).Any();
if (!firstTimeFlow && !await _unitOfWork.UserRepository.IsUserAdminAsync(
await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername())))
{
return BadRequest("You are not permitted to create an admin account");
}
}
UserName = registerDto.Username,
Email = registerDto.Email,
UserPreferences = new AppUserPreferences(),
ApiKey = HashUtil.ApiKey()
};
var user = _mapper.Map<AppUser>(registerDto);
user.UserPreferences ??= new AppUserPreferences();
user.ApiKey = HashUtil.ApiKey();
var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
if (!settings.EnableAuthentication && !registerDto.IsAdmin)
{
_logger.LogInformation("User {UserName} is being registered as non-admin with no server authentication. Using default password", registerDto.Username);
registerDto.Password = AccountService.DefaultPassword;
}
// I am removing Authentication disabled code
// var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
// if (!settings.EnableAuthentication && !registerDto.IsAdmin)
// {
// _logger.LogInformation("User {UserName} is being registered as non-admin with no server authentication. Using default password", registerDto.Username);
// registerDto.Password = AccountService.DefaultPassword;
// }
var result = await _userManager.CreateAsync(user, registerDto.Password);
if (!result.Succeeded) return BadRequest(result.Errors);
var token = await _userManager.GenerateEmailConfirmationTokenAsync(user);
if (string.IsNullOrEmpty(token)) return BadRequest("There was an issue generating a confirmation token.");
if (!await ConfirmEmailToken(token, user)) return BadRequest($"There was an issue validating your email: {token}");
var role = registerDto.IsAdmin ? PolicyConstants.AdminRole : PolicyConstants.PlebRole;
var roleResult = await _userManager.AddToRoleAsync(user, role);
var roleResult = await _userManager.AddToRoleAsync(user, PolicyConstants.AdminRole);
if (!roleResult.Succeeded) return BadRequest(result.Errors);
// When we register an admin, we need to grant them access to all Libraries.
if (registerDto.IsAdmin)
{
_logger.LogInformation("{UserName} is being registered as admin. Granting access to all libraries",
user.UserName);
var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList();
foreach (var lib in libraries)
{
lib.AppUsers ??= new List<AppUser>();
lib.AppUsers.Add(user);
}
if (libraries.Any() && !await _unitOfWork.CommitAsync())
_logger.LogError("There was an issue granting library access. Please do this manually");
}
// // When we register an admin, we need to grant them access to all Libraries.
// if (registerDto.IsAdmin)
// {
// _logger.LogInformation("{UserName} is being registered as admin. Granting access to all libraries",
// user.UserName);
// var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList();
// foreach (var lib in libraries)
// {
// lib.AppUsers ??= new List<AppUser>();
// lib.AppUsers.Add(user);
// }
//
// if (libraries.Any() && !await _unitOfWork.CommitAsync())
// _logger.LogError("There was an issue granting library access. Please do this manually");
// }
return new UserDto
{
Username = user.UserName,
Email = user.Email,
Token = await _tokenService.CreateToken(user),
RefreshToken = await _tokenService.CreateRefreshToken(user),
ApiKey = user.ApiKey,
@ -153,6 +167,7 @@ namespace API.Controllers
return BadRequest("Something went wrong when registering user");
}
/// <summary>
/// Perform a login. Will send JWT Token of the logged in user back.
/// </summary>
@ -167,6 +182,15 @@ namespace API.Controllers
if (user == null) return Unauthorized("Invalid username");
// Check if the user has an email, if not, inform them so they can migrate
var validPassword = await _signInManager.UserManager.CheckPasswordAsync(user, loginDto.Password);
if (string.IsNullOrEmpty(user.Email) && !user.EmailConfirmed && validPassword)
{
_logger.LogCritical("User {UserName} does not have an email. Providing a one time migration", user.UserName);
return Unauthorized(
"You are missing an email on your account. Please wait while we migrate your account.");
}
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
if (!settings.EnableAuthentication && !isAdmin)
@ -178,7 +202,10 @@ namespace API.Controllers
var result = await _signInManager
.CheckPasswordSignInAsync(user, loginDto.Password, false);
if (!result.Succeeded) return Unauthorized("Your credentials are not correct.");
if (!result.Succeeded)
{
return Unauthorized(result.IsNotAllowed ? "You must confirm your email first" : "Your credentials are not correct.");
}
// Update LastActive on account
user.LastActive = DateTime.Now;
@ -192,6 +219,7 @@ namespace API.Controllers
return new UserDto
{
Username = user.UserName,
Email = user.Email,
Token = await _tokenService.CreateToken(user),
RefreshToken = await _tokenService.CreateRefreshToken(user),
ApiKey = user.ApiKey,
@ -285,5 +313,366 @@ namespace API.Controllers
return BadRequest("Something went wrong, unable to reset key");
}
/// <summary>
/// Update the user account. This can only affect Username, Email (will require confirming), Roles, and Library access.
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("update")]
public async Task<ActionResult> UpdateAccount(UpdateUserDto dto)
{
var adminUser = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (!await _unitOfWork.UserRepository.IsUserAdminAsync(adminUser)) return Unauthorized("You do not have permission");
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(dto.UserId);
if (user == null) return BadRequest("User does not exist");
// Check if username is changing
if (!user.UserName.Equals(dto.Username))
{
// Validate username change
var errors = await _accountService.ValidateUsername(dto.Username);
if (errors.Any()) return BadRequest("Username already taken");
user.UserName = dto.Username;
_unitOfWork.UserRepository.Update(user);
}
if (!user.Email.Equals(dto.Email))
{
// Validate username change
var errors = await _accountService.ValidateEmail(dto.Email);
if (errors.Any()) return BadRequest("Email already registered");
// NOTE: This needs to be handled differently, like save it in a temp variable in DB until email is validated. For now, I wont allow it
}
// Update roles
var existingRoles = await _userManager.GetRolesAsync(user);
var hasAdminRole = dto.Roles.Contains(PolicyConstants.AdminRole);
if (!hasAdminRole)
{
dto.Roles.Add(PolicyConstants.PlebRole);
}
if (existingRoles.Except(dto.Roles).Any())
{
var roles = dto.Roles;
var roleResult = await _userManager.RemoveFromRolesAsync(user, existingRoles);
if (!roleResult.Succeeded) return BadRequest(roleResult.Errors);
roleResult = await _userManager.AddToRolesAsync(user, roles);
if (!roleResult.Succeeded) return BadRequest(roleResult.Errors);
}
var allLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList();
List<Library> libraries;
if (hasAdminRole)
{
_logger.LogInformation("{UserName} is being registered as admin. Granting access to all libraries",
user.UserName);
libraries = allLibraries;
}
else
{
// Remove user from all libraries
foreach (var lib in allLibraries)
{
lib.AppUsers ??= new List<AppUser>();
lib.AppUsers.Remove(user);
}
libraries = (await _unitOfWork.LibraryRepository.GetLibraryForIdsAsync(dto.Libraries)).ToList();
}
foreach (var lib in libraries)
{
lib.AppUsers ??= new List<AppUser>();
lib.AppUsers.Add(user);
}
if (!_unitOfWork.HasChanges()) return Ok();
if (await _unitOfWork.CommitAsync())
{
return Ok();
}
await _unitOfWork.RollbackAsync();
return BadRequest("There was an exception when updating the user");
}
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("invite")]
public async Task<ActionResult<string>> InviteUser(InviteUserDto dto)
{
var adminUser = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
_logger.LogInformation("{User} is inviting {Email} to the server", adminUser.UserName, dto.Email);
// Check if there is an existing invite
var emailValidationErrors = await _accountService.ValidateEmail(dto.Email);
if (emailValidationErrors.Any())
{
var invitedUser = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email);
if (await _userManager.IsEmailConfirmedAsync(invitedUser))
return BadRequest($"User is already registered as {invitedUser.UserName}");
return BadRequest("User is already invited under this email and has yet to accepted invite.");
}
// Create a new user
var user = new AppUser()
{
UserName = dto.Email,
Email = dto.Email,
ApiKey = HashUtil.ApiKey(),
UserPreferences = new AppUserPreferences()
};
try
{
var result = await _userManager.CreateAsync(user, AccountService.DefaultPassword);
if (!result.Succeeded) return BadRequest(result.Errors);
// Assign Roles
var roles = dto.Roles;
var hasAdminRole = dto.Roles.Contains(PolicyConstants.AdminRole);
if (!hasAdminRole)
{
roles.Add(PolicyConstants.PlebRole);
}
foreach (var role in roles)
{
if (!PolicyConstants.ValidRoles.Contains(role)) continue;
var roleResult = await _userManager.AddToRoleAsync(user, role);
if (!roleResult.Succeeded)
return
BadRequest(roleResult.Errors); // TODO: Combine all these return BadRequest into one big thing
}
// Grant access to libraries
List<Library> libraries;
if (hasAdminRole)
{
_logger.LogInformation("{UserName} is being registered as admin. Granting access to all libraries",
user.UserName);
libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList();
}
else
{
libraries = (await _unitOfWork.LibraryRepository.GetLibraryForIdsAsync(dto.Libraries)).ToList();
}
foreach (var lib in libraries)
{
lib.AppUsers ??= new List<AppUser>();
lib.AppUsers.Add(user);
}
await _unitOfWork.CommitAsync();
var token = await _userManager.GenerateEmailConfirmationTokenAsync(user);
if (string.IsNullOrEmpty(token)) return BadRequest("There was an issue sending email");
var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString();
var emailLink =
$"{Request.Scheme}://{host}{Request.PathBase}/registration/confirm-email?token={HttpUtility.UrlEncode(token)}&email={HttpUtility.UrlEncode(dto.Email)}";
_logger.LogInformation("[Invite User]: Email Link: {Link}", emailLink);
if (dto.SendEmail)
{
await _emailService.SendConfirmationEmail(new ConfirmationEmailDto()
{
EmailAddress = dto.Email,
InvitingUser = adminUser.UserName,
ServerConfirmationLink = emailLink
});
}
return Ok(emailLink);
}
catch (Exception ex)
{
_unitOfWork.UserRepository.Delete(user);
await _unitOfWork.CommitAsync();
}
return BadRequest("There was an error setting up your account. Please check the logs");
}
[HttpPost("confirm-email")]
public async Task<ActionResult<UserDto>> ConfirmEmail(ConfirmEmailDto dto)
{
var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email);
// Validate Password and Username
var validationErrors = new List<ApiException>();
validationErrors.AddRange(await _accountService.ValidateUsername(dto.Username));
validationErrors.AddRange(await _accountService.ValidatePassword(user, dto.Password));
if (validationErrors.Any())
{
return BadRequest(validationErrors);
}
if (!await ConfirmEmailToken(dto.Token, user)) return BadRequest("Invalid Email Token");
user.UserName = dto.Username;
var errors = await _accountService.ChangeUserPassword(user, dto.Password);
if (errors.Any())
{
return BadRequest(errors);
}
await _unitOfWork.CommitAsync();
user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(user.UserName,
AppUserIncludes.UserPreferences);
// Perform Login code
return new UserDto
{
Username = user.UserName,
Email = user.Email,
Token = await _tokenService.CreateToken(user),
RefreshToken = await _tokenService.CreateRefreshToken(user),
ApiKey = user.ApiKey,
Preferences = _mapper.Map<UserPreferencesDto>(user.UserPreferences)
};
}
[AllowAnonymous]
[HttpPost("confirm-migration-email")]
public async Task<ActionResult<UserDto>> ConfirmMigrationEmail(ConfirmMigrationEmailDto dto)
{
var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email);
if (user == null) return Unauthorized("This email is not on system");
if (!await ConfirmEmailToken(dto.Token, user)) return BadRequest("Invalid Email Token");
await _unitOfWork.CommitAsync();
user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(user.UserName,
AppUserIncludes.UserPreferences);
// Perform Login code
return new UserDto
{
Username = user.UserName,
Email = user.Email,
Token = await _tokenService.CreateToken(user),
RefreshToken = await _tokenService.CreateRefreshToken(user),
ApiKey = user.ApiKey,
Preferences = _mapper.Map<UserPreferencesDto>(user.UserPreferences)
};
}
[HttpPost("resend-confirmation-email")]
public async Task<ActionResult<string>> ResendConfirmationSendEmail([FromQuery] int userId)
{
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
if (user == null) return BadRequest("User does not exist");
if (string.IsNullOrEmpty(user.Email))
return BadRequest(
"This user needs to migrate. Have them log out and login to trigger a migration flow");
if (user.EmailConfirmed) return BadRequest("User already confirmed");
var token = await _userManager.GenerateEmailConfirmationTokenAsync(user);
var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString();
var emailLink =
$"{Request.Scheme}://{host}{Request.PathBase}/registration/confirm-migration-email?token={HttpUtility.UrlEncode(token)}&email={HttpUtility.UrlEncode(user.Email)}";
_logger.LogInformation("[Email Migration]: Email Link: {Link}", emailLink);
await _emailService.SendMigrationEmail(new EmailMigrationDto()
{
EmailAddress = user.Email,
Username = user.UserName,
ServerConfirmationLink = emailLink
});
return Ok(emailLink);
}
/// <summary>
/// This is similar to invite. Essentially we authenticate the user's password then go through invite email flow
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[AllowAnonymous]
[HttpPost("migrate-email")]
public async Task<ActionResult<string>> MigrateEmail(MigrateUserEmailDto dto)
{
// Check if there is an existing invite
var emailValidationErrors = await _accountService.ValidateEmail(dto.Email);
if (emailValidationErrors.Any())
{
var invitedUser = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email);
if (await _userManager.IsEmailConfirmedAsync(invitedUser))
return BadRequest($"User is already registered as {invitedUser.UserName}");
return BadRequest("User is already invited under this email and has yet to accepted invite.");
}
var user = await _userManager.Users
.Include(u => u.UserPreferences)
.SingleOrDefaultAsync(x => x.NormalizedUserName == dto.Username.ToUpper());
if (user == null) return Unauthorized("Invalid username");
var validPassword = await _signInManager.UserManager.CheckPasswordAsync(user, dto.Password);
if (!validPassword) return Unauthorized("Your credentials are not correct");
try
{
var token = await _userManager.GenerateEmailConfirmationTokenAsync(user);
if (string.IsNullOrEmpty(token)) return BadRequest("There was an issue sending email");
user.Email = dto.Email;
_unitOfWork.UserRepository.Update(user);
await _unitOfWork.CommitAsync();
var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString();
var emailLink =
$"{Request.Scheme}://{host}{Request.PathBase}/registration/confirm-migration-email?token={HttpUtility.UrlEncode(token)}&email={HttpUtility.UrlEncode(dto.Email)}";
_logger.LogInformation("[Email Migration]: Email Link: {Link}", emailLink);
if (dto.SendEmail)
{
await _emailService.SendMigrationEmail(new EmailMigrationDto()
{
EmailAddress = dto.Email,
Username = user.UserName,
ServerConfirmationLink = emailLink
});
}
return Ok(emailLink);
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an issue during email migration. Contact support");
_unitOfWork.UserRepository.Delete(user);
await _unitOfWork.CommitAsync();
}
return BadRequest("There was an error setting up your account. Please check the logs");
}
private async Task<bool> ConfirmEmailToken(string token, AppUser user)
{
var result = await _userManager.ConfirmEmailAsync(user, token);
if (!result.Succeeded)
{
_logger.LogCritical("Email validation failed");
if (result.Errors.Any())
{
foreach (var error in result.Errors)
{
_logger.LogCritical("Email validation error: {Message}", error.Description);
}
}
return false;
}
return true;
}
}
}

View file

@ -10,7 +10,6 @@ using API.Extensions;
using API.Services;
using HtmlAgilityPack;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using VersOne.Epub;

View file

@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using API.Comparators;
using API.Constants;
using API.Data;
using API.DTOs.Downloads;

View file

@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.Data.Metadata;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Filtering;

View file

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.NetworkInformation;
using System.Threading.Tasks;
using API.DTOs.Stats;
using API.DTOs.Update;
@ -28,10 +29,11 @@ namespace API.Controllers
private readonly IVersionUpdaterService _versionUpdaterService;
private readonly IStatsService _statsService;
private readonly ICleanupService _cleanupService;
private readonly IEmailService _emailService;
public ServerController(IHostApplicationLifetime applicationLifetime, ILogger<ServerController> logger, IConfiguration config,
IBackupService backupService, IArchiveService archiveService, ICacheService cacheService,
IVersionUpdaterService versionUpdaterService, IStatsService statsService, ICleanupService cleanupService)
IVersionUpdaterService versionUpdaterService, IStatsService statsService, ICleanupService cleanupService, IEmailService emailService)
{
_applicationLifetime = applicationLifetime;
_logger = logger;
@ -42,6 +44,7 @@ namespace API.Controllers
_versionUpdaterService = versionUpdaterService;
_statsService = statsService;
_cleanupService = cleanupService;
_emailService = emailService;
}
/// <summary>
@ -119,5 +122,16 @@ namespace API.Controllers
{
return Ok(await _versionUpdaterService.GetAllReleases());
}
/// <summary>
/// Is this server accessible to the outside net
/// </summary>
/// <returns></returns>
[HttpGet("accessible")]
[AllowAnonymous]
public async Task<ActionResult<bool>> IsServerAccessible()
{
return await _emailService.CheckIfAccessible(Request.Host.ToString());
}
}
}

View file

@ -36,19 +36,29 @@ namespace API.Controllers
[HttpGet]
public async Task<ActionResult<IEnumerable<MemberDto>>> GetUsers()
{
return Ok(await _unitOfWork.UserRepository.GetMembersAsync());
return Ok(await _unitOfWork.UserRepository.GetEmailConfirmedMemberDtosAsync());
}
[Authorize(Policy = "RequireAdminRole")]
[HttpGet("pending")]
public async Task<ActionResult<IEnumerable<MemberDto>>> GetPendingUsers()
{
return Ok(await _unitOfWork.UserRepository.GetPendingMemberDtosAsync());
}
[AllowAnonymous]
[HttpGet("names")]
public async Task<ActionResult<IEnumerable<MemberDto>>> GetUserNames()
{
// This is only for disabled auth flow - being removed
var setting = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
if (setting.EnableAuthentication)
{
return Unauthorized("This API cannot be used given your server's configuration");
}
var members = await _unitOfWork.UserRepository.GetMembersAsync();
var members = await _unitOfWork.UserRepository.GetEmailConfirmedMemberDtosAsync();
return Ok(members.Select(m => m.Username));
}
@ -94,5 +104,6 @@ namespace API.Controllers
return BadRequest("There was an issue saving preferences.");
}
}
}

View file

@ -0,0 +1,16 @@
using System.ComponentModel.DataAnnotations;
namespace API.DTOs.Account;
public class ConfirmEmailDto
{
[Required]
public string Email { get; set; }
[Required]
public string Token { get; set; }
[Required]
[StringLength(32, MinimumLength = 6)]
public string Password { get; set; }
[Required]
public string Username { get; set; }
}

View file

@ -0,0 +1,7 @@
namespace API.DTOs.Account;
public class ConfirmMigrationEmailDto
{
public string Email { get; set; }
public string Token { get; set; }
}

View file

@ -0,0 +1,21 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace API.DTOs.Account;
public class InviteUserDto
{
[Required]
public string Email { get; init; }
/// <summary>
/// List of Roles to assign to user. If admin not present, Pleb will be applied.
/// If admin present, all libraries will be granted access and will ignore those from DTO.
/// </summary>
public ICollection<string> Roles { get; init; }
/// <summary>
/// A list of libraries to grant access to
/// </summary>
public IList<int> Libraries { get; init; }
public bool SendEmail { get; init; } = true;
}

View file

@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;
namespace API.DTOs.Account;
public class MigrateUserEmailDto
{
public string Email { get; set; }
public string Username { get; set; }
public string Password { get; set; }
public bool SendEmail { get; set; }
}

View file

@ -0,0 +1,23 @@
using System.Collections.Generic;
namespace API.DTOs.Account;
public record UpdateUserDto
{
public int UserId { get; set; }
public string Username { get; set; }
/// <summary>
/// This field will not result in any change to the User model. Changing email is not supported.
/// </summary>
public string Email { get; set; }
/// <summary>
/// List of Roles to assign to user. If admin not present, Pleb will be applied.
/// If admin present, all libraries will be granted access and will ignore those from DTO.
/// </summary>
public IList<string> Roles { get; init; }
/// <summary>
/// A list of libraries to grant access to
/// </summary>
public IList<int> Libraries { get; init; }
}

View file

@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using API.DTOs.Metadata;
using API.Entities;
namespace API.DTOs
{

View file

@ -0,0 +1,8 @@
namespace API.DTOs.Email;
public class ConfirmationEmailDto
{
public string InvitingUser { get; init; }
public string EmailAddress { get; init; }
public string ServerConfirmationLink { get; init; }
}

View file

@ -0,0 +1,8 @@
namespace API.DTOs.Email;
public class EmailMigrationDto
{
public string EmailAddress { get; init; }
public string Username { get; init; }
public string ServerConfirmationLink { get; init; }
}

View file

@ -1,6 +1,4 @@
using System;
namespace API.DTOs.Filtering;
namespace API.DTOs.Filtering;
/// <summary>
/// Represents the Reading Status. This is a flag and allows multiple statues

View file

@ -4,15 +4,16 @@ using System.Collections.Generic;
namespace API.DTOs
{
/// <summary>
/// Represents a member of a Kavita server.
/// Represents a member of a Kavita server.
/// </summary>
public class MemberDto
{
public int Id { get; init; }
public string Username { get; init; }
public string Email { get; init; }
public DateTime Created { get; init; }
public DateTime LastActive { get; init; }
public IEnumerable<LibraryDto> Libraries { get; init; }
public IEnumerable<string> Roles { get; init; }
}
}
}

View file

@ -1,5 +1,4 @@
using System;
using API.Entities.Enums;
using API.Entities.Enums;
namespace API.DTOs.Reader
{

View file

@ -7,6 +7,8 @@ namespace API.DTOs
[Required]
public string Username { get; init; }
[Required]
public string Email { get; init; }
[Required]
[StringLength(32, MinimumLength = 6)]
public string Password { get; set; }
public bool IsAdmin { get; init; }

View file

@ -4,6 +4,7 @@ namespace API.DTOs
public class UserDto
{
public string Username { get; init; }
public string Email { get; init; }
public string Token { get; init; }
public string RefreshToken { get; init; }
public string ApiKey { get; init; }

View file

@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Abstractions;
using System.Linq;
using API.Services;
using Kavita.Common;

View file

@ -1,4 +1,5 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
@ -36,6 +37,7 @@ public interface ILibraryRepository
Task<bool> DeleteLibrary(int libraryId);
Task<IEnumerable<Library>> GetLibrariesForUserIdAsync(int userId);
Task<LibraryType> GetLibraryTypeAsync(int libraryId);
Task<IEnumerable<Library>> GetLibraryForIdsAsync(IList<int> libraryIds);
}
public class LibraryRepository : ILibraryRepository
@ -108,6 +110,13 @@ public class LibraryRepository : ILibraryRepository
.SingleAsync();
}
public async Task<IEnumerable<Library>> GetLibraryForIdsAsync(IList<int> libraryIds)
{
return await _context.Library
.Where(x => libraryIds.Contains(x.Id))
.ToListAsync();
}
public async Task<IEnumerable<LibraryDto>> GetLibraryDtosAsync()
{
return await _context.Library

View file

@ -50,13 +50,13 @@ public class TagRepository : ITagRepository
public async Task RemoveAllTagNoLongerAssociated(bool removeExternal = false)
{
var TagsWithNoConnections = await _context.Tag
var tagsWithNoConnections = await _context.Tag
.Include(p => p.SeriesMetadatas)
.Include(p => p.Chapters)
.Where(p => p.SeriesMetadatas.Count == 0 && p.Chapters.Count == 0 && p.ExternalTag == removeExternal)
.ToListAsync();
_context.Tag.RemoveRange(TagsWithNoConnections);
_context.Tag.RemoveRange(tagsWithNoConnections);
await _context.SaveChangesAsync();
}

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
using API.Constants;
@ -20,7 +21,8 @@ public enum AppUserIncludes
Progress = 2,
Bookmarks = 4,
ReadingLists = 8,
Ratings = 16
Ratings = 16,
UserPreferences = 32
}
public interface IUserRepository
@ -29,7 +31,8 @@ public interface IUserRepository
void Update(AppUserPreferences preferences);
void Update(AppUserBookmark bookmark);
public void Delete(AppUser user);
Task<IEnumerable<MemberDto>> GetMembersAsync();
Task<IEnumerable<MemberDto>> GetEmailConfirmedMemberDtosAsync();
Task<IEnumerable<MemberDto>> GetPendingMemberDtosAsync();
Task<IEnumerable<AppUser>> GetAdminUsersAsync();
Task<IEnumerable<AppUser>> GetNonAdminUsersAsync();
Task<bool> IsUserAdminAsync(AppUser user);
@ -48,6 +51,7 @@ public interface IUserRepository
Task<int> GetUserIdByUsernameAsync(string username);
Task<AppUser> GetUserWithReadingListsByUsernameAsync(string username);
Task<IList<AppUserBookmark>> GetAllBookmarksByIds(IList<int> bookmarkIds);
Task<AppUser> GetUserByEmailAsync(string email);
}
public class UserRepository : IUserRepository
@ -156,6 +160,13 @@ public class UserRepository : IUserRepository
query = query.Include(u => u.Ratings);
}
if (includeFlags.HasFlag(AppUserIncludes.UserPreferences))
{
query = query.Include(u => u.UserPreferences);
}
return query;
}
@ -198,6 +209,11 @@ public class UserRepository : IUserRepository
.ToListAsync();
}
public async Task<AppUser> GetUserByEmailAsync(string email)
{
return await _context.AppUser.SingleOrDefaultAsync(u => u.Email.Equals(email));
}
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
{
return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole);
@ -280,9 +296,10 @@ public class UserRepository : IUserRepository
}
public async Task<IEnumerable<MemberDto>> GetMembersAsync()
public async Task<IEnumerable<MemberDto>> GetEmailConfirmedMemberDtosAsync()
{
return await _context.Users
.Where(u => u.EmailConfirmed)
.Include(x => x.Libraries)
.Include(r => r.UserRoles)
.ThenInclude(r => r.Role)
@ -291,6 +308,7 @@ public class UserRepository : IUserRepository
{
Id = u.Id,
Username = u.UserName,
Email = u.Email,
Created = u.Created,
LastActive = u.LastActive,
Roles = u.UserRoles.Select(r => r.Role.Name).ToList(),
@ -305,4 +323,42 @@ public class UserRepository : IUserRepository
.AsNoTracking()
.ToListAsync();
}
public async Task<IEnumerable<MemberDto>> GetPendingMemberDtosAsync()
{
return await _context.Users
.Where(u => !u.EmailConfirmed)
.Include(x => x.Libraries)
.Include(r => r.UserRoles)
.ThenInclude(r => r.Role)
.OrderBy(u => u.UserName)
.Select(u => new MemberDto
{
Id = u.Id,
Username = u.UserName,
Email = u.Email,
Created = u.Created,
LastActive = u.LastActive,
Roles = u.UserRoles.Select(r => r.Role.Name).ToList(),
Libraries = u.Libraries.Select(l => new LibraryDto
{
Name = l.Name,
Type = l.Type,
LastScanned = l.LastScanned,
Folders = l.Folders.Select(x => x.Path).ToList()
}).ToList()
})
.AsNoTracking()
.ToListAsync();
}
public async Task<bool> ValidateUserExists(string username)
{
if (await _userManager.Users.AnyAsync(x => x.NormalizedUserName == username.ToUpper()))
{
throw new ValidationException("Username is taken.");
}
return true;
}
}

View file

@ -1,7 +1,6 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Comparators;
using API.DTOs;
using API.Entities;
using API.Extensions;

View file

@ -21,6 +21,7 @@ public enum AgeRating
[Description("Everyone 10+")]
Everyone10Plus = 5,
[Description("PG")]
// ReSharper disable once InconsistentNaming
PG = 6,
[Description("Kids to Adults")]
KidsToAdults = 7,

View file

@ -1,5 +1,4 @@
using System;
using System.Collections.Generic;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using API.Entities.Enums;
using API.Entities.Interfaces;

View file

@ -37,6 +37,7 @@ namespace API.Extensions
services.AddScoped<IReaderService, ReaderService>();
services.AddScoped<IReadingItemService, ReadingItemService>();
services.AddScoped<IAccountService, AccountService>();
services.AddScoped<IEmailService, EmailService>();
services.AddScoped<IFileSystem, FileSystem>();

View file

@ -1,6 +1,5 @@
using System.IO;
using System.Linq;
using System.Runtime.Intrinsics.Arm;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

View file

@ -1,12 +1,9 @@
using System;
using System.Text;
using System.Text;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.Entities;
using ExCSS;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization.Infrastructure;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@ -27,6 +24,8 @@ namespace API.Extensions
opt.Password.RequireUppercase = false;
opt.Password.RequireNonAlphanumeric = false;
opt.Password.RequiredLength = 6;
opt.SignIn.RequireConfirmedEmail = true;
})
.AddTokenProvider<DataProtectorTokenProvider<AppUser>>(TokenOptions.DefaultProvider)
.AddRoles<AppRole>()
@ -35,6 +34,7 @@ namespace API.Extensions
.AddRoleValidator<RoleValidator<AppRole>>()
.AddEntityFrameworkStores<DataContext>();
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{

View file

@ -1,7 +1,6 @@
using System.Collections.Generic;
using System.Linq;
using API.Entities;
using API.Entities.Enums;
using API.Parser;
namespace API.Extensions

View file

@ -1,7 +1,6 @@
using System.Collections.Generic;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Parser;
using API.Services.Tasks.Scanner;

View file

@ -2,7 +2,6 @@
using System.Collections.Generic;
using System.Data;
using System.Data.Common;
using API.DTOs;
using Microsoft.EntityFrameworkCore;
namespace API.Helpers

View file

@ -3,7 +3,6 @@ using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using API.Entities.Enums;
using API.Services;
namespace API.Parser
{

View file

@ -8,7 +8,6 @@ using API.Data;
using API.Entities;
using API.Entities.Enums;
using API.Services;
using API.Services.Tasks;
using Kavita.Common;
using Kavita.Common.EnvironmentInfo;
using Microsoft.AspNetCore.Hosting;

View file

@ -1,9 +1,12 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.Entities;
using API.Errors;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace API.Services
@ -11,30 +14,29 @@ namespace API.Services
public interface IAccountService
{
Task<IEnumerable<ApiException>> ChangeUserPassword(AppUser user, string newPassword);
Task<IEnumerable<ApiException>> ValidatePassword(AppUser user, string password);
Task<IEnumerable<ApiException>> ValidateUsername(string username);
Task<IEnumerable<ApiException>> ValidateEmail(string email);
}
public class AccountService : IAccountService
{
private readonly UserManager<AppUser> _userManager;
private readonly ILogger<AccountService> _logger;
private readonly IUnitOfWork _unitOfWork;
public const string DefaultPassword = "[k.2@RZ!mxCQkJzE";
public AccountService(UserManager<AppUser> userManager, ILogger<AccountService> logger)
public AccountService(UserManager<AppUser> userManager, ILogger<AccountService> logger, IUnitOfWork unitOfWork)
{
_userManager = userManager;
_logger = logger;
_unitOfWork = unitOfWork;
}
public async Task<IEnumerable<ApiException>> ChangeUserPassword(AppUser user, string newPassword)
{
foreach (var validator in _userManager.PasswordValidators)
{
var validationResult = await validator.ValidateAsync(_userManager, user, newPassword);
if (!validationResult.Succeeded)
{
return validationResult.Errors.Select(e => new ApiException(400, e.Code, e.Description));
}
}
var passwordValidationIssues = (await ValidatePassword(user, newPassword)).ToList();
if (passwordValidationIssues.Any()) return passwordValidationIssues;
var result = await _userManager.RemovePasswordAsync(user);
if (!result.Succeeded)
@ -53,5 +55,42 @@ namespace API.Services
return new List<ApiException>();
}
public async Task<IEnumerable<ApiException>> ValidatePassword(AppUser user, string password)
{
foreach (var validator in _userManager.PasswordValidators)
{
var validationResult = await validator.ValidateAsync(_userManager, user, password);
if (!validationResult.Succeeded)
{
return validationResult.Errors.Select(e => new ApiException(400, e.Code, e.Description));
}
}
return Array.Empty<ApiException>();
}
public async Task<IEnumerable<ApiException>> ValidateUsername(string username)
{
if (await _userManager.Users.AnyAsync(x => x.NormalizedUserName == username.ToUpper()))
{
return new List<ApiException>()
{
new ApiException(400, "Username is already taken")
};
}
return Array.Empty<ApiException>();
}
public async Task<IEnumerable<ApiException>> ValidateEmail(string email)
{
var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(email);
if (user == null) return Array.Empty<ApiException>();
return new List<ApiException>()
{
new ApiException(400, "Email is already registered")
};
}
}
}

View file

@ -7,7 +7,6 @@ using System.Linq;
using System.Threading.Tasks;
using System.Xml.Serialization;
using API.Archive;
using API.Comparators;
using API.Data.Metadata;
using API.Extensions;
using API.Services.Tasks;

View file

@ -171,7 +171,7 @@ namespace API.Services
stylesheetHtml = stylesheetHtml.Insert(0, importBuilder.ToString());
EscapeCSSImportReferences(ref stylesheetHtml, apiBase, prepend);
EscapeCssImportReferences(ref stylesheetHtml, apiBase, prepend);
EscapeFontFamilyReferences(ref stylesheetHtml, apiBase, prepend);
@ -200,7 +200,7 @@ namespace API.Services
return RemoveWhiteSpaceFromStylesheets(stylesheet.ToCss());
}
private static void EscapeCSSImportReferences(ref string stylesheetHtml, string apiBase, string prepend)
private static void EscapeCssImportReferences(ref string stylesheetHtml, string apiBase, string prepend)
{
foreach (Match match in Parser.Parser.CssImportUrlRegex.Matches(stylesheetHtml))
{

View file

@ -1,6 +1,4 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;

View file

@ -1,5 +1,4 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
@ -7,7 +6,7 @@ using System.IO.Abstractions;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using API.Comparators;
using API.Entities.Enums;
using API.Extensions;
using Microsoft.Extensions.Logging;
@ -22,7 +21,7 @@ namespace API.Services
string TempDirectory { get; }
string ConfigDirectory { get; }
/// <summary>
/// Original BookmarkDirectory. Only used for resetting directory. Use <see cref="ServerSettings.BackupDirectory"/> for actual path.
/// Original BookmarkDirectory. Only used for resetting directory. Use <see cref="ServerSettingKey.BackupDirectory"/> for actual path.
/// </summary>
string BookmarkDirectory { get; }
/// <summary>
@ -682,7 +681,7 @@ namespace API.Services
FileSystem.Path.Join(directoryName, "test.txt"),
string.Empty);
}
catch (Exception ex)
catch (Exception)
{
ClearAndDeleteDirectory(directoryName);
return false;

View file

@ -0,0 +1,103 @@
using System;
using System.Net.Http;
using System.Threading.Tasks;
using API.DTOs.Email;
using API.Services.Tasks;
using Flurl.Http;
using Kavita.Common.EnvironmentInfo;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace API.Services;
public interface IEmailService
{
Task SendConfirmationEmail(ConfirmationEmailDto data);
Task<bool> CheckIfAccessible(string host);
Task SendMigrationEmail(EmailMigrationDto data);
}
public class EmailService : IEmailService
{
private readonly ILogger<EmailService> _logger;
private const string ApiUrl = "https://email.kavitareader.com";
public EmailService(ILogger<EmailService> logger)
{
_logger = logger;
FlurlHttp.ConfigureClient(ApiUrl, cli =>
cli.Settings.HttpClientFactory = new UntrustedCertClientFactory());
}
public async Task SendConfirmationEmail(ConfirmationEmailDto data)
{
var success = await SendEmailWithPost(ApiUrl + "/api/email/confirm", data);
if (!success)
{
_logger.LogError("There was a critical error sending Confirmation email");
}
}
public async Task<bool> CheckIfAccessible(string host)
{
return await SendEmailWithGet(ApiUrl + "/api/email/reachable?host=" + host);
}
public async Task SendMigrationEmail(EmailMigrationDto data)
{
await SendEmailWithPost(ApiUrl + "/api/email/email-migration", data);
}
private static async Task<bool> SendEmailWithGet(string url)
{
try
{
var response = await (url)
.WithHeader("Accept", "application/json")
.WithHeader("User-Agent", "Kavita")
.WithHeader("x-api-key", "MsnvA2DfQqxSK5jh")
.WithHeader("x-kavita-version", BuildInfo.Version)
.WithHeader("Content-Type", "application/json")
.WithTimeout(TimeSpan.FromSeconds(30))
.GetStringAsync();
if (!string.IsNullOrEmpty(response) && bool.Parse(response))
{
return true;
}
}
catch (Exception)
{
return false;
}
return false;
}
private static async Task<bool> SendEmailWithPost(string url, object data)
{
try
{
var response = await (url)
.WithHeader("Accept", "application/json")
.WithHeader("User-Agent", "Kavita")
.WithHeader("x-api-key", "MsnvA2DfQqxSK5jh")
.WithHeader("x-kavita-version", BuildInfo.Version)
.WithHeader("Content-Type", "application/json")
.WithTimeout(TimeSpan.FromSeconds(30))
.PostJsonAsync(data);
if (response.StatusCode != StatusCodes.Status200OK)
{
return false;
}
}
catch (Exception)
{
return false;
}
return true;
}
}

View file

@ -1,16 +1,13 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using API.Comparators;
using API.Data;
using API.Data.Metadata;
using API.Data.Repositories;
using API.Data.Scanner;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Helpers;
using API.SignalR;
@ -70,7 +67,7 @@ public class MetadataService : IMetadataService
if (firstFile == null) return false;
_logger.LogDebug("[MetadataService] Generating cover image for {File}", firstFile?.FilePath);
_logger.LogDebug("[MetadataService] Generating cover image for {File}", firstFile.FilePath);
chapter.CoverImage = _readingItemService.GetCoverImage(firstFile.FilePath, ImageService.GetChapterFormat(chapter.Id, chapter.VolumeId), firstFile.Format);
return true;
@ -144,7 +141,7 @@ public class MetadataService : IMetadataService
/// </summary>
/// <param name="series"></param>
/// <param name="forceUpdate"></param>
private void ProcessSeriesMetadataUpdate(Series series, ICollection<Person> allPeople, ICollection<Genre> allGenres, ICollection<Tag> allTags, bool forceUpdate)
private void ProcessSeriesMetadataUpdate(Series series, bool forceUpdate)
{
_logger.LogDebug("[MetadataService] Processing series {SeriesName}", series.OriginalName);
try
@ -220,17 +217,12 @@ public class MetadataService : IMetadataService
});
_logger.LogDebug("[MetadataService] Fetched {SeriesCount} series for refresh", nonLibrarySeries.Count);
var allPeople = await _unitOfWork.PersonRepository.GetAllPeople();
var allGenres = await _unitOfWork.GenreRepository.GetAllGenresAsync();
var allTags = await _unitOfWork.TagRepository.GetAllTagsAsync();
var seriesIndex = 0;
foreach (var series in nonLibrarySeries)
{
try
{
ProcessSeriesMetadataUpdate(series, allPeople, allGenres, allTags, forceUpdate);
ProcessSeriesMetadataUpdate(series, forceUpdate);
}
catch (Exception ex)
{
@ -270,12 +262,11 @@ public class MetadataService : IMetadataService
await _unitOfWork.GenreRepository.RemoveAllGenreNoLongerAssociated();
}
// TODO: I can probably refactor RefreshMetadata and RefreshMetadataForSeries to be the same by utilizing chunk size of 1, so most of the code can be the same.
// TODO: Write out a single piece of code that can iterate over a collection/chunk and perform custom actions
private async Task PerformScan(Library library, bool forceUpdate, Action<int, Chunk> action)
{
var chunkInfo = await _unitOfWork.SeriesRepository.GetChunkInfo(library.Id);
var stopwatch = Stopwatch.StartNew();
var totalTime = 0L;
_logger.LogInformation("[MetadataService] Refreshing Library {LibraryName}. Total Items: {TotalSize}. Total Chunks: {TotalChunks} with {ChunkSize} size", library.Name, chunkInfo.TotalSize, chunkInfo.TotalChunks, chunkInfo.ChunkSize);
await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress,
MessageFactory.RefreshMetadataProgressEvent(library.Id, 0F));
@ -283,7 +274,6 @@ public class MetadataService : IMetadataService
for (var chunk = 1; chunk <= chunkInfo.TotalChunks; chunk++)
{
if (chunkInfo.TotalChunks == 0) continue;
totalTime += stopwatch.ElapsedMilliseconds;
stopwatch.Restart();
action(chunk, chunkInfo);
@ -346,11 +336,7 @@ public class MetadataService : IMetadataService
await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress,
MessageFactory.RefreshMetadataProgressEvent(libraryId, 0F));
var allPeople = await _unitOfWork.PersonRepository.GetAllPeople();
var allGenres = await _unitOfWork.GenreRepository.GetAllGenresAsync();
var allTags = await _unitOfWork.TagRepository.GetAllTagsAsync();
ProcessSeriesMetadataUpdate(series, allPeople, allGenres, allTags, forceUpdate);
ProcessSeriesMetadataUpdate(series, forceUpdate);
await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress,
MessageFactory.RefreshMetadataProgressEvent(libraryId, 1F));

View file

@ -30,17 +30,13 @@ public class ReaderService : IReaderService
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<ReaderService> _logger;
private readonly IDirectoryService _directoryService;
private readonly ICacheService _cacheService;
private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer();
private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst();
public ReaderService(IUnitOfWork unitOfWork, ILogger<ReaderService> logger, IDirectoryService directoryService, ICacheService cacheService)
public ReaderService(IUnitOfWork unitOfWork, ILogger<ReaderService> logger)
{
_unitOfWork = unitOfWork;
_logger = logger;
_directoryService = directoryService;
_cacheService = cacheService;
}
public static string FormatBookmarkFolderPath(string baseDirectory, int userId, int seriesId, int chapterId)

View file

@ -35,7 +35,6 @@ public class TaskScheduler : ITaskScheduler
private readonly IStatsService _statsService;
private readonly IVersionUpdaterService _versionUpdaterService;
private readonly IDirectoryService _directoryService;
public static BackgroundJobServer Client => new BackgroundJobServer();
private static readonly Random Rnd = new Random();
@ -43,8 +42,7 @@ public class TaskScheduler : ITaskScheduler
public TaskScheduler(ICacheService cacheService, ILogger<TaskScheduler> logger, IScannerService scannerService,
IUnitOfWork unitOfWork, IMetadataService metadataService, IBackupService backupService,
ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService,
IDirectoryService directoryService)
ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService)
{
_cacheService = cacheService;
_logger = logger;
@ -55,7 +53,6 @@ public class TaskScheduler : ITaskScheduler
_cleanupService = cleanupService;
_statsService = statsService;
_versionUpdaterService = versionUpdaterService;
_directoryService = directoryService;
}
public async Task ScheduleTasks()

View file

@ -569,7 +569,7 @@ public class ScannerService : IScannerService
PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Translator).Select(p => p.Name), PersonRole.Translator,
person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person));
TagHelper.UpdateTag(allTags, chapter.Tags.Select(t => t.Title), false, (tag, added) =>
TagHelper.UpdateTag(allTags, chapter.Tags.Select(t => t.Title), false, (tag, _) =>
TagHelper.AddTagIfNotExists(series.Metadata.Tags, tag));
GenreHelper.UpdateGenre(allGenres, chapter.Genres.Select(t => t.Title), false, genre =>
@ -821,7 +821,7 @@ public class ScannerService : IScannerService
// Remove all tags that aren't matching between chapter tags and metadata
TagHelper.KeepOnlySameTagBetweenLists(chapter.Tags, tags.Select(t => DbFactory.Tag(t, false)).ToList());
TagHelper.UpdateTag(allTags, tags, false,
(tag, added) =>
(tag, _) =>
{
chapter.Tags.Add(tag);
});

View file

@ -6,7 +6,6 @@ using System.Net;
using System.Net.Sockets;
using System.Threading.Tasks;
using API.Data;
using API.Entities;
using API.Extensions;
using API.Middleware;
using API.Services;
@ -21,10 +20,8 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;