diff --git a/API/API.csproj b/API/API.csproj index 0af5832f3..4853568e0 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -40,7 +40,7 @@ - + diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index e44ce1781..05867362f 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -79,14 +79,25 @@ namespace API.Controllers var user = await _userManager.Users.SingleOrDefaultAsync(x => x.UserName == resetPasswordDto.UserName); if (user == null) return Ok(); // Don't report BadRequest as that would allow brute forcing to find accounts on system + var isAdmin = User.IsInRole(PolicyConstants.AdminRole); - if (resetPasswordDto.UserName == User.GetUsername() && !(User.IsInRole(PolicyConstants.ChangePasswordRole) || User.IsInRole(PolicyConstants.AdminRole))) + if (resetPasswordDto.UserName == User.GetUsername() && !(User.IsInRole(PolicyConstants.ChangePasswordRole) || isAdmin)) return Unauthorized("You are not permitted to this operation."); - if (resetPasswordDto.UserName != User.GetUsername() && !User.IsInRole(PolicyConstants.AdminRole)) + if (resetPasswordDto.UserName != User.GetUsername() && !isAdmin) return Unauthorized("You are not permitted to this operation."); + if (string.IsNullOrEmpty(resetPasswordDto.OldPassword) && !isAdmin) + return BadRequest(new ApiException(400, "You must enter your existing password to change your account unless you're an admin")); + + // If you're an admin and the username isn't yours, you don't need to validate the password + var isResettingOtherUser = (resetPasswordDto.UserName != User.GetUsername() && isAdmin); + if (!isResettingOtherUser && !await _userManager.CheckPasswordAsync(user, resetPasswordDto.OldPassword)) + { + return BadRequest("Invalid Password"); + } + var errors = await _accountService.ChangeUserPassword(user, resetPasswordDto.Password); if (errors.Any()) { diff --git a/API/Controllers/CollectionController.cs b/API/Controllers/CollectionController.cs index a98e28952..0b2f2bcd6 100644 --- a/API/Controllers/CollectionController.cs +++ b/API/Controllers/CollectionController.cs @@ -99,6 +99,7 @@ namespace API.Controllers /// /// /// + [Authorize(Policy = "RequireAdminRole")] [HttpPost("update-for-series")] public async Task AddToMultipleSeries(CollectionTagBulkAddDto dto) { diff --git a/API/Controllers/ImageController.cs b/API/Controllers/ImageController.cs index 69f058bfa..f83df2068 100644 --- a/API/Controllers/ImageController.cs +++ b/API/Controllers/ImageController.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using API.Data; using API.Entities.Enums; +using API.Extensions; using API.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -16,7 +17,6 @@ namespace API.Controllers { private readonly IUnitOfWork _unitOfWork; private readonly IDirectoryService _directoryService; - private const int ImageCacheSeconds = 1 * 60; /// public ImageController(IUnitOfWork unitOfWork, IDirectoryService directoryService) @@ -31,7 +31,7 @@ namespace API.Controllers /// /// [HttpGet("chapter-cover")] - [ResponseCache(Duration = ImageCacheSeconds, Location = ResponseCacheLocation.Client, NoStore = false)] + [ResponseCache(CacheProfileName = "Images")] public async Task GetChapterCoverImage(int chapterId) { var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.ChapterRepository.GetChapterCoverImageAsync(chapterId)); @@ -47,7 +47,7 @@ namespace API.Controllers /// /// [HttpGet("volume-cover")] - [ResponseCache(Duration = ImageCacheSeconds, Location = ResponseCacheLocation.Client, NoStore = false)] + [ResponseCache(CacheProfileName = "Images")] public async Task GetVolumeCoverImage(int volumeId) { var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.VolumeRepository.GetVolumeCoverImageAsync(volumeId)); @@ -62,7 +62,7 @@ namespace API.Controllers /// /// Id of Series /// - [ResponseCache(Duration = ImageCacheSeconds, Location = ResponseCacheLocation.Client, NoStore = false)] + [ResponseCache(CacheProfileName = "Images")] [HttpGet("series-cover")] public async Task GetSeriesCoverImage(int seriesId) { @@ -70,6 +70,8 @@ namespace API.Controllers if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"No cover image"); var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", ""); + Response.AddCacheHeader(path); + return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path)); } @@ -79,7 +81,7 @@ namespace API.Controllers /// /// [HttpGet("collection-cover")] - [ResponseCache(Duration = ImageCacheSeconds, Location = ResponseCacheLocation.Client, NoStore = false)] + [ResponseCache(CacheProfileName = "Images")] public async Task GetCollectionCoverImage(int collectionTagId) { var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.CollectionTagRepository.GetCoverImageAsync(collectionTagId)); @@ -95,7 +97,7 @@ namespace API.Controllers /// /// [HttpGet("readinglist-cover")] - [ResponseCache(Duration = ImageCacheSeconds, Location = ResponseCacheLocation.Client, NoStore = false)] + [ResponseCache(CacheProfileName = "Images")] public async Task GetReadingListCoverImage(int readingListId) { var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.ReadingListRepository.GetCoverImageAsync(readingListId)); @@ -114,7 +116,7 @@ namespace API.Controllers /// API Key for user. Needed to authenticate request /// [HttpGet("bookmark")] - [ResponseCache(Duration = ImageCacheSeconds, Location = ResponseCacheLocation.Client, NoStore = false)] + [ResponseCache(CacheProfileName = "Images")] public async Task GetBookmarkImage(int chapterId, int pageNum, string apiKey) { var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); @@ -134,9 +136,9 @@ namespace API.Controllers /// /// Filename of file. This is used with upload/upload-by-url /// - [AllowAnonymous] + [Authorize(Policy="RequireAdminRole")] [HttpGet("cover-upload")] - [ResponseCache(Duration = ImageCacheSeconds, Location = ResponseCacheLocation.Client, NoStore = false)] + [ResponseCache(CacheProfileName = "Images")] public ActionResult GetCoverUploadImage(string filename) { if (filename.Contains("..")) return BadRequest("Invalid Filename"); diff --git a/API/Controllers/PluginController.cs b/API/Controllers/PluginController.cs index b6162bb3a..dfb32f406 100644 --- a/API/Controllers/PluginController.cs +++ b/API/Controllers/PluginController.cs @@ -2,6 +2,7 @@ using API.Data; using API.DTOs; using API.Services; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -26,6 +27,7 @@ namespace API.Controllers /// /// Name of the Plugin /// + [AllowAnonymous] [HttpPost("authenticate")] public async Task> Authenticate(string apiKey, string pluginName) { diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index c8ffcb1d9..bafac20d2 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -11,7 +11,6 @@ using API.Entities; using API.Entities.Enums; using API.Extensions; using API.Services; -using API.SignalR; using Hangfire; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -48,7 +47,7 @@ namespace API.Controllers /// /// [HttpGet("pdf")] - [ResponseCache(Duration = 60 * 10, Location = ResponseCacheLocation.Client, NoStore = false)] + [ResponseCache(CacheProfileName = "Hour")] public async Task GetPdf(int chapterId) { var chapter = await _cacheService.Ensure(chapterId); @@ -80,7 +79,7 @@ namespace API.Controllers /// /// [HttpGet("image")] - [ResponseCache(Duration = 60 * 10, Location = ResponseCacheLocation.Client, NoStore = false)] + [ResponseCache(CacheProfileName = "Hour")] [AllowAnonymous] public async Task GetImage(int chapterId, int page) { @@ -112,7 +111,8 @@ namespace API.Controllers /// We must use api key as bookmarks could be leaked to other users via the API /// [HttpGet("bookmark-image")] - [ResponseCache(Duration = 60 * 10, Location = ResponseCacheLocation.Client, NoStore = false)] + [ResponseCache(CacheProfileName = "Hour")] + [AllowAnonymous] public async Task GetBookmarkImage(int seriesId, string apiKey, int page) { if (page < 0) page = 0; @@ -554,6 +554,7 @@ namespace API.Controllers { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks); if (user.Bookmarks == null) return Ok("Nothing to remove"); + try { var bookmarksToRemove = user.Bookmarks.Where(bmk => bmk.SeriesId == dto.SeriesId).ToList(); @@ -580,7 +581,42 @@ namespace API.Controllers } return BadRequest("Could not clear bookmarks"); + } + /// + /// Removes all bookmarks for all chapters linked to a Series + /// + /// + /// + [HttpPost("bulk-remove-bookmarks")] + public async Task BulkRemoveBookmarks(BulkRemoveBookmarkForSeriesDto dto) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks); + if (user.Bookmarks == null) return Ok("Nothing to remove"); + + try + { + foreach (var seriesId in dto.SeriesIds) + { + var bookmarksToRemove = user.Bookmarks.Where(bmk => bmk.SeriesId == seriesId).ToList(); + user.Bookmarks = user.Bookmarks.Where(bmk => bmk.SeriesId != seriesId).ToList(); + _unitOfWork.UserRepository.Update(user); + await _bookmarkService.DeleteBookmarkFiles(bookmarksToRemove); + } + + + if (!_unitOfWork.HasChanges() || await _unitOfWork.CommitAsync()) + { + return Ok(); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception when trying to clear bookmarks"); + await _unitOfWork.RollbackAsync(); + } + + return BadRequest("Could not clear bookmarks"); } /// diff --git a/API/Controllers/ReadingListController.cs b/API/Controllers/ReadingListController.cs index ccd27a783..53d6cfb56 100644 --- a/API/Controllers/ReadingListController.cs +++ b/API/Controllers/ReadingListController.cs @@ -508,6 +508,7 @@ namespace API.Controllers private async Task AddChaptersToReadingList(int seriesId, IList chapterIds, ReadingList readingList) { + // TODO: Move to ReadingListService and Unit Test readingList.Items ??= new List(); var lastOrder = 0; if (readingList.Items.Any()) diff --git a/API/DTOs/Account/ResetPasswordDto.cs b/API/DTOs/Account/ResetPasswordDto.cs index 2e7ef4d66..563aad9f4 100644 --- a/API/DTOs/Account/ResetPasswordDto.cs +++ b/API/DTOs/Account/ResetPasswordDto.cs @@ -4,10 +4,20 @@ namespace API.DTOs.Account { public class ResetPasswordDto { + /// + /// The Username of the User + /// [Required] public string UserName { get; init; } + /// + /// The new password + /// [Required] [StringLength(32, MinimumLength = 6)] public string Password { get; init; } + /// + /// The old, existing password. If an admin is performing the change, this is not required. Otherwise, it is. + /// + public string OldPassword { get; init; } } } diff --git a/API/DTOs/Reader/BulkRemoveBookmarkForSeriesDto.cs b/API/DTOs/Reader/BulkRemoveBookmarkForSeriesDto.cs new file mode 100644 index 000000000..2408154b8 --- /dev/null +++ b/API/DTOs/Reader/BulkRemoveBookmarkForSeriesDto.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace API.DTOs.Reader +{ + public class BulkRemoveBookmarkForSeriesDto + { + public ICollection SeriesIds { get; init; } + } +} diff --git a/API/Extensions/HttpExtensions.cs b/API/Extensions/HttpExtensions.cs index 778d48bf8..419483fed 100644 --- a/API/Extensions/HttpExtensions.cs +++ b/API/Extensions/HttpExtensions.cs @@ -39,5 +39,23 @@ namespace API.Extensions response.Headers.Add(HeaderNames.ETag, string.Concat(sha1.ComputeHash(content).Select(x => x.ToString("X2")))); response.Headers.CacheControl = $"private,max-age=100"; } + + /// + /// Calculates SHA256 hash for a cover image filename and sets as ETag. Ensures Cache-Control: private header is added. + /// + /// + /// + /// Maximum amount of seconds to set for Cache-Control + public static void AddCacheHeader(this HttpResponse response, string filename, int maxAge = 10) + { + if (filename is not {Length: > 0}) return; + var hashContent = filename + File.GetLastWriteTimeUtc(filename); + using var sha1 = SHA256.Create(); + response.Headers.Add("ETag", string.Concat(sha1.ComputeHash(Encoding.UTF8.GetBytes(hashContent)).Select(x => x.ToString("X2")))); + if (maxAge != 10) + { + response.Headers.CacheControl = $"max-age={maxAge}"; + } + } } } diff --git a/API/Helpers/Filters/ETagFromFilename.cs b/API/Helpers/Filters/ETagFromFilename.cs new file mode 100644 index 000000000..30b798ea4 --- /dev/null +++ b/API/Helpers/Filters/ETagFromFilename.cs @@ -0,0 +1,234 @@ +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Net.Http.Headers; +using Newtonsoft.Json; + +namespace API.Helpers.Filters; + +// NOTE: I'm leaving this in, but I don't think it's needed. Will validate in next release. + +//[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = false)] +// public class ETagFromFilename : ActionFilterAttribute, IAsyncActionFilter +// { +// public override async Task OnActionExecutionAsync(ActionExecutingContext executingContext, +// ActionExecutionDelegate next) +// { +// var request = executingContext.HttpContext.Request; +// +// var executedContext = await next(); +// var response = executedContext.HttpContext.Response; +// +// // Computing ETags for Response Caching on GET requests +// if (request.Method == HttpMethod.Get.Method && response.StatusCode == (int) HttpStatusCode.OK) +// { +// ValidateETagForResponseCaching(executedContext); +// } +// } +// +// private void ValidateETagForResponseCaching(ActionExecutedContext executedContext) +// { +// if (executedContext.Result == null) +// { +// return; +// } +// +// var request = executedContext.HttpContext.Request; +// var response = executedContext.HttpContext.Response; +// +// var objectResult = executedContext.Result as ObjectResult; +// if (objectResult == null) return; +// var result = (PhysicalFileResult) objectResult.Value; +// +// // generate ETag from LastModified property +// //var etag = GenerateEtagFromFilename(result.); +// +// // generates ETag from the entire response Content +// //var etag = GenerateEtagFromResponseBodyWithHash(result); +// +// if (request.Headers.ContainsKey(HeaderNames.IfNoneMatch)) +// { +// // fetch etag from the incoming request header +// var incomingEtag = request.Headers[HeaderNames.IfNoneMatch].ToString(); +// +// // if both the etags are equal +// // raise a 304 Not Modified Response +// if (incomingEtag.Equals(etag)) +// { +// executedContext.Result = new StatusCodeResult((int) HttpStatusCode.NotModified); +// } +// } +// +// // add ETag response header +// response.Headers.Add(HeaderNames.ETag, new[] {etag}); +// } +// + // private static string GenerateEtagFromFilename(HttpResponse response, string filename, int maxAge = 10) + // { + // if (filename is not {Length: > 0}) return string.Empty; + // var hashContent = filename + File.GetLastWriteTimeUtc(filename); + // using var sha1 = SHA256.Create(); + // return string.Concat(sha1.ComputeHash(Encoding.UTF8.GetBytes(hashContent)).Select(x => x.ToString("X2"))); + // } +// } + +[AttributeUsage(AttributeTargets.Method)] +public class ETagFilter : Attribute, IActionFilter +{ + private readonly int[] _statusCodes; + + public ETagFilter(params int[] statusCodes) + { + _statusCodes = statusCodes; + if (statusCodes.Length == 0) _statusCodes = new[] { 200 }; + } + + public void OnActionExecuting(ActionExecutingContext context) + { + } + + public void OnActionExecuted(ActionExecutedContext context) + { + if (context.HttpContext.Request.Method != "GET" || context.HttpContext.Request.Method != "HEAD") return; + if (!_statusCodes.Contains(context.HttpContext.Response.StatusCode)) return; + + var etag = string.Empty;; + //I just serialize the result to JSON, could do something less costly + if (context.Result is PhysicalFileResult) + { + // Do a cheap LastWriteTime etag gen + if (context.Result is PhysicalFileResult fileResult) + { + etag = ETagGenerator.GenerateEtagFromFilename(fileResult.FileName); + context.HttpContext.Response.Headers.LastModified = File.GetLastWriteTimeUtc(fileResult.FileName).ToLongDateString(); + } + } + + if (string.IsNullOrEmpty(etag)) + { + var content = JsonConvert.SerializeObject(context.Result); + etag = ETagGenerator.GetETag(context.HttpContext.Request.Path.ToString(), Encoding.UTF8.GetBytes(content)); + } + + + if (context.HttpContext.Request.Headers.IfNoneMatch.ToString() == etag) + { + context.Result = new StatusCodeResult(304); + } + + //context.HttpContext.Response.Headers.ETag = etag; + } + + +} + +// Helper class that generates the etag from a key (route) and content (response) +public static class ETagGenerator +{ + public static string GetETag(string key, byte[] contentBytes) + { + var keyBytes = Encoding.UTF8.GetBytes(key); + var combinedBytes = Combine(keyBytes, contentBytes); + + return GenerateETag(combinedBytes); + } + + private static string GenerateETag(byte[] data) + { + using var md5 = MD5.Create(); + var hash = md5.ComputeHash(data); + var hex = BitConverter.ToString(hash); + return hex.Replace("-", ""); + } + + private static byte[] Combine(byte[] a, byte[] b) + { + var c = new byte[a.Length + b.Length]; + Buffer.BlockCopy(a, 0, c, 0, a.Length); + Buffer.BlockCopy(b, 0, c, a.Length, b.Length); + return c; + } + + public static string GenerateEtagFromFilename(string filename) + { + if (filename is not {Length: > 0}) return string.Empty; + var hashContent = filename + File.GetLastWriteTimeUtc(filename); + using var md5 = MD5.Create(); + return string.Concat(md5.ComputeHash(Encoding.UTF8.GetBytes(hashContent)).Select(x => x.ToString("X2"))); + } +} + +// /// +// /// Enables HTTP Response CacheControl management with ETag values. +// /// +// public class ClientCacheWithEtagAttribute : ActionFilterAttribute +// { +// private readonly TimeSpan _clientCache; +// +// private readonly HttpMethod[] _supportedRequestMethods = { +// HttpMethod.Get, +// HttpMethod.Head +// }; +// +// /// +// /// Default constructor +// /// +// /// Indicates for how long the client should cache the response. The value is in seconds +// public ClientCacheWithEtagAttribute(int clientCacheInSeconds) +// { +// _clientCache = TimeSpan.FromSeconds(clientCacheInSeconds); +// } +// +// public override async Task OnActionExecutionAsync(ActionExecutingContext executingContext, ActionExecutionDelegate next) +// { +// +// if (executingContext.Response?.Content == null) +// { +// return; +// } +// +// var body = await executingContext.Response.Content.ReadAsStringAsync(); +// if (body == null) +// { +// return; +// } +// +// var computedEntityTag = GetETag(Encoding.UTF8.GetBytes(body)); +// +// if (actionExecutedContext.Request.Headers.IfNoneMatch.Any() +// && actionExecutedContext.Request.Headers.IfNoneMatch.First().Tag.Trim('"').Equals(computedEntityTag, StringComparison.InvariantCultureIgnoreCase)) +// { +// actionExecutedContext.Response.StatusCode = HttpStatusCode.NotModified; +// actionExecutedContext.Response.Content = null; +// } +// +// var cacheControlHeader = new CacheControlHeaderValue +// { +// Private = true, +// MaxAge = _clientCache +// }; +// +// actionExecutedContext.Response.Headers.ETag = new EntityTagHeaderValue($"\"{computedEntityTag}\"", false); +// actionExecutedContext.Response.Headers.CacheControl = cacheControlHeader; +// } +// +// private static string GetETag(byte[] contentBytes) +// { +// using (var md5 = MD5.Create()) +// { +// var hash = md5.ComputeHash(contentBytes); +// string hex = BitConverter.ToString(hash); +// return hex.Replace("-", ""); +// } +// } +// } + diff --git a/API/Services/DirectoryService.cs b/API/Services/DirectoryService.cs index a69521f5a..d3976da67 100644 --- a/API/Services/DirectoryService.cs +++ b/API/Services/DirectoryService.cs @@ -90,6 +90,11 @@ namespace API.Services SiteThemeDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "themes"); ExistOrCreate(SiteThemeDirectory); + ExistOrCreate(CoverImageDirectory); + ExistOrCreate(CacheDirectory); + ExistOrCreate(LogDirectory); + ExistOrCreate(TempDirectory); + ExistOrCreate(BookmarkDirectory); } /// diff --git a/API/Services/MetadataService.cs b/API/Services/MetadataService.cs index fa3853201..0ead1f523 100644 --- a/API/Services/MetadataService.cs +++ b/API/Services/MetadataService.cs @@ -158,7 +158,7 @@ public class MetadataService : IMetadataService /// /// /// - private async Task ProcessSeriesMetadataUpdate(Series series, bool forceUpdate) + private async Task ProcessSeriesCoverGen(Series series, bool forceUpdate) { _logger.LogDebug("[MetadataService] Processing series {SeriesName}", series.OriginalName); try @@ -250,7 +250,7 @@ public class MetadataService : IMetadataService try { - await ProcessSeriesMetadataUpdate(series, forceUpdate); + await ProcessSeriesCoverGen(series, forceUpdate); } catch (Exception ex) { @@ -303,7 +303,7 @@ public class MetadataService : IMetadataService await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.CoverUpdateProgressEvent(libraryId, 0F, ProgressEventType.Started, series.Name)); - await ProcessSeriesMetadataUpdate(series, forceUpdate); + await ProcessSeriesCoverGen(series, forceUpdate); if (_unitOfWork.HasChanges()) diff --git a/API/Startup.cs b/API/Startup.cs index aa57fd70d..e71520dbc 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -25,6 +25,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.ResponseCompression; using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.Configuration; @@ -52,7 +53,23 @@ namespace API public void ConfigureServices(IServiceCollection services) { services.AddApplicationServices(_config, _env); - services.AddControllers(); + services.AddControllers(options => + { + options.CacheProfiles.Add("Images", + new CacheProfile() + { + Duration = 60, + Location = ResponseCacheLocation.None, + NoStore = false + }); + options.CacheProfiles.Add("Hour", + new CacheProfile() + { + Duration = 60 * 10, + Location = ResponseCacheLocation.None, + NoStore = false + }); + }); services.Configure(options => { options.ForwardedHeaders = ForwardedHeaders.All; @@ -252,6 +269,12 @@ namespace API context.Response.Headers[Microsoft.Net.Http.Headers.HeaderNames.Vary] = new[] { "Accept-Encoding" }; + // Don't let the site be iframed outside the same origin (clickjacking) + context.Response.Headers.XFrameOptions = "SAMEORIGIN"; + + // Setup CSP to ensure we load assets only from these origins + context.Response.Headers.Add("Content-Security-Policy", "default-src 'self' frame-ancestors 'none';"); + await next(); }); diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index a8db84d76..f59583a52 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -168,8 +168,8 @@ export class AccountService implements OnDestroy { return this.httpClient.post(this.baseUrl + 'account/confirm-password-reset', model); } - resetPassword(username: string, password: string) { - return this.httpClient.post(this.baseUrl + 'account/reset-password', {username, password}, {responseType: 'json' as 'text'}); + resetPassword(username: string, password: string, oldPassword: string) { + return this.httpClient.post(this.baseUrl + 'account/reset-password', {username, password, oldPassword}, {responseType: 'json' as 'text'}); } update(model: {email: string, roles: Array, libraries: Array, userId: number}) { diff --git a/UI/Web/src/app/_services/reader.service.ts b/UI/Web/src/app/_services/reader.service.ts index e7927a9f1..516d04c9e 100644 --- a/UI/Web/src/app/_services/reader.service.ts +++ b/UI/Web/src/app/_services/reader.service.ts @@ -67,7 +67,10 @@ export class ReaderService { } clearBookmarks(seriesId: number) { - return this.httpClient.post(this.baseUrl + 'reader/remove-bookmarks', {seriesId}); + return this.httpClient.post(this.baseUrl + 'reader/remove-bookmarks', {seriesId}, {responseType: 'text' as 'json'}); + } + clearMultipleBookmarks(seriesIds: Array) { + return this.httpClient.post(this.baseUrl + 'reader/bulk-remove-bookmarks', {seriesIds}, {responseType: 'text' as 'json'}); } /** diff --git a/UI/Web/src/app/admin/_modals/reset-password-modal/reset-password-modal.component.ts b/UI/Web/src/app/admin/_modals/reset-password-modal/reset-password-modal.component.ts index f876784d0..22cfe289f 100644 --- a/UI/Web/src/app/admin/_modals/reset-password-modal/reset-password-modal.component.ts +++ b/UI/Web/src/app/admin/_modals/reset-password-modal/reset-password-modal.component.ts @@ -1,9 +1,8 @@ import { Component, Input, OnInit } from '@angular/core'; -import { UntypedFormGroup, UntypedFormControl, Validators } from '@angular/forms'; +import { FormGroup, FormControl, Validators } from '@angular/forms'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { Member } from 'src/app/_models/member'; import { AccountService } from 'src/app/_services/account.service'; -import { MemberService } from 'src/app/_services/member.service'; @Component({ selector: 'app-reset-password-modal', @@ -14,8 +13,8 @@ export class ResetPasswordModalComponent implements OnInit { @Input() member!: Member; errorMessage = ''; - resetPasswordForm: UntypedFormGroup = new UntypedFormGroup({ - password: new UntypedFormControl('', [Validators.required]), + resetPasswordForm: FormGroup = new FormGroup({ + password: new FormControl('', [Validators.required]), }); constructor(public modal: NgbActiveModal, private accountService: AccountService) { } @@ -24,7 +23,7 @@ export class ResetPasswordModalComponent implements OnInit { } save() { - this.accountService.resetPassword(this.member.username, this.resetPasswordForm.value.password).subscribe(() => { + this.accountService.resetPassword(this.member.username, this.resetPasswordForm.value.password,'').subscribe(() => { this.modal.close(); }); } diff --git a/UI/Web/src/app/bookmark/bookmarks/bookmarks.component.ts b/UI/Web/src/app/bookmark/bookmarks/bookmarks.component.ts index 34587b685..1d5dcc83f 100644 --- a/UI/Web/src/app/bookmark/bookmarks/bookmarks.component.ts +++ b/UI/Web/src/app/bookmark/bookmarks/bookmarks.component.ts @@ -1,7 +1,7 @@ -import { Component, HostListener, OnDestroy, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, HostListener, OnDestroy, OnInit } from '@angular/core'; import { Router } from '@angular/router'; import { ToastrService } from 'ngx-toastr'; -import { take, takeWhile, finalize, Subject, forkJoin } from 'rxjs'; +import { take, Subject } from 'rxjs'; import { BulkSelectionService } from 'src/app/cards/bulk-selection.service'; import { ConfirmService } from 'src/app/shared/confirm.service'; import { DownloadService } from 'src/app/shared/_services/download.service'; @@ -16,7 +16,8 @@ import { SeriesService } from 'src/app/_services/series.service'; @Component({ selector: 'app-bookmarks', templateUrl: './bookmarks.component.html', - styleUrls: ['./bookmarks.component.scss'] + styleUrls: ['./bookmarks.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush }) export class BookmarksComponent implements OnInit, OnDestroy { @@ -36,7 +37,7 @@ export class BookmarksComponent implements OnInit, OnDestroy { private downloadService: DownloadService, private toastr: ToastrService, private confirmService: ConfirmService, public bulkSelectionService: BulkSelectionService, public imageService: ImageService, private actionFactoryService: ActionFactoryService, - private router: Router) { } + private router: Router, private readonly cdRef: ChangeDetectorRef) { } ngOnInit(): void { this.loadBookmarks(); @@ -96,12 +97,12 @@ export class BookmarksComponent implements OnInit, OnDestroy { if (!await this.confirmService.confirm('Are you sure you want to clear all bookmarks for multiple series? This cannot be undone.')) { break; } - - forkJoin(seriesIds.map(id => this.readerService.clearBookmarks(id))).subscribe(() => { + + this.readerService.clearMultipleBookmarks(seriesIds).subscribe(() => { this.toastr.success('Bookmarks have been removed'); this.bulkSelectionService.deselectAll(); this.loadBookmarks(); - }) + }); break; default: break; @@ -110,6 +111,7 @@ export class BookmarksComponent implements OnInit, OnDestroy { loadBookmarks() { this.loadingBookmarks = true; + this.cdRef.markForCheck(); this.readerService.getAllBookmarks().pipe(take(1)).subscribe(bookmarks => { this.bookmarks = bookmarks; this.seriesIds = {}; @@ -127,7 +129,9 @@ export class BookmarksComponent implements OnInit, OnDestroy { this.seriesService.getAllSeriesByIds(ids).subscribe(series => { this.series = series; this.loadingBookmarks = false; + this.cdRef.markForCheck(); }); + this.cdRef.markForCheck(); }); } @@ -141,6 +145,7 @@ export class BookmarksComponent implements OnInit, OnDestroy { } this.clearingSeries[series.id] = true; + this.cdRef.markForCheck(); this.readerService.clearBookmarks(series.id).subscribe(() => { const index = this.series.indexOf(series); if (index > -1) { @@ -148,6 +153,7 @@ export class BookmarksComponent implements OnInit, OnDestroy { } this.clearingSeries[series.id] = false; this.toastr.success(series.name + '\'s bookmarks have been removed'); + this.cdRef.markForCheck(); }); } @@ -157,18 +163,13 @@ export class BookmarksComponent implements OnInit, OnDestroy { downloadBookmarks(series: Series) { this.downloadingSeries[series.id] = true; + this.cdRef.markForCheck(); this.downloadService.download('bookmark', this.bookmarks.filter(bmk => bmk.seriesId === series.id), (d) => { if (!d) { this.downloadingSeries[series.id] = false; + this.cdRef.markForCheck(); } }); - // this.downloadService.downloadBookmarks(this.bookmarks.filter(bmk => bmk.seriesId === series.id)).pipe( - // takeWhile(val => { - // return val.state != 'DONE'; - // }), - // finalize(() => { - // this.downloadingSeries[series.id] = false; - // })).subscribe(() => {/* No Operation */}); } } diff --git a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.scss b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.scss index 0ca7fc453..6ed54f212 100644 --- a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.scss +++ b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.scss @@ -1,5 +1,5 @@ .scrollable-modal { - max-height: calc(var(--vh) * 100 - 198px); // 600px + max-height: calc(var(--vh) * 100 - 198px); overflow: auto; } diff --git a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts index 4c2d1bec9..177ad9293 100644 --- a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts +++ b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts @@ -417,7 +417,7 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { } close() { - this.modal.close({success: false, series: undefined}); + this.modal.close({success: false, series: undefined, coverImageUpdate: this.coverImageReset}); } fetchCollectionTags(filter: string = '') { @@ -458,7 +458,7 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { this.saveNestedComponents.emit(); forkJoin(apis).subscribe(results => { - this.modal.close({success: true, series: model, coverImageUpdate: selectedIndex > 0}); + this.modal.close({success: true, series: model, coverImageUpdate: selectedIndex > 0 || this.coverImageReset}); }); } diff --git a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts index 9cf516bc8..90232c554 100644 --- a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts +++ b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts @@ -151,7 +151,7 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, OnChanges, targetIndex += this.jumpBarKeys[i].size; } - this.virtualScroller.scrollToIndex(targetIndex, true, 800, 1000); + this.virtualScroller.scrollToIndex(targetIndex, true, 0, 1000); this.jumpbarService.saveResumeKey(this.header, jumpKey.key); this.changeDetectionRef.markForCheck(); return; diff --git a/UI/Web/src/app/cards/series-card/series-card.component.ts b/UI/Web/src/app/cards/series-card/series-card.component.ts index 67e01ee54..e72e5f6df 100644 --- a/UI/Web/src/app/cards/series-card/series-card.component.ts +++ b/UI/Web/src/app/cards/series-card/series-card.component.ts @@ -100,6 +100,9 @@ export class SeriesCardComponent implements OnInit, OnChanges, OnDestroy { case Action.AddToWantToReadList: this.actionService.addMultipleSeriesToWantToReadList([series.id]); break; + case Action.RemoveFromWantToReadList: + this.actionService.removeMultipleSeriesFromWantToReadList([series.id]); + break; case(Action.AddToCollection): this.actionService.addMultipleSeriesToCollectionTag([series]); break; diff --git a/UI/Web/src/app/library-detail/library-detail.component.html b/UI/Web/src/app/library-detail/library-detail.component.html index 633b18f8c..74a4ab8ac 100644 --- a/UI/Web/src/app/library-detail/library-detail.component.html +++ b/UI/Web/src/app/library-detail/library-detail.component.html @@ -41,6 +41,6 @@ -
+
diff --git a/UI/Web/src/app/reading-list/_modals/add-to-list-modal/add-to-list-modal.component.html b/UI/Web/src/app/reading-list/_modals/add-to-list-modal/add-to-list-modal.component.html index 394def5ce..47efc6f02 100644 --- a/UI/Web/src/app/reading-list/_modals/add-to-list-modal/add-to-list-modal.component.html +++ b/UI/Web/src/app/reading-list/_modals/add-to-list-modal/add-to-list-modal.component.html @@ -6,7 +6,7 @@
-