From 2809233de07bc9001f0b45bd56a61297d176ec62 Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Mon, 9 Aug 2021 08:52:24 -0500 Subject: [PATCH] Update Notifier (#464) # Added - Added: Ability to check for updates (stable-only) and be notified with a changelog. This is a first pass implementation. - Added: Ability to use SignalR within Kavita (websockets) ===================================== * (some debug code present). Implemented the ability to check and log if the server is up to date or not. * Fixed a bug for dark mode where anchor buttons wouldn't have the correct font color. Suppress filter/sort button if there is no filters to show. Debug: Active indicators for users currently on your server. Refactored code to send update notification only to admins. Admins now get a popup where they can open the Github release (docker users can just close). * Fixed an issue where getLibraryNames on first load would call for as many cards there was on the screen. Now we call it much earlier and the data is cached faster. * Fixed a dark mode bug from previous commit * Release notes is now rendered markdown * Implemented the ability to check for an update ad-hoc. Response will come via websocket to all admins. * Fixed a missing padding * Cleanup, added some temp code to carousel * Cleaned up old stat stuff from dev config and added debug only flow for checking for update * Misc cleanup * Added readonly to one variable * Fixed In Progress not showing for all series due to pagination bug * Fixed the In progress API returning back series that had another users progress on them. Added SplitQuery which speeds up query significantly. * SplitQuery in GetRecentlyAdded for a speed increase on API. Fixed the logic on VersionUpdaterService to properly send on non-dev systems. Disable the check button once it's triggered once since the API does a task, so it can't return anything. * Cleaned up the admin actions to be more friendly on mobile. * Cleaned up the message as we wait for SingalR to notify the user * more textual changes * Code smells --- API/API.csproj | 4 + API/Controllers/SeriesController.cs | 2 +- API/Controllers/ServerController.cs | 13 +- API/Controllers/StatsController.cs | 7 +- API/Data/SeriesRepository.cs | 1 + .../ApplicationServiceExtensions.cs | 5 + API/Extensions/IdentityServiceExtensions.cs | 23 ++- API/Interfaces/ITaskScheduler.cs | 4 +- .../Services/IVersionUpdaterService.cs | 11 ++ API/Services/Clients/StatsApiClient.cs | 8 +- .../StartupTasksHostedService.cs | 3 +- API/Services/TaskScheduler.cs | 19 ++- API/Services/Tasks/VersionUpdaterService.cs | 112 +++++++++++++++ API/SignalR/MessageHub.cs | 46 ++++++ API/SignalR/Presence/PresenceTracker.cs | 99 +++++++++++++ API/SignalR/PresenceHub.cs | 38 +++++ API/SignalR/SignalRMessage.cs | 11 ++ API/Startup.cs | 4 + API/appsettings.Development.json | 5 - Kavita.Common/Kavita.Common.csproj | 2 +- UI/Web/package-lock.json | 136 ++++++++++++++---- UI/Web/package.json | 1 + UI/Web/src/app/_guards/admin.guard.ts | 4 +- UI/Web/src/app/_guards/auth.guard.ts | 4 +- .../_models/events/update-version-event.ts | 8 ++ UI/Web/src/app/_services/account.service.ts | 9 +- UI/Web/src/app/_services/library.service.ts | 2 +- .../src/app/_services/message-hub.service.ts | 57 ++++++++ .../src/app/_services/presence-hub.service.ts | 40 ++++++ UI/Web/src/app/_services/server.service.ts | 4 + UI/Web/src/app/admin/admin.module.ts | 3 +- .../manage-system.component.html | 40 +++--- .../manage-system/manage-system.component.ts | 9 +- .../manage-users/manage-users.component.html | 2 +- .../manage-users/manage-users.component.scss | 3 + .../manage-users/manage-users.component.ts | 19 ++- .../all-collections.component.ts | 1 - UI/Web/src/app/app.component.ts | 11 +- .../carousel-reel/carousel-reel.component.ts | 5 +- UI/Web/src/app/library/library.component.ts | 7 +- .../shared/_services/dom-helper.service.ts | 2 +- .../card-detail-layout.component.html | 2 +- .../shared/card-item/card-item.component.ts | 2 - UI/Web/src/app/shared/shared.module.ts | 4 +- .../update-notification-modal.component.html | 15 ++ .../update-notification-modal.component.scss | 5 + .../update-notification-modal.component.ts | 26 ++++ .../app/user-login/user-login.component.html | 2 +- UI/Web/src/assets/themes/dark.scss | 7 +- UI/Web/src/environments/environment.prod.ts | 3 +- UI/Web/src/environments/environment.ts | 3 +- 51 files changed, 753 insertions(+), 100 deletions(-) create mode 100644 API/Interfaces/Services/IVersionUpdaterService.cs create mode 100644 API/Services/Tasks/VersionUpdaterService.cs create mode 100644 API/SignalR/MessageHub.cs create mode 100644 API/SignalR/Presence/PresenceTracker.cs create mode 100644 API/SignalR/PresenceHub.cs create mode 100644 API/SignalR/SignalRMessage.cs create mode 100644 UI/Web/src/app/_models/events/update-version-event.ts create mode 100644 UI/Web/src/app/_services/message-hub.service.ts create mode 100644 UI/Web/src/app/_services/presence-hub.service.ts create mode 100644 UI/Web/src/app/shared/update-notification/update-notification-modal.component.html create mode 100644 UI/Web/src/app/shared/update-notification/update-notification-modal.component.scss create mode 100644 UI/Web/src/app/shared/update-notification/update-notification-modal.component.ts diff --git a/API/API.csproj b/API/API.csproj index fd4e7f9ac..504cf7271 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -33,14 +33,18 @@ + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index 7bf59c7e0..9b00f2c46 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -168,7 +168,7 @@ namespace API.Controllers [HttpPost("in-progress")] public async Task>> GetInProgress(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0) { - // NOTE: This has to be done manually like this due to the DisinctBy requirement + // NOTE: This has to be done manually like this due to the DistinctBy requirement var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); var results = await _unitOfWork.SeriesRepository.GetInProgress(user.Id, libraryId, userParams, filterDto); diff --git a/API/Controllers/ServerController.cs b/API/Controllers/ServerController.cs index 028d180df..40686162c 100644 --- a/API/Controllers/ServerController.cs +++ b/API/Controllers/ServerController.cs @@ -3,6 +3,7 @@ using System.IO; using System.Threading.Tasks; using API.DTOs.Stats; using API.Extensions; +using API.Interfaces; using API.Interfaces.Services; using API.Services.Tasks; using Kavita.Common; @@ -23,9 +24,10 @@ namespace API.Controllers private readonly IBackupService _backupService; private readonly IArchiveService _archiveService; private readonly ICacheService _cacheService; + private readonly ITaskScheduler _taskScheduler; public ServerController(IHostApplicationLifetime applicationLifetime, ILogger logger, IConfiguration config, - IBackupService backupService, IArchiveService archiveService, ICacheService cacheService) + IBackupService backupService, IArchiveService archiveService, ICacheService cacheService, ITaskScheduler taskScheduler) { _applicationLifetime = applicationLifetime; _logger = logger; @@ -33,6 +35,7 @@ namespace API.Controllers _backupService = backupService; _archiveService = archiveService; _cacheService = cacheService; + _taskScheduler = taskScheduler; } /// @@ -99,7 +102,11 @@ namespace API.Controllers } } - - + [HttpPost("check-update")] + public ActionResult CheckForUpdates() + { + _taskScheduler.CheckForUpdate(); + return Ok(); + } } } diff --git a/API/Controllers/StatsController.cs b/API/Controllers/StatsController.cs index d219db790..09fcf9738 100644 --- a/API/Controllers/StatsController.cs +++ b/API/Controllers/StatsController.cs @@ -29,12 +29,11 @@ namespace API.Controllers return Ok(); } - catch (Exception e) + catch (Exception ex) { - _logger.LogError(e, "Error updating the usage statistics"); - Console.WriteLine(e); + _logger.LogError(ex, "Error updating the usage statistics"); throw; } } } -} \ No newline at end of file +} diff --git a/API/Data/SeriesRepository.cs b/API/Data/SeriesRepository.cs index 92d19042b..2e223c0c8 100644 --- a/API/Data/SeriesRepository.cs +++ b/API/Data/SeriesRepository.cs @@ -334,6 +334,7 @@ namespace API.Data .Where(s => s.LibraryId == libraryId && formats.Contains(s.Format)) .OrderByDescending(s => s.Created) .ProjectTo(_mapper.ConfigurationProvider) + .AsSplitQuery() .AsNoTracking(); return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index f33b72809..ad784045b 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -4,6 +4,7 @@ using API.Interfaces; using API.Interfaces.Services; using API.Services; using API.Services.Tasks; +using API.SignalR.Presence; using Kavita.Common; using Microsoft.AspNetCore.Hosting; using Microsoft.EntityFrameworkCore; @@ -32,9 +33,13 @@ namespace API.Extensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); services.AddSqLite(config, env); services.AddLogging(config); + services.AddSignalR(); } private static void AddSqLite(this IServiceCollection services, IConfiguration config, diff --git a/API/Extensions/IdentityServiceExtensions.cs b/API/Extensions/IdentityServiceExtensions.cs index 5310cf2ef..9b32c9320 100644 --- a/API/Extensions/IdentityServiceExtensions.cs +++ b/API/Extensions/IdentityServiceExtensions.cs @@ -1,4 +1,5 @@ using System.Text; +using System.Threading.Tasks; using API.Constants; using API.Data; using API.Entities; @@ -24,7 +25,7 @@ namespace API.Extensions .AddSignInManager>() .AddRoleValidator>() .AddEntityFrameworkStores(); - + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { @@ -35,14 +36,30 @@ namespace API.Extensions ValidateIssuer = false, ValidateAudience = false }; + + options.Events = new JwtBearerEvents() + { + OnMessageReceived = context => + { + var accessToken = context.Request.Query["access_token"]; + var path = context.HttpContext.Request.Path; + // Only use query string based token on SignalR hubs + if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs")) + { + context.Token = accessToken; + } + + return Task.CompletedTask; + } + }; }); services.AddAuthorization(opt => { opt.AddPolicy("RequireAdminRole", policy => policy.RequireRole(PolicyConstants.AdminRole)); opt.AddPolicy("RequireDownloadRole", policy => policy.RequireRole(PolicyConstants.DownloadRole, PolicyConstants.AdminRole)); }); - + return services; } } -} \ No newline at end of file +} diff --git a/API/Interfaces/ITaskScheduler.cs b/API/Interfaces/ITaskScheduler.cs index e02ba80b8..ea674d155 100644 --- a/API/Interfaces/ITaskScheduler.cs +++ b/API/Interfaces/ITaskScheduler.cs @@ -6,13 +6,15 @@ /// For use on Server startup /// void ScheduleTasks(); + void ScheduleStatsTasks(); + void ScheduleUpdaterTasks(); void ScanLibrary(int libraryId, bool forceUpdate = false); void CleanupChapters(int[] chapterIds); void RefreshMetadata(int libraryId, bool forceUpdate = true); void CleanupTemp(); void RefreshSeriesMetadata(int libraryId, int seriesId); void ScanSeries(int libraryId, int seriesId, bool forceUpdate = false); - void ScheduleStatsTasks(); void CancelStatsTasks(); + void CheckForUpdate(); } } diff --git a/API/Interfaces/Services/IVersionUpdaterService.cs b/API/Interfaces/Services/IVersionUpdaterService.cs new file mode 100644 index 000000000..a47de72cf --- /dev/null +++ b/API/Interfaces/Services/IVersionUpdaterService.cs @@ -0,0 +1,11 @@ +using System; +using System.Threading.Tasks; + +namespace API.Interfaces.Services +{ + public interface IVersionUpdaterService + { + public Task CheckForUpdate(); + + } +} diff --git a/API/Services/Clients/StatsApiClient.cs b/API/Services/Clients/StatsApiClient.cs index e6845178d..e1a274398 100644 --- a/API/Services/Clients/StatsApiClient.cs +++ b/API/Services/Clients/StatsApiClient.cs @@ -39,16 +39,12 @@ namespace API.Services.Clients response = responseContent }; - _logger.LogError(e, "The StatsServer did not respond successfully. {Content}", info); - - Console.WriteLine(e); + _logger.LogError(e, "KavitaStats did not respond successfully. {Content}", info); throw; } catch (Exception e) { - _logger.LogError(e, "An error happened during the request to the Stats Server"); - - Console.WriteLine(e); + _logger.LogError(e, "An error happened during the request to KavitaStats"); throw; } } diff --git a/API/Services/HostedServices/StartupTasksHostedService.cs b/API/Services/HostedServices/StartupTasksHostedService.cs index 95f87006e..02e2fa3d9 100644 --- a/API/Services/HostedServices/StartupTasksHostedService.cs +++ b/API/Services/HostedServices/StartupTasksHostedService.cs @@ -23,6 +23,7 @@ namespace API.Services.HostedServices var taskScheduler = scope.ServiceProvider.GetRequiredService(); taskScheduler.ScheduleTasks(); + taskScheduler.ScheduleUpdaterTasks(); try { @@ -51,4 +52,4 @@ namespace API.Services.HostedServices public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; } -} \ No newline at end of file +} diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index 675f51352..3e0939b2a 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -21,13 +21,14 @@ namespace API.Services private readonly ICleanupService _cleanupService; private readonly IStatsService _statsService; + private readonly IVersionUpdaterService _versionUpdaterService; public static BackgroundJobServer Client => new BackgroundJobServer(); public TaskScheduler(ICacheService cacheService, ILogger logger, IScannerService scannerService, IUnitOfWork unitOfWork, IMetadataService metadataService, IBackupService backupService, - ICleanupService cleanupService, IStatsService statsService) + ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService) { _cacheService = cacheService; _logger = logger; @@ -37,6 +38,7 @@ namespace API.Services _backupService = backupService; _cleanupService = cleanupService; _statsService = statsService; + _versionUpdaterService = versionUpdaterService; } public void ScheduleTasks() @@ -97,6 +99,16 @@ namespace API.Services #endregion + #region UpdateTasks + + public void ScheduleUpdaterTasks() + { + _logger.LogInformation("Scheduling Auto-Update tasks"); + RecurringJob.AddOrUpdate("check-updates", () => _versionUpdaterService.CheckForUpdate(), Cron.Daily); + + } + #endregion + public void ScanLibrary(int libraryId, bool forceUpdate = false) { _logger.LogInformation("Enqueuing library scan for: {LibraryId}", libraryId); @@ -138,5 +150,10 @@ namespace API.Services { BackgroundJob.Enqueue(() => _backupService.BackupDatabase()); } + + public void CheckForUpdate() + { + BackgroundJob.Enqueue(() => _versionUpdaterService.CheckForUpdate()); + } } } diff --git a/API/Services/Tasks/VersionUpdaterService.cs b/API/Services/Tasks/VersionUpdaterService.cs new file mode 100644 index 000000000..9f65f136b --- /dev/null +++ b/API/Services/Tasks/VersionUpdaterService.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using API.Interfaces.Services; +using API.SignalR; +using API.SignalR.Presence; +using Flurl.Http; +using Kavita.Common.EnvironmentInfo; +using MarkdownDeep; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace API.Services.Tasks +{ + internal class GithubReleaseMetadata + { + /// + /// Name of the Tag + /// v0.4.3 + /// + public string Tag_Name { get; init; } + /// + /// Name of the Release + /// + public string Name { get; init; } + /// + /// Body of the Release + /// + public string Body { get; init; } + /// + /// Url of the release on Github + /// + public string Html_Url { get; init; } + + } + public class VersionUpdaterService : IVersionUpdaterService + { + private readonly ILogger _logger; + private readonly IHubContext _messageHub; + private readonly IPresenceTracker _tracker; + private readonly Markdown _markdown = new MarkdownDeep.Markdown(); + + public VersionUpdaterService(ILogger logger, IHubContext messageHub, IPresenceTracker tracker) + { + _logger = logger; + _messageHub = messageHub; + _tracker = tracker; + } + + /// + /// Scheduled Task that checks if a newer version is available. If it is, will check if User is currently connected and push + /// a message. + /// + public async Task CheckForUpdate() + { + + var update = await GetGithubRelease(); + + if (update == null || string.IsNullOrEmpty(update.Tag_Name)) return; + + var admins = await _tracker.GetOnlineAdmins(); + var version = update.Tag_Name.Replace("v", string.Empty); + var updateVersion = new Version(version); + if (BuildInfo.Version < updateVersion) + { + _logger.LogInformation("Server is out of date. Current: {CurrentVersion}. Available: {AvailableUpdate}", BuildInfo.Version, updateVersion); + await SendEvent(update, admins); + } + else if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development) + { + _logger.LogInformation("Server is up to date. Current: {CurrentVersion}", BuildInfo.Version); + await SendEvent(update, admins); + } + } + + private async Task SendEvent(GithubReleaseMetadata update, IReadOnlyList admins) + { + var version = update.Tag_Name.Replace("v", string.Empty); + var updateVersion = new Version(version); + var connections = new List(); + foreach (var admin in admins) + { + connections.AddRange(await _tracker.GetConnectionsForUser(admin)); + } + + await _messageHub.Clients.Users(admins).SendAsync("UpdateAvailable", new SignalRMessage + { + Name = "UpdateAvailable", + Body = new + { + CurrentVersion = version, + UpdateVersion = updateVersion.ToString(), + UpdateBody = _markdown.Transform(update.Body.Trim()), + UpdateTitle = update.Name, + UpdateUrl = update.Html_Url, + IsDocker = new OsInfo(Array.Empty()).IsDocker + } + }); + } + + private static async Task GetGithubRelease() + { + var update = await "https://api.github.com/repos/Kareadita/Kavita/releases/latest" + .WithHeader("Accept", "application/json") + .WithHeader("User-Agent", "Kavita") + .GetJsonAsync(); + + return update; + } + } +} diff --git a/API/SignalR/MessageHub.cs b/API/SignalR/MessageHub.cs new file mode 100644 index 000000000..2ec9cc469 --- /dev/null +++ b/API/SignalR/MessageHub.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; + +namespace API.SignalR +{ + + [Authorize] + public class MessageHub : Hub + { + private static readonly HashSet _connections = new HashSet(); + + public static bool IsConnected + { + get + { + lock (_connections) + { + return _connections.Count != 0; + } + } + } + + public override async Task OnConnectedAsync() + { + lock (_connections) + { + _connections.Add(Context.ConnectionId); + } + + await base.OnConnectedAsync(); + } + + public override async Task OnDisconnectedAsync(Exception exception) + { + lock (_connections) + { + _connections.Remove(Context.ConnectionId); + } + + await base.OnDisconnectedAsync(exception); + } + } +} diff --git a/API/SignalR/Presence/PresenceTracker.cs b/API/SignalR/Presence/PresenceTracker.cs new file mode 100644 index 000000000..ac9bb28d1 --- /dev/null +++ b/API/SignalR/Presence/PresenceTracker.cs @@ -0,0 +1,99 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.Interfaces; + +namespace API.SignalR.Presence +{ + public interface IPresenceTracker + { + Task UserConnected(string username, string connectionId); + Task UserDisconnected(string username, string connectionId); + Task GetOnlineAdmins(); + Task> GetConnectionsForUser(string username); + + } + + /// + /// This is a singleton service for tracking what users have a SignalR connection and their difference connectionIds + /// + public class PresenceTracker : IPresenceTracker + { + private readonly IUnitOfWork _unitOfWork; + private static readonly Dictionary> OnlineUsers = new Dictionary>(); + + public PresenceTracker(IUnitOfWork unitOfWork) + { + _unitOfWork = unitOfWork; + } + + public Task UserConnected(string username, string connectionId) + { + lock (OnlineUsers) + { + if (OnlineUsers.ContainsKey(username)) + { + OnlineUsers[username].Add(connectionId); + } + else + { + OnlineUsers.Add(username, new List() { connectionId }); + } + } + + return Task.CompletedTask; + } + + public Task UserDisconnected(string username, string connectionId) + { + lock (OnlineUsers) + { + if (!OnlineUsers.ContainsKey(username)) return Task.CompletedTask; + + OnlineUsers[username].Remove(connectionId); + + if (OnlineUsers[username].Count == 0) + { + OnlineUsers.Remove(username); + } + } + return Task.CompletedTask; + } + + public static Task GetOnlineUsers() + { + string[] onlineUsers; + lock (OnlineUsers) + { + onlineUsers = OnlineUsers.OrderBy(k => k.Key).Select(k => k.Key).ToArray(); + } + + return Task.FromResult(onlineUsers); + } + + public async Task GetOnlineAdmins() + { + string[] onlineUsers; + lock (OnlineUsers) + { + onlineUsers = OnlineUsers.OrderBy(k => k.Key).Select(k => k.Key).ToArray(); + } + + var admins = await _unitOfWork.UserRepository.GetAdminUsersAsync(); + var result = admins.Select(a => a.UserName).Intersect(onlineUsers).ToArray(); + + return result; + } + + public Task> GetConnectionsForUser(string username) + { + List connectionIds; + lock (OnlineUsers) + { + connectionIds = OnlineUsers.GetValueOrDefault(username); + } + + return Task.FromResult(connectionIds); + } + } +} diff --git a/API/SignalR/PresenceHub.cs b/API/SignalR/PresenceHub.cs new file mode 100644 index 000000000..93aa5d59b --- /dev/null +++ b/API/SignalR/PresenceHub.cs @@ -0,0 +1,38 @@ +using System; +using System.Threading.Tasks; +using API.Extensions; +using API.SignalR.Presence; +using Microsoft.AspNetCore.SignalR; + +namespace API.SignalR +{ + public class PresenceHub : Hub + { + private readonly IPresenceTracker _tracker; + + public PresenceHub(IPresenceTracker tracker) + { + _tracker = tracker; + } + + public override async Task OnConnectedAsync() + { + await _tracker.UserConnected(Context.User.GetUsername(), Context.ConnectionId); + + var currentUsers = await PresenceTracker.GetOnlineUsers(); + await Clients.All.SendAsync("GetOnlineUsers", currentUsers); + + + } + + public override async Task OnDisconnectedAsync(Exception exception) + { + await _tracker.UserDisconnected(Context.User.GetUsername(), Context.ConnectionId); + + var currentUsers = await PresenceTracker.GetOnlineUsers(); + await Clients.All.SendAsync("GetOnlineUsers", currentUsers); + + await base.OnDisconnectedAsync(exception); + } + } +} diff --git a/API/SignalR/SignalRMessage.cs b/API/SignalR/SignalRMessage.cs new file mode 100644 index 000000000..89b992b4b --- /dev/null +++ b/API/SignalR/SignalRMessage.cs @@ -0,0 +1,11 @@ +namespace API.SignalR +{ + public class SignalRMessage + { + public object Body { get; set; } + public string Name { get; set; } + + //[JsonIgnore] + //public ModelAction Action { get; set; } // This will be for when we add new flows + } +} diff --git a/API/Startup.cs b/API/Startup.cs index 3741399f9..a502dc46c 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -5,6 +5,7 @@ using API.Extensions; using API.Middleware; using API.Services; using API.Services.HostedServices; +using API.SignalR; using Hangfire; using Hangfire.MemoryStorage; using Kavita.Common.EnvironmentInfo; @@ -104,6 +105,7 @@ namespace API app.UseCors(policy => policy .AllowAnyHeader() .AllowAnyMethod() + .AllowCredentials() // For SignalR token query param .WithOrigins("http://localhost:4200") .WithExposedHeaders("Content-Disposition", "Pagination")); } @@ -138,6 +140,8 @@ namespace API app.UseEndpoints(endpoints => { endpoints.MapControllers(); + endpoints.MapHub("hubs/messages"); + endpoints.MapHub("hubs/presence"); endpoints.MapHangfireDashboard(); endpoints.MapFallbackToController("Index", "Fallback"); }); diff --git a/API/appsettings.Development.json b/API/appsettings.Development.json index 9d93e100a..b5dc22df1 100644 --- a/API/appsettings.Development.json +++ b/API/appsettings.Development.json @@ -3,11 +3,6 @@ "DefaultConnection": "Data source=kavita.db" }, "TokenKey": "super secret unguessable key", - "StatsOptions": { - "ServerUrl": "http://localhost:5002", - "ServerSecret": "here's where the api key goes", - "SendDataAt": "23:50" - }, "Logging": { "LogLevel": { "Default": "Debug", diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index ab41e74ee..3b8c61c44 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -19,4 +19,4 @@ - \ No newline at end of file + diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index 3a78bd9de..0a5ec258b 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -19,6 +19,7 @@ "@angular/platform-browser-dynamic": "~11.0.0", "@angular/router": "~11.0.0", "@fortawesome/fontawesome-free": "^5.15.1", + "@microsoft/signalr": "^5.0.8", "@ng-bootstrap/ng-bootstrap": "^9.1.0", "@ngx-lite/nav-drawer": "^0.4.6", "@ngx-lite/util": "0.0.0", @@ -2762,6 +2763,18 @@ "schema-utils": "^2.7.0" } }, + "node_modules/@microsoft/signalr": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-5.0.8.tgz", + "integrity": "sha512-g5U7zGa1CeoPztA1VGLiB418sZ6gt8ZEOsX8krpegyMquzH2Qinny1zQjNsg3mgjGlJI+FXD5bO4gVsHGUp2hA==", + "dependencies": { + "abort-controller": "^3.0.0", + "eventsource": "^1.0.7", + "fetch-cookie": "^0.7.3", + "node-fetch": "^2.6.0", + "ws": "^6.0.0" + } + }, "node_modules/@ng-bootstrap/ng-bootstrap": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-9.1.0.tgz", @@ -3710,6 +3723,17 @@ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "dev": true }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz", @@ -4146,8 +4170,7 @@ "node_modules/async-limiter": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", - "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", - "dev": true + "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==" }, "node_modules/asynckit": { "version": "0.4.0", @@ -6947,6 +6970,11 @@ "next-tick": "~1.0.0" } }, + "node_modules/es6-denodeify": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/es6-denodeify/-/es6-denodeify-0.1.5.tgz", + "integrity": "sha1-MdTV/pxVA+ElRgQ5MQ4WoqPznB8=" + }, "node_modules/es6-iterator": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", @@ -7111,6 +7139,14 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "engines": { + "node": ">=6" + } + }, "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", @@ -7130,7 +7166,6 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-1.0.7.tgz", "integrity": "sha512-4Ln17+vVT0k8aWq+t/bF5arcS3EpT9gYtW66EPacdj/mAFevznsnyoHLPy2BA8gbIQeIHoPsvwmfBftfcG//BQ==", - "dev": true, "dependencies": { "original": "^1.0.0" }, @@ -7568,6 +7603,15 @@ "bser": "2.1.1" } }, + "node_modules/fetch-cookie": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-0.7.3.tgz", + "integrity": "sha512-rZPkLnI8x5V+zYAiz8QonAHsTb4BY+iFowFBI1RFn0zrO343AVp9X7/yUj/9wL6Ef/8fLls8b/vGtzUvmyAUGA==", + "dependencies": { + "es6-denodeify": "^0.1.1", + "tough-cookie": "^2.3.3" + } + }, "node_modules/figgy-pudding": { "version": "3.5.2", "resolved": "https://registry.npmjs.org/figgy-pudding/-/figgy-pudding-3.5.2.tgz", @@ -12730,6 +12774,14 @@ "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", "dev": true }, + "node_modules/node-fetch": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", + "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==", + "engines": { + "node": "4.x || >=6.0.0" + } + }, "node_modules/node-forge": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.10.0.tgz", @@ -13434,7 +13486,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/original/-/original-1.0.2.tgz", "integrity": "sha512-hyBVl6iqqUOJ8FqRe+l/gS8H+kKYjrEndd5Pm1MfBtsEKA038HkkdbAl/72EAXGyonD/PFsvmVG+EvcIpliMBg==", - "dev": true, "dependencies": { "url-parse": "^1.4.3" } @@ -15188,8 +15239,7 @@ "node_modules/psl": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", - "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==", - "dev": true + "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==" }, "node_modules/public-encrypt": { "version": "4.0.3", @@ -15246,7 +15296,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", - "dev": true, "engines": { "node": ">=6" } @@ -15291,8 +15340,7 @@ "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "dev": true + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" }, "node_modules/randombytes": { "version": "2.1.0", @@ -15764,8 +15812,7 @@ "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=", - "dev": true + "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=" }, "node_modules/resolve": { "version": "1.19.0", @@ -17973,7 +18020,6 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", - "dev": true, "dependencies": { "psl": "^1.1.28", "punycode": "^2.1.1" @@ -18462,7 +18508,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.1.tgz", "integrity": "sha512-HOfCOUJt7iSYzEx/UqgtwKRMC6EU91NFhsCHMv9oM03VJcVo2Qrp8T8kI9D7amFf1cu+/3CEhgb3rF9zL7k85Q==", - "dev": true, "dependencies": { "querystringify": "^2.1.1", "requires-port": "^1.0.0" @@ -20086,7 +20131,6 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.1.tgz", "integrity": "sha512-GIyAXC2cB7LjvpgMt9EKS2ldqr0MTrORaleiOno6TweZ6r3TKtoFQWay/2PceJ3RuBasOHzXNn5Lrw1X0bEjqA==", - "dev": true, "dependencies": { "async-limiter": "~1.0.0" } @@ -22606,6 +22650,18 @@ "schema-utils": "^2.7.0" } }, + "@microsoft/signalr": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-5.0.8.tgz", + "integrity": "sha512-g5U7zGa1CeoPztA1VGLiB418sZ6gt8ZEOsX8krpegyMquzH2Qinny1zQjNsg3mgjGlJI+FXD5bO4gVsHGUp2hA==", + "requires": { + "abort-controller": "^3.0.0", + "eventsource": "^1.0.7", + "fetch-cookie": "^0.7.3", + "node-fetch": "^2.6.0", + "ws": "^6.0.0" + } + }, "@ng-bootstrap/ng-bootstrap": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-9.1.0.tgz", @@ -23454,6 +23510,14 @@ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "dev": true }, + "abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "requires": { + "event-target-shim": "^5.0.0" + } + }, "accepts": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz", @@ -23812,8 +23876,7 @@ "async-limiter": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", - "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", - "dev": true + "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==" }, "asynckit": { "version": "0.4.0", @@ -26209,6 +26272,11 @@ "next-tick": "~1.0.0" } }, + "es6-denodeify": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/es6-denodeify/-/es6-denodeify-0.1.5.tgz", + "integrity": "sha1-MdTV/pxVA+ElRgQ5MQ4WoqPznB8=" + }, "es6-iterator": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", @@ -26334,6 +26402,11 @@ "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=", "dev": true }, + "event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==" + }, "eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", @@ -26350,7 +26423,6 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-1.0.7.tgz", "integrity": "sha512-4Ln17+vVT0k8aWq+t/bF5arcS3EpT9gYtW66EPacdj/mAFevznsnyoHLPy2BA8gbIQeIHoPsvwmfBftfcG//BQ==", - "dev": true, "requires": { "original": "^1.0.0" } @@ -26734,6 +26806,15 @@ "bser": "2.1.1" } }, + "fetch-cookie": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-0.7.3.tgz", + "integrity": "sha512-rZPkLnI8x5V+zYAiz8QonAHsTb4BY+iFowFBI1RFn0zrO343AVp9X7/yUj/9wL6Ef/8fLls8b/vGtzUvmyAUGA==", + "requires": { + "es6-denodeify": "^0.1.1", + "tough-cookie": "^2.3.3" + } + }, "figgy-pudding": { "version": "3.5.2", "resolved": "https://registry.npmjs.org/figgy-pudding/-/figgy-pudding-3.5.2.tgz", @@ -30910,6 +30991,11 @@ "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", "dev": true }, + "node-fetch": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", + "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==" + }, "node-forge": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.10.0.tgz", @@ -31478,7 +31564,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/original/-/original-1.0.2.tgz", "integrity": "sha512-hyBVl6iqqUOJ8FqRe+l/gS8H+kKYjrEndd5Pm1MfBtsEKA038HkkdbAl/72EAXGyonD/PFsvmVG+EvcIpliMBg==", - "dev": true, "requires": { "url-parse": "^1.4.3" } @@ -32939,8 +33024,7 @@ "psl": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", - "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==", - "dev": true + "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==" }, "public-encrypt": { "version": "4.0.3", @@ -33000,8 +33084,7 @@ "punycode": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", - "dev": true + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" }, "q": { "version": "1.5.1", @@ -33030,8 +33113,7 @@ "querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "dev": true + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" }, "randombytes": { "version": "2.1.0", @@ -33430,8 +33512,7 @@ "requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=", - "dev": true + "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=" }, "resolve": { "version": "1.19.0", @@ -35285,7 +35366,6 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", - "dev": true, "requires": { "psl": "^1.1.28", "punycode": "^2.1.1" @@ -35688,7 +35768,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.1.tgz", "integrity": "sha512-HOfCOUJt7iSYzEx/UqgtwKRMC6EU91NFhsCHMv9oM03VJcVo2Qrp8T8kI9D7amFf1cu+/3CEhgb3rF9zL7k85Q==", - "dev": true, "requires": { "querystringify": "^2.1.1", "requires-port": "^1.0.0" @@ -37058,7 +37137,6 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.1.tgz", "integrity": "sha512-GIyAXC2cB7LjvpgMt9EKS2ldqr0MTrORaleiOno6TweZ6r3TKtoFQWay/2PceJ3RuBasOHzXNn5Lrw1X0bEjqA==", - "dev": true, "requires": { "async-limiter": "~1.0.0" } diff --git a/UI/Web/package.json b/UI/Web/package.json index 79f566f1b..f8ab03458 100644 --- a/UI/Web/package.json +++ b/UI/Web/package.json @@ -26,6 +26,7 @@ "@angular/platform-browser-dynamic": "~11.0.0", "@angular/router": "~11.0.0", "@fortawesome/fontawesome-free": "^5.15.1", + "@microsoft/signalr": "^5.0.8", "@ng-bootstrap/ng-bootstrap": "^9.1.0", "@ngx-lite/nav-drawer": "^0.4.6", "@ngx-lite/util": "0.0.0", diff --git a/UI/Web/src/app/_guards/admin.guard.ts b/UI/Web/src/app/_guards/admin.guard.ts index d59b8643c..e9483530e 100644 --- a/UI/Web/src/app/_guards/admin.guard.ts +++ b/UI/Web/src/app/_guards/admin.guard.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'; import { CanActivate } from '@angular/router'; import { ToastrService } from 'ngx-toastr'; import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { map, take } from 'rxjs/operators'; import { User } from '../_models/user'; import { AccountService } from '../_services/account.service'; @@ -14,7 +14,7 @@ export class AdminGuard implements CanActivate { canActivate(): Observable { // this automaticallys subs due to being router guard - return this.accountService.currentUser$.pipe( + return this.accountService.currentUser$.pipe(take(1), map((user: User) => { if (this.accountService.hasAdminRole(user)) { return true; diff --git a/UI/Web/src/app/_guards/auth.guard.ts b/UI/Web/src/app/_guards/auth.guard.ts index c35e8ff87..f477290fc 100644 --- a/UI/Web/src/app/_guards/auth.guard.ts +++ b/UI/Web/src/app/_guards/auth.guard.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'; import { CanActivate, Router } from '@angular/router'; import { ToastrService } from 'ngx-toastr'; import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { map, take } from 'rxjs/operators'; import { User } from '../_models/user'; import { AccountService } from '../_services/account.service'; @@ -14,7 +14,7 @@ export class AuthGuard implements CanActivate { constructor(private accountService: AccountService, private router: Router, private toastr: ToastrService) {} canActivate(): Observable { - return this.accountService.currentUser$.pipe( + return this.accountService.currentUser$.pipe(take(1), map((user: User) => { if (user) { return true; diff --git a/UI/Web/src/app/_models/events/update-version-event.ts b/UI/Web/src/app/_models/events/update-version-event.ts new file mode 100644 index 000000000..d0754d81a --- /dev/null +++ b/UI/Web/src/app/_models/events/update-version-event.ts @@ -0,0 +1,8 @@ +export interface UpdateVersionEvent { + currentVersion: string; + updateVersion: string; + updateBody: string; + updateTitle: string; + updateUrl: string; + isDocker: boolean; +} \ No newline at end of file diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index f3847a524..ab83d8b88 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -7,6 +7,8 @@ import { Preferences } from '../_models/preferences/preferences'; import { User } from '../_models/user'; import * as Sentry from "@sentry/angular"; import { Router } from '@angular/router'; +import { MessageHubService } from './message-hub.service'; +import { PresenceHubService } from './presence-hub.service'; @Injectable({ providedIn: 'root' @@ -23,7 +25,8 @@ export class AccountService implements OnDestroy { private readonly onDestroy = new Subject(); - constructor(private httpClient: HttpClient, private router: Router) {} + constructor(private httpClient: HttpClient, private router: Router, + private messageHub: MessageHubService, private presenceHub: PresenceHubService) {} ngOnDestroy(): void { this.onDestroy.next(); @@ -48,6 +51,8 @@ export class AccountService implements OnDestroy { const user = response; if (user) { this.setCurrentUser(user); + this.messageHub.createHubConnection(user); + this.presenceHub.createHubConnection(user); } }), takeUntil(this.onDestroy) @@ -79,6 +84,8 @@ export class AccountService implements OnDestroy { this.currentUser = undefined; // Upon logout, perform redirection this.router.navigateByUrl('/login'); + this.messageHub.stopHubConnection(); + this.presenceHub.stopHubConnection(); } register(model: {username: string, password: string, isAdmin?: boolean}) { diff --git a/UI/Web/src/app/_services/library.service.ts b/UI/Web/src/app/_services/library.service.ts index d77871f91..e6991e592 100644 --- a/UI/Web/src/app/_services/library.service.ts +++ b/UI/Web/src/app/_services/library.service.ts @@ -1,7 +1,7 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { of } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { map, take } from 'rxjs/operators'; import { environment } from 'src/environments/environment'; import { Library, LibraryType } from '../_models/library'; import { SearchResult } from '../_models/search-result'; diff --git a/UI/Web/src/app/_services/message-hub.service.ts b/UI/Web/src/app/_services/message-hub.service.ts new file mode 100644 index 000000000..948761d53 --- /dev/null +++ b/UI/Web/src/app/_services/message-hub.service.ts @@ -0,0 +1,57 @@ +import { Injectable } from '@angular/core'; +import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr'; +import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; +import { User } from '@sentry/angular'; +import { environment } from 'src/environments/environment'; +import { UpdateNotificationModalComponent } from '../shared/update-notification/update-notification-modal.component'; + +export enum EVENTS { + UpdateAvailable = 'UpdateAvailable' +} + +@Injectable({ + providedIn: 'root' +}) +export class MessageHubService { + hubUrl = environment.hubUrl; + private hubConnection!: HubConnection; + private updateNotificationModalRef: NgbModalRef | null = null; + + constructor(private modalService: NgbModal) { } + + createHubConnection(user: User) { + this.hubConnection = new HubConnectionBuilder() + .withUrl(this.hubUrl + 'messages', { + accessTokenFactory: () => user.token + }) + .withAutomaticReconnect() + .build(); + + this.hubConnection + .start() + .catch(err => console.error(err)); + + this.hubConnection.on('receiveMessage', body => { + console.log('[Hub] Body: ', body); + }); + + this.hubConnection.on(EVENTS.UpdateAvailable, resp => { + // Ensure only 1 instance of UpdateNotificationModal can be open at once + if (this.updateNotificationModalRef != null) { return; } + this.updateNotificationModalRef = this.modalService.open(UpdateNotificationModalComponent, { scrollable: true, size: 'lg' }); + this.updateNotificationModalRef.componentInstance.updateData = resp.body; + this.updateNotificationModalRef.closed.subscribe(() => { + this.updateNotificationModalRef = null; + }); + }); + } + + stopHubConnection() { + this.hubConnection.stop().catch(err => console.error(err)); + } + + sendMessage(methodName: string, body?: any) { + return this.hubConnection.invoke(methodName, body); + } + +} diff --git a/UI/Web/src/app/_services/presence-hub.service.ts b/UI/Web/src/app/_services/presence-hub.service.ts new file mode 100644 index 000000000..f0fe970d1 --- /dev/null +++ b/UI/Web/src/app/_services/presence-hub.service.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@angular/core'; +import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr'; +import { User } from '@sentry/angular'; +import { ToastrService } from 'ngx-toastr'; +import { BehaviorSubject } from 'rxjs'; +import { environment } from 'src/environments/environment'; + +@Injectable({ + providedIn: 'root' +}) +export class PresenceHubService { + + hubUrl = environment.hubUrl; + private hubConnection!: HubConnection; + private onlineUsersSource = new BehaviorSubject([]); + onlineUsers$ = this.onlineUsersSource.asObservable(); + + constructor(private toatsr: ToastrService) { } + + createHubConnection(user: User) { + this.hubConnection = new HubConnectionBuilder() + .withUrl(this.hubUrl + 'presence', { + accessTokenFactory: () => user.token + }) + .withAutomaticReconnect() + .build(); + + this.hubConnection + .start() + .catch(err => console.error(err)); + + this.hubConnection.on('GetOnlineUsers', (usernames: string[]) => { + this.onlineUsersSource.next(usernames); + }); + } + + stopHubConnection() { + this.hubConnection.stop().catch(err => console.error(err)); + } +} diff --git a/UI/Web/src/app/_services/server.service.ts b/UI/Web/src/app/_services/server.service.ts index a3964f908..5274b48ea 100644 --- a/UI/Web/src/app/_services/server.service.ts +++ b/UI/Web/src/app/_services/server.service.ts @@ -27,4 +27,8 @@ export class ServerService { backupDatabase() { return this.httpClient.post(this.baseUrl + 'server/backup-db', {}); } + + checkForUpdate() { + return this.httpClient.post(this.baseUrl + 'server/check-update', {}); + } } diff --git a/UI/Web/src/app/admin/admin.module.ts b/UI/Web/src/app/admin/admin.module.ts index fe9125b5c..13a59e48b 100644 --- a/UI/Web/src/app/admin/admin.module.ts +++ b/UI/Web/src/app/admin/admin.module.ts @@ -2,7 +2,7 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { AdminRoutingModule } from './admin-routing.module'; import { DashboardComponent } from './dashboard/dashboard.component'; -import { NgbNavModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; +import { NgbDropdownModule, NgbNavModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; import { ManageLibraryComponent } from './manage-library/manage-library.component'; import { ManageUsersComponent } from './manage-users/manage-users.component'; import { LibraryEditorModalComponent } from './_modals/library-editor-modal/library-editor-modal.component'; @@ -40,6 +40,7 @@ import { ManageSystemComponent } from './manage-system/manage-system.component'; FormsModule, NgbNavModule, NgbTooltipModule, + NgbDropdownModule, SharedModule, ], providers: [] diff --git a/UI/Web/src/app/admin/manage-system/manage-system.component.html b/UI/Web/src/app/admin/manage-system/manage-system.component.html index b8f99e6cf..4a2cf4b64 100644 --- a/UI/Web/src/app/admin/manage-system/manage-system.component.html +++ b/UI/Web/src/app/admin/manage-system/manage-system.component.html @@ -1,23 +1,29 @@
- - - +
+ +
+ + + + +
+

About System

diff --git a/UI/Web/src/app/admin/manage-system/manage-system.component.ts b/UI/Web/src/app/admin/manage-system/manage-system.component.ts index 0e5e95ca4..9772e56f4 100644 --- a/UI/Web/src/app/admin/manage-system/manage-system.component.ts +++ b/UI/Web/src/app/admin/manage-system/manage-system.component.ts @@ -1,6 +1,5 @@ import { Component, OnInit } from '@angular/core'; import { FormControl, FormGroup, Validators } from '@angular/forms'; -import { Title } from '@angular/platform-browser'; import { ToastrService } from 'ngx-toastr'; import { take } from 'rxjs/operators'; import { DownloadService } from 'src/app/shared/_services/download.service'; @@ -22,6 +21,7 @@ export class ManageSystemComponent implements OnInit { clearCacheInProgress: boolean = false; backupDBInProgress: boolean = false; + hasCheckedForUpdate: boolean = false; constructor(private settingsService: SettingsService, private toastr: ToastrService, private serverService: ServerService, public downloadService: DownloadService) { } @@ -80,4 +80,11 @@ export class ManageSystemComponent implements OnInit { }); } + checkForUpdates() { + this.hasCheckedForUpdate = true; + this.serverService.checkForUpdate().subscribe(() => { + this.toastr.info('This might take a few minutes. If an update is available, the server will notify you.'); + }); + } + } diff --git a/UI/Web/src/app/admin/manage-users/manage-users.component.html b/UI/Web/src/app/admin/manage-users/manage-users.component.html index 6d4f93352..df1881ea8 100644 --- a/UI/Web/src/app/admin/manage-users/manage-users.component.html +++ b/UI/Web/src/app/admin/manage-users/manage-users.component.html @@ -9,7 +9,7 @@
  • - {{member.username | titlecase}} Admin + {{member.username | titlecase}} Admin
    diff --git a/UI/Web/src/app/admin/manage-users/manage-users.component.scss b/UI/Web/src/app/admin/manage-users/manage-users.component.scss index e69de29bb..04f1c6b73 100644 --- a/UI/Web/src/app/admin/manage-users/manage-users.component.scss +++ b/UI/Web/src/app/admin/manage-users/manage-users.component.scss @@ -0,0 +1,3 @@ +.presence { + font-size: 12px; +} \ No newline at end of file diff --git a/UI/Web/src/app/admin/manage-users/manage-users.component.ts b/UI/Web/src/app/admin/manage-users/manage-users.component.ts index 15078cce1..f54967540 100644 --- a/UI/Web/src/app/admin/manage-users/manage-users.component.ts +++ b/UI/Web/src/app/admin/manage-users/manage-users.component.ts @@ -1,6 +1,6 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnDestroy, OnInit } from '@angular/core'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { take } from 'rxjs/operators'; +import { take, takeUntil } from 'rxjs/operators'; import { MemberService } from 'src/app/_services/member.service'; import { Member } from 'src/app/_models/member'; import { User } from 'src/app/_models/user'; @@ -10,13 +10,15 @@ import { ToastrService } from 'ngx-toastr'; import { ResetPasswordModalComponent } from '../_modals/reset-password-modal/reset-password-modal.component'; import { ConfirmService } from 'src/app/shared/confirm.service'; import { EditRbsModalComponent } from '../_modals/edit-rbs-modal/edit-rbs-modal.component'; +import { PresenceHubService } from 'src/app/_services/presence-hub.service'; +import { Subject } from 'rxjs'; @Component({ selector: 'app-manage-users', templateUrl: './manage-users.component.html', styleUrls: ['./manage-users.component.scss'] }) -export class ManageUsersComponent implements OnInit { +export class ManageUsersComponent implements OnInit, OnDestroy { members: Member[] = []; loggedInUsername = ''; @@ -25,20 +27,29 @@ export class ManageUsersComponent implements OnInit { createMemberToggle = false; loadingMembers = false; + private onDestroy = new Subject(); + constructor(private memberService: MemberService, private accountService: AccountService, private modalService: NgbModal, private toastr: ToastrService, - private confirmService: ConfirmService) { + private confirmService: ConfirmService, + public presence: PresenceHubService) { this.accountService.currentUser$.pipe(take(1)).subscribe((user: User) => { this.loggedInUsername = user.username; }); + } ngOnInit(): void { this.loadMembers(); } + ngOnDestroy() { + this.onDestroy.next(); + this.onDestroy.complete(); + } + loadMembers() { this.loadingMembers = true; this.memberService.getMembers().subscribe(members => { diff --git a/UI/Web/src/app/all-collections/all-collections.component.ts b/UI/Web/src/app/all-collections/all-collections.component.ts index ddd4b6407..b05e01a04 100644 --- a/UI/Web/src/app/all-collections/all-collections.component.ts +++ b/UI/Web/src/app/all-collections/all-collections.component.ts @@ -69,7 +69,6 @@ export class AllCollectionsComponent implements OnInit { } loadPage() { - // TODO: See if we can move this pagination code into layout code const page = this.route.snapshot.queryParamMap.get('page'); if (page != null) { if (this.seriesPagination === undefined || this.seriesPagination === null) { diff --git a/UI/Web/src/app/app.component.ts b/UI/Web/src/app/app.component.ts index b406619f2..f2241df40 100644 --- a/UI/Web/src/app/app.component.ts +++ b/UI/Web/src/app/app.component.ts @@ -1,6 +1,10 @@ import { Component, OnInit } from '@angular/core'; +import { take } from 'rxjs/operators'; import { AccountService } from './_services/account.service'; +import { LibraryService } from './_services/library.service'; +import { MessageHubService } from './_services/message-hub.service'; import { NavService } from './_services/nav.service'; +import { PresenceHubService } from './_services/presence-hub.service'; import { StatsService } from './_services/stats.service'; @Component({ @@ -10,7 +14,9 @@ import { StatsService } from './_services/stats.service'; }) export class AppComponent implements OnInit { - constructor(private accountService: AccountService, public navService: NavService, private statsService: StatsService) { } + constructor(private accountService: AccountService, public navService: NavService, + private statsService: StatsService, private messageHub: MessageHubService, + private presenceHub: PresenceHubService, private libraryService: LibraryService) { } ngOnInit(): void { this.setCurrentUser(); @@ -28,6 +34,9 @@ export class AppComponent implements OnInit { if (user) { this.navService.setDarkMode(user.preferences.siteDarkMode); + this.messageHub.createHubConnection(user); + this.presenceHub.createHubConnection(user); + this.libraryService.getLibraryNames().pipe(take(1)).subscribe(() => {/* No Operation */}); } else { this.navService.setDarkMode(true); } diff --git a/UI/Web/src/app/carousel/carousel-reel/carousel-reel.component.ts b/UI/Web/src/app/carousel/carousel-reel/carousel-reel.component.ts index 372fed44f..59f84271f 100644 --- a/UI/Web/src/app/carousel/carousel-reel/carousel-reel.component.ts +++ b/UI/Web/src/app/carousel/carousel-reel/carousel-reel.component.ts @@ -15,8 +15,11 @@ export class CarouselReelComponent implements OnInit { swiper!: Swiper; + trackByIdentity: (index: number, item: any) => string; - constructor() { } + constructor() { + this.trackByIdentity = (index: number, item: any) => `${this.title}_${item.id}_${item?.name}_${item?.pagesRead}_${index}`; + } ngOnInit(): void {} diff --git a/UI/Web/src/app/library/library.component.ts b/UI/Web/src/app/library/library.component.ts index ea51ed429..ce0c03a47 100644 --- a/UI/Web/src/app/library/library.component.ts +++ b/UI/Web/src/app/library/library.component.ts @@ -49,7 +49,7 @@ export class LibraryComponent implements OnInit, OnDestroy { this.accountService.currentUser$.pipe(take(1)).subscribe(user => { this.user = user; this.isAdmin = this.accountService.hasAdminRole(this.user); - this.libraryService.getLibrariesForMember().subscribe(libraries => { + this.libraryService.getLibrariesForMember().pipe(take(1)).subscribe(libraries => { this.libraries = libraries; this.isLoading = false; }); @@ -81,8 +81,9 @@ export class LibraryComponent implements OnInit, OnDestroy { if (series === true || series === false) { if (!series) {return;} } - - if ((series as Series).pagesRead !== (series as Series).pages && (series as Series).pagesRead !== 0) { + // If the update to Series doesn't affect the requirement to be in this stream, then ignore update request + const seriesObj = (series as Series); + if (seriesObj.pagesRead !== seriesObj.pages && seriesObj.pagesRead !== 0) { return; } diff --git a/UI/Web/src/app/shared/_services/dom-helper.service.ts b/UI/Web/src/app/shared/_services/dom-helper.service.ts index 0f0344b5f..22aa1adf3 100644 --- a/UI/Web/src/app/shared/_services/dom-helper.service.ts +++ b/UI/Web/src/app/shared/_services/dom-helper.service.ts @@ -65,7 +65,7 @@ export class DomHelperService { if (tagName === 'A' || tagName === 'AREA') { return (el.attributes.getNamedItem('href') !== ''); } - return !el.attributes.hasOwnProperty('disabled'); // TODO: check for cases when: disabled="true" and disabled="false" + return !el.attributes.hasOwnProperty('disabled'); // check for cases when: disabled="true" and disabled="false" } return false; } diff --git a/UI/Web/src/app/shared/card-detail-layout/card-detail-layout.component.html b/UI/Web/src/app/shared/card-detail-layout/card-detail-layout.component.html index 4520816d2..d005bab63 100644 --- a/UI/Web/src/app/shared/card-detail-layout/card-detail-layout.component.html +++ b/UI/Web/src/app/shared/card-detail-layout/card-detail-layout.component.html @@ -10,7 +10,7 @@

    - diff --git a/UI/Web/src/app/shared/card-item/card-item.component.ts b/UI/Web/src/app/shared/card-item/card-item.component.ts index 0bb884535..4fa0fbbc5 100644 --- a/UI/Web/src/app/shared/card-item/card-item.component.ts +++ b/UI/Web/src/app/shared/card-item/card-item.component.ts @@ -10,8 +10,6 @@ import { ActionItem } from 'src/app/_services/action-factory.service'; import { ImageService } from 'src/app/_services/image.service'; import { LibraryService } from 'src/app/_services/library.service'; import { UtilityService } from '../_services/utility.service'; -// import 'lazysizes'; -// import 'lazysizes/plugins/attrchange/ls.attrchange'; @Component({ selector: 'app-card-item', diff --git a/UI/Web/src/app/shared/shared.module.ts b/UI/Web/src/app/shared/shared.module.ts index 5e7b60c8a..6209c1b7b 100644 --- a/UI/Web/src/app/shared/shared.module.ts +++ b/UI/Web/src/app/shared/shared.module.ts @@ -17,6 +17,7 @@ import { CardDetailLayoutComponent } from './card-detail-layout/card-detail-layo import { ShowIfScrollbarDirective } from './show-if-scrollbar.directive'; import { A11yClickDirective } from './a11y-click.directive'; import { SeriesFormatComponent } from './series-format/series-format.component'; +import { UpdateNotificationModalComponent } from './update-notification/update-notification-modal.component'; @NgModule({ @@ -33,7 +34,8 @@ import { SeriesFormatComponent } from './series-format/series-format.component'; CardDetailLayoutComponent, ShowIfScrollbarDirective, A11yClickDirective, - SeriesFormatComponent + SeriesFormatComponent, + UpdateNotificationModalComponent ], imports: [ CommonModule, diff --git a/UI/Web/src/app/shared/update-notification/update-notification-modal.component.html b/UI/Web/src/app/shared/update-notification/update-notification-modal.component.html new file mode 100644 index 000000000..172aefaf2 --- /dev/null +++ b/UI/Web/src/app/shared/update-notification/update-notification-modal.component.html @@ -0,0 +1,15 @@ + + + + \ No newline at end of file diff --git a/UI/Web/src/app/shared/update-notification/update-notification-modal.component.scss b/UI/Web/src/app/shared/update-notification/update-notification-modal.component.scss new file mode 100644 index 000000000..0f737bca4 --- /dev/null +++ b/UI/Web/src/app/shared/update-notification/update-notification-modal.component.scss @@ -0,0 +1,5 @@ +.update-body { + width: 100%; + word-wrap: break-word; + white-space: pre-wrap; +} \ No newline at end of file diff --git a/UI/Web/src/app/shared/update-notification/update-notification-modal.component.ts b/UI/Web/src/app/shared/update-notification/update-notification-modal.component.ts new file mode 100644 index 000000000..a5b0d5f18 --- /dev/null +++ b/UI/Web/src/app/shared/update-notification/update-notification-modal.component.ts @@ -0,0 +1,26 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { UpdateVersionEvent } from 'src/app/_models/events/update-version-event'; + + + +@Component({ + selector: 'app-update-notification-modal', + templateUrl: './update-notification-modal.component.html', + styleUrls: ['./update-notification-modal.component.scss'] +}) +export class UpdateNotificationModalComponent implements OnInit { + + @Input() updateData!: UpdateVersionEvent; + + + constructor(public modal: NgbActiveModal) { } + + ngOnInit(): void { + } + + close() { + this.modal.close({success: false, series: undefined}); + } + +} diff --git a/UI/Web/src/app/user-login/user-login.component.html b/UI/Web/src/app/user-login/user-login.component.html index 346c77d62..553522c81 100644 --- a/UI/Web/src/app/user-login/user-login.component.html +++ b/UI/Web/src/app/user-login/user-login.component.html @@ -9,7 +9,7 @@
    - +
    diff --git a/UI/Web/src/assets/themes/dark.scss b/UI/Web/src/assets/themes/dark.scss index 310b1075c..185c3671e 100644 --- a/UI/Web/src/assets/themes/dark.scss +++ b/UI/Web/src/assets/themes/dark.scss @@ -23,6 +23,11 @@ $dark-item-accent-bg: #292d32; color: #4ac694; } + a.btn { + color: white; + } + + .accent { background-color: $dark-form-background !important; color: lightgray !important; @@ -37,7 +42,7 @@ $dark-item-accent-bg: #292d32; } } - .btn-information, .btn-outline-secondary { + .btn-information, .btn-outline-secondary, pre { color: $dark-text-color; } diff --git a/UI/Web/src/environments/environment.prod.ts b/UI/Web/src/environments/environment.prod.ts index a381a524c..011772854 100644 --- a/UI/Web/src/environments/environment.prod.ts +++ b/UI/Web/src/environments/environment.prod.ts @@ -1,4 +1,5 @@ export const environment = { production: true, - apiUrl: '/api/' + apiUrl: '/api/', + hubUrl: '/hubs/' }; diff --git a/UI/Web/src/environments/environment.ts b/UI/Web/src/environments/environment.ts index a37f0643f..0d3078f07 100644 --- a/UI/Web/src/environments/environment.ts +++ b/UI/Web/src/environments/environment.ts @@ -4,7 +4,8 @@ export const environment = { production: false, - apiUrl: 'http://localhost:5000/api/' + apiUrl: 'http://localhost:5000/api/', + hubUrl: 'http://localhost:5000/hubs/' }; /*