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/' }; /*