Holiday Bugfixes (#1762)

* Don't show "not much going on" when we are actively downloading

* Swipe to paginate is now behind a flag in the user preferences.

* Added a new server setting for host name, if the server sits behind a reverse proxy. If this is set, email link generation will use it and will not perform any checks on accessibility (thus email will always send)

* Refactored the code that checks if the server is accessible to check if host name is set, and thus return rue if so.

* Added back the system drawing library for markdown parsing.

* Fixed a validation error

* Fixed a bug where folder watching could get re-triggered when it was disabled at a server level.

* Made the manga reader loader absolute positioned for better visibility

* Indentation
This commit is contained in:
Joe Milazzo 2023-01-30 00:50:19 -08:00 committed by GitHub
parent 2a47029209
commit 5e9bbd0768
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 1986 additions and 49 deletions

View File

@ -96,6 +96,7 @@
</PackageReference> </PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" />
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="7.0.6" /> <PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="7.0.6" />
<PackageReference Include="System.Drawing.Common" Version="6.0.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.24.0" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.24.0" />
<PackageReference Include="System.IO.Abstractions" Version="17.2.3" /> <PackageReference Include="System.IO.Abstractions" Version="17.2.3" />
<PackageReference Include="VersOne.Epub" Version="3.3.0-alpha1" /> <PackageReference Include="VersOne.Epub" Version="3.3.0-alpha1" />

View File

@ -324,10 +324,11 @@ public class AccountController : BaseApiController
// Send a confirmation email // Send a confirmation email
try try
{ {
var emailLink = GenerateEmailLink(user.ConfirmationToken, "confirm-email-update", dto.Email); var emailLink = await _accountService.GenerateEmailLink(Request, user.ConfirmationToken, "confirm-email-update", dto.Email);
_logger.LogCritical("[Update Email]: Email Link for {UserName}: {Link}", user.UserName, emailLink); _logger.LogCritical("[Update Email]: Email Link for {UserName}: {Link}", user.UserName, emailLink);
var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString();
var accessible = await _emailService.CheckIfAccessible(host);
var accessible = await _accountService.CheckIfAccessible(Request);
if (accessible) if (accessible)
{ {
try try
@ -495,7 +496,7 @@ public class AccountController : BaseApiController
if (string.IsNullOrEmpty(user.ConfirmationToken)) if (string.IsNullOrEmpty(user.ConfirmationToken))
return BadRequest("Manual setup is unable to be completed. Please cancel and recreate the invite."); return BadRequest("Manual setup is unable to be completed. Please cancel and recreate the invite.");
return GenerateEmailLink(user.ConfirmationToken, "confirm-email", user.Email, withBaseUrl); return await _accountService.GenerateEmailLink(Request, user.ConfirmationToken, "confirm-email", user.Email, withBaseUrl);
} }
@ -603,11 +604,10 @@ public class AccountController : BaseApiController
try try
{ {
var emailLink = GenerateEmailLink(user.ConfirmationToken, "confirm-email", dto.Email); var emailLink = await _accountService.GenerateEmailLink(Request, user.ConfirmationToken, "confirm-email", dto.Email);
_logger.LogCritical("[Invite User]: Email Link for {UserName}: {Link}", user.UserName, emailLink); _logger.LogCritical("[Invite User]: Email Link for {UserName}: {Link}", user.UserName, emailLink);
_logger.LogCritical("[Invite User]: Token {UserName}: {Token}", user.UserName, user.ConfirmationToken); _logger.LogCritical("[Invite User]: Token {UserName}: {Token}", user.UserName, user.ConfirmationToken);
var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString(); var accessible = await _accountService.CheckIfAccessible(Request);
var accessible = await _emailService.CheckIfAccessible(host);
if (accessible) if (accessible)
{ {
try try
@ -795,10 +795,9 @@ public class AccountController : BaseApiController
return BadRequest("You do not have an email on account or it has not been confirmed"); return BadRequest("You do not have an email on account or it has not been confirmed");
var token = await _userManager.GeneratePasswordResetTokenAsync(user); var token = await _userManager.GeneratePasswordResetTokenAsync(user);
var emailLink = GenerateEmailLink(token, "confirm-reset-password", user.Email); var emailLink = await _accountService.GenerateEmailLink(Request, token, "confirm-reset-password", user.Email);
_logger.LogCritical("[Forgot Password]: Email Link for {UserName}: {Link}", user.UserName, emailLink); _logger.LogCritical("[Forgot Password]: Email Link for {UserName}: {Link}", user.UserName, emailLink);
var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString(); if (await _accountService.CheckIfAccessible(Request))
if (await _emailService.CheckIfAccessible(host))
{ {
await _emailService.SendPasswordResetEmail(new PasswordResetEmailDto() await _emailService.SendPasswordResetEmail(new PasswordResetEmailDto()
{ {
@ -851,6 +850,11 @@ public class AccountController : BaseApiController
}; };
} }
/// <summary>
/// Resend an invite to a user already invited
/// </summary>
/// <param name="userId"></param>
/// <returns></returns>
[HttpPost("resend-confirmation-email")] [HttpPost("resend-confirmation-email")]
public async Task<ActionResult<string>> ResendConfirmationSendEmail([FromQuery] int userId) public async Task<ActionResult<string>> ResendConfirmationSendEmail([FromQuery] int userId)
{ {
@ -863,9 +867,11 @@ public class AccountController : BaseApiController
if (user.EmailConfirmed) return BadRequest("User already confirmed"); if (user.EmailConfirmed) return BadRequest("User already confirmed");
var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); var token = await _userManager.GenerateEmailConfirmationTokenAsync(user);
var emailLink = GenerateEmailLink(token, "confirm-email", user.Email); var emailLink = await _accountService.GenerateEmailLink(Request, token, "confirm-email", user.Email);
_logger.LogCritical("[Email Migration]: Email Link: {Link}", emailLink); _logger.LogCritical("[Email Migration]: Email Link: {Link}", emailLink);
_logger.LogCritical("[Email Migration]: Token {UserName}: {Token}", user.UserName, token); _logger.LogCritical("[Email Migration]: Token {UserName}: {Token}", user.UserName, token);
if (await _accountService.CheckIfAccessible(Request))
{
await _emailService.SendMigrationEmail(new EmailMigrationDto() await _emailService.SendMigrationEmail(new EmailMigrationDto()
{ {
EmailAddress = user.Email, EmailAddress = user.Email,
@ -873,16 +879,10 @@ public class AccountController : BaseApiController
ServerConfirmationLink = emailLink, ServerConfirmationLink = emailLink,
InstallId = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallId)).Value InstallId = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallId)).Value
}); });
return Ok(emailLink); return Ok(emailLink);
} }
private string GenerateEmailLink(string token, string routePart, string email, bool withHost = true) return Ok("The server is not accessible externally. Please ");
{
var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString();
if (withHost) return $"{Request.Scheme}://{host}{Request.PathBase}/registration/{routePart}?token={HttpUtility.UrlEncode(token)}&email={HttpUtility.UrlEncode(email)}";
return $"registration/{routePart}?token={HttpUtility.UrlEncode(token)}&email={HttpUtility.UrlEncode(email)}";
} }
/// <summary> /// <summary>

View File

@ -36,10 +36,11 @@ public class ServerController : BaseApiController
private readonly IEmailService _emailService; private readonly IEmailService _emailService;
private readonly IBookmarkService _bookmarkService; private readonly IBookmarkService _bookmarkService;
private readonly IScannerService _scannerService; private readonly IScannerService _scannerService;
private readonly IAccountService _accountService;
public ServerController(IHostApplicationLifetime applicationLifetime, ILogger<ServerController> logger, public ServerController(IHostApplicationLifetime applicationLifetime, ILogger<ServerController> logger,
IBackupService backupService, IArchiveService archiveService, IVersionUpdaterService versionUpdaterService, IStatsService statsService, IBackupService backupService, IArchiveService archiveService, IVersionUpdaterService versionUpdaterService, IStatsService statsService,
ICleanupService cleanupService, IEmailService emailService, IBookmarkService bookmarkService, IScannerService scannerService) ICleanupService cleanupService, IEmailService emailService, IBookmarkService bookmarkService, IScannerService scannerService, IAccountService accountService)
{ {
_applicationLifetime = applicationLifetime; _applicationLifetime = applicationLifetime;
_logger = logger; _logger = logger;
@ -51,6 +52,7 @@ public class ServerController : BaseApiController
_emailService = emailService; _emailService = emailService;
_bookmarkService = bookmarkService; _bookmarkService = bookmarkService;
_scannerService = scannerService; _scannerService = scannerService;
_accountService = accountService;
} }
/// <summary> /// <summary>
@ -189,12 +191,13 @@ public class ServerController : BaseApiController
/// <summary> /// <summary>
/// Is this server accessible to the outside net /// Is this server accessible to the outside net
/// </summary> /// </summary>
/// <remarks>If the instance has the HostName set, this will return true whether or not it is accessible externally</remarks>
/// <returns></returns> /// <returns></returns>
[HttpGet("accessible")] [HttpGet("accessible")]
[AllowAnonymous] [AllowAnonymous]
public async Task<ActionResult<bool>> IsServerAccessible() public async Task<ActionResult<bool>> IsServerAccessible()
{ {
return await _emailService.CheckIfAccessible(Request.Host.ToString()); return Ok(await _accountService.CheckIfAccessible(Request));
} }
[HttpGet("jobs")] [HttpGet("jobs")]

View File

@ -182,6 +182,13 @@ public class SettingsController : BaseApiController
_unitOfWork.SettingsRepository.Update(setting); _unitOfWork.SettingsRepository.Update(setting);
} }
if (setting.Key == ServerSettingKey.HostName && updateSettingsDto.HostName + string.Empty != setting.Value)
{
setting.Value = (updateSettingsDto.HostName + string.Empty).Trim();
if (setting.Value.EndsWith("/")) setting.Value = setting.Value.Substring(0, setting.Value.Length - 1);
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.BookmarkDirectory && bookmarkDirectory != setting.Value) if (setting.Key == ServerSettingKey.BookmarkDirectory && bookmarkDirectory != setting.Value)
{ {

View File

@ -111,6 +111,7 @@ public class UsersController : BaseApiController
existingPreferences.LayoutMode = preferencesDto.LayoutMode; existingPreferences.LayoutMode = preferencesDto.LayoutMode;
existingPreferences.PromptForDownloadSize = preferencesDto.PromptForDownloadSize; existingPreferences.PromptForDownloadSize = preferencesDto.PromptForDownloadSize;
existingPreferences.NoTransitions = preferencesDto.NoTransitions; existingPreferences.NoTransitions = preferencesDto.NoTransitions;
existingPreferences.SwipeToPaginate = preferencesDto.SwipeToPaginate;
_unitOfWork.UserRepository.Update(existingPreferences); _unitOfWork.UserRepository.Update(existingPreferences);

View File

@ -66,4 +66,8 @@ public class ServerSettingDto
/// If the server should save covers as WebP encoding /// If the server should save covers as WebP encoding
/// </summary> /// </summary>
public bool ConvertCoverToWebP { get; set; } public bool ConvertCoverToWebP { get; set; }
/// <summary>
/// The Host name (ie Reverse proxy domain name) for the server
/// </summary>
public string HostName { get; set; }
} }

View File

@ -46,6 +46,11 @@ public class UserPreferencesDto
/// </summary> /// </summary>
[Required] [Required]
public string BackgroundColor { get; set; } = "#000000"; public string BackgroundColor { get; set; } = "#000000";
[Required]
/// <summary>
/// Manga Reader Option: Should swiping trigger pagination
/// </summary>
public bool SwipeToPaginate { get; set; }
/// <summary> /// <summary>
/// Manga Reader Option: Allow the menu to close after 6 seconds without interaction /// Manga Reader Option: Allow the menu to close after 6 seconds without interaction
/// </summary> /// </summary>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
public partial class SwipeToPaginatePref : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "SwipeToPaginate",
table: "AppUserPreferences",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "SwipeToPaginate",
table: "AppUserPreferences");
}
}
}

View File

@ -249,6 +249,9 @@ namespace API.Data.Migrations
b.Property<bool>("ShowScreenHints") b.Property<bool>("ShowScreenHints")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<bool>("SwipeToPaginate")
.HasColumnType("INTEGER");
b.Property<int?>("ThemeId") b.Property<int?>("ThemeId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");

View File

@ -102,6 +102,7 @@ public static class Seed
new() {Key = ServerSettingKey.TotalLogs, Value = "30"}, new() {Key = ServerSettingKey.TotalLogs, Value = "30"},
new() {Key = ServerSettingKey.EnableFolderWatching, Value = "false"}, new() {Key = ServerSettingKey.EnableFolderWatching, Value = "false"},
new() {Key = ServerSettingKey.ConvertCoverToWebP, Value = "false"}, new() {Key = ServerSettingKey.ConvertCoverToWebP, Value = "false"},
new() {Key = ServerSettingKey.HostName, Value = string.Empty},
}.ToArray()); }.ToArray());
foreach (var defaultSetting in DefaultSettings) foreach (var defaultSetting in DefaultSettings)

View File

@ -1,4 +1,5 @@
using API.Entities.Enums; using System;
using API.Entities.Enums;
using API.Entities.Enums.UserPreferences; using API.Entities.Enums.UserPreferences;
namespace API.Entities; namespace API.Entities;
@ -26,7 +27,6 @@ public class AppUserPreferences
/// </example> /// </example>
/// </summary> /// </summary>
public ReaderMode ReaderMode { get; set; } public ReaderMode ReaderMode { get; set; }
/// <summary> /// <summary>
/// Manga Reader Option: Allow the menu to close after 6 seconds without interaction /// Manga Reader Option: Allow the menu to close after 6 seconds without interaction
/// </summary> /// </summary>
@ -48,6 +48,10 @@ public class AppUserPreferences
/// </summary> /// </summary>
public string BackgroundColor { get; set; } = "#000000"; public string BackgroundColor { get; set; } = "#000000";
/// <summary> /// <summary>
/// Manga Reader Option: Should swiping trigger pagination
/// </summary>
public bool SwipeToPaginate { get; set; }
/// <summary>
/// Book Reader Option: Override extra Margin /// Book Reader Option: Override extra Margin
/// </summary> /// </summary>
public int BookReaderMargin { get; set; } = 15; public int BookReaderMargin { get; set; } = 15;

View File

@ -105,4 +105,10 @@ public enum ServerSettingKey
/// </summary> /// </summary>
[Description("ConvertCoverToWebP")] [Description("ConvertCoverToWebP")]
ConvertCoverToWebP = 19, ConvertCoverToWebP = 19,
/// <summary>
/// The Host name (ie Reverse proxy domain name) for the server. Used for email link generation
/// </summary>
[Description("HostName")]
HostName = 20,
} }

View File

@ -66,6 +66,9 @@ public class ServerSettingConverter : ITypeConverter<IEnumerable<ServerSetting>,
case ServerSettingKey.TotalLogs: case ServerSettingKey.TotalLogs:
destination.TotalLogs = int.Parse(row.Value); destination.TotalLogs = int.Parse(row.Value);
break; break;
case ServerSettingKey.HostName:
destination.HostName = row.Value;
break;
} }
} }

View File

@ -2,12 +2,15 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Web;
using API.Constants; using API.Constants;
using API.Data; using API.Data;
using API.Entities; using API.Entities;
using API.Errors; using API.Errors;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace API.Services; namespace API.Services;
@ -20,6 +23,8 @@ public interface IAccountService
Task<IEnumerable<ApiException>> ValidateEmail(string email); Task<IEnumerable<ApiException>> ValidateEmail(string email);
Task<bool> HasBookmarkPermission(AppUser user); Task<bool> HasBookmarkPermission(AppUser user);
Task<bool> HasDownloadPermission(AppUser user); Task<bool> HasDownloadPermission(AppUser user);
Task<bool> CheckIfAccessible(HttpRequest request);
Task<string> GenerateEmailLink(HttpRequest request, string token, string routePart, string email, bool withHost = true);
} }
public class AccountService : IAccountService public class AccountService : IAccountService
@ -27,13 +32,44 @@ public class AccountService : IAccountService
private readonly UserManager<AppUser> _userManager; private readonly UserManager<AppUser> _userManager;
private readonly ILogger<AccountService> _logger; private readonly ILogger<AccountService> _logger;
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
private readonly IHostEnvironment _environment;
private readonly IEmailService _emailService;
public const string DefaultPassword = "[k.2@RZ!mxCQkJzE"; public const string DefaultPassword = "[k.2@RZ!mxCQkJzE";
private const string LocalHost = "localhost:4200";
public AccountService(UserManager<AppUser> userManager, ILogger<AccountService> logger, IUnitOfWork unitOfWork) public AccountService(UserManager<AppUser> userManager, ILogger<AccountService> logger, IUnitOfWork unitOfWork,
IHostEnvironment environment, IEmailService emailService)
{ {
_userManager = userManager; _userManager = userManager;
_logger = logger; _logger = logger;
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_environment = environment;
_emailService = emailService;
}
/// <summary>
/// Checks if the instance is accessible. If the host name is filled out, then it will assume it is accessible as email generation will use host name.
/// </summary>
/// <param name="request"></param>
/// <returns></returns>
public async Task<bool> CheckIfAccessible(HttpRequest request)
{
var host = _environment.IsDevelopment() ? LocalHost : request.Host.ToString();
return !string.IsNullOrEmpty((await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).HostName) || await _emailService.CheckIfAccessible(host);
}
public async Task<string> GenerateEmailLink(HttpRequest request, string token, string routePart, string email, bool withHost = true)
{
var serverSettings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
var host = _environment.IsDevelopment() ? LocalHost : request.Host.ToString();
var basePart = $"{request.Scheme}://{host}{request.PathBase}/";
if (!string.IsNullOrEmpty(serverSettings.HostName))
{
basePart = serverSettings.HostName;
}
if (withHost) return $"{basePart}/registration/{routePart}?token={HttpUtility.UrlEncode(token)}&email={HttpUtility.UrlEncode(email)}";
return $"registration/{routePart}?token={HttpUtility.UrlEncode(token)}&email={HttpUtility.UrlEncode(email)}";
} }
public async Task<IEnumerable<ApiException>> ChangeUserPassword(AppUser user, string newPassword) public async Task<IEnumerable<ApiException>> ChangeUserPassword(AppUser user, string newPassword)

View File

@ -74,7 +74,14 @@ public class LibraryWatcher : ILibraryWatcher
public async Task StartWatching() public async Task StartWatching()
{ {
_logger.LogInformation("[LibraryWatcher] Starting file watchers"); FileWatchers.Clear();
WatcherDictionary.Clear();
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableFolderWatching)
{
_logger.LogInformation("Folder watching is disabled at the server level, thus ignoring any requests to create folder watching");
return;
}
var libraryFolders = (await _unitOfWork.LibraryRepository.GetLibraryDtosAsync()) var libraryFolders = (await _unitOfWork.LibraryRepository.GetLibraryDtosAsync())
.Where(l => l.FolderWatching) .Where(l => l.FolderWatching)
@ -84,6 +91,8 @@ public class LibraryWatcher : ILibraryWatcher
.Where(_directoryService.Exists) .Where(_directoryService.Exists)
.ToList(); .ToList();
_logger.LogInformation("[LibraryWatcher] Starting file watchers for {Count} library folders", libraryFolders.Count);
foreach (var libraryFolder in libraryFolders) foreach (var libraryFolder in libraryFolders)
{ {
_logger.LogDebug("[LibraryWatcher] Watching {FolderPath}", libraryFolder); _logger.LogDebug("[LibraryWatcher] Watching {FolderPath}", libraryFolder);
@ -107,7 +116,7 @@ public class LibraryWatcher : ILibraryWatcher
WatcherDictionary[libraryFolder].Add(watcher); WatcherDictionary[libraryFolder].Add(watcher);
} }
_logger.LogInformation("[LibraryWatcher] Watching {Count} folders", FileWatchers.Count); _logger.LogInformation("[LibraryWatcher] Watching {Count} folders", libraryFolders.Count);
} }
public void StopWatching() public void StopWatching()

View File

@ -19,6 +19,7 @@ export interface Preferences {
backgroundColor: string; backgroundColor: string;
showScreenHints: boolean; showScreenHints: boolean;
emulateBook: boolean; emulateBook: boolean;
swipeToPaginate: boolean;
// Book Reader // Book Reader
bookReaderMargin: number; bookReaderMargin: number;

View File

@ -14,4 +14,5 @@ export interface ServerSettings {
totalBackups: number; totalBackups: number;
totalLogs: number; totalLogs: number;
enableFolderWatching: boolean; enableFolderWatching: boolean;
hostName: string;
} }

View File

@ -1,6 +1,6 @@
<div class="container-fluid"> <div class="container-fluid">
<form [formGroup]="settingsForm" *ngIf="serverSettings !== undefined"> <form [formGroup]="settingsForm" *ngIf="serverSettings !== undefined">
<p class="text-warning pt-2">Port and Swagger require a manual restart of Kavita to take effect.</p> <p class="text-warning pt-2">Changing Port requires a manual restart of Kavita to take effect.</p>
<div class="mb-3"> <div class="mb-3">
<label for="settings-cachedir" class="form-label">Cache Directory</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="cacheDirectoryTooltip" role="button" tabindex="0"></i> <label for="settings-cachedir" class="form-label">Cache Directory</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="cacheDirectoryTooltip" role="button" tabindex="0"></i>
<ng-template #cacheDirectoryTooltip>Where the server place temporary files when reading. This will be cleaned up on a regular basis.</ng-template> <ng-template #cacheDirectoryTooltip>Where the server place temporary files when reading. This will be cleaned up on a regular basis.</ng-template>
@ -20,6 +20,19 @@
</div> </div>
</div> </div>
<div class="mb-3">
<label for="settings-hostname" class="form-label">Host Name</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="hostNameTooltip" role="button" tabindex="0"></i>
<ng-template #hostNameTooltip>Domain Name (of Reverse Proxy). If set, email generation will always use this.</ng-template>
<span class="visually-hidden" id="settings-hostname-help">Domain Name (of Reverse Proxy). If set, email generation will always use this.</span>
<input id="settings-hostname" aria-describedby="settings-hostname-help" class="form-control" formControlName="hostName" type="text"
[class.is-invalid]="settingsForm.get('hostName')?.invalid && settingsForm.get('hostName')?.touched">
<div id="hostname-validations" class="invalid-feedback" *ngIf="settingsForm.dirty || settingsForm.touched">
<div *ngIf="settingsForm.get('hostName')?.errors?.pattern">
Host name must start with http(s) and not end in /
</div>
</div>
</div>
<div class="row g-0 mb-2"> <div class="row g-0 mb-2">
<div class="col-md-3 col-sm-12 pe-2"> <div class="col-md-3 col-sm-12 pe-2">
<label for="settings-port" class="form-label">Port</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="portTooltip" role="button" tabindex="0"></i> <label for="settings-port" class="form-label">Port</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="portTooltip" role="button" tabindex="0"></i>

View File

@ -51,6 +51,7 @@ export class ManageSettingsComponent implements OnInit {
this.settingsForm.addControl('totalLogs', new FormControl(this.serverSettings.totalLogs, [Validators.required, Validators.min(1), Validators.max(30)])); this.settingsForm.addControl('totalLogs', new FormControl(this.serverSettings.totalLogs, [Validators.required, Validators.min(1), Validators.max(30)]));
this.settingsForm.addControl('enableFolderWatching', new FormControl(this.serverSettings.enableFolderWatching, [Validators.required])); this.settingsForm.addControl('enableFolderWatching', new FormControl(this.serverSettings.enableFolderWatching, [Validators.required]));
this.settingsForm.addControl('convertBookmarkToWebP', new FormControl(this.serverSettings.convertBookmarkToWebP, [])); this.settingsForm.addControl('convertBookmarkToWebP', new FormControl(this.serverSettings.convertBookmarkToWebP, []));
this.settingsForm.addControl('hostName', new FormControl(this.serverSettings.hostName, [Validators.pattern(/^(http:|https:)+[^\s]+[\w]$/)]));
}); });
} }
@ -69,6 +70,7 @@ export class ManageSettingsComponent implements OnInit {
this.settingsForm.get('totalLogs')?.setValue(this.serverSettings.totalLogs); this.settingsForm.get('totalLogs')?.setValue(this.serverSettings.totalLogs);
this.settingsForm.get('enableFolderWatching')?.setValue(this.serverSettings.enableFolderWatching); this.settingsForm.get('enableFolderWatching')?.setValue(this.serverSettings.enableFolderWatching);
this.settingsForm.get('convertBookmarkToWebP')?.setValue(this.serverSettings.convertBookmarkToWebP); this.settingsForm.get('convertBookmarkToWebP')?.setValue(this.serverSettings.convertBookmarkToWebP);
this.settingsForm.get('hostName')?.setValue(this.serverSettings.hostName);
this.settingsForm.markAsPristine(); this.settingsForm.markAsPristine();
} }

View File

@ -134,14 +134,12 @@ export class ManageUsersComponent implements OnInit, OnDestroy {
} }
await this.confirmService.alert( await this.confirmService.alert(
'Please click this link to confirm your email. You must confirm to be able to login. You may need to log out of the current account before clicking. <br/> <a href="' + email + '" target="_blank" rel="noopener noreferrer">' + email + '</a>'); 'Please click this link to confirm your email. You must confirm to be able to login. You may need to log out of the current account before clicking. <br/> <a href="' + email + '" target="_blank" rel="noopener noreferrer">' + email + '</a>');
}); });
}); });
} }
setup(member: Member) { setup(member: Member) {
this.accountService.getInviteUrl(member.id, false).subscribe(url => { this.accountService.getInviteUrl(member.id, false).subscribe(url => {
console.log('Invite Url: ', url);
if (url) { if (url) {
this.router.navigateByUrl(url); this.router.navigateByUrl(url);
} }

View File

@ -26,7 +26,7 @@
</div> </div>
</div> </div>
</div> </div>
<app-loading [loading]="isLoading"></app-loading> <app-loading [loading]="isLoading" [absolute]="true"></app-loading>
<div class="reading-area" <div class="reading-area"
ngSwipe (swipeEnd)="onSwipeEnd($event)" (swipeMove)="onSwipeMove($event)" ngSwipe (swipeEnd)="onSwipeEnd($event)" (swipeMove)="onSwipeMove($event)"
[ngStyle]="{'background-color': backgroundColor, 'height': readerMode === ReaderMode.Webtoon ? 'inherit' : 'calc(var(--vh)*100)'}" #readingArea> [ngStyle]="{'background-color': backgroundColor, 'height': readerMode === ReaderMode.Webtoon ? 'inherit' : 'calc(var(--vh)*100)'}" #readingArea>
@ -233,6 +233,15 @@
</div> </div>
</div> </div>
</div> </div>
<div class="mb-3">
<div class="mb-3">
<div class="form-check form-switch">
<input type="checkbox" id="swipe-to-paginate" formControlName="swipeToPaginate" class="form-check-input" >
<label class="form-check-label" for="swipe-to-paginate">Swipe Enabled</label>
</div>
</div>
</div>
</div> </div>
<div class="col-md-3 col-sm-12"> <div class="col-md-3 col-sm-12">
<div class="mb-3"> <div class="mb-3">
@ -251,8 +260,10 @@
<input type="range" class="form-range" id="darkness" <input type="range" class="form-range" id="darkness"
min="10" max="100" step="1" formControlName="darkness"> min="10" max="100" step="1" formControlName="darkness">
</div> </div>
<div class="col-md-6 col-sm-12"> <div class="col-md-6 col-sm-12">
<button class="btn btn-primary" (click)="savePref()">Save to Preferences</button> <button class="btn btn-primary" (click)="savePref()">Save Globally</button>
</div> </div>
</div> </div>
</form> </form>

View File

@ -467,7 +467,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
fittingOption: new FormControl(this.mangaReaderService.translateScalingOption(this.scalingOption)), fittingOption: new FormControl(this.mangaReaderService.translateScalingOption(this.scalingOption)),
layoutMode: new FormControl(this.layoutMode), layoutMode: new FormControl(this.layoutMode),
darkness: new FormControl(100), darkness: new FormControl(100),
emulateBook: new FormControl(this.user.preferences.emulateBook) emulateBook: new FormControl(this.user.preferences.emulateBook),
swipeToPaginate: new FormControl(this.user.preferences.swipeToPaginate)
}); });
this.readerModeSubject.next(this.readerMode); this.readerModeSubject.next(this.readerMode);
@ -973,6 +974,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
} }
triggerSwipePagination(direction: KeyDirection) { triggerSwipePagination(direction: KeyDirection) {
if (!this.generalSettingsForm.get('swipeToPaginate')?.value) return;
switch(direction) { switch(direction) {
case KeyDirection.Down: case KeyDirection.Down:
this.nextPage(); this.nextPage();
@ -1098,6 +1101,9 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
this.resetSwipeModifiers(); this.resetSwipeModifiers();
this.isLoading = true;
this.cdRef.markForCheck();
this.pagingDirectionSubject.next(PAGING_DIRECTION.FORWARD); this.pagingDirectionSubject.next(PAGING_DIRECTION.FORWARD);
const pageAmount = Math.max(this.canvasRenderer.getPageAmount(PAGING_DIRECTION.FORWARD), this.singleRenderer.getPageAmount(PAGING_DIRECTION.FORWARD), const pageAmount = Math.max(this.canvasRenderer.getPageAmount(PAGING_DIRECTION.FORWARD), this.singleRenderer.getPageAmount(PAGING_DIRECTION.FORWARD),
@ -1125,6 +1131,9 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
this.resetSwipeModifiers(); this.resetSwipeModifiers();
this.isLoading = true;
this.cdRef.markForCheck();
this.pagingDirectionSubject.next(PAGING_DIRECTION.BACKWARDS); this.pagingDirectionSubject.next(PAGING_DIRECTION.BACKWARDS);
@ -1241,6 +1250,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
// Originally this was only for fit to height, but when swiping was introduced, it made more sense to do it always to reset to the same view // Originally this was only for fit to height, but when swiping was introduced, it made more sense to do it always to reset to the same view
this.readingArea.nativeElement.scroll(0,0); this.readingArea.nativeElement.scroll(0,0);
this.isLoading = false;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
} }
@ -1601,6 +1611,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
data.autoCloseMenu = this.autoCloseMenu; data.autoCloseMenu = this.autoCloseMenu;
data.readingDirection = this.readingDirection; data.readingDirection = this.readingDirection;
data.emulateBook = modelSettings.emulateBook; data.emulateBook = modelSettings.emulateBook;
data.swipeToPaginate = modelSettings.swipeToPaginate;
this.accountService.updatePreferences(data).subscribe((updatedPrefs) => { this.accountService.updatePreferences(data).subscribe((updatedPrefs) => {
this.toastr.success('User preferences updated'); this.toastr.success('User preferences updated');
if (this.user) { if (this.user) {

View File

@ -165,9 +165,13 @@
<li class="list-group-item dark-menu-item" *ngIf="onlineUsers.length > 1"> <li class="list-group-item dark-menu-item" *ngIf="onlineUsers.length > 1">
<div>{{onlineUsers.length}} Users online</div> <div>{{onlineUsers.length}} Users online</div>
</li> </li>
<li class="list-group-item dark-menu-item" *ngIf="activeEvents === 0 && onlineUsers.length <= 1">Not much going on here</li>
<li class="list-group-item dark-menu-item" *ngIf="debugMode">Active Events: {{activeEvents}}</li> <li class="list-group-item dark-menu-item" *ngIf="debugMode">Active Events: {{activeEvents}}</li>
</ng-container> </ng-container>
<ng-container *ngIf="downloadService.activeDownloads$ | async as activeDownloads">
<li class="list-group-item dark-menu-item" *ngIf="activeEvents === 0 && activeDownloads.length === 0">Not much going on here</li>
</ng-container>
</ul> </ul>
</ng-template> </ng-template>
</ng-container> </ng-container>

View File

@ -55,7 +55,8 @@ export class EventsWidgetComponent implements OnInit, OnDestroy {
constructor(public messageHub: MessageHubService, private modalService: NgbModal, constructor(public messageHub: MessageHubService, private modalService: NgbModal,
private accountService: AccountService, private confirmService: ConfirmService, private accountService: AccountService, private confirmService: ConfirmService,
private readonly cdRef: ChangeDetectorRef, public downloadService: DownloadService) { } private readonly cdRef: ChangeDetectorRef, public downloadService: DownloadService) {
}
ngOnDestroy(): void { ngOnDestroy(): void {
this.onDestroy.next(); this.onDestroy.next();

View File

@ -1,7 +1,17 @@
<ng-container *ngIf="loading"> <ng-container *ngIf="loading">
<div class="d-flex justify-content-center"> <ng-container *ngIf="absolute; else relative">
<div class="position-absolute top-50 start-50 translate-middle" style="z-index: 999">
<div class="spinner-border text-primary" role="status"> <div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span> <span class="visually-hidden">Loading...</span>
</div> </div>
</div> </div>
</ng-container> </ng-container>
<ng-template #relative>
<div class="d-flex justify-content-center">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</ng-template>
</ng-container>

View File

@ -10,10 +10,15 @@ export class LoadingComponent implements OnInit {
@Input() loading: boolean = false; @Input() loading: boolean = false;
@Input() message: string = ''; @Input() message: string = '';
/**
* Uses absolute positioning to ensure it loads over content
*/
@Input() absolute: boolean = false;
constructor(private readonly cdRef: ChangeDetectorRef) { } constructor(private readonly cdRef: ChangeDetectorRef) { }
ngOnInit(): void { ngOnInit(): void {
console.log('absolute: ', this.absolute);
} }
} }

View File

@ -179,6 +179,12 @@
</div> </div>
</div> </div>
<div class="col-md-6 col-sm-12 pe-2 mb-2"> <div class="col-md-6 col-sm-12 pe-2 mb-2">
<div class="mb-3 mt-1">
<div class="form-check form-switch">
<input type="checkbox" id="swipe-to-paginate" role="switch" formControlName="swipeToPaginate" class="form-check-input" [value]="true">
<label class="form-check-label me-1" for="swipe-to-paginate">Swipe to Paginate</label><i class="fa fa-info-circle" aria-hidden="true" placement="top" ngbTooltip="Should swiping on the screen cause the next or previous page to be triggered" role="button" tabindex="0"></i>
</div>
</div>
</div> </div>
</div> </div>

View File

@ -127,6 +127,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
this.settingsForm.addControl('readerMode', new FormControl(this.user.preferences.readerMode, [])); this.settingsForm.addControl('readerMode', new FormControl(this.user.preferences.readerMode, []));
this.settingsForm.addControl('layoutMode', new FormControl(this.user.preferences.layoutMode, [])); this.settingsForm.addControl('layoutMode', new FormControl(this.user.preferences.layoutMode, []));
this.settingsForm.addControl('emulateBook', new FormControl(this.user.preferences.emulateBook, [])); this.settingsForm.addControl('emulateBook', new FormControl(this.user.preferences.emulateBook, []));
this.settingsForm.addControl('swipeToPaginate', new FormControl(this.user.preferences.swipeToPaginate, []));
this.settingsForm.addControl('bookReaderFontFamily', new FormControl(this.user.preferences.bookReaderFontFamily, [])); this.settingsForm.addControl('bookReaderFontFamily', new FormControl(this.user.preferences.bookReaderFontFamily, []));
this.settingsForm.addControl('bookReaderFontSize', new FormControl(this.user.preferences.bookReaderFontSize, [])); this.settingsForm.addControl('bookReaderFontSize', new FormControl(this.user.preferences.bookReaderFontSize, []));
@ -187,6 +188,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
this.settingsForm.get('promptForDownloadSize')?.setValue(this.user.preferences.promptForDownloadSize); this.settingsForm.get('promptForDownloadSize')?.setValue(this.user.preferences.promptForDownloadSize);
this.settingsForm.get('noTransitions')?.setValue(this.user.preferences.noTransitions); this.settingsForm.get('noTransitions')?.setValue(this.user.preferences.noTransitions);
this.settingsForm.get('emulateBook')?.setValue(this.user.preferences.emulateBook); this.settingsForm.get('emulateBook')?.setValue(this.user.preferences.emulateBook);
this.settingsForm.get('swipeToPaginate')?.setValue(this.user.preferences.swipeToPaginate);
this.cdRef.markForCheck(); this.cdRef.markForCheck();
this.settingsForm.markAsPristine(); this.settingsForm.markAsPristine();
} }
@ -217,7 +219,8 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
blurUnreadSummaries: modelSettings.blurUnreadSummaries, blurUnreadSummaries: modelSettings.blurUnreadSummaries,
promptForDownloadSize: modelSettings.promptForDownloadSize, promptForDownloadSize: modelSettings.promptForDownloadSize,
noTransitions: modelSettings.noTransitions, noTransitions: modelSettings.noTransitions,
emulateBook: modelSettings.emulateBook emulateBook: modelSettings.emulateBook,
swipeToPaginate: modelSettings.swipeToPaginate
}; };
this.observableHandles.push(this.accountService.updatePreferences(data).subscribe((updatedPrefs) => { this.observableHandles.push(this.accountService.updatePreferences(data).subscribe((updatedPrefs) => {

View File

@ -7,7 +7,7 @@
"name": "GPL-3.0", "name": "GPL-3.0",
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
}, },
"version": "0.6.1.28" "version": "0.6.1.29"
}, },
"servers": [ "servers": [
{ {
@ -729,10 +729,12 @@
"tags": [ "tags": [
"Account" "Account"
], ],
"summary": "Resend an invite to a user already invited",
"parameters": [ "parameters": [
{ {
"name": "userId", "name": "userId",
"in": "query", "in": "query",
"description": "",
"schema": { "schema": {
"type": "integer", "type": "integer",
"format": "int32" "format": "int32"
@ -7245,6 +7247,7 @@
"Server" "Server"
], ],
"summary": "Is this server accessible to the outside net", "summary": "Is this server accessible to the outside net",
"description": "If the instance has the HostName set, this will return true whether or not it is accessible externally",
"responses": { "responses": {
"200": { "200": {
"description": "Success", "description": "Success",
@ -9511,6 +9514,10 @@
"description": "Manga Reader Option: Background color of the reader", "description": "Manga Reader Option: Background color of the reader",
"nullable": true "nullable": true
}, },
"swipeToPaginate": {
"type": "boolean",
"description": "Manga Reader Option: Should swiping trigger pagination"
},
"bookReaderMargin": { "bookReaderMargin": {
"type": "integer", "type": "integer",
"description": "Book Reader Option: Override extra Margin", "description": "Book Reader Option: Override extra Margin",
@ -13318,6 +13325,11 @@
"convertCoverToWebP": { "convertCoverToWebP": {
"type": "boolean", "type": "boolean",
"description": "If the server should save covers as WebP encoding" "description": "If the server should save covers as WebP encoding"
},
"hostName": {
"type": "string",
"description": "The Host name (ie Reverse proxy domain name) for the server",
"nullable": true
} }
}, },
"additionalProperties": false "additionalProperties": false
@ -14285,7 +14297,8 @@
"readerMode", "readerMode",
"readingDirection", "readingDirection",
"scalingOption", "scalingOption",
"showScreenHints" "showScreenHints",
"swipeToPaginate"
], ],
"type": "object", "type": "object",
"properties": { "properties": {
@ -14313,6 +14326,9 @@
"type": "string", "type": "string",
"description": "Manga Reader Option: Background color of the reader" "description": "Manga Reader Option: Background color of the reader"
}, },
"swipeToPaginate": {
"type": "boolean"
},
"autoCloseMenu": { "autoCloseMenu": {
"type": "boolean", "type": "boolean",
"description": "Manga Reader Option: Allow the menu to close after 6 seconds without interaction" "description": "Manga Reader Option: Allow the menu to close after 6 seconds without interaction"