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:
parent
6e6b72a5b5
commit
efb527035d
109 changed files with 2041 additions and 407 deletions
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.");
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
16
API/DTOs/Account/ConfirmEmailDto.cs
Normal file
16
API/DTOs/Account/ConfirmEmailDto.cs
Normal 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; }
|
||||
}
|
7
API/DTOs/Account/ConfirmMigrationEmailDto.cs
Normal file
7
API/DTOs/Account/ConfirmMigrationEmailDto.cs
Normal file
|
@ -0,0 +1,7 @@
|
|||
namespace API.DTOs.Account;
|
||||
|
||||
public class ConfirmMigrationEmailDto
|
||||
{
|
||||
public string Email { get; set; }
|
||||
public string Token { get; set; }
|
||||
}
|
21
API/DTOs/Account/InviteUserDto.cs
Normal file
21
API/DTOs/Account/InviteUserDto.cs
Normal 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;
|
||||
}
|
11
API/DTOs/Account/MigrateUserEmailDto.cs
Normal file
11
API/DTOs/Account/MigrateUserEmailDto.cs
Normal 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; }
|
||||
}
|
23
API/DTOs/Account/UpdateUserDto.cs
Normal file
23
API/DTOs/Account/UpdateUserDto.cs
Normal 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; }
|
||||
|
||||
}
|
|
@ -1,7 +1,6 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using API.DTOs.Metadata;
|
||||
using API.Entities;
|
||||
|
||||
namespace API.DTOs
|
||||
{
|
||||
|
|
8
API/DTOs/Email/ConfirmationEmailDto.cs
Normal file
8
API/DTOs/Email/ConfirmationEmailDto.cs
Normal 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; }
|
||||
}
|
8
API/DTOs/Email/EmailMigrationDto.cs
Normal file
8
API/DTOs/Email/EmailMigrationDto.cs
Normal 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; }
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
using System;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.DTOs.Reader
|
||||
{
|
||||
|
|
|
@ -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; }
|
||||
|
|
|
@ -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; }
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>();
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 =>
|
||||
{
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Parser;
|
||||
|
||||
namespace API.Extensions
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using API.DTOs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace API.Helpers
|
||||
|
|
|
@ -3,7 +3,6 @@ using System.IO;
|
|||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using API.Entities.Enums;
|
||||
using API.Services;
|
||||
|
||||
namespace API.Parser
|
||||
{
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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")
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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))
|
||||
{
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
103
API/Services/EmailService.cs
Normal file
103
API/Services/EmailService.cs
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue