diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000000..02cbdf1529 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,103 @@ +name: Bug Report +description: Create a report to help us improve +title: "" +labels: ["needs-triage"] +assignees: +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! + - type: markdown + attributes: + value: | + If you have a feature request, please go to our [Feature Requests](https://feats.kavitareader.com) page. + - type: textarea + id: what-happened + attributes: + label: What happened? + description: Also tell us, what steps you took so we can try to reproduce. + placeholder: Tell us what you see! + value: "" + validations: + required: true + - type: textarea + id: what-was-expected + attributes: + label: What did you expect? + description: What did you expect to happen? + placeholder: Tell us what you expected to see! + value: "" + validations: + required: true + - type: textarea + id: version + attributes: + label: Version + description: What version of our software are you running? + placeholder: Can be found by going to Server Settings > System + value: "" + validations: + required: true + - type: dropdown + id: OS + attributes: + label: What OS is Kavita being run on? + multiple: false + options: + - Docker + - Windows + - Linux + - Mac + - type: dropdown + id: desktop-OS + attributes: + label: If issue being seen on Desktop, what OS are you running where you see the issue? + multiple: false + options: + - Windows + - Linux + - Mac + - type: dropdown + id: desktop-browsers + attributes: + label: If issue being seen on Desktop, what browsers are you seeing the problem on? + multiple: true + options: + - Firefox + - Chrome + - Safari + - Microsoft Edge + - type: dropdown + id: mobile-OS + attributes: + label: If issue being seen on Mobile, what OS are you running where you see the issue? + multiple: false + options: + - Android + - iOS + - type: dropdown + id: mobile-browsers + attributes: + label: If issue being seen on Mobile, what browsers are you seeing the problem on? + multiple: true + options: + - Firefox + - Chrome + - Safari + - Microsoft Edge + - type: textarea + id: logs + attributes: + label: Relevant log output + description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. + render: shell + - type: textarea + id: anything-else + attributes: + label: Additional Notes + description: Any other information about the issue not covered in this form? + placeholder: e.g. Running Kavita on a raspberry pi + value: "" + validations: + required: true \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..ec4bb386bc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false \ No newline at end of file diff --git a/.github/workflows/sonar-scan.yml b/.github/workflows/sonar-scan.yml index 6673f3f007..52585bed12 100644 --- a/.github/workflows/sonar-scan.yml +++ b/.github/workflows/sonar-scan.yml @@ -36,68 +36,68 @@ jobs: name: csproj path: Kavita.Common/Kavita.Common.csproj -# test: -# name: Install Sonar & Test -# needs: build -# runs-on: windows-latest -# steps: -# - name: Checkout Repo -# uses: actions/checkout@v2 -# with: -# fetch-depth: 0 -# -# - name: Setup .NET Core -# uses: actions/setup-dotnet@v1 -# with: -# include-prerelease: True -# dotnet-version: '6.0' -# -# - name: Install dependencies -# run: dotnet restore -# -# - name: Set up JDK 11 -# uses: actions/setup-java@v1 -# with: -# java-version: 1.11 -# -# - name: Cache SonarCloud packages -# uses: actions/cache@v1 -# with: -# path: ~\sonar\cache -# key: ${{ runner.os }}-sonar -# restore-keys: ${{ runner.os }}-sonar -# -# - name: Cache SonarCloud scanner -# id: cache-sonar-scanner -# uses: actions/cache@v1 -# with: -# path: .\.sonar\scanner -# key: ${{ runner.os }}-sonar-scanner -# restore-keys: ${{ runner.os }}-sonar-scanner -# -# - name: Install SonarCloud scanner -# if: steps.cache-sonar-scanner.outputs.cache-hit != 'true' -# shell: powershell -# run: | -# New-Item -Path .\.sonar\scanner -ItemType Directory -# dotnet tool update dotnet-sonarscanner --tool-path .\.sonar\scanner -# -# - name: Sonar Scan -# env: -# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any -# SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} -# shell: powershell -# run: | -# .\.sonar\scanner\dotnet-sonarscanner begin /k:"Kareadita_Kavita" /o:"kareadita" /d:sonar.login="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" -# dotnet build --configuration Release -# .\.sonar\scanner\dotnet-sonarscanner end /d:sonar.login="${{ secrets.SONAR_TOKEN }}" -# -# - name: Test -# run: dotnet test --no-restore --verbosity normal + test: + name: Install Sonar & Test + needs: build + runs-on: windows-latest + steps: + - name: Checkout Repo + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Setup .NET Core + uses: actions/setup-dotnet@v1 + with: + include-prerelease: True + dotnet-version: '6.0' + + - name: Install dependencies + run: dotnet restore + + - name: Set up JDK 11 + uses: actions/setup-java@v1 + with: + java-version: 1.11 + + - name: Cache SonarCloud packages + uses: actions/cache@v1 + with: + path: ~\sonar\cache + key: ${{ runner.os }}-sonar + restore-keys: ${{ runner.os }}-sonar + + - name: Cache SonarCloud scanner + id: cache-sonar-scanner + uses: actions/cache@v1 + with: + path: .\.sonar\scanner + key: ${{ runner.os }}-sonar-scanner + restore-keys: ${{ runner.os }}-sonar-scanner + + - name: Install SonarCloud scanner + if: steps.cache-sonar-scanner.outputs.cache-hit != 'true' + shell: powershell + run: | + New-Item -Path .\.sonar\scanner -ItemType Directory + dotnet tool update dotnet-sonarscanner --tool-path .\.sonar\scanner + + - name: Sonar Scan + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + shell: powershell + run: | + .\.sonar\scanner\dotnet-sonarscanner begin /k:"Kareadita_Kavita" /o:"kareadita" /d:sonar.login="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" + dotnet build --configuration Release + .\.sonar\scanner\dotnet-sonarscanner end /d:sonar.login="${{ secrets.SONAR_TOKEN }}" + + - name: Test + run: dotnet test --no-restore --verbosity normal version: name: Bump version on Develop push - needs: [ build ] + needs: [ build, test ] runs-on: ubuntu-latest if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }} steps: @@ -125,7 +125,7 @@ jobs: develop: name: Build Nightly Docker if Develop push - needs: [ build, version ] + needs: [ build, test, version ] runs-on: ubuntu-latest if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }} steps: @@ -229,7 +229,7 @@ jobs: stable: name: Build Stable Docker if Main push - needs: [ build ] + needs: [ build, test ] runs-on: ubuntu-latest if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} steps: diff --git a/.gitignore b/.gitignore index 1ee5668164..bc03f54c7d 100644 --- a/.gitignore +++ b/.gitignore @@ -502,6 +502,9 @@ UI/Web/dist/ /API.Tests/Extensions/Test Data/modified on run.txt # All config files/folders in config except appsettings.json +/API/config-bak/ +/API/config-bak/*.* +/API/config-bak/**/ /API/config/covers/ /API/config/logs/ /API/config/backups/ diff --git a/API.Benchmark/ParserBenchmarks.cs b/API.Benchmark/ParserBenchmarks.cs index ef12331ccc..98e83eb000 100644 --- a/API.Benchmark/ParserBenchmarks.cs +++ b/API.Benchmark/ParserBenchmarks.cs @@ -29,37 +29,26 @@ public ParserBenchmarks() Console.WriteLine($"Performing benchmark on {_names.Count} series"); } - private static void NormalizeOriginal(string name) - { - Regex.Replace(name.ToLower(), "[^a-zA-Z0-9]", string.Empty); - } - - private static void NormalizeNew(string name) + private static string Normalize(string name) { // ReSharper disable once UnusedVariable var ret = NormalizeRegex.Replace(name, string.Empty).ToLower(); + var normalized = NormalizeRegex.Replace(name, string.Empty).ToLower(); + return string.IsNullOrEmpty(normalized) ? name : normalized; } + [Benchmark] public void TestNormalizeName() { foreach (var name in _names) { - NormalizeOriginal(name); + Normalize(name); } } - [Benchmark] - public void TestNormalizeName_New() - { - foreach (var name in _names) - { - NormalizeNew(name); - } - } - [Benchmark] public void TestIsEpub() { diff --git a/API.Benchmark/TestBenchmark.cs b/API.Benchmark/TestBenchmark.cs index 618a8b93cb..c5d2d18e1c 100644 --- a/API.Benchmark/TestBenchmark.cs +++ b/API.Benchmark/TestBenchmark.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using API.Comparators; using API.DTOs; using API.Extensions; using BenchmarkDotNet.Attributes; diff --git a/API.Tests/Extensions/SeriesExtensionsTests.cs b/API.Tests/Extensions/SeriesExtensionsTests.cs index fc5b5b8ca3..c00ade1e86 100644 --- a/API.Tests/Extensions/SeriesExtensionsTests.cs +++ b/API.Tests/Extensions/SeriesExtensionsTests.cs @@ -5,7 +5,6 @@ using API.Extensions; using API.Parser; using API.Services.Tasks.Scanner; -using API.Tests.Helpers; using Xunit; namespace API.Tests.Extensions diff --git a/API.Tests/Helpers/CacheHelperTests.cs b/API.Tests/Helpers/CacheHelperTests.cs index 9d1024ff0a..723742bc6d 100644 --- a/API.Tests/Helpers/CacheHelperTests.cs +++ b/API.Tests/Helpers/CacheHelperTests.cs @@ -5,8 +5,6 @@ using API.Entities; using API.Helpers; using API.Services; -using Microsoft.Extensions.Logging; -using NSubstitute; using Xunit; namespace API.Tests.Helpers; diff --git a/API.Tests/Helpers/EntityFactory.cs b/API.Tests/Helpers/EntityFactory.cs index 7b55df1088..25b807c32a 100644 --- a/API.Tests/Helpers/EntityFactory.cs +++ b/API.Tests/Helpers/EntityFactory.cs @@ -35,7 +35,7 @@ public static Volume CreateVolume(string volumeNumber, List chapters = }; } - public static Chapter CreateChapter(string range, bool isSpecial, List files = null) + public static Chapter CreateChapter(string range, bool isSpecial, List files = null, int pageCount = 0) { return new Chapter() { @@ -43,7 +43,7 @@ public static Chapter CreateChapter(string range, bool isSpecial, List(), - Pages = 0, + Pages = pageCount, }; } diff --git a/API.Tests/Parser/ComicParserTests.cs b/API.Tests/Parser/ComicParserTests.cs index fe0cd0961b..73f7cede4b 100644 --- a/API.Tests/Parser/ComicParserTests.cs +++ b/API.Tests/Parser/ComicParserTests.cs @@ -1,6 +1,4 @@ -using System.Collections.Generic; using System.IO.Abstractions.TestingHelpers; -using API.Entities.Enums; using API.Parser; using API.Services; using Microsoft.Extensions.Logging; @@ -186,6 +184,10 @@ public void ParseComicChapterTest(string filename, string expected) [InlineData("Asterix - HS - Les 12 travaux d'Astérix", true)] [InlineData("Sillage Hors Série - Le Collectionneur - Concordance-DKFR", true)] [InlineData("laughs", false)] + [InlineData("Annual Days of Summer", false)] + [InlineData("Adventure Time 2013 Annual #001 (2013)", true)] + [InlineData("Adventure Time 2013_Annual_#001 (2013)", true)] + [InlineData("Adventure Time 2013_-_Annual #001 (2013)", true)] public void ParseComicSpecialTest(string input, bool expected) { Assert.Equal(expected, !string.IsNullOrEmpty(API.Parser.Parser.ParseComicSpecial(input))); diff --git a/API.Tests/Parser/MangaParserTests.cs b/API.Tests/Parser/MangaParserTests.cs index fe4dd5e42b..171e582cb8 100644 --- a/API.Tests/Parser/MangaParserTests.cs +++ b/API.Tests/Parser/MangaParserTests.cs @@ -1,6 +1,4 @@ -using System.Collections.Generic; using API.Entities.Enums; -using API.Parser; using Xunit; using Xunit.Abstractions; diff --git a/API.Tests/Parser/ParserTest.cs b/API.Tests/Parser/ParserTest.cs index 5068b39b3f..02cd81aa4e 100644 --- a/API.Tests/Parser/ParserTest.cs +++ b/API.Tests/Parser/ParserTest.cs @@ -1,5 +1,4 @@ using System.Linq; -using API.Entities.Enums; using Xunit; using static API.Parser.Parser; @@ -133,12 +132,27 @@ public void MinimumNumberFromRangeTest(string input, float expected) Assert.Equal(expected, MinimumNumberFromRange(input)); } + [Theory] + [InlineData("12-14", 14)] + [InlineData("24", 24)] + [InlineData("18-04", 18)] + [InlineData("18-04.5", 18)] + [InlineData("40", 40)] + [InlineData("40a-040b", 0)] + [InlineData("40.1_a", 0)] + public void MaximumNumberFromRangeTest(string input, float expected) + { + Assert.Equal(expected, MaximumNumberFromRange(input)); + } + [Theory] [InlineData("Darker Than Black", "darkerthanblack")] [InlineData("Darker Than Black - Something", "darkerthanblacksomething")] [InlineData("Darker Than_Black", "darkerthanblack")] [InlineData("Citrus", "citrus")] [InlineData("Citrus+", "citrus+")] + [InlineData("Again!!!!", "again")] + [InlineData("카비타", "카비타")] [InlineData("", "")] public void NormalizeTest(string input, string expected) { diff --git a/API.Tests/Services/ArchiveServiceTests.cs b/API.Tests/Services/ArchiveServiceTests.cs index 526abde3ea..7de8bb2bff 100644 --- a/API.Tests/Services/ArchiveServiceTests.cs +++ b/API.Tests/Services/ArchiveServiceTests.cs @@ -257,6 +257,17 @@ public void ShouldHaveComicInfo_WithAuthors() Assert.Equal("Junya Inoue", comicInfo.Writer); } + [Fact] + public void ShouldHaveComicInfo_TopLevelFileOnly() + { + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/ComicInfos"); + var archive = Path.Join(testDirectory, "ComicInfo_duplicateInfos.zip"); + + var comicInfo = _archiveService.GetComicInfo(archive); + Assert.NotNull(comicInfo); + Assert.Equal("BTOOOM!", comicInfo.Series); + } + #endregion #region CanParseComicInfo diff --git a/API.Tests/Services/BookmarkServiceTests.cs b/API.Tests/Services/BookmarkServiceTests.cs new file mode 100644 index 0000000000..5f862d35f9 --- /dev/null +++ b/API.Tests/Services/BookmarkServiceTests.cs @@ -0,0 +1,337 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.IO; +using System.IO.Abstractions.TestingHelpers; +using System.Linq; +using System.Threading.Tasks; +using API.Data; +using API.Data.Repositories; +using API.DTOs.Reader; +using API.Entities; +using API.Entities.Enums; +using API.Services; +using AutoMapper; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace API.Tests.Services; + +public class BookmarkServiceTests +{ + private readonly IUnitOfWork _unitOfWork; + private readonly DbConnection _connection; + private readonly DataContext _context; + + private const string CacheDirectory = "C:/kavita/config/cache/"; + private const string CoverImageDirectory = "C:/kavita/config/covers/"; + private const string BackupDirectory = "C:/kavita/config/backups/"; + private const string BookmarkDirectory = "C:/kavita/config/bookmarks/"; + + + public BookmarkServiceTests() + { + var contextOptions = new DbContextOptionsBuilder() + .UseSqlite(CreateInMemoryDatabase()) + .Options; + _connection = RelationalOptionsExtension.Extract(contextOptions).Connection; + + _context = new DataContext(contextOptions); + Task.Run(SeedDb).GetAwaiter().GetResult(); + + _unitOfWork = new UnitOfWork(_context, Substitute.For(), null); + } + + #region Setup + + private static DbConnection CreateInMemoryDatabase() + { + var connection = new SqliteConnection("Filename=:memory:"); + + connection.Open(); + + return connection; + } + + private async Task SeedDb() + { + await _context.Database.MigrateAsync(); + var filesystem = CreateFileSystem(); + + await Seed.SeedSettings(_context, new DirectoryService(Substitute.For>(), filesystem)); + + var setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.CacheDirectory).SingleAsync(); + setting.Value = CacheDirectory; + + setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BackupDirectory).SingleAsync(); + setting.Value = BackupDirectory; + + setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BookmarkDirectory).SingleAsync(); + setting.Value = BookmarkDirectory; + + _context.ServerSetting.Update(setting); + + _context.Library.Add(new Library() + { + Name = "Manga", + Folders = new List() + { + new FolderPath() + { + Path = "C:/data/" + } + } + }); + return await _context.SaveChangesAsync() > 0; + } + + private async Task ResetDB() + { + _context.Series.RemoveRange(_context.Series.ToList()); + _context.Users.RemoveRange(_context.Users.ToList()); + _context.AppUserBookmark.RemoveRange(_context.AppUserBookmark.ToList()); + + await _context.SaveChangesAsync(); + } + + private static MockFileSystem CreateFileSystem() + { + var fileSystem = new MockFileSystem(); + fileSystem.Directory.SetCurrentDirectory("C:/kavita/"); + fileSystem.AddDirectory("C:/kavita/config/"); + fileSystem.AddDirectory(CacheDirectory); + fileSystem.AddDirectory(CoverImageDirectory); + fileSystem.AddDirectory(BackupDirectory); + fileSystem.AddDirectory(BookmarkDirectory); + fileSystem.AddDirectory("C:/data/"); + + return fileSystem; + } + + #endregion + + #region BookmarkPage + + [Fact] + public async Task BookmarkPage_ShouldCopyTheFileAndUpdateDB() + { + var filesystem = CreateFileSystem(); + filesystem.AddFile($"{CacheDirectory}1/0001.jpg", new MockFileData("123")); + + // Delete all Series to reset state + await ResetDB(); + + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + new Volume() + { + Chapters = new List() + { + new Chapter() + { + + } + } + } + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "Joe" + }); + + await _context.SaveChangesAsync(); + + + var ds = new DirectoryService(Substitute.For>(), filesystem); + var bookmarkService = new BookmarkService(Substitute.For>(), _unitOfWork, ds); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Bookmarks); + + var result = await bookmarkService.BookmarkPage(user, new BookmarkDto() + { + ChapterId = 1, + Page = 1, + SeriesId = 1, + VolumeId = 1 + }, $"{CacheDirectory}1/0001.jpg"); + + + Assert.True(result); + Assert.Equal(1, ds.GetFiles(BookmarkDirectory, searchOption:SearchOption.AllDirectories).Count()); + Assert.NotNull(await _unitOfWork.UserRepository.GetBookmarkAsync(1)); + } + + [Fact] + public async Task BookmarkPage_ShouldDeleteFileOnUnbookmark() + { + var filesystem = CreateFileSystem(); + filesystem.AddFile($"{CacheDirectory}1/0001.jpg", new MockFileData("123")); + filesystem.AddFile($"{BookmarkDirectory}1/1/0001.jpg", new MockFileData("123")); + + // Delete all Series to reset state + await ResetDB(); + + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + new Volume() + { + Chapters = new List() + { + new Chapter() + { + + } + } + } + } + }); + + + _context.AppUser.Add(new AppUser() + { + UserName = "Joe", + Bookmarks = new List() + { + new AppUserBookmark() + { + Page = 1, + ChapterId = 1, + FileName = $"1/1/0001.jpg", + SeriesId = 1, + VolumeId = 1 + } + } + }); + + await _context.SaveChangesAsync(); + + + var ds = new DirectoryService(Substitute.For>(), filesystem); + var bookmarkService = new BookmarkService(Substitute.For>(), _unitOfWork, ds); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Bookmarks); + + var result = await bookmarkService.RemoveBookmarkPage(user, new BookmarkDto() + { + ChapterId = 1, + Page = 1, + SeriesId = 1, + VolumeId = 1 + }); + + + Assert.True(result); + Assert.Equal(0, ds.GetFiles(BookmarkDirectory, searchOption:SearchOption.AllDirectories).Count()); + Assert.Null(await _unitOfWork.UserRepository.GetBookmarkAsync(1)); + } + + #endregion + + #region DeleteBookmarkFiles + + [Fact] + public async Task DeleteBookmarkFiles_ShouldDeleteOnlyPassedFiles() + { + var filesystem = CreateFileSystem(); + filesystem.AddFile($"{CacheDirectory}1/0001.jpg", new MockFileData("123")); + filesystem.AddFile($"{BookmarkDirectory}1/1/1/0001.jpg", new MockFileData("123")); + filesystem.AddFile($"{BookmarkDirectory}1/2/1/0002.jpg", new MockFileData("123")); + filesystem.AddFile($"{BookmarkDirectory}1/2/1/0001.jpg", new MockFileData("123")); + + // Delete all Series to reset state + await ResetDB(); + + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + new Volume() + { + Chapters = new List() + { + new Chapter() + { + + } + } + } + } + }); + + + _context.AppUser.Add(new AppUser() + { + UserName = "Joe", + Bookmarks = new List() + { + new AppUserBookmark() + { + Page = 1, + ChapterId = 1, + FileName = $"1/1/1/0001.jpg", + SeriesId = 1, + VolumeId = 1 + }, + new AppUserBookmark() + { + Page = 2, + ChapterId = 1, + FileName = $"1/2/1/0002.jpg", + SeriesId = 2, + VolumeId = 1 + }, + new AppUserBookmark() + { + Page = 1, + ChapterId = 2, + FileName = $"1/2/1/0001.jpg", + SeriesId = 2, + VolumeId = 1 + } + } + }); + + await _context.SaveChangesAsync(); + + + var ds = new DirectoryService(Substitute.For>(), filesystem); + var bookmarkService = new BookmarkService(Substitute.For>(), _unitOfWork, ds); + + await bookmarkService.DeleteBookmarkFiles(new [] {new AppUserBookmark() + { + Page = 1, + ChapterId = 1, + FileName = $"1/1/1/0001.jpg", + SeriesId = 1, + VolumeId = 1 + }}); + + + Assert.Equal(2, ds.GetFiles(BookmarkDirectory, searchOption:SearchOption.AllDirectories).Count()); + Assert.False(ds.FileSystem.FileInfo.FromFileName(Path.Join(BookmarkDirectory, "1/1/1/0001.jpg")).Exists); + } + #endregion +} diff --git a/API.Tests/Services/CleanupServiceTests.cs b/API.Tests/Services/CleanupServiceTests.cs index 419dd41266..44cba64a4d 100644 --- a/API.Tests/Services/CleanupServiceTests.cs +++ b/API.Tests/Services/CleanupServiceTests.cs @@ -61,8 +61,6 @@ private static DbConnection CreateInMemoryDatabase() return connection; } - public void Dispose() => _connection.Dispose(); - private async Task SeedDb() { await _context.Database.MigrateAsync(); @@ -364,70 +362,142 @@ public void CleanupBackups_LeaveLestExpired() #endregion - #region CleanupBookmarks - - [Fact] - public async Task CleanupBookmarks_LeaveAllFiles() - { - var filesystem = CreateFileSystem(); - filesystem.AddFile($"{BookmarkDirectory}1/1/1/0001.jpg", new MockFileData("")); - filesystem.AddFile($"{BookmarkDirectory}1/1/1/0002.jpg", new MockFileData("")); - - // Delete all Series to reset state - await ResetDB(); - - _context.Series.Add(new Series() - { - Name = "Test", - Library = new Library() { - Name = "Test LIb", - Type = LibraryType.Manga, - }, - Volumes = new List() - { - new Volume() - { - Chapters = new List() - { - new Chapter() - { - - } - } - } - } - }); - - await _context.SaveChangesAsync(); - - _context.AppUser.Add(new AppUser() - { - Bookmarks = new List() - { - new AppUserBookmark() - { - AppUserId = 1, - ChapterId = 1, - Page = 1, - FileName = "1/1/1/0001.jpg", - SeriesId = 1, - VolumeId = 1 - } - } - }); - - await _context.SaveChangesAsync(); - - - var ds = new DirectoryService(Substitute.For>(), filesystem); - var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, - ds); - - await cleanupService.CleanupBookmarks(); - - Assert.Equal(1, ds.GetFiles(BookmarkDirectory, searchOption:SearchOption.AllDirectories).Count()); - - } - - #endregion + // #region CleanupBookmarks + // + // [Fact] + // public async Task CleanupBookmarks_LeaveAllFiles() + // { + // var filesystem = CreateFileSystem(); + // filesystem.AddFile($"{BookmarkDirectory}1/1/1/0001.jpg", new MockFileData("")); + // filesystem.AddFile($"{BookmarkDirectory}1/1/1/0002.jpg", new MockFileData("")); + // + // // Delete all Series to reset state + // await ResetDB(); + // + // _context.Series.Add(new Series() + // { + // Name = "Test", + // Library = new Library() { + // Name = "Test LIb", + // Type = LibraryType.Manga, + // }, + // Volumes = new List() + // { + // new Volume() + // { + // Chapters = new List() + // { + // new Chapter() + // { + // + // } + // } + // } + // } + // }); + // + // await _context.SaveChangesAsync(); + // + // _context.AppUser.Add(new AppUser() + // { + // Bookmarks = new List() + // { + // new AppUserBookmark() + // { + // AppUserId = 1, + // ChapterId = 1, + // Page = 1, + // FileName = "1/1/1/0001.jpg", + // SeriesId = 1, + // VolumeId = 1 + // }, + // new AppUserBookmark() + // { + // AppUserId = 1, + // ChapterId = 1, + // Page = 2, + // FileName = "1/1/1/0002.jpg", + // SeriesId = 1, + // VolumeId = 1 + // } + // } + // }); + // + // await _context.SaveChangesAsync(); + // + // + // var ds = new DirectoryService(Substitute.For>(), filesystem); + // var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, + // ds); + // + // await cleanupService.CleanupBookmarks(); + // + // Assert.Equal(2, ds.GetFiles(BookmarkDirectory, searchOption:SearchOption.AllDirectories).Count()); + // + // } + // + // [Fact] + // public async Task CleanupBookmarks_LeavesOneFiles() + // { + // var filesystem = CreateFileSystem(); + // filesystem.AddFile($"{BookmarkDirectory}1/1/1/0001.jpg", new MockFileData("")); + // filesystem.AddFile($"{BookmarkDirectory}1/1/2/0002.jpg", new MockFileData("")); + // + // // Delete all Series to reset state + // await ResetDB(); + // + // _context.Series.Add(new Series() + // { + // Name = "Test", + // Library = new Library() { + // Name = "Test LIb", + // Type = LibraryType.Manga, + // }, + // Volumes = new List() + // { + // new Volume() + // { + // Chapters = new List() + // { + // new Chapter() + // { + // + // } + // } + // } + // } + // }); + // + // await _context.SaveChangesAsync(); + // + // _context.AppUser.Add(new AppUser() + // { + // Bookmarks = new List() + // { + // new AppUserBookmark() + // { + // AppUserId = 1, + // ChapterId = 1, + // Page = 1, + // FileName = "1/1/1/0001.jpg", + // SeriesId = 1, + // VolumeId = 1 + // } + // } + // }); + // + // await _context.SaveChangesAsync(); + // + // + // var ds = new DirectoryService(Substitute.For>(), filesystem); + // var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, + // ds); + // + // await cleanupService.CleanupBookmarks(); + // + // Assert.Equal(1, ds.GetFiles(BookmarkDirectory, searchOption:SearchOption.AllDirectories).Count()); + // Assert.Equal(1, ds.FileSystem.Directory.GetDirectories($"{BookmarkDirectory}1/1/").Length); + // } + // + // #endregion } diff --git a/API.Tests/Services/DirectoryServiceTests.cs b/API.Tests/Services/DirectoryServiceTests.cs index bdbb7a238f..391b4eac4b 100644 --- a/API.Tests/Services/DirectoryServiceTests.cs +++ b/API.Tests/Services/DirectoryServiceTests.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.IO; -using System.IO.Abstractions; using System.IO.Abstractions.TestingHelpers; using System.Linq; using System.Text; diff --git a/API.Tests/Services/ParseScannedFilesTests.cs b/API.Tests/Services/ParseScannedFilesTests.cs index fd55143a13..e3b0b498f0 100644 --- a/API.Tests/Services/ParseScannedFilesTests.cs +++ b/API.Tests/Services/ParseScannedFilesTests.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Data.Common; using System.IO.Abstractions.TestingHelpers; using System.Linq; @@ -10,10 +11,8 @@ using API.Parser; using API.Services; using API.Services.Tasks.Scanner; -using API.SignalR; using API.Tests.Helpers; using AutoMapper; -using Microsoft.AspNetCore.SignalR; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -97,8 +96,6 @@ private static DbConnection CreateInMemoryDatabase() return connection; } - public void Dispose() => _connection.Dispose(); - private async Task SeedDb() { await _context.Database.MigrateAsync(); diff --git a/API.Tests/Services/ReaderServiceTests.cs b/API.Tests/Services/ReaderServiceTests.cs index 940bc2ebed..05d076b3f7 100644 --- a/API.Tests/Services/ReaderServiceTests.cs +++ b/API.Tests/Services/ReaderServiceTests.cs @@ -1,6 +1,6 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Data.Common; -using System.IO; using System.IO.Abstractions.TestingHelpers; using System.Linq; using System.Threading.Tasks; @@ -11,10 +11,8 @@ using API.Entities.Enums; using API.Helpers; using API.Services; -using API.SignalR; using API.Tests.Helpers; using AutoMapper; -using Microsoft.AspNetCore.SignalR; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -26,9 +24,8 @@ namespace API.Tests.Services; public class ReaderServiceTests { - private readonly ILogger _logger = Substitute.For>(); + private readonly IUnitOfWork _unitOfWork; - private readonly IHubContext _messageHub = Substitute.For>(); private readonly DbConnection _connection; private readonly DataContext _context; @@ -62,8 +59,6 @@ private static DbConnection CreateInMemoryDatabase() return connection; } - public void Dispose() => _connection.Dispose(); - private async Task SeedDb() { await _context.Database.MigrateAsync(); @@ -152,10 +147,7 @@ public async Task CapPageToChapterTest() await _context.SaveChangesAsync(); - var fileSystem = new MockFileSystem(); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); Assert.Equal(0, await readerService.CapPageToChapter(1, -1)); Assert.Equal(1, await readerService.CapPageToChapter(1, 10)); @@ -199,10 +191,7 @@ public async Task SaveReadingProgress_ShouldCreateNewEntity() await _context.SaveChangesAsync(); - var fileSystem = new MockFileSystem(); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); var successful = await readerService.SaveReadingProgress(new ProgressDto() { @@ -251,10 +240,7 @@ public async Task SaveReadingProgress_ShouldUpdateExisting() await _context.SaveChangesAsync(); - var fileSystem = new MockFileSystem(); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); var successful = await readerService.SaveReadingProgress(new ProgressDto() { @@ -324,10 +310,7 @@ public async Task MarkChaptersAsReadTest() await _context.SaveChangesAsync(); - var fileSystem = new MockFileSystem(); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); var volumes = await _unitOfWork.VolumeRepository.GetVolumes(1); readerService.MarkChaptersAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters); @@ -377,10 +360,7 @@ public async Task MarkChapterAsUnreadTest() await _context.SaveChangesAsync(); - var fileSystem = new MockFileSystem(); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); var volumes = (await _unitOfWork.VolumeRepository.GetVolumes(1)).ToList(); readerService.MarkChaptersAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters); @@ -440,10 +420,7 @@ public async Task GetNextChapterIdAsync_ShouldGetNextVolume() await _context.SaveChangesAsync(); - var fileSystem = new MockFileSystem(); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); var nextChapter = await readerService.GetNextChapterIdAsync(1, 1, 1, 1); var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); @@ -489,10 +466,7 @@ public async Task GetNextChapterIdAsync_ShouldRollIntoNextVolume() await _context.SaveChangesAsync(); - var fileSystem = new MockFileSystem(); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); var nextChapter = await readerService.GetNextChapterIdAsync(1, 1, 2, 1); @@ -501,7 +475,50 @@ public async Task GetNextChapterIdAsync_ShouldRollIntoNextVolume() } [Fact] - public async Task GetNextChapterIdAsync_ShouldNotMoveFromVolumeToSpecial() + public async Task GetNextChapterIdAsync_ShouldRollIntoChaptersFromVolume() + { + await ResetDB(); + + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("1", false, new List()), + EntityFactory.CreateChapter("2", false, new List()), + }), + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("21", false, new List()), + EntityFactory.CreateChapter("22", false, new List()), + }), + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + + + var nextChapter = await readerService.GetNextChapterIdAsync(1, 2, 4, 1); + Assert.NotEqual(-1, nextChapter); + var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); + Assert.Equal("1", actualChapter.Range); + } + + [Fact] + public async Task GetNextChapterIdAsync_ShouldFindNoNextChapterFromSpecial() { await ResetDB(); @@ -534,16 +551,133 @@ public async Task GetNextChapterIdAsync_ShouldNotMoveFromVolumeToSpecial() await _context.SaveChangesAsync(); - var fileSystem = new MockFileSystem(); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + + + var nextChapter = await readerService.GetNextChapterIdAsync(1, 2, 4, 1); + Assert.Equal(-1, nextChapter); + } + + [Fact] + public async Task GetNextChapterIdAsync_ShouldFindNoNextChapterFromVolume() + { + await ResetDB(); + + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("1", false, new List()), + EntityFactory.CreateChapter("2", false, new List()), + }), + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + + + var nextChapter = await readerService.GetNextChapterIdAsync(1, 1, 2, 1); + Assert.Equal(-1, nextChapter); + } + + [Fact] + public async Task GetNextChapterIdAsync_ShouldFindNoNextChapterFromLastChapter() + { + await ResetDB(); + + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("1", false, new List()), + EntityFactory.CreateChapter("2", false, new List()), + }), + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("1", false, new List()), + EntityFactory.CreateChapter("2", false, new List()), + }), + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); var nextChapter = await readerService.GetNextChapterIdAsync(1, 1, 2, 1); Assert.Equal(-1, nextChapter); } + [Fact] + public async Task GetNextChapterIdAsync_ShouldMoveFromVolumeToSpecial() + { + await ResetDB(); + + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("1", false, new List()), + EntityFactory.CreateChapter("2", false, new List()), + }), + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("A.cbz", true, new List()), + EntityFactory.CreateChapter("B.cbz", true, new List()), + }), + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + + + var nextChapter = await readerService.GetNextChapterIdAsync(1, 1, 2, 1); + Assert.NotEqual(-1, nextChapter); + var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); + Assert.Equal("A.cbz", actualChapter.Range); + } + [Fact] public async Task GetNextChapterIdAsync_ShouldMoveFromSpecialToSpecial() { @@ -578,10 +712,7 @@ public async Task GetNextChapterIdAsync_ShouldMoveFromSpecialToSpecial() await _context.SaveChangesAsync(); - var fileSystem = new MockFileSystem(); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); var nextChapter = await readerService.GetNextChapterIdAsync(1, 2, 3, 1); @@ -634,10 +765,7 @@ public async Task GetPrevChapterIdAsync_ShouldGetPrevVolume() await _context.SaveChangesAsync(); - var fileSystem = new MockFileSystem(); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); var prevChapter = await readerService.GetPrevChapterIdAsync(1, 1, 2, 1); var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter); @@ -683,10 +811,7 @@ public async Task GetPrevChapterIdAsync_ShouldRollIntoPrevVolume() await _context.SaveChangesAsync(); - var fileSystem = new MockFileSystem(); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); var prevChapter = await readerService.GetPrevChapterIdAsync(1, 2, 3, 1); @@ -728,10 +853,7 @@ public async Task GetPrevChapterIdAsync_ShouldMoveFromVolumeToSpecial() await _context.SaveChangesAsync(); - var fileSystem = new MockFileSystem(); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); var prevChapter = await readerService.GetPrevChapterIdAsync(1, 1, 1, 1); @@ -741,7 +863,7 @@ public async Task GetPrevChapterIdAsync_ShouldMoveFromVolumeToSpecial() } [Fact] - public async Task GetPrevChapterIdAsync_ShouldMoveFromSpecialToSpecial() + public async Task GetPrevChapterIdAsync_ShouldFindNoPrevChapterFromVolume() { await ResetDB(); @@ -759,10 +881,80 @@ public async Task GetPrevChapterIdAsync_ShouldMoveFromSpecialToSpecial() EntityFactory.CreateChapter("1", false, new List()), EntityFactory.CreateChapter("2", false, new List()), }), + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + + + var prevChapter = await readerService.GetPrevChapterIdAsync(1, 1, 1, 1); + Assert.Equal(-1, prevChapter); + } + + [Fact] + public async Task GetPrevChapterIdAsync_ShouldFindNoPrevChapterFromVolumeWithZeroChapter() + { + await ResetDB(); + + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("0", false, new List()), + }), + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + + + var prevChapter = await readerService.GetPrevChapterIdAsync(1, 1, 1, 1); + Assert.Equal(-1, prevChapter); + } + + [Fact] + public async Task GetPrevChapterIdAsync_ShouldFindNoPrevChapterFromVolumeWithZeroChapterAndHasNormalChapters() + { + await ResetDB(); + + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { EntityFactory.CreateVolume("0", new List() { - EntityFactory.CreateChapter("A.cbz", true, new List()), - EntityFactory.CreateChapter("B.cbz", true, new List()), + EntityFactory.CreateChapter("1", false, new List()), + EntityFactory.CreateChapter("2", false, new List()), + }), + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("0", false, new List()), }), } }); @@ -774,41 +966,595 @@ public async Task GetPrevChapterIdAsync_ShouldMoveFromSpecialToSpecial() await _context.SaveChangesAsync(); - var fileSystem = new MockFileSystem(); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); - var prevChapter = await readerService.GetPrevChapterIdAsync(1, 2, 4, 1); - Assert.NotEqual(-1, prevChapter); - var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter); - Assert.Equal("A.cbz", actualChapter.Range); + var prevChapter = await readerService.GetPrevChapterIdAsync(1, 1, 1, 1); + Assert.Equal(-1, prevChapter); } - #endregion + [Fact] + public async Task GetPrevChapterIdAsync_ShouldFindNoPrevChapterFromChapter() + { + await ResetDB(); + + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("1", false, new List()), + EntityFactory.CreateChapter("2", false, new List()), + }), + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + + + var prevChapter = await readerService.GetPrevChapterIdAsync(1, 1, 1, 1); + Assert.Equal(-1, prevChapter); + } + + [Fact] + public async Task GetPrevChapterIdAsync_ShouldMoveFromSpecialToSpecial() + { + await ResetDB(); + + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("1", false, new List()), + EntityFactory.CreateChapter("2", false, new List()), + }), + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("A.cbz", true, new List()), + EntityFactory.CreateChapter("B.cbz", true, new List()), + }), + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + + + var prevChapter = await readerService.GetPrevChapterIdAsync(1, 2, 4, 1); + Assert.NotEqual(-1, prevChapter); + var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter); + Assert.Equal("A.cbz", actualChapter.Range); + } + + [Fact] + public async Task GetPrevChapterIdAsync_ShouldMoveFromChapterToVolume() + { + await ResetDB(); + + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("1", false, new List()), + EntityFactory.CreateChapter("2", false, new List()), + }), + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("21", false, new List()), + EntityFactory.CreateChapter("22", false, new List()), + }), + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + + + var prevChapter = await readerService.GetPrevChapterIdAsync(1, 1, 1, 1); + Assert.NotEqual(-1, prevChapter); + var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter); + Assert.Equal("22", actualChapter.Range); + } + #endregion + + #region GetContinuePoint + + [Fact] + public async Task GetContinuePoint_ShouldReturnFirstNonSpecial() + { + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("1", false, new List(), 1), + EntityFactory.CreateChapter("2", false, new List(), 1), + }), + EntityFactory.CreateVolume("2", new List() + { + EntityFactory.CreateChapter("21", false, new List(), 1), + EntityFactory.CreateChapter("22", false, new List(), 1), + }), + EntityFactory.CreateVolume("3", new List() + { + EntityFactory.CreateChapter("31", false, new List(), 1), + EntityFactory.CreateChapter("32", false, new List(), 1), + }), + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + + // Save progress on first volume chapters and 1st of second volume + await readerService.SaveReadingProgress(new ProgressDto() + { + PageNum = 1, + ChapterId = 1, + SeriesId = 1, + VolumeId = 1 + }, 1); + await readerService.SaveReadingProgress(new ProgressDto() + { + PageNum = 1, + ChapterId = 2, + SeriesId = 1, + VolumeId = 1 + }, 1); + await readerService.SaveReadingProgress(new ProgressDto() + { + PageNum = 1, + ChapterId = 3, + SeriesId = 1, + VolumeId = 2 + }, 1); + + await _context.SaveChangesAsync(); + + var nextChapter = await readerService.GetContinuePoint(1, 1); + + Assert.Equal("22", nextChapter.Range); + + + } + + [Fact] + public async Task GetContinuePoint_ShouldReturnFirstSpecial() + { + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("1", false, new List(), 1), + EntityFactory.CreateChapter("2", false, new List(), 1), + }), + EntityFactory.CreateVolume("2", new List() + { + EntityFactory.CreateChapter("21", false, new List(), 1), + }), + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("31", false, new List(), 1), + EntityFactory.CreateChapter("32", false, new List(), 1), + }), + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + + // Save progress on first volume chapters and 1st of second volume + await readerService.SaveReadingProgress(new ProgressDto() + { + PageNum = 1, + ChapterId = 1, + SeriesId = 1, + VolumeId = 1 + }, 1); + await readerService.SaveReadingProgress(new ProgressDto() + { + PageNum = 1, + ChapterId = 2, + SeriesId = 1, + VolumeId = 1 + }, 1); + await readerService.SaveReadingProgress(new ProgressDto() + { + PageNum = 1, + ChapterId = 3, + SeriesId = 1, + VolumeId = 2 + }, 1); + + await _context.SaveChangesAsync(); + + var nextChapter = await readerService.GetContinuePoint(1, 1); + + Assert.Equal("31", nextChapter.Range); + } + + [Fact] + public async Task GetContinuePoint_ShouldReturnFirstChapter_WhenAllRead() + { + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("1", false, new List(), 1), + EntityFactory.CreateChapter("2", false, new List(), 1), + }), + EntityFactory.CreateVolume("2", new List() + { + EntityFactory.CreateChapter("21", false, new List(), 1), + }), + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + + // Save progress on first volume chapters and 1st of second volume + await readerService.SaveReadingProgress(new ProgressDto() + { + PageNum = 1, + ChapterId = 1, + SeriesId = 1, + VolumeId = 1 + }, 1); + await readerService.SaveReadingProgress(new ProgressDto() + { + PageNum = 1, + ChapterId = 2, + SeriesId = 1, + VolumeId = 1 + }, 1); + await readerService.SaveReadingProgress(new ProgressDto() + { + PageNum = 1, + ChapterId = 3, + SeriesId = 1, + VolumeId = 2 + }, 1); + + await _context.SaveChangesAsync(); + + var nextChapter = await readerService.GetContinuePoint(1, 1); + + Assert.Equal("1", nextChapter.Range); + } + + [Fact] + public async Task GetContinuePoint_ShouldReturnFirstChapter_WhenAllReadAndAllChapters() + { + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("1", false, new List(), 1), + EntityFactory.CreateChapter("2", false, new List(), 1), + EntityFactory.CreateChapter("3", false, new List(), 1), + }), + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + + // Save progress on first volume chapters and 1st of second volume + await readerService.SaveReadingProgress(new ProgressDto() + { + PageNum = 1, + ChapterId = 1, + SeriesId = 1, + VolumeId = 1 + }, 1); + await readerService.SaveReadingProgress(new ProgressDto() + { + PageNum = 1, + ChapterId = 2, + SeriesId = 1, + VolumeId = 1 + }, 1); + await readerService.SaveReadingProgress(new ProgressDto() + { + PageNum = 1, + ChapterId = 3, + SeriesId = 1, + VolumeId = 1 + }, 1); + + await _context.SaveChangesAsync(); + + var nextChapter = await readerService.GetContinuePoint(1, 1); + + Assert.Equal("1", nextChapter.Range); + } + + [Fact] + public async Task GetContinuePoint_ShouldReturnFirstSpecial_WhenAllReadAndAllChapters() + { + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("1", false, new List(), 1), + EntityFactory.CreateChapter("2", false, new List(), 1), + EntityFactory.CreateChapter("3", false, new List(), 1), + EntityFactory.CreateChapter("Some Special Title", true, new List(), 1), + }), + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + + // Save progress on first volume chapters and 1st of second volume + await readerService.SaveReadingProgress(new ProgressDto() + { + PageNum = 1, + ChapterId = 1, + SeriesId = 1, + VolumeId = 1 + }, 1); + await readerService.SaveReadingProgress(new ProgressDto() + { + PageNum = 1, + ChapterId = 2, + SeriesId = 1, + VolumeId = 1 + }, 1); + await readerService.SaveReadingProgress(new ProgressDto() + { + PageNum = 1, + ChapterId = 3, + SeriesId = 1, + VolumeId = 1 + }, 1); + + await _context.SaveChangesAsync(); + + var nextChapter = await readerService.GetContinuePoint(1, 1); + + Assert.Equal("Some Special Title", nextChapter.Range); + } + + #endregion + + #region MarkChaptersUntilAsRead + + [Fact] + public async Task MarkChaptersUntilAsRead_ShouldMarkAllChaptersAsRead() + { + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("1", false, new List(), 1), + EntityFactory.CreateChapter("2", false, new List(), 1), + EntityFactory.CreateChapter("3", false, new List(), 1), + EntityFactory.CreateChapter("Some Special Title", true, new List(), 1), + }), + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); + await readerService.MarkChaptersUntilAsRead(user, 1, 5); + await _context.SaveChangesAsync(); + + // Validate correct chapters have read status + Assert.Equal(1, (await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1)).PagesRead); + Assert.Equal(1, (await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(2, 1)).PagesRead); + Assert.Equal(1, (await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(3, 1)).PagesRead); + Assert.Null((await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(4, 1))); + } + + [Fact] + public async Task MarkChaptersUntilAsRead_ShouldMarkUptTillChapterNumberAsRead() + { + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("1", false, new List(), 1), + EntityFactory.CreateChapter("2", false, new List(), 1), + EntityFactory.CreateChapter("2.5", false, new List(), 1), + EntityFactory.CreateChapter("3", false, new List(), 1), + EntityFactory.CreateChapter("Some Special Title", true, new List(), 1), + }), + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); + await readerService.MarkChaptersUntilAsRead(user, 1, 2.5f); + await _context.SaveChangesAsync(); + + // Validate correct chapters have read status + Assert.Equal(1, (await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1)).PagesRead); + Assert.Equal(1, (await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(2, 1)).PagesRead); + Assert.Equal(1, (await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(3, 1)).PagesRead); + Assert.Null((await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(4, 1))); + Assert.Null((await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(5, 1))); + } + + [Fact] + public async Task MarkChaptersUntilAsRead_ShouldNotReadOnlyVolumesWithChapter0() + { + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("0", false, new List(), 1), + }), + EntityFactory.CreateVolume("2", new List() + { + EntityFactory.CreateChapter("0", false, new List(), 1), + }), + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); + await readerService.MarkChaptersUntilAsRead(user, 1, 2); + await _context.SaveChangesAsync(); + + // Validate correct chapters have read status + Assert.False(await _unitOfWork.AppUserProgressRepository.UserHasProgress(LibraryType.Manga, 1)); + } + + #endregion + - // #region GetNumberOfPages - // - // [Fact] - // public void GetNumberOfPages_EPUB() - // { - // const string testDirectory = "/manga/"; - // var fileSystem = new MockFileSystem(); - // - // var actualFile = Path.Join(Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/BookService/EPUB"), "The Golden Harpoon; Or, Lost Among the Floes A Story of the Whaling Grounds.epub") - // fileSystem.File.WriteAllBytes("${testDirectory}test.epub", File.ReadAllBytes(actualFile)); - // - // fileSystem.AddDirectory(CacheDirectory); - // - // var ds = new DirectoryService(Substitute.For>(), fileSystem); - // var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); - // var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); - // - // - // } - // - // - // #endregion } diff --git a/API.Tests/Services/ScannerServiceTests.cs b/API.Tests/Services/ScannerServiceTests.cs index b78c6be358..280fe5c10f 100644 --- a/API.Tests/Services/ScannerServiceTests.cs +++ b/API.Tests/Services/ScannerServiceTests.cs @@ -1,29 +1,12 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Data.Common; -using System.IO; -using System.IO.Abstractions.TestingHelpers; +using System.Collections.Generic; using System.Linq; -using System.Threading.Tasks; -using API.Data; using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; -using API.Helpers; using API.Parser; -using API.Services; using API.Services.Tasks; using API.Services.Tasks.Scanner; -using API.SignalR; using API.Tests.Helpers; -using AutoMapper; -using Microsoft.AspNetCore.SignalR; -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.Extensions.Logging; -using NSubstitute; using Xunit; namespace API.Tests.Services diff --git a/API.Tests/Services/Test Data/ArchiveService/ComicInfos/ComicInfo_duplicateInfos.zip b/API.Tests/Services/Test Data/ArchiveService/ComicInfos/ComicInfo_duplicateInfos.zip new file mode 100644 index 0000000000..53182a1680 Binary files /dev/null and b/API.Tests/Services/Test Data/ArchiveService/ComicInfos/ComicInfo_duplicateInfos.zip differ diff --git a/API/API.csproj b/API/API.csproj index fbf15067ea..42e0d11076 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -14,6 +14,7 @@ bin\Debug\API.xml + 1701;1702;1591 @@ -49,17 +50,17 @@ - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - + @@ -100,6 +101,9 @@ + + + diff --git a/API/Constants/PolicyConstants.cs b/API/Constants/PolicyConstants.cs index 389a87eeef..8e8a2bc5d7 100644 --- a/API/Constants/PolicyConstants.cs +++ b/API/Constants/PolicyConstants.cs @@ -1,4 +1,6 @@ -namespace API.Constants +using System.Collections.Immutable; + +namespace API.Constants { /// /// Role-based Security @@ -17,5 +19,12 @@ public static class PolicyConstants /// Used to give a user ability to download files from the server /// public const string DownloadRole = "Download"; + /// + /// Used to give a user ability to change their own password + /// + public const string ChangePasswordRole = "Change Password"; + + public static readonly ImmutableArray ValidRoles = + ImmutableArray.Create(AdminRole, PlebRole, DownloadRole, ChangePasswordRole); } } diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index 3d3a93f85c..77b14ec21b 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -3,18 +3,25 @@ using System.Linq; using System.Reflection; using System.Threading.Tasks; +using System.Web; using API.Constants; using API.Data; +using API.Data.Repositories; using API.DTOs; using API.DTOs.Account; +using API.DTOs.Email; using API.Entities; +using API.Entities.Enums; +using API.Errors; using API.Extensions; using API.Services; using AutoMapper; using Kavita.Common; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace API.Controllers @@ -31,13 +38,15 @@ public class AccountController : BaseApiController private readonly ILogger _logger; private readonly IMapper _mapper; private readonly IAccountService _accountService; + private readonly IEmailService _emailService; + private readonly IHostEnvironment _environment; /// public AccountController(UserManager userManager, SignInManager signInManager, ITokenService tokenService, IUnitOfWork unitOfWork, ILogger logger, - IMapper mapper, IAccountService accountService) + IMapper mapper, IAccountService accountService, IEmailService emailService, IHostEnvironment environment) { _userManager = userManager; _signInManager = signInManager; @@ -46,6 +55,8 @@ public AccountController(UserManager userManager, _logger = logger; _mapper = mapper; _accountService = accountService; + _emailService = emailService; + _environment = environment; } /// @@ -59,7 +70,7 @@ public async Task UpdatePassword(ResetPasswordDto resetPasswordDto _logger.LogInformation("{UserName} is changing {ResetUser}'s password", User.GetUsername(), resetPasswordDto.UserName); var user = await _userManager.Users.SingleAsync(x => x.UserName == resetPasswordDto.UserName); - if (resetPasswordDto.UserName != User.GetUsername() && !User.IsInRole(PolicyConstants.AdminRole)) + if (resetPasswordDto.UserName != User.GetUsername() && !(User.IsInRole(PolicyConstants.AdminRole) || User.IsInRole(PolicyConstants.ChangePasswordRole))) return Unauthorized("You are not permitted to this operation."); var errors = await _accountService.ChangeUserPassword(user, resetPasswordDto.Password); @@ -73,72 +84,49 @@ public async Task UpdatePassword(ResetPasswordDto resetPasswordDto } /// - /// Register a new user on the server + /// Register the first user (admin) on the server. Will not do anything if an admin is already confirmed /// /// /// [HttpPost("register")] - public async Task> Register(RegisterDto registerDto) + public async Task> RegisterFirstUser(RegisterDto registerDto) { + var admins = await _userManager.GetUsersInRoleAsync("Admin"); + if (admins.Count > 0) return BadRequest("Not allowed"); + try { - if (await _userManager.Users.AnyAsync(x => x.NormalizedUserName == registerDto.Username.ToUpper())) + var usernameValidation = await _accountService.ValidateUsername(registerDto.Username); + if (usernameValidation.Any()) { - return BadRequest("Username is taken."); + return BadRequest(usernameValidation); } - // If we are registering an admin account, ensure there are no existing admins or user registering is an admin - if (registerDto.IsAdmin) - { - var firstTimeFlow = !(await _userManager.GetUsersInRoleAsync("Admin")).Any(); - if (!firstTimeFlow && !await _unitOfWork.UserRepository.IsUserAdminAsync( - await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()))) - { - return BadRequest("You are not permitted to create an admin account"); - } - } - - var user = _mapper.Map(registerDto); - user.UserPreferences ??= new AppUserPreferences(); - user.ApiKey = HashUtil.ApiKey(); - - var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); - if (!settings.EnableAuthentication && !registerDto.IsAdmin) + var user = new AppUser() { - _logger.LogInformation("User {UserName} is being registered as non-admin with no server authentication. Using default password", registerDto.Username); - registerDto.Password = AccountService.DefaultPassword; - } + UserName = registerDto.Username, + Email = registerDto.Email, + UserPreferences = new AppUserPreferences(), + ApiKey = HashUtil.ApiKey() + }; var result = await _userManager.CreateAsync(user, registerDto.Password); - if (!result.Succeeded) return BadRequest(result.Errors); + var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); + if (string.IsNullOrEmpty(token)) return BadRequest("There was an issue generating a confirmation token."); + if (!await ConfirmEmailToken(token, user)) return BadRequest($"There was an issue validating your email: {token}"); - var role = registerDto.IsAdmin ? PolicyConstants.AdminRole : PolicyConstants.PlebRole; - var roleResult = await _userManager.AddToRoleAsync(user, role); + var roleResult = await _userManager.AddToRoleAsync(user, PolicyConstants.AdminRole); if (!roleResult.Succeeded) return BadRequest(result.Errors); - // When we register an admin, we need to grant them access to all Libraries. - if (registerDto.IsAdmin) - { - _logger.LogInformation("{UserName} is being registered as admin. Granting access to all libraries", - user.UserName); - var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); - foreach (var lib in libraries) - { - lib.AppUsers ??= new List(); - lib.AppUsers.Add(user); - } - - if (libraries.Any() && !await _unitOfWork.CommitAsync()) - _logger.LogError("There was an issue granting library access. Please do this manually"); - } - return new UserDto { Username = user.UserName, + Email = user.Email, Token = await _tokenService.CreateToken(user), + RefreshToken = await _tokenService.CreateRefreshToken(user), ApiKey = user.ApiKey, Preferences = _mapper.Map(user.UserPreferences) }; @@ -152,6 +140,7 @@ await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()))) return BadRequest("Something went wrong when registering user"); } + /// /// Perform a login. Will send JWT Token of the logged in user back. /// @@ -166,18 +155,27 @@ public async Task> Login(LoginDto loginDto) if (user == null) return Unauthorized("Invalid username"); - var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); - var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); - if (!settings.EnableAuthentication && !isAdmin) + // Check if the user has an email, if not, inform them so they can migrate + var validPassword = await _signInManager.UserManager.CheckPasswordAsync(user, loginDto.Password); + if (string.IsNullOrEmpty(user.Email) && !user.EmailConfirmed && validPassword) + { + _logger.LogCritical("User {UserName} does not have an email. Providing a one time migration", user.UserName); + return Unauthorized( + "You are missing an email on your account. Please wait while we migrate your account."); + } + + if (!validPassword) { - _logger.LogDebug("User {UserName} is logging in with authentication disabled", loginDto.Username); - loginDto.Password = AccountService.DefaultPassword; + return Unauthorized("Your credentials are not correct"); } var result = await _signInManager .CheckPasswordSignInAsync(user, loginDto.Password, false); - if (!result.Succeeded) return Unauthorized("Your credentials are not correct."); + if (!result.Succeeded) + { + return Unauthorized(result.IsNotAllowed ? "You must confirm your email first" : "Your credentials are not correct."); + } // Update LastActive on account user.LastActive = DateTime.Now; @@ -191,12 +189,26 @@ public async Task> Login(LoginDto loginDto) return new UserDto { Username = user.UserName, + Email = user.Email, Token = await _tokenService.CreateToken(user), + RefreshToken = await _tokenService.CreateRefreshToken(user), ApiKey = user.ApiKey, Preferences = _mapper.Map(user.UserPreferences) }; } + [HttpPost("refresh-token")] + public async Task> RefreshToken([FromBody] TokenRequestDto tokenRequestDto) + { + var token = await _tokenService.ValidateRefreshToken(tokenRequestDto); + if (token == null) + { + return Unauthorized(new { message = "Invalid token" }); + } + + return Ok(token); + } + /// /// Get All Roles back. See /// @@ -211,65 +223,442 @@ public ActionResult> GetRoles() f => (string) f.GetValue(null)).Values.ToList(); } + /// - /// Sets the given roles to the user. + /// Resets the API Key assigned with a user /// - /// /// - [HttpPost("update-rbs")] - public async Task UpdateRoles(UpdateRbsDto updateRbsDto) + [HttpPost("reset-api-key")] + public async Task> ResetApiKey() { - var user = await _userManager.Users - .Include(u => u.UserPreferences) - .SingleOrDefaultAsync(x => x.NormalizedUserName == updateRbsDto.Username.ToUpper()); - if (updateRbsDto.Roles.Contains(PolicyConstants.AdminRole) || - updateRbsDto.Roles.Contains(PolicyConstants.PlebRole)) + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + + user.ApiKey = HashUtil.ApiKey(); + + if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync()) { - return BadRequest("Invalid Roles"); + return Ok(user.ApiKey); } - var existingRoles = (await _userManager.GetRolesAsync(user)) - .Where(s => s != PolicyConstants.AdminRole && s != PolicyConstants.PlebRole) - .ToList(); + await _unitOfWork.RollbackAsync(); + return BadRequest("Something went wrong, unable to reset key"); - // Find what needs to be added and what needs to be removed - var rolesToRemove = existingRoles.Except(updateRbsDto.Roles); - var result = await _userManager.AddToRolesAsync(user, updateRbsDto.Roles); + } - if (!result.Succeeded) + /// + /// Update the user account. This can only affect Username, Email (will require confirming), Roles, and Library access. + /// + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("update")] + public async Task UpdateAccount(UpdateUserDto dto) + { + var adminUser = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + if (!await _unitOfWork.UserRepository.IsUserAdminAsync(adminUser)) return Unauthorized("You do not have permission"); + + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(dto.UserId); + if (user == null) return BadRequest("User does not exist"); + + // Check if username is changing + if (!user.UserName.Equals(dto.Username)) { - await _unitOfWork.RollbackAsync(); - return BadRequest("Something went wrong, unable to update user's roles"); + // Validate username change + var errors = await _accountService.ValidateUsername(dto.Username); + if (errors.Any()) return BadRequest("Username already taken"); + user.UserName = dto.Username; + _unitOfWork.UserRepository.Update(user); + } + + if (!user.Email.Equals(dto.Email)) + { + // Validate username change + var errors = await _accountService.ValidateEmail(dto.Email); + if (errors.Any()) return BadRequest("Email already registered"); + // NOTE: This needs to be handled differently, like save it in a temp variable in DB until email is validated. For now, I wont allow it + } + + // Update roles + var existingRoles = await _userManager.GetRolesAsync(user); + var hasAdminRole = dto.Roles.Contains(PolicyConstants.AdminRole); + if (!hasAdminRole) + { + dto.Roles.Add(PolicyConstants.PlebRole); + } + if (existingRoles.Except(dto.Roles).Any() || dto.Roles.Except(existingRoles).Any()) + { + var roles = dto.Roles; + + var roleResult = await _userManager.RemoveFromRolesAsync(user, existingRoles); + if (!roleResult.Succeeded) return BadRequest(roleResult.Errors); + roleResult = await _userManager.AddToRolesAsync(user, roles); + if (!roleResult.Succeeded) return BadRequest(roleResult.Errors); + } + + + var allLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); + List libraries; + if (hasAdminRole) + { + _logger.LogInformation("{UserName} is being registered as admin. Granting access to all libraries", + user.UserName); + libraries = allLibraries; + } + else + { + // Remove user from all libraries + foreach (var lib in allLibraries) + { + lib.AppUsers ??= new List(); + lib.AppUsers.Remove(user); + } + + libraries = (await _unitOfWork.LibraryRepository.GetLibraryForIdsAsync(dto.Libraries)).ToList(); } - if ((await _userManager.RemoveFromRolesAsync(user, rolesToRemove)).Succeeded) + + foreach (var lib in libraries) + { + lib.AppUsers ??= new List(); + lib.AppUsers.Add(user); + } + + if (!_unitOfWork.HasChanges()) return Ok(); + if (await _unitOfWork.CommitAsync()) { return Ok(); } await _unitOfWork.RollbackAsync(); - return BadRequest("Something went wrong, unable to update user's roles"); + return BadRequest("There was an exception when updating the user"); + } + + + + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("invite")] + public async Task> InviteUser(InviteUserDto dto) + { + var adminUser = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + if (adminUser == null) return Unauthorized("You need to login"); + _logger.LogInformation("{User} is inviting {Email} to the server", adminUser.UserName, dto.Email); + + // Check if there is an existing invite + var emailValidationErrors = await _accountService.ValidateEmail(dto.Email); + if (emailValidationErrors.Any()) + { + var invitedUser = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); + if (await _userManager.IsEmailConfirmedAsync(invitedUser)) + return BadRequest($"User is already registered as {invitedUser.UserName}"); + return BadRequest("User is already invited under this email and has yet to accepted invite."); + } + // Create a new user + var user = new AppUser() + { + UserName = dto.Email, + Email = dto.Email, + ApiKey = HashUtil.ApiKey(), + UserPreferences = new AppUserPreferences() + }; + + try + { + var result = await _userManager.CreateAsync(user, AccountService.DefaultPassword); + if (!result.Succeeded) return BadRequest(result.Errors); + + // Assign Roles + var roles = dto.Roles; + var hasAdminRole = dto.Roles.Contains(PolicyConstants.AdminRole); + if (!hasAdminRole) + { + roles.Add(PolicyConstants.PlebRole); + } + + foreach (var role in roles) + { + if (!PolicyConstants.ValidRoles.Contains(role)) continue; + var roleResult = await _userManager.AddToRoleAsync(user, role); + if (!roleResult.Succeeded) + return + BadRequest(roleResult.Errors); + } + + // Grant access to libraries + List libraries; + if (hasAdminRole) + { + _logger.LogInformation("{UserName} is being registered as admin. Granting access to all libraries", + user.UserName); + libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); + } + else + { + libraries = (await _unitOfWork.LibraryRepository.GetLibraryForIdsAsync(dto.Libraries)).ToList(); + } + + foreach (var lib in libraries) + { + lib.AppUsers ??= new List(); + lib.AppUsers.Add(user); + } + + await _unitOfWork.CommitAsync(); + + + var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); + if (string.IsNullOrEmpty(token)) return BadRequest("There was an issue sending email"); + + var emailLink = GenerateEmailLink(token, "confirm-email", dto.Email); + _logger.LogInformation("[Invite User]: Email Link for {UserName}: {Link}", user.UserName, emailLink); + if (dto.SendEmail) + { + await _emailService.SendConfirmationEmail(new ConfirmationEmailDto() + { + EmailAddress = dto.Email, + InvitingUser = adminUser.UserName, + ServerConfirmationLink = emailLink + }); + } + return Ok(emailLink); + } + catch (Exception) + { + _unitOfWork.UserRepository.Delete(user); + await _unitOfWork.CommitAsync(); + } + + return BadRequest("There was an error setting up your account. Please check the logs"); + } + + [HttpPost("confirm-email")] + public async Task> ConfirmEmail(ConfirmEmailDto dto) + { + var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); + + // Validate Password and Username + var validationErrors = new List(); + validationErrors.AddRange(await _accountService.ValidateUsername(dto.Username)); + validationErrors.AddRange(await _accountService.ValidatePassword(user, dto.Password)); + + if (validationErrors.Any()) + { + return BadRequest(validationErrors); + } + + + if (!await ConfirmEmailToken(dto.Token, user)) return BadRequest("Invalid Email Token"); + + user.UserName = dto.Username; + var errors = await _accountService.ChangeUserPassword(user, dto.Password); + if (errors.Any()) + { + return BadRequest(errors); + } + await _unitOfWork.CommitAsync(); + + + user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(user.UserName, + AppUserIncludes.UserPreferences); + + // Perform Login code + return new UserDto + { + Username = user.UserName, + Email = user.Email, + Token = await _tokenService.CreateToken(user), + RefreshToken = await _tokenService.CreateRefreshToken(user), + ApiKey = user.ApiKey, + Preferences = _mapper.Map(user.UserPreferences) + }; + } + + [AllowAnonymous] + [HttpPost("confirm-password-reset")] + public async Task> ConfirmForgotPassword(ConfirmPasswordResetDto dto) + { + var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); + if (user == null) + { + return BadRequest("Invalid Details"); + } + + var result = await _userManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, "ResetPassword", dto.Token); + if (!result) return BadRequest("Unable to reset password, your email token is not correct."); + + var errors = await _accountService.ChangeUserPassword(user, dto.Password); + return errors.Any() ? BadRequest(errors) : Ok("Password updated"); } + /// - /// Resets the API Key assigned with a user + /// Will send user a link to update their password to their email or prompt them if not accessible /// + /// /// - [HttpPost("reset-api-key")] - public async Task> ResetApiKey() + [AllowAnonymous] + [HttpPost("forgot-password")] + public async Task> ForgotPassword([FromQuery] string email) { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(email); + if (user == null) + { + _logger.LogError("There are no users with email: {Email} but user is requesting password reset", email); + return Ok("An email will be sent to the email if it exists in our database"); + } - user.ApiKey = HashUtil.ApiKey(); + var emailLink = GenerateEmailLink(await _userManager.GeneratePasswordResetTokenAsync(user), "confirm-reset-password", user.Email); + _logger.LogInformation("[Forgot Password]: Email Link for {UserName}: {Link}", user.UserName, emailLink); + var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString(); + if (await _emailService.CheckIfAccessible(host)) + { + await _emailService.SendPasswordResetEmail(new PasswordResetEmailDto() + { + EmailAddress = user.Email, + ServerConfirmationLink = emailLink, + InstallId = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallId)).Value + }); + return Ok("Email sent"); + } - if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync()) + return Ok("Your server is not accessible. The Link to reset your password is in the logs."); + } + + [AllowAnonymous] + [HttpPost("confirm-migration-email")] + public async Task> ConfirmMigrationEmail(ConfirmMigrationEmailDto dto) + { + var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); + if (user == null) return BadRequest("This email is not on system"); + + if (!await ConfirmEmailToken(dto.Token, user)) return BadRequest("Invalid Email Token"); + + await _unitOfWork.CommitAsync(); + + user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(user.UserName, + AppUserIncludes.UserPreferences); + + // Perform Login code + return new UserDto { - return Ok(user.ApiKey); + Username = user.UserName, + Email = user.Email, + Token = await _tokenService.CreateToken(user), + RefreshToken = await _tokenService.CreateRefreshToken(user), + ApiKey = user.ApiKey, + Preferences = _mapper.Map(user.UserPreferences) + }; + } + + [HttpPost("resend-confirmation-email")] + public async Task> ResendConfirmationSendEmail([FromQuery] int userId) + { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); + if (user == null) return BadRequest("User does not exist"); + + if (string.IsNullOrEmpty(user.Email)) + return BadRequest( + "This user needs to migrate. Have them log out and login to trigger a migration flow"); + if (user.EmailConfirmed) return BadRequest("User already confirmed"); + + var emailLink = GenerateEmailLink(await _userManager.GenerateEmailConfirmationTokenAsync(user), "confirm-email", user.Email); + _logger.LogInformation("[Email Migration]: Email Link: {Link}", emailLink); + await _emailService.SendMigrationEmail(new EmailMigrationDto() + { + EmailAddress = user.Email, + Username = user.UserName, + ServerConfirmationLink = emailLink, + InstallId = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallId)).Value + }); + + + return Ok(emailLink); + } + + private string GenerateEmailLink(string token, string routePart, string email) + { + var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString(); + var emailLink = + $"{Request.Scheme}://{host}{Request.PathBase}/registration/{routePart}?token={HttpUtility.UrlEncode(token)}&email={HttpUtility.UrlEncode(email)}"; + return emailLink; + } + + /// + /// This is similar to invite. Essentially we authenticate the user's password then go through invite email flow + /// + /// + /// + [AllowAnonymous] + [HttpPost("migrate-email")] + public async Task> MigrateEmail(MigrateUserEmailDto dto) + { + // Check if there is an existing invite + var emailValidationErrors = await _accountService.ValidateEmail(dto.Email); + if (emailValidationErrors.Any()) + { + var invitedUser = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); + if (await _userManager.IsEmailConfirmedAsync(invitedUser)) + return BadRequest($"User is already registered as {invitedUser.UserName}"); + + _logger.LogInformation("A user is attempting to login, but hasn't accepted email invite"); + return BadRequest("User is already invited under this email and has yet to accepted invite."); } - await _unitOfWork.RollbackAsync(); - return BadRequest("Something went wrong, unable to reset key"); + var user = await _userManager.Users + .Include(u => u.UserPreferences) + .SingleOrDefaultAsync(x => x.NormalizedUserName == dto.Username.ToUpper()); + if (user == null) return BadRequest("Invalid username"); + + var validPassword = await _signInManager.UserManager.CheckPasswordAsync(user, dto.Password); + if (!validPassword) return BadRequest("Your credentials are not correct"); + + try + { + var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); + if (string.IsNullOrEmpty(token)) return BadRequest("There was an issue sending email"); + user.Email = dto.Email; + _unitOfWork.UserRepository.Update(user); + await _unitOfWork.CommitAsync(); + + var emailLink = GenerateEmailLink(await _userManager.GenerateEmailConfirmationTokenAsync(user), "confirm-migration-email", user.Email); + _logger.LogInformation("[Email Migration]: Email Link for {UserName}: {Link}", dto.Username, emailLink); + // Always send an email, even if the user can't click it just to get them conformable with the system + await _emailService.SendMigrationEmail(new EmailMigrationDto() + { + EmailAddress = dto.Email, + Username = user.UserName, + ServerConfirmationLink = emailLink + }); + return Ok(emailLink); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an issue during email migration. Contact support"); + _unitOfWork.UserRepository.Delete(user); + await _unitOfWork.CommitAsync(); + } + + return BadRequest("There was an error setting up your account. Please check the logs"); + } + + private async Task ConfirmEmailToken(string token, AppUser user) + { + var result = await _userManager.ConfirmEmailAsync(user, token); + if (!result.Succeeded) + { + _logger.LogCritical("Email validation failed"); + if (result.Errors.Any()) + { + foreach (var error in result.Errors) + { + _logger.LogCritical("Email validation error: {Message}", error.Description); + } + } + + return false; + } + + return true; } } } diff --git a/API/Controllers/BookController.cs b/API/Controllers/BookController.cs index 473640df74..89b2d3de4d 100644 --- a/API/Controllers/BookController.cs +++ b/API/Controllers/BookController.cs @@ -3,14 +3,12 @@ using System.Linq; using System.Threading.Tasks; using API.Data; -using API.DTOs; using API.DTOs.Reader; using API.Entities.Enums; using API.Extensions; using API.Services; using HtmlAgilityPack; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using VersOne.Epub; @@ -78,11 +76,16 @@ public async Task GetBookPageResources(int chapterId, [FromQuery] return File(content, contentType, $"{chapterId}-{file}"); } + /// + /// This will return a list of mappings from ID -> page num. ID will be the xhtml key and page num will be the reading order + /// this is used to rewrite anchors in the book text so that we always load properly in FE + /// + /// This is essentially building the table of contents + /// + /// [HttpGet("{chapterId}/chapters")] public async Task>> GetBookChapters(int chapterId) { - // This will return a list of mappings from ID -> pagenum. ID will be the xhtml key and pagenum will be the reading order - // this is used to rewrite anchors in the book text so that we always load properly in FE var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId); using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath); var mappings = await _bookService.CreateKeyToPageMappingAsync(book); @@ -127,7 +130,7 @@ public async Task>> GetBookChapters(in var tocPage = book.Content.Html.Keys.FirstOrDefault(k => k.ToUpper().Contains("TOC")); if (tocPage == null) return Ok(chaptersList); - // Find all anchor tags, for each anchor we get inner text, to lower then titlecase on UI. Get href and generate page content + // Find all anchor tags, for each anchor we get inner text, to lower then title case on UI. Get href and generate page content var doc = new HtmlDocument(); var content = await book.Content.Html[tocPage].ReadContentAsync(); doc.LoadHtml(content); @@ -151,7 +154,7 @@ public async Task>> GetBookChapters(in if (!string.IsNullOrEmpty(key) && mappings.ContainsKey(key)) { var part = string.Empty; - if (anchor.Attributes["href"].Value.Contains("#")) + if (anchor.Attributes["href"].Value.Contains('#')) { part = anchor.Attributes["href"].Value.Split("#")[1]; } @@ -253,7 +256,7 @@ public async Task> GetBookPage(int chapterId, [FromQuery] i return BadRequest("Could not find the appropriate html for that page"); } - private void LogBookErrors(EpubBookRef book, EpubTextContentFileRef contentFileRef, HtmlDocument doc) + private void LogBookErrors(EpubBookRef book, EpubContentFileRef contentFileRef, HtmlDocument doc) { _logger.LogError("{FilePath} has an invalid html file (Page {PageName})", book.FilePath, contentFileRef.FileName); foreach (var error in doc.ParseErrors) diff --git a/API/Controllers/CollectionController.cs b/API/Controllers/CollectionController.cs index 9f297273fb..89921d5f29 100644 --- a/API/Controllers/CollectionController.cs +++ b/API/Controllers/CollectionController.cs @@ -6,8 +6,10 @@ using API.DTOs.CollectionTags; using API.Entities.Metadata; using API.Extensions; +using API.SignalR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; namespace API.Controllers { @@ -17,11 +19,13 @@ namespace API.Controllers public class CollectionController : BaseApiController { private readonly IUnitOfWork _unitOfWork; + private readonly IHubContext _messageHub; /// - public CollectionController(IUnitOfWork unitOfWork) + public CollectionController(IUnitOfWork unitOfWork, IHubContext messageHub) { _unitOfWork = unitOfWork; + _messageHub = messageHub; } /// @@ -51,7 +55,7 @@ public async Task> GetAllTags() public async Task> SearchTags(string queryString) { queryString ??= ""; - queryString = queryString.Replace(@"%", ""); + queryString = queryString.Replace(@"%", string.Empty); if (queryString.Length == 0) return await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync(); return await _unitOfWork.CollectionTagRepository.SearchTagDtosAsync(queryString); @@ -152,6 +156,7 @@ public async Task UpdateSeriesForTag(UpdateSeriesForTagDto updateS { tag.CoverImageLocked = false; tag.CoverImage = string.Empty; + await _messageHub.Clients.All.SendAsync(SignalREvents.CoverUpdate, MessageFactory.CoverUpdateEvent(tag.Id, "collectionTag")); _unitOfWork.CollectionTagRepository.Update(tag); } diff --git a/API/Controllers/DownloadController.cs b/API/Controllers/DownloadController.cs index c253fb9ee3..bb84138b2e 100644 --- a/API/Controllers/DownloadController.cs +++ b/API/Controllers/DownloadController.cs @@ -3,7 +3,7 @@ using System.IO; using System.Linq; using System.Threading.Tasks; -using API.Comparators; +using API.Constants; using API.Data; using API.DTOs.Downloads; using API.Entities; @@ -13,33 +13,35 @@ using API.SignalR; using Kavita.Common; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; namespace API.Controllers { - [Authorize(Policy = "RequireDownloadRole")] + [Authorize(Policy="RequireDownloadRole")] public class DownloadController : BaseApiController { private readonly IUnitOfWork _unitOfWork; private readonly IArchiveService _archiveService; private readonly IDirectoryService _directoryService; - private readonly ICacheService _cacheService; private readonly IDownloadService _downloadService; private readonly IHubContext _messageHub; - private readonly NumericComparer _numericComparer; + private readonly UserManager _userManager; + private readonly ILogger _logger; private const string DefaultContentType = "application/octet-stream"; public DownloadController(IUnitOfWork unitOfWork, IArchiveService archiveService, IDirectoryService directoryService, - ICacheService cacheService, IDownloadService downloadService, IHubContext messageHub) + IDownloadService downloadService, IHubContext messageHub, UserManager userManager, ILogger logger) { _unitOfWork = unitOfWork; _archiveService = archiveService; _directoryService = directoryService; - _cacheService = cacheService; _downloadService = downloadService; _messageHub = messageHub; - _numericComparer = new NumericComparer(); + _userManager = userManager; + _logger = logger; } [HttpGet("volume-size")] @@ -63,9 +65,12 @@ public async Task> GetSeriesSize(int seriesId) return Ok(_directoryService.GetTotalSize(files.Select(c => c.FilePath))); } + [Authorize(Policy="RequireDownloadRole")] [HttpGet("volume")] public async Task DownloadVolume(int volumeId) { + if (!await HasDownloadPermission()) return BadRequest("You do not have permission"); + var files = await _unitOfWork.VolumeRepository.GetFilesForVolume(volumeId); var volume = await _unitOfWork.VolumeRepository.GetVolumeByIdAsync(volumeId); var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId); @@ -79,6 +84,13 @@ public async Task DownloadVolume(int volumeId) } } + private async Task HasDownloadPermission() + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + var roles = await _userManager.GetRolesAsync(user); + return roles.Contains(PolicyConstants.DownloadRole) || roles.Contains(PolicyConstants.AdminRole); + } + private async Task GetFirstFileDownload(IEnumerable files) { var (bytes, contentType, fileDownloadName) = await _downloadService.GetFirstFileDownload(files); @@ -88,6 +100,7 @@ private async Task GetFirstFileDownload(IEnumerable fil [HttpGet("chapter")] public async Task DownloadChapter(int chapterId) { + if (!await HasDownloadPermission()) return BadRequest("You do not have permission"); var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId); var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId); var volume = await _unitOfWork.VolumeRepository.GetVolumeByIdAsync(chapter.VolumeId); @@ -104,22 +117,40 @@ public async Task DownloadChapter(int chapterId) private async Task DownloadFiles(ICollection files, string tempFolder, string downloadName) { - await _messageHub.Clients.All.SendAsync(SignalREvents.DownloadProgress, - MessageFactory.DownloadProgressEvent(User.GetUsername(), Path.GetFileNameWithoutExtension(downloadName), 0F)); - if (files.Count == 1) + try { - return await GetFirstFileDownload(files); + await _messageHub.Clients.All.SendAsync(SignalREvents.DownloadProgress, + MessageFactory.DownloadProgressEvent(User.GetUsername(), + Path.GetFileNameWithoutExtension(downloadName), 0F)); + if (files.Count == 1) + { + await _messageHub.Clients.All.SendAsync(SignalREvents.DownloadProgress, + MessageFactory.DownloadProgressEvent(User.GetUsername(), + Path.GetFileNameWithoutExtension(downloadName), 1F)); + return await GetFirstFileDownload(files); + } + + var (fileBytes, _) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath), + tempFolder); + await _messageHub.Clients.All.SendAsync(SignalREvents.DownloadProgress, + MessageFactory.DownloadProgressEvent(User.GetUsername(), + Path.GetFileNameWithoutExtension(downloadName), 1F)); + return File(fileBytes, DefaultContentType, downloadName); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception when trying to download files"); + await _messageHub.Clients.All.SendAsync(SignalREvents.DownloadProgress, + MessageFactory.DownloadProgressEvent(User.GetUsername(), + Path.GetFileNameWithoutExtension(downloadName), 1F)); + throw; } - var (fileBytes, _) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath), - tempFolder); - await _messageHub.Clients.All.SendAsync(SignalREvents.DownloadProgress, - MessageFactory.DownloadProgressEvent(User.GetUsername(), Path.GetFileNameWithoutExtension(downloadName), 1F)); - return File(fileBytes, DefaultContentType, downloadName); } [HttpGet("series")] public async Task DownloadSeries(int seriesId) { + if (!await HasDownloadPermission()) return BadRequest("You do not have permission"); var files = await _unitOfWork.SeriesRepository.GetFilesForSeries(seriesId); var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); try @@ -135,20 +166,28 @@ public async Task DownloadSeries(int seriesId) [HttpPost("bookmarks")] public async Task DownloadBookmarkPages(DownloadBookmarkDto downloadBookmarkDto) { + if (!await HasDownloadPermission()) return BadRequest("You do not have permission"); + // We know that all bookmarks will be for one single seriesId var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(downloadBookmarkDto.Bookmarks.First().SeriesId); var bookmarkDirectory = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value; + var files = (await _unitOfWork.UserRepository.GetAllBookmarksByIds(downloadBookmarkDto.Bookmarks .Select(b => b.Id) .ToList())) - .Select(b => Parser.Parser.NormalizePath(_directoryService.FileSystem.Path.Join(bookmarkDirectory, b.FileName))); + .Select(b => Parser.Parser.NormalizePath(_directoryService.FileSystem.Path.Join(bookmarkDirectory, $"{b.ChapterId}_{b.FileName}"))); + var filename = $"{series.Name} - Bookmarks.zip"; + await _messageHub.Clients.All.SendAsync(SignalREvents.DownloadProgress, + MessageFactory.DownloadProgressEvent(User.GetUsername(), Path.GetFileNameWithoutExtension(filename), 0F)); var (fileBytes, _) = await _archiveService.CreateZipForDownload(files, $"download_{user.Id}_{series.Id}_bookmarks"); - return File(fileBytes, DefaultContentType, $"{series.Name} - Bookmarks.zip"); + await _messageHub.Clients.All.SendAsync(SignalREvents.DownloadProgress, + MessageFactory.DownloadProgressEvent(User.GetUsername(), Path.GetFileNameWithoutExtension(filename), 1F)); + return File(fileBytes, DefaultContentType, filename); } } diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index 9cdd061588..3084ab3528 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -6,6 +6,7 @@ using API.Data; using API.Data.Repositories; using API.DTOs; +using API.DTOs.Search; using API.Entities; using API.Entities.Enums; using API.Extensions; @@ -224,17 +225,19 @@ public async Task UpdateLibrary(UpdateLibraryDto libraryForUserDto } [HttpGet("search")] - public async Task>> Search(string queryString) + public async Task> Search(string queryString) { - queryString = Uri.UnescapeDataString(queryString).Trim().Replace(@"%", string.Empty); + queryString = Uri.UnescapeDataString(queryString).Trim().Replace(@"%", string.Empty).Replace(":", string.Empty); - var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); // Get libraries user has access to - var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId)).ToList(); + var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).ToList(); if (!libraries.Any()) return BadRequest("User does not have access to any libraries"); + if (!libraries.Any()) return BadRequest("User does not have access to any libraries"); + var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); - var series = await _unitOfWork.SeriesRepository.SearchSeries(libraries.Select(l => l.Id).ToArray(), queryString); + var series = await _unitOfWork.SeriesRepository.SearchSeries(user.Id, isAdmin, libraries.Select(l => l.Id).ToArray(), queryString); return Ok(series); } diff --git a/API/Controllers/MetadataController.cs b/API/Controllers/MetadataController.cs index 4aa13691f3..d3e0806ed6 100644 --- a/API/Controllers/MetadataController.cs +++ b/API/Controllers/MetadataController.cs @@ -111,7 +111,7 @@ public async Task>> GetAllPublicationStatus(str { Title = t.ToDescription(), Value = t - })); + }).OrderBy(t => t.Title)); } /// diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index 9b7e87d627..21e184e33d 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -10,6 +10,7 @@ using API.DTOs.CollectionTags; using API.DTOs.Filtering; using API.DTOs.OPDS; +using API.DTOs.Search; using API.Entities; using API.Entities.Enums; using API.Extensions; @@ -424,6 +425,8 @@ public async Task SearchSeries(string apiKey, [FromQuery] string if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) return BadRequest("OPDS is not enabled on this server"); var userId = await GetUser(apiKey); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); + if (string.IsNullOrEmpty(query)) { return BadRequest("You must pass a query parameter"); @@ -434,15 +437,51 @@ public async Task SearchSeries(string apiKey, [FromQuery] string if (!libraries.Any()) return BadRequest("User does not have access to any libraries"); - var series = await _unitOfWork.SeriesRepository.SearchSeries(libraries.Select(l => l.Id).ToArray(), query); + var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); + + var series = await _unitOfWork.SeriesRepository.SearchSeries(userId, isAdmin, libraries.Select(l => l.Id).ToArray(), query); var feed = CreateFeed(query, $"{apiKey}/series?query=" + query, apiKey); SetFeedId(feed, "search-series"); - foreach (var seriesDto in series) + foreach (var seriesDto in series.Series) { feed.Entries.Add(CreateSeries(seriesDto, apiKey)); } + foreach (var collection in series.Collections) + { + feed.Entries.Add(new FeedEntry() + { + Id = collection.Id.ToString(), + Title = collection.Title, + Summary = collection.Summary, + Links = new List() + { + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, + Prefix + $"{apiKey}/collections/{collection.Id}"), + CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, + $"/api/image/collection-cover?collectionId={collection.Id}"), + CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, + $"/api/image/collection-cover?collectionId={collection.Id}") + } + }); + } + + foreach (var readingListDto in series.ReadingLists) + { + feed.Entries.Add(new FeedEntry() + { + Id = readingListDto.Id.ToString(), + Title = readingListDto.Title, + Summary = readingListDto.Summary, + Links = new List() + { + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/reading-list/{readingListDto.Id}"), + } + }); + } + + return CreateXmlResult(SerializeXml(feed)); } @@ -579,7 +618,7 @@ private static ContentResult CreateXmlResult(string xml) private static void AddPagination(Feed feed, PagedList list, string href) { var url = href; - if (href.Contains("?")) + if (href.Contains('?')) { url += "&"; } diff --git a/API/Controllers/PluginController.cs b/API/Controllers/PluginController.cs index 5f2d99ba35..b6162bb3a7 100644 --- a/API/Controllers/PluginController.cs +++ b/API/Controllers/PluginController.cs @@ -32,6 +32,7 @@ public async Task> Authenticate(string apiKey, string plug // NOTE: In order to log information about plugins, we need some Plugin Description information for each request // Should log into access table so we can tell the user var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); + if (userId <= 0) return Unauthorized(); var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); _logger.LogInformation("Plugin {PluginName} has authenticated with {UserName} ({UserId})'s API Key", pluginName, user.UserName, userId); return new UserDto diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index 3028d1fee7..a71d73e42b 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -8,7 +8,6 @@ using API.DTOs; using API.DTOs.Reader; using API.Entities; -using API.Entities.Enums; using API.Extensions; using API.Services; using API.Services.Tasks; @@ -28,12 +27,13 @@ public class ReaderController : BaseApiController private readonly IReaderService _readerService; private readonly IDirectoryService _directoryService; private readonly ICleanupService _cleanupService; + private readonly IBookmarkService _bookmarkService; /// public ReaderController(ICacheService cacheService, IUnitOfWork unitOfWork, ILogger logger, IReaderService readerService, IDirectoryService directoryService, - ICleanupService cleanupService) + ICleanupService cleanupService, IBookmarkService bookmarkService) { _cacheService = cacheService; _unitOfWork = unitOfWork; @@ -41,6 +41,7 @@ public ReaderController(ICacheService cacheService, _readerService = readerService; _directoryService = directoryService; _cleanupService = cleanupService; + _bookmarkService = bookmarkService; } /// @@ -356,6 +357,64 @@ public async Task BookmarkProgress(ProgressDto progressDto) return BadRequest("Could not save progress"); } + /// + /// Continue point is the chapter which you should start reading again from. If there is no progress on a series, then the first chapter will be returned (non-special unless only specials). + /// Otherwise, loop through the chapters and volumes in order to find the next chapter which has progress. + /// + /// + [HttpGet("continue-point")] + public async Task> GetContinuePoint(int seriesId) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + + return Ok(await _readerService.GetContinuePoint(seriesId, userId)); + } + + /// + /// Returns if the user has reading progress on the Series + /// + /// + /// + [HttpGet("has-progress")] + public async Task> HasProgress(int seriesId) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + return Ok(await _unitOfWork.AppUserProgressRepository.HasAnyProgressOnSeriesAsync(seriesId, userId)); + } + + /// + /// Marks every chapter that is sorted below the passed number as Read. This will not mark any specials as read. + /// + /// This is built for Tachiyomi and is not expected to be called by any other place + /// + [HttpPost("mark-chapter-until-as-read")] + public async Task> MarkChaptersUntilAsRead(int seriesId, float chapterNumber) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); + user.Progresses ??= new List(); + + if (chapterNumber < 1.0f) + { + // This is a hack to track volume number. We need to map it back by x100 + var volumeNumber = int.Parse($"{chapterNumber * 100f}"); + await _readerService.MarkVolumesUntilAsRead(user, seriesId, volumeNumber); + } + else + { + await _readerService.MarkChaptersUntilAsRead(user, seriesId, chapterNumber); + } + + + _unitOfWork.UserRepository.Update(user); + + if (!_unitOfWork.HasChanges()) return Ok(true); + if (await _unitOfWork.CommitAsync()) return Ok(true); + + await _unitOfWork.RollbackAsync(); + return Ok(false); + } + + /// /// Returns a list of bookmarked pages for a given Chapter /// @@ -393,6 +452,7 @@ public async Task RemoveBookmarks(RemoveBookmarkForSeriesDto dto) if (user.Bookmarks == null) return Ok("Nothing to remove"); try { + var bookmarksToRemove = user.Bookmarks.Where(bmk => bmk.SeriesId == dto.SeriesId).ToList(); user.Bookmarks = user.Bookmarks.Where(bmk => bmk.SeriesId != dto.SeriesId).ToList(); _unitOfWork.UserRepository.Update(user); @@ -400,7 +460,7 @@ public async Task RemoveBookmarks(RemoveBookmarkForSeriesDto dto) { try { - await _cleanupService.CleanupBookmarks(); + await _bookmarkService.DeleteBookmarkFiles(bookmarksToRemove); } catch (Exception ex) { @@ -456,49 +516,17 @@ public async Task BookmarkPage(BookmarkDto bookmarkDto) { // Don't let user save past total pages. bookmarkDto.Page = await _readerService.CapPageToChapter(bookmarkDto.ChapterId, bookmarkDto.Page); + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks); + var chapter = await _cacheService.Ensure(bookmarkDto.ChapterId); + if (chapter == null) return BadRequest("Could not find cached image. Reload and try again."); + var path = _cacheService.GetCachedPagePath(chapter, bookmarkDto.Page); - try - { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks); - var userBookmark = - await _unitOfWork.UserRepository.GetBookmarkForPage(bookmarkDto.Page, bookmarkDto.ChapterId, user.Id); - - // We need to get the image - var chapter = await _cacheService.Ensure(bookmarkDto.ChapterId); - if (chapter == null) return BadRequest("There was an issue finding image file for reading"); - var path = _cacheService.GetCachedPagePath(chapter, bookmarkDto.Page); - var fileInfo = new FileInfo(path); - - var bookmarkDirectory = - (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value; - _directoryService.CopyFileToDirectory(path, Path.Join(bookmarkDirectory, - $"{user.Id}", $"{bookmarkDto.SeriesId}", $"{bookmarkDto.ChapterId}")); - - - if (userBookmark == null) - { - user.Bookmarks ??= new List(); - user.Bookmarks.Add(new AppUserBookmark() - { - Page = bookmarkDto.Page, - VolumeId = bookmarkDto.VolumeId, - SeriesId = bookmarkDto.SeriesId, - ChapterId = bookmarkDto.ChapterId, - FileName = Path.Join($"{user.Id}", $"{bookmarkDto.SeriesId}", $"{bookmarkDto.ChapterId}", fileInfo.Name) - - }); - _unitOfWork.UserRepository.Update(user); - } - - await _unitOfWork.CommitAsync(); - } - catch (Exception) + if (await _bookmarkService.BookmarkPage(user, bookmarkDto, path)) { - await _unitOfWork.RollbackAsync(); - return BadRequest("Could not save bookmark"); + return Ok(); } - return Ok(); + return BadRequest("Could not save bookmark"); } /// @@ -510,24 +538,11 @@ public async Task BookmarkPage(BookmarkDto bookmarkDto) public async Task UnBookmarkPage(BookmarkDto bookmarkDto) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks); - if (user.Bookmarks == null) return Ok(); - try { - user.Bookmarks = user.Bookmarks.Where(x => - x.ChapterId == bookmarkDto.ChapterId - && x.AppUserId == user.Id - && x.Page != bookmarkDto.Page).ToList(); - _unitOfWork.UserRepository.Update(user); - - if (await _unitOfWork.CommitAsync()) - { - return Ok(); - } - } - catch (Exception) + if (await _bookmarkService.RemoveBookmarkPage(user, bookmarkDto)) { - await _unitOfWork.RollbackAsync(); + return Ok(); } return BadRequest("Could not remove bookmark"); diff --git a/API/Controllers/ReadingListController.cs b/API/Controllers/ReadingListController.cs index 9391105cb8..34a7e47b8b 100644 --- a/API/Controllers/ReadingListController.cs +++ b/API/Controllers/ReadingListController.cs @@ -164,12 +164,15 @@ public async Task DeleteReadFromList([FromQuery] int readingListId public async Task DeleteList([FromQuery] int readingListId) { var user = await _unitOfWork.UserRepository.GetUserWithReadingListsByUsernameAsync(User.GetUsername()); + var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); var readingList = user.ReadingLists.SingleOrDefault(r => r.Id == readingListId); - if (readingList == null) + if (readingList == null && !isAdmin) { return BadRequest("User is not associated with this reading list"); } + readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(readingListId); + user.ReadingLists.Remove(readingList); if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync()) @@ -211,7 +214,7 @@ public async Task> CreateList(CreateReadingListDto } /// - /// Update the properites (title, summary) of a reading list + /// Update the properties (title, summary) of a reading list /// /// /// diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index ad2faeb0fe..397109c099 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -3,7 +3,6 @@ using System.Linq; using System.Threading.Tasks; using API.Data; -using API.Data.Metadata; using API.Data.Repositories; using API.DTOs; using API.DTOs.Filtering; @@ -148,7 +147,7 @@ public async Task> GetVolume(int volumeId) } [HttpGet("chapter")] - public async Task> GetChapter(int chapterId) + public async Task> GetChapter(int chapterId) { return Ok(await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId)); } @@ -237,6 +236,20 @@ public async Task>> GetRecentlyAdded(FilterD return Ok(series); } + [HttpPost("recently-updated-series")] + public async Task>> GetRecentlyAddedChapters() + { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + return Ok(await _unitOfWork.SeriesRepository.GetRecentlyUpdatedSeries(userId)); + } + + [HttpPost("recently-added-chapters")] + public async Task>> GetRecentlyAddedChaptersAlt() + { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + return Ok(await _unitOfWork.SeriesRepository.GetRecentlyAddedChapters(userId)); + } + [HttpPost("all")] public async Task>> GetAllSeries(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0) { diff --git a/API/Controllers/ServerController.cs b/API/Controllers/ServerController.cs index 45fb22ce5a..7f5c16b0b2 100644 --- a/API/Controllers/ServerController.cs +++ b/API/Controllers/ServerController.cs @@ -24,24 +24,24 @@ public class ServerController : BaseApiController private readonly IConfiguration _config; private readonly IBackupService _backupService; private readonly IArchiveService _archiveService; - private readonly ICacheService _cacheService; private readonly IVersionUpdaterService _versionUpdaterService; private readonly IStatsService _statsService; private readonly ICleanupService _cleanupService; + private readonly IEmailService _emailService; public ServerController(IHostApplicationLifetime applicationLifetime, ILogger logger, IConfiguration config, - IBackupService backupService, IArchiveService archiveService, ICacheService cacheService, - IVersionUpdaterService versionUpdaterService, IStatsService statsService, ICleanupService cleanupService) + IBackupService backupService, IArchiveService archiveService, IVersionUpdaterService versionUpdaterService, IStatsService statsService, + ICleanupService cleanupService, IEmailService emailService) { _applicationLifetime = applicationLifetime; _logger = logger; _config = config; _backupService = backupService; _archiveService = archiveService; - _cacheService = cacheService; _versionUpdaterService = versionUpdaterService; _statsService = statsService; _cleanupService = cleanupService; + _emailService = emailService; } /// @@ -108,6 +108,9 @@ public async Task GetLogs() } } + /// + /// Checks for updates, if no updates that are > current version installed, returns null + /// [HttpGet("check-update")] public async Task> CheckForUpdates() { @@ -119,5 +122,16 @@ public async Task>> GetChangelog { return Ok(await _versionUpdaterService.GetAllReleases()); } + + /// + /// Is this server accessible to the outside net + /// + /// + [HttpGet("accessible")] + [AllowAnonymous] + public async Task> IsServerAccessible() + { + return await _emailService.CheckIfAccessible(Request.Host.ToString()); + } } } diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs index 76b30acf89..d42b775b23 100644 --- a/API/Controllers/SettingsController.cs +++ b/API/Controllers/SettingsController.cs @@ -4,14 +4,17 @@ using System.Linq; using System.Threading.Tasks; using API.Data; +using API.DTOs.Email; using API.DTOs.Settings; using API.Entities.Enums; using API.Extensions; using API.Helpers.Converters; using API.Services; using AutoMapper; +using Flurl.Http; using Kavita.Common; using Kavita.Common.Extensions; +using Kavita.Common.Helpers; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -23,19 +26,19 @@ public class SettingsController : BaseApiController private readonly ILogger _logger; private readonly IUnitOfWork _unitOfWork; private readonly ITaskScheduler _taskScheduler; - private readonly IAccountService _accountService; private readonly IDirectoryService _directoryService; private readonly IMapper _mapper; + private readonly IEmailService _emailService; public SettingsController(ILogger logger, IUnitOfWork unitOfWork, ITaskScheduler taskScheduler, - IAccountService accountService, IDirectoryService directoryService, IMapper mapper) + IDirectoryService directoryService, IMapper mapper, IEmailService emailService) { _logger = logger; _unitOfWork = unitOfWork; _taskScheduler = taskScheduler; - _accountService = accountService; _directoryService = directoryService; _mapper = mapper; + _emailService = emailService; } [AllowAnonymous] @@ -66,6 +69,36 @@ public async Task> ResetSettings() return await UpdateSettings(_mapper.Map(Seed.DefaultSettings)); } + /// + /// Resets the email service url + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("reset-email-url")] + public async Task> ResetEmailServiceUrlSettings() + { + _logger.LogInformation("{UserName} is resetting Email Service Url Setting", User.GetUsername()); + var emailSetting = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl); + emailSetting.Value = EmailService.DefaultApiUrl; + _unitOfWork.SettingsRepository.Update(emailSetting); + + if (!await _unitOfWork.CommitAsync()) + { + await _unitOfWork.RollbackAsync(); + } + + return Ok(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()); + } + + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("test-email-url")] + public async Task> TestEmailServiceUrl(TestEmailDto dto) + { + return Ok(await _emailService.TestConnectivity(dto.Url)); + } + + + [Authorize(Policy = "RequireAdminRole")] [HttpPost] public async Task> UpdateSettings(ServerSettingDto updateSettingsDto) @@ -84,7 +117,6 @@ public async Task> UpdateSettings(ServerSettingDt // We do not allow CacheDirectory changes, so we will ignore. var currentSettings = await _unitOfWork.SettingsRepository.GetSettingsAsync(); - var updateAuthentication = false; var updateBookmarks = false; var originalBookmarkDirectory = _directoryService.BookmarkDirectory; @@ -163,13 +195,6 @@ public async Task> UpdateSettings(ServerSettingDt } - if (setting.Key == ServerSettingKey.EnableAuthentication && updateSettingsDto.EnableAuthentication + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.EnableAuthentication + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - updateAuthentication = true; - } - if (setting.Key == ServerSettingKey.AllowStatCollection && updateSettingsDto.AllowStatCollection + string.Empty != setting.Value) { setting.Value = updateSettingsDto.AllowStatCollection + string.Empty; @@ -183,6 +208,15 @@ public async Task> UpdateSettings(ServerSettingDt await _taskScheduler.ScheduleStatsTasks(); } } + + if (setting.Key == ServerSettingKey.EmailServiceUrl && updateSettingsDto.EmailServiceUrl + string.Empty != setting.Value) + { + setting.Value = string.IsNullOrEmpty(updateSettingsDto.EmailServiceUrl) ? EmailService.DefaultApiUrl : updateSettingsDto.EmailServiceUrl; + FlurlHttp.ConfigureClient(setting.Value, cli => + cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); + + _unitOfWork.SettingsRepository.Update(setting); + } } if (!_unitOfWork.HasChanges()) return Ok(updateSettingsDto); @@ -191,21 +225,6 @@ public async Task> UpdateSettings(ServerSettingDt { await _unitOfWork.CommitAsync(); - if (updateAuthentication) - { - var users = await _unitOfWork.UserRepository.GetNonAdminUsersAsync(); - foreach (var user in users) - { - var errors = await _accountService.ChangeUserPassword(user, AccountService.DefaultPassword); - if (!errors.Any()) continue; - - await _unitOfWork.RollbackAsync(); - return BadRequest(errors); - } - - _logger.LogInformation("Server authentication changed. Updated all non-admins to default password"); - } - if (updateBookmarks) { _directoryService.ExistOrCreate(bookmarkDirectory); @@ -253,12 +272,5 @@ public async Task> GetOpdsEnabled() var settingsDto = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); return Ok(settingsDto.EnableOpds); } - - [HttpGet("authentication-enabled")] - public async Task> GetAuthenticationEnabled() - { - var settingsDto = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); - return Ok(settingsDto.EnableAuthentication); - } } } diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs index 7662fdf954..dd6e975ab5 100644 --- a/API/Controllers/UsersController.cs +++ b/API/Controllers/UsersController.cs @@ -36,22 +36,17 @@ public async Task DeleteUser(string username) [HttpGet] public async Task>> GetUsers() { - return Ok(await _unitOfWork.UserRepository.GetMembersAsync()); + return Ok(await _unitOfWork.UserRepository.GetEmailConfirmedMemberDtosAsync()); } - [AllowAnonymous] - [HttpGet("names")] - public async Task>> GetUserNames() + [Authorize(Policy = "RequireAdminRole")] + [HttpGet("pending")] + public async Task>> GetPendingUsers() { - var setting = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); - if (setting.EnableAuthentication) - { - return Unauthorized("This API cannot be used given your server's configuration"); - } - var members = await _unitOfWork.UserRepository.GetMembersAsync(); - return Ok(members.Select(m => m.Username)); + return Ok(await _unitOfWork.UserRepository.GetPendingMemberDtosAsync()); } + [HttpGet("has-reading-progress")] public async Task> HasReadingProgress(int libraryId) { diff --git a/API/DTOs/Account/ConfirmEmailDto.cs b/API/DTOs/Account/ConfirmEmailDto.cs new file mode 100644 index 0000000000..2258357969 --- /dev/null +++ b/API/DTOs/Account/ConfirmEmailDto.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; + +namespace API.DTOs.Account; + +public class ConfirmEmailDto +{ + [Required] + public string Email { get; set; } + [Required] + public string Token { get; set; } + [Required] + [StringLength(32, MinimumLength = 6)] + public string Password { get; set; } + [Required] + public string Username { get; set; } +} diff --git a/API/DTOs/Account/ConfirmMigrationEmailDto.cs b/API/DTOs/Account/ConfirmMigrationEmailDto.cs new file mode 100644 index 0000000000..07e0aa1ca6 --- /dev/null +++ b/API/DTOs/Account/ConfirmMigrationEmailDto.cs @@ -0,0 +1,7 @@ +namespace API.DTOs.Account; + +public class ConfirmMigrationEmailDto +{ + public string Email { get; set; } + public string Token { get; set; } +} diff --git a/API/DTOs/Account/ConfirmPasswordResetDto.cs b/API/DTOs/Account/ConfirmPasswordResetDto.cs new file mode 100644 index 0000000000..603508ac48 --- /dev/null +++ b/API/DTOs/Account/ConfirmPasswordResetDto.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace API.DTOs.Account; + +public class ConfirmPasswordResetDto +{ + [Required] + public string Email { get; set; } + [Required] + public string Token { get; set; } + [Required] + [StringLength(32, MinimumLength = 6)] + public string Password { get; set; } +} diff --git a/API/DTOs/Account/InviteUserDto.cs b/API/DTOs/Account/InviteUserDto.cs new file mode 100644 index 0000000000..04c9c11030 --- /dev/null +++ b/API/DTOs/Account/InviteUserDto.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace API.DTOs.Account; + +public class InviteUserDto +{ + [Required] + public string Email { get; init; } + /// + /// List of Roles to assign to user. If admin not present, Pleb will be applied. + /// If admin present, all libraries will be granted access and will ignore those from DTO. + /// + public ICollection Roles { get; init; } + /// + /// A list of libraries to grant access to + /// + public IList Libraries { get; init; } + + public bool SendEmail { get; init; } = true; +} diff --git a/API/DTOs/Account/MigrateUserEmailDto.cs b/API/DTOs/Account/MigrateUserEmailDto.cs new file mode 100644 index 0000000000..aa947d5d1a --- /dev/null +++ b/API/DTOs/Account/MigrateUserEmailDto.cs @@ -0,0 +1,9 @@ +namespace API.DTOs.Account; + +public class MigrateUserEmailDto +{ + public string Email { get; set; } + public string Username { get; set; } + public string Password { get; set; } + public bool SendEmail { get; set; } +} diff --git a/API/DTOs/Account/TokenRequestDto.cs b/API/DTOs/Account/TokenRequestDto.cs new file mode 100644 index 0000000000..508e0c75c0 --- /dev/null +++ b/API/DTOs/Account/TokenRequestDto.cs @@ -0,0 +1,7 @@ +namespace API.DTOs.Account; + +public class TokenRequestDto +{ + public string Token { get; init; } + public string RefreshToken { get; init; } +} diff --git a/API/DTOs/Account/UpdateUserDto.cs b/API/DTOs/Account/UpdateUserDto.cs new file mode 100644 index 0000000000..f3afb98a5a --- /dev/null +++ b/API/DTOs/Account/UpdateUserDto.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; + +namespace API.DTOs.Account; + +public record UpdateUserDto +{ + public int UserId { get; set; } + public string Username { get; set; } + /// + /// This field will not result in any change to the User model. Changing email is not supported. + /// + public string Email { get; set; } + /// + /// List of Roles to assign to user. If admin not present, Pleb will be applied. + /// If admin present, all libraries will be granted access and will ignore those from DTO. + /// + public IList Roles { get; init; } + /// + /// A list of libraries to grant access to + /// + public IList Libraries { get; init; } + +} diff --git a/API/DTOs/ChapterDto.cs b/API/DTOs/ChapterDto.cs index 6a4effe16c..10956b5290 100644 --- a/API/DTOs/ChapterDto.cs +++ b/API/DTOs/ChapterDto.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using API.DTOs.Metadata; -using API.Entities; namespace API.DTOs { diff --git a/API/DTOs/Email/ConfirmationEmailDto.cs b/API/DTOs/Email/ConfirmationEmailDto.cs new file mode 100644 index 0000000000..a64d92f91d --- /dev/null +++ b/API/DTOs/Email/ConfirmationEmailDto.cs @@ -0,0 +1,12 @@ +namespace API.DTOs.Email; + +public class ConfirmationEmailDto +{ + public string InvitingUser { get; init; } + public string EmailAddress { get; init; } + public string ServerConfirmationLink { get; init; } + /// + /// InstallId of this Kavita Instance + /// + public string InstallId { get; init; } +} diff --git a/API/DTOs/Email/EmailMigrationDto.cs b/API/DTOs/Email/EmailMigrationDto.cs new file mode 100644 index 0000000000..e7a9414050 --- /dev/null +++ b/API/DTOs/Email/EmailMigrationDto.cs @@ -0,0 +1,12 @@ +namespace API.DTOs.Email; + +public class EmailMigrationDto +{ + public string EmailAddress { get; init; } + public string Username { get; init; } + public string ServerConfirmationLink { get; init; } + /// + /// InstallId of this Kavita Instance + /// + public string InstallId { get; init; } +} diff --git a/API/DTOs/Email/EmailTestResultDto.cs b/API/DTOs/Email/EmailTestResultDto.cs new file mode 100644 index 0000000000..a41a6027d5 --- /dev/null +++ b/API/DTOs/Email/EmailTestResultDto.cs @@ -0,0 +1,10 @@ +namespace API.DTOs.Email; + +/// +/// Represents if Test Email Service URL was successful or not and if any error occured +/// +public class EmailTestResultDto +{ + public bool Successful { get; set; } + public string ErrorMessage { get; set; } +} diff --git a/API/DTOs/Email/PasswordResetEmailDto.cs b/API/DTOs/Email/PasswordResetEmailDto.cs new file mode 100644 index 0000000000..503a9c5e3f --- /dev/null +++ b/API/DTOs/Email/PasswordResetEmailDto.cs @@ -0,0 +1,11 @@ +namespace API.DTOs.Email; + +public class PasswordResetEmailDto +{ + public string EmailAddress { get; init; } + public string ServerConfirmationLink { get; init; } + /// + /// InstallId of this Kavita Instance + /// + public string InstallId { get; init; } +} diff --git a/API/DTOs/Email/TestEmailDto.cs b/API/DTOs/Email/TestEmailDto.cs new file mode 100644 index 0000000000..dba9d05f0c --- /dev/null +++ b/API/DTOs/Email/TestEmailDto.cs @@ -0,0 +1,6 @@ +namespace API.DTOs.Email; + +public class TestEmailDto +{ + public string Url { get; set; } +} diff --git a/API/DTOs/Filtering/FilterDto.cs b/API/DTOs/Filtering/FilterDto.cs index 863e66e6e4..fba9a74930 100644 --- a/API/DTOs/Filtering/FilterDto.cs +++ b/API/DTOs/Filtering/FilterDto.cs @@ -80,7 +80,7 @@ public class FilterDto /// /// Sorting Options for a query. Defaults to null, which uses the queries natural sorting order /// - public SortOptions SortOptions { get; init; } = null; + public SortOptions SortOptions { get; set; } = null; /// /// Age Ratings. Empty list will return everything back /// diff --git a/API/DTOs/Filtering/ReadStatus.cs b/API/DTOs/Filtering/ReadStatus.cs index e2452fdc17..eeb786714d 100644 --- a/API/DTOs/Filtering/ReadStatus.cs +++ b/API/DTOs/Filtering/ReadStatus.cs @@ -1,6 +1,4 @@ -using System; - -namespace API.DTOs.Filtering; +namespace API.DTOs.Filtering; /// /// Represents the Reading Status. This is a flag and allows multiple statues diff --git a/API/DTOs/GroupedSeriesDto.cs b/API/DTOs/GroupedSeriesDto.cs new file mode 100644 index 0000000000..9795da16e5 --- /dev/null +++ b/API/DTOs/GroupedSeriesDto.cs @@ -0,0 +1,32 @@ +using System; +using API.Entities.Enums; + +namespace API.DTOs; +/// +/// This is a representation of a Series with some amount of underlying files within it. This is used for Recently Updated Series section +/// +public class GroupedSeriesDto +{ + public string SeriesName { get; set; } + public int SeriesId { get; set; } + public int LibraryId { get; set; } + public LibraryType LibraryType { get; set; } + public DateTime Created { get; set; } + /// + /// Chapter Id if this is a chapter. Not guaranteed to be set. + /// + public int ChapterId { get; set; } = 0; + /// + /// Volume Id if this is a chapter. Not guaranteed to be set. + /// + public int VolumeId { get; set; } = 0; + /// + /// This is used only on the UI. It is just index of being added. + /// + public int Id { get; set; } + public MangaFormat Format { get; set; } + /// + /// Number of items that are updated. This provides a sort of grouping when multiple chapters are added per Volume/Series + /// + public int Count { get; set; } +} diff --git a/API/DTOs/MemberDto.cs b/API/DTOs/MemberDto.cs index 88a16aa7c8..8215cebc22 100644 --- a/API/DTOs/MemberDto.cs +++ b/API/DTOs/MemberDto.cs @@ -4,15 +4,16 @@ namespace API.DTOs { /// - /// Represents a member of a Kavita server. + /// Represents a member of a Kavita server. /// public class MemberDto { public int Id { get; init; } public string Username { get; init; } + public string Email { get; init; } public DateTime Created { get; init; } public DateTime LastActive { get; init; } public IEnumerable Libraries { get; init; } public IEnumerable Roles { get; init; } } -} \ No newline at end of file +} diff --git a/API/DTOs/BookChapterItem.cs b/API/DTOs/Reader/BookChapterItem.cs similarity index 95% rename from API/DTOs/BookChapterItem.cs rename to API/DTOs/Reader/BookChapterItem.cs index 68d1fce409..9db676cc5e 100644 --- a/API/DTOs/BookChapterItem.cs +++ b/API/DTOs/Reader/BookChapterItem.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace API.DTOs +namespace API.DTOs.Reader { public class BookChapterItem { @@ -16,6 +16,6 @@ public class BookChapterItem /// Page Number to load for the chapter /// public int Page { get; set; } - public ICollection Children { get; set; } + public ICollection Children { get; set; } } -} \ No newline at end of file +} diff --git a/API/DTOs/RecentlyAddedItemDto.cs b/API/DTOs/RecentlyAddedItemDto.cs new file mode 100644 index 0000000000..6c7df8b4d1 --- /dev/null +++ b/API/DTOs/RecentlyAddedItemDto.cs @@ -0,0 +1,34 @@ +using System; +using API.Entities.Enums; + +namespace API.DTOs; + +/// +/// A mesh of data for Recently added volume/chapters +/// +public class RecentlyAddedItemDto +{ + public string SeriesName { get; set; } + public int SeriesId { get; set; } + public int LibraryId { get; set; } + public LibraryType LibraryType { get; set; } + /// + /// This will automatically map to Volume X, Chapter Y, etc. + /// + public string Title { get; set; } + public DateTime Created { get; set; } + /// + /// Chapter Id if this is a chapter. Not guaranteed to be set. + /// + public int ChapterId { get; set; } = 0; + /// + /// Volume Id if this is a chapter. Not guaranteed to be set. + /// + public int VolumeId { get; set; } = 0; + /// + /// This is used only on the UI. It is just index of being added. + /// + public int Id { get; set; } + public MangaFormat Format { get; set; } + +} diff --git a/API/DTOs/RegisterDto.cs b/API/DTOs/RegisterDto.cs index 1bf598f5d5..95814b88fd 100644 --- a/API/DTOs/RegisterDto.cs +++ b/API/DTOs/RegisterDto.cs @@ -7,8 +7,9 @@ public class RegisterDto [Required] public string Username { get; init; } [Required] + public string Email { get; init; } + [Required] [StringLength(32, MinimumLength = 6)] public string Password { get; set; } - public bool IsAdmin { get; init; } } } diff --git a/API/DTOs/SearchResultDto.cs b/API/DTOs/Search/SearchResultDto.cs similarity index 94% rename from API/DTOs/SearchResultDto.cs rename to API/DTOs/Search/SearchResultDto.cs index 6d7ba9f58a..328ff7a1fc 100644 --- a/API/DTOs/SearchResultDto.cs +++ b/API/DTOs/Search/SearchResultDto.cs @@ -1,6 +1,6 @@ using API.Entities.Enums; -namespace API.DTOs +namespace API.DTOs.Search { public class SearchResultDto { diff --git a/API/DTOs/Search/SearchResultGroupDto.cs b/API/DTOs/Search/SearchResultGroupDto.cs new file mode 100644 index 0000000000..b21209dcac --- /dev/null +++ b/API/DTOs/Search/SearchResultGroupDto.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using API.DTOs.CollectionTags; +using API.DTOs.Metadata; +using API.DTOs.ReadingLists; + +namespace API.DTOs.Search; + +/// +/// Represents all Search results for a query +/// +public class SearchResultGroupDto +{ + public IEnumerable Libraries { get; set; } + public IEnumerable Series { get; set; } + public IEnumerable Collections { get; set; } + public IEnumerable ReadingLists { get; set; } + public IEnumerable Persons { get; set; } + public IEnumerable Genres { get; set; } + public IEnumerable Tags { get; set; } + +} diff --git a/API/DTOs/Settings/ServerSettingDTO.cs b/API/DTOs/Settings/ServerSettingDTO.cs index 4004c65b12..03f853d33e 100644 --- a/API/DTOs/Settings/ServerSettingDTO.cs +++ b/API/DTOs/Settings/ServerSettingDTO.cs @@ -23,11 +23,6 @@ public class ServerSettingDto /// Enables OPDS connections to be made to the server. /// public bool EnableOpds { get; set; } - - /// - /// Enables Authentication on the server. Defaults to true. - /// - public bool EnableAuthentication { get; set; } /// /// Base Url for the kavita. Requires restart to take effect. /// @@ -37,5 +32,10 @@ public class ServerSettingDto /// /// If null or empty string, will default back to default install setting aka public string BookmarksDirectory { get; set; } + /// + /// Email service to use for the invite user flow, forgot password, etc. + /// + /// If null or empty string, will default back to default install setting aka + public string EmailServiceUrl { get; set; } } } diff --git a/API/DTOs/Stats/ServerInfoDto.cs b/API/DTOs/Stats/ServerInfoDto.cs index 46a8c9ae1f..9176a81ff2 100644 --- a/API/DTOs/Stats/ServerInfoDto.cs +++ b/API/DTOs/Stats/ServerInfoDto.cs @@ -8,5 +8,7 @@ public class ServerInfoDto public string DotnetVersion { get; set; } public string KavitaVersion { get; set; } public int NumOfCores { get; set; } + public int NumberOfLibraries { get; set; } + public bool HasBookmarks { get; set; } } } diff --git a/API/DTOs/UserDto.cs b/API/DTOs/UserDto.cs index d9c232578b..7a7a234e7c 100644 --- a/API/DTOs/UserDto.cs +++ b/API/DTOs/UserDto.cs @@ -4,7 +4,9 @@ namespace API.DTOs public class UserDto { public string Username { get; init; } + public string Email { get; init; } public string Token { get; init; } + public string RefreshToken { get; init; } public string ApiKey { get; init; } public UserPreferencesDto Preferences { get; set; } } diff --git a/API/Data/DbFactory.cs b/API/Data/DbFactory.cs index d5b3434f78..3952243fba 100644 --- a/API/Data/DbFactory.cs +++ b/API/Data/DbFactory.cs @@ -61,6 +61,11 @@ public static Chapter Chapter(ParserInfo info) }; } + public static SeriesMetadata SeriesMetadata(ComicInfo info) + { + return SeriesMetadata(Array.Empty()); + } + public static SeriesMetadata SeriesMetadata(ICollection collectionTags) { return new SeriesMetadata() diff --git a/API/Data/Metadata/ComicInfo.cs b/API/Data/Metadata/ComicInfo.cs index cc7154f93c..0f213d8482 100644 --- a/API/Data/Metadata/ComicInfo.cs +++ b/API/Data/Metadata/ComicInfo.cs @@ -103,17 +103,6 @@ public static void CleanComicInfo(ComicInfo info) info.Characters = Parser.Parser.CleanAuthor(info.Characters); info.Translator = Parser.Parser.CleanAuthor(info.Translator); info.CoverArtist = Parser.Parser.CleanAuthor(info.CoverArtist); - - - // if (!string.IsNullOrEmpty(info.Web)) - // { - // // ComicVine stores the Issue number in Number field and does not use Volume. - // if (!info.Web.Contains("https://comicvine.gamespot.com/")) return; - // if (info.Volume.Equals("1")) - // { - // info.Volume = Parser.Parser.DefaultVolume; - // } - // } } diff --git a/API/Data/MigrateBookmarks.cs b/API/Data/MigrateBookmarks.cs index 043b3e0a41..294acc57a0 100644 --- a/API/Data/MigrateBookmarks.cs +++ b/API/Data/MigrateBookmarks.cs @@ -9,13 +9,13 @@ namespace API.Data; /// -/// Responsible to migrate existing bookmarks to files +/// Responsible to migrate existing bookmarks to files. Introduced in v0.4.9.27 /// public static class MigrateBookmarks { - private static readonly Version VersionBookmarksChanged = new Version(0, 4, 9, 27); /// - /// This will migrate existing bookmarks to bookmark folder based + /// This will migrate existing bookmarks to bookmark folder based. + /// If the bookmarks folder already exists, this will not run. /// /// Bookmark directory is configurable. This will always use the default bookmark directory. /// diff --git a/API/Data/MigrateChangePasswordRoles.cs b/API/Data/MigrateChangePasswordRoles.cs new file mode 100644 index 0000000000..d9a07ab244 --- /dev/null +++ b/API/Data/MigrateChangePasswordRoles.cs @@ -0,0 +1,30 @@ +using System.Threading.Tasks; +using API.Constants; +using API.Entities; +using Microsoft.AspNetCore.Identity; + +namespace API.Data; + +/// +/// New role introduced in v0.5.1. Adds the role to all users. +/// +public static class MigrateChangePasswordRoles +{ + /// + /// Will not run if any users have the ChangePassword role already + /// + /// + /// + public static async Task Migrate(IUnitOfWork unitOfWork, UserManager userManager) + { + var usersWithRole = await userManager.GetUsersInRoleAsync(PolicyConstants.ChangePasswordRole); + if (usersWithRole.Count != 0) return; + + var allUsers = await unitOfWork.UserRepository.GetAllUsers(); + foreach (var user in allUsers) + { + await userManager.RemoveFromRoleAsync(user, "ChangePassword"); + await userManager.AddToRoleAsync(user, PolicyConstants.ChangePasswordRole); + } + } +} diff --git a/API/Data/Repositories/AppUserProgressRepository.cs b/API/Data/Repositories/AppUserProgressRepository.cs index 37fc686937..d9799aa220 100644 --- a/API/Data/Repositories/AppUserProgressRepository.cs +++ b/API/Data/Repositories/AppUserProgressRepository.cs @@ -12,6 +12,7 @@ public interface IAppUserProgressRepository Task CleanupAbandonedChapters(); Task UserHasProgress(LibraryType libraryType, int userId); Task GetUserProgressAsync(int chapterId, int userId); + Task HasAnyProgressOnSeriesAsync(int seriesId, int userId); } public class AppUserProgressRepository : IAppUserProgressRepository @@ -76,6 +77,12 @@ public async Task UserHasProgress(LibraryType libraryType, int userId) .AnyAsync(); } + public async Task HasAnyProgressOnSeriesAsync(int seriesId, int userId) + { + return await _context.AppUserProgresses + .AnyAsync(aup => aup.PagesRead > 0 && aup.AppUserId == userId && aup.SeriesId == seriesId); + } + public async Task GetUserProgressAsync(int chapterId, int userId) { return await _context.AppUserProgresses diff --git a/API/Data/Repositories/GenreRepository.cs b/API/Data/Repositories/GenreRepository.cs index 05f2052f43..c2d5db2afa 100644 --- a/API/Data/Repositories/GenreRepository.cs +++ b/API/Data/Repositories/GenreRepository.cs @@ -67,6 +67,7 @@ public async Task> GetAllGenreDtosForLibrariesAsync(IList libraryIds.Contains(s.LibraryId)) .SelectMany(s => s.Metadata.Genres) .Distinct() + .OrderBy(p => p.Title) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); } diff --git a/API/Data/Repositories/LibraryRepository.cs b/API/Data/Repositories/LibraryRepository.cs index 26fc517a21..4a3681de36 100644 --- a/API/Data/Repositories/LibraryRepository.cs +++ b/API/Data/Repositories/LibraryRepository.cs @@ -36,6 +36,7 @@ public interface ILibraryRepository Task DeleteLibrary(int libraryId); Task> GetLibrariesForUserIdAsync(int userId); Task GetLibraryTypeAsync(int libraryId); + Task> GetLibraryForIdsAsync(IList libraryIds); } public class LibraryRepository : ILibraryRepository @@ -108,6 +109,13 @@ public async Task GetLibraryTypeAsync(int libraryId) .SingleAsync(); } + public async Task> GetLibraryForIdsAsync(IList libraryIds) + { + return await _context.Library + .Where(x => libraryIds.Contains(x.Id)) + .ToListAsync(); + } + public async Task> GetLibraryDtosAsync() { return await _context.Library diff --git a/API/Data/Repositories/PersonRepository.cs b/API/Data/Repositories/PersonRepository.cs index 71ec696391..3715584594 100644 --- a/API/Data/Repositories/PersonRepository.cs +++ b/API/Data/Repositories/PersonRepository.cs @@ -66,6 +66,8 @@ public async Task> GetAllPeopleDtosForLibrariesAsync(List .Where(s => libraryIds.Contains(s.LibraryId)) .SelectMany(s => s.Metadata.People) .Distinct() + .OrderBy(p => p.Name) + .AsNoTracking() .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); } @@ -74,6 +76,7 @@ public async Task> GetAllPeopleDtosForLibrariesAsync(List public async Task> GetAllPeople() { return await _context.Person + .OrderBy(p => p.Name) .ToListAsync(); } } diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index e8ffa9e16c..0c8caa2e1b 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -8,6 +8,8 @@ using API.DTOs.CollectionTags; using API.DTOs.Filtering; using API.DTOs.Metadata; +using API.DTOs.ReadingLists; +using API.DTOs.Search; using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; @@ -21,6 +23,23 @@ namespace API.Data.Repositories; +internal class RecentlyAddedSeries +{ + public int LibraryId { get; init; } + public LibraryType LibraryType { get; init; } + public DateTime Created { get; init; } + public int SeriesId { get; init; } + public string SeriesName { get; init; } + public MangaFormat Format { get; init; } + public int ChapterId { get; init; } + public int VolumeId { get; init; } + public string ChapterNumber { get; init; } + public string ChapterRange { get; init; } + public string ChapterTitle { get; init; } + public bool IsSpecial { get; init; } + public int VolumeNumber { get; init; } +} + public interface ISeriesRepository { void Attach(Series series); @@ -39,10 +58,12 @@ public interface ISeriesRepository /// /// Does not add user information like progress, ratings, etc. /// + /// + /// /// - /// Series name to search for + /// /// - Task> SearchSeries(int[] libraryIds, string searchQuery); + Task SearchSeries(int userId, bool isAdmin, int[] libraryIds, string searchQuery); Task> GetSeriesForLibraryIdAsync(int libraryId); Task GetSeriesDtoByIdAsync(int seriesId, int userId); Task DeleteSeriesAsync(int seriesId); @@ -73,6 +94,8 @@ public interface ISeriesRepository Task> GetAllAgeRatingsDtosForLibrariesAsync(List libraryIds); Task> GetAllLanguagesForLibrariesAsync(List libraryIds); Task> GetAllPublicationStatusesDtosForLibrariesAsync(List libraryIds); + Task> GetRecentlyUpdatedSeries(int userId); + Task> GetRecentlyAddedChapters(int userId); } public class SeriesRepository : ISeriesRepository @@ -124,6 +147,7 @@ public async Task DoesSeriesNameExistInLibrary(string name, MangaFormat fo .CountAsync() > 1; } + public async Task> GetSeriesForLibraryIdAsync(int libraryId) { return await _context.Series @@ -209,15 +233,18 @@ public async Task GetFullSeriesForSeriesIdAsync(int seriesId) .SingleOrDefaultAsync(); } + /// + /// Gets all series + /// + /// + /// + /// + /// + /// public async Task> GetSeriesDtoForLibraryIdAsync(int libraryId, int userId, UserParams userParams, FilterDto filter) { var query = await CreateFilteredSearchQueryable(userId, libraryId, filter); - if (filter.SortOptions == null) - { - query = query.OrderBy(s => s.SortName); - } - var retSeries = query .ProjectTo(_mapper.ConfigurationProvider) .AsSplitQuery() @@ -244,9 +271,25 @@ private async Task> GetUserLibraries(int libraryId, int userId) }; } - public async Task> SearchSeries(int[] libraryIds, string searchQuery) + public async Task SearchSeries(int userId, bool isAdmin, int[] libraryIds, string searchQuery) { - return await _context.Series + + var result = new SearchResultGroupDto(); + + var seriesIds = _context.Series + .Where(s => libraryIds.Contains(s.LibraryId)) + .Select(s => s.Id) + .ToList(); + + result.Libraries = await _context.Library + .Where(l => libraryIds.Contains(l.Id)) + .Where(s => EF.Functions.Like(s.Name, $"%{searchQuery}%")) + .OrderBy(l => l.Name) + .AsSplitQuery() + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + + result.Series = await _context.Series .Where(s => libraryIds.Contains(s.LibraryId)) .Where(s => EF.Functions.Like(s.Name, $"%{searchQuery}%") || EF.Functions.Like(s.OriginalName, $"%{searchQuery}%") @@ -254,16 +297,55 @@ public async Task> SearchSeries(int[] libraryIds, s .Include(s => s.Library) .OrderBy(s => s.SortName) .AsNoTracking() + .AsSplitQuery() .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); - } - + result.ReadingLists = await _context.ReadingList + .Where(rl => rl.AppUserId == userId || rl.Promoted) + .Where(rl => EF.Functions.Like(rl.Title, $"%{searchQuery}%")) + .AsSplitQuery() + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + result.Collections = await _context.CollectionTag + .Where(s => EF.Functions.Like(s.Title, $"%{searchQuery}%") + || EF.Functions.Like(s.NormalizedTitle, $"%{searchQuery}%")) + .Where(s => s.Promoted || isAdmin) + .OrderBy(s => s.Title) + .AsNoTracking() + .OrderBy(c => c.NormalizedTitle) + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + result.Persons = await _context.SeriesMetadata + .Where(sm => seriesIds.Contains(sm.SeriesId)) + .SelectMany(sm => sm.People.Where(t => EF.Functions.Like(t.Name, $"%{searchQuery}%"))) + .AsSplitQuery() + .Distinct() + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + result.Genres = await _context.SeriesMetadata + .Where(sm => seriesIds.Contains(sm.SeriesId)) + .SelectMany(sm => sm.Genres.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%"))) + .AsSplitQuery() + .OrderBy(t => t.Title) + .Distinct() + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + result.Tags = await _context.SeriesMetadata + .Where(sm => seriesIds.Contains(sm.SeriesId)) + .SelectMany(sm => sm.Tags.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%"))) + .AsSplitQuery() + .OrderBy(t => t.Title) + .Distinct() + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + return result; + } public async Task GetSeriesDtoByIdAsync(int seriesId, int userId) { @@ -277,9 +359,6 @@ public async Task GetSeriesDtoByIdAsync(int seriesId, int userId) return seriesList[0]; } - - - public async Task DeleteSeriesAsync(int seriesId) { var series = await _context.Series.Where(s => s.Id == seriesId).SingleOrDefaultAsync(); @@ -345,7 +424,7 @@ public async Task GetChapterIdsForSeriesAsync(IList seriesIds) } /// - /// This returns a dictonary mapping seriesId -> list of chapters back for each series id passed + /// This returns a dictionary mapping seriesId -> list of chapters back for each series id passed /// /// /// @@ -452,7 +531,6 @@ private IList ExtractFilters(int libraryId, int userId, FilterDto f allPeopleIds.AddRange(filter.Publisher); allPeopleIds.AddRange(filter.CoverArtist); allPeopleIds.AddRange(filter.Translators); - //allPeopleIds.AddRange(filter.Artist); hasPeopleFilter = allPeopleIds.Count > 0; hasGenresFilter = filter.Genres.Count > 0; @@ -566,7 +644,7 @@ private async Task> CreateFilteredSearchQueryable(int userId, && (!hasPeopleFilter || s.Metadata.People.Any(p => allPeopleIds.Contains(p.Id))) && (!hasCollectionTagFilter || s.Metadata.CollectionTags.Any(t => filter.CollectionTags.Contains(t.Id))) - && (!hasRatingFilter || s.Ratings.Any(r => r.Rating >= filter.Rating)) + && (!hasRatingFilter || s.Ratings.Any(r => r.Rating >= filter.Rating && r.AppUserId == userId)) && (!hasProgressFilter || seriesIds.Contains(s.Id)) && (!hasAgeRating || filter.AgeRating.Contains(s.Metadata.AgeRating)) && (!hasTagsFilter || s.Metadata.Tags.Any(t => filter.Tags.Contains(t.Id))) @@ -575,34 +653,32 @@ private async Task> CreateFilteredSearchQueryable(int userId, ) .AsNoTracking(); - if (filter.SortOptions != null) + // If no sort options, default to using SortName + filter.SortOptions ??= new SortOptions() { - if (filter.SortOptions.IsAscending) + IsAscending = true, + SortField = SortField.SortName + }; + + if (filter.SortOptions.IsAscending) + { + query = filter.SortOptions.SortField switch { - if (filter.SortOptions.SortField == SortField.SortName) - { - query = query.OrderBy(s => s.SortName); - } else if (filter.SortOptions.SortField == SortField.CreatedDate) - { - query = query.OrderBy(s => s.Created); - } else if (filter.SortOptions.SortField == SortField.LastModifiedDate) - { - query = query.OrderBy(s => s.LastModified); - } - } - else + SortField.SortName => query.OrderBy(s => s.SortName), + SortField.CreatedDate => query.OrderBy(s => s.Created), + SortField.LastModifiedDate => query.OrderBy(s => s.LastModified), + _ => query + }; + } + else + { + query = filter.SortOptions.SortField switch { - if (filter.SortOptions.SortField == SortField.SortName) - { - query = query.OrderByDescending(s => s.SortName); - } else if (filter.SortOptions.SortField == SortField.CreatedDate) - { - query = query.OrderByDescending(s => s.Created); - } else if (filter.SortOptions.SortField == SortField.LastModifiedDate) - { - query = query.OrderByDescending(s => s.LastModified); - } - } + SortField.SortName => query.OrderByDescending(s => s.SortName), + SortField.CreatedDate => query.OrderByDescending(s => s.Created), + SortField.LastModifiedDate => query.OrderByDescending(s => s.LastModified), + _ => query + }; } return query; @@ -777,6 +853,7 @@ public async Task> GetAllLanguagesForLibrariesAsync(List var ret = await _context.Series .Where(s => libraryIds.Contains(s.LibraryId)) .Select(s => s.Metadata.Language) + .AsNoTracking() .Distinct() .ToListAsync(); @@ -786,7 +863,9 @@ public async Task> GetAllLanguagesForLibrariesAsync(List { Title = CultureInfo.GetCultureInfo(s).DisplayName, IsoCode = s - }).ToList(); + }) + .OrderBy(s => s.Title) + .ToList(); } public async Task> GetAllPublicationStatusesDtosForLibrariesAsync(List libraryIds) @@ -800,6 +879,154 @@ public async Task> GetAllPublicationStatusesDtosForL Value = s, Title = s.ToDescription() }) + .OrderBy(s => s.Title) + .ToListAsync(); + } + + private static string RecentlyAddedItemTitle(RecentlyAddedSeries item) + { + switch (item.LibraryType) + { + case LibraryType.Book: + return string.Empty; + case LibraryType.Comic: + return "Issue"; + case LibraryType.Manga: + default: + return "Chapter"; + } + } + + /// + /// Show all recently added chapters. Provide some mapping for chapter 0 -> Volume 1 + /// + /// + /// + public async Task> GetRecentlyAddedChapters(int userId) + { + var ret = await GetRecentlyAddedChaptersQuery(userId); + + var items = new List(); + foreach (var item in ret) + { + var dto = new RecentlyAddedItemDto() + { + LibraryId = item.LibraryId, + LibraryType = item.LibraryType, + SeriesId = item.SeriesId, + SeriesName = item.SeriesName, + Created = item.Created, + Id = items.Count, + Format = item.Format + }; + + // Add title and Volume/Chapter Id + var chapterTitle = RecentlyAddedItemTitle(item); + string title; + if (item.ChapterNumber.Equals(Parser.Parser.DefaultChapter)) + { + if ((item.VolumeNumber + string.Empty).Equals(Parser.Parser.DefaultChapter)) + { + title = item.ChapterTitle; + } + else + { + title = "Volume " + item.VolumeNumber; + } + + dto.VolumeId = item.VolumeId; + } + else + { + title = item.IsSpecial + ? item.ChapterRange + : $"{chapterTitle} {item.ChapterRange}"; + dto.ChapterId = item.ChapterId; + } + + dto.Title = title; + items.Add(dto); + } + + + return items; + + } + + + /// + /// Return recently updated series, regardless of read progress, and group the number of volume or chapters added. + /// + /// Used to ensure user has access to libraries + /// + public async Task> GetRecentlyUpdatedSeries(int userId) + { + var ret = await GetRecentlyAddedChaptersQuery(userId, 150); + + + var seriesMap = new Dictionary(); + var index = 0; + foreach (var item in ret) + { + if (seriesMap.ContainsKey(item.SeriesName)) + { + seriesMap[item.SeriesName].Count += 1; + } + else + { + seriesMap[item.SeriesName] = new GroupedSeriesDto() + { + LibraryId = item.LibraryId, + LibraryType = item.LibraryType, + SeriesId = item.SeriesId, + SeriesName = item.SeriesName, + Created = item.Created, + Id = index, + Format = item.Format, + Count = 1 + }; + index += 1; + } + } + + return seriesMap.Values.ToList(); + } + + private async Task> GetRecentlyAddedChaptersQuery(int userId, int maxRecords = 50) + { + var libraries = await _context.AppUser + .Where(u => u.Id == userId) + .SelectMany(u => u.Libraries.Select(l => new {LibraryId = l.Id, LibraryType = l.Type})) + .ToListAsync(); + var libraryIds = libraries.Select(l => l.LibraryId).ToList(); + + var withinLastWeek = DateTime.Now - TimeSpan.FromDays(12); + var ret = await _context.Chapter + .Where(c => c.Created >= withinLastWeek) + .AsNoTracking() + .Include(c => c.Volume) + .ThenInclude(v => v.Series) + .ThenInclude(s => s.Library) + .OrderByDescending(c => c.Created) + .Select(c => new RecentlyAddedSeries() + { + LibraryId = c.Volume.Series.LibraryId, + LibraryType = c.Volume.Series.Library.Type, + Created = c.Created, + SeriesId = c.Volume.Series.Id, + SeriesName = c.Volume.Series.Name, + VolumeId = c.VolumeId, + ChapterId = c.Id, + Format = c.Volume.Series.Format, + ChapterNumber = c.Number, + ChapterRange = c.Range, + IsSpecial = c.IsSpecial, + VolumeNumber = c.Volume.Number, + ChapterTitle = c.Title + }) + .Take(maxRecords) + .Where(c => c.Created >= withinLastWeek && libraryIds.Contains(c.LibraryId)) .ToListAsync(); + return ret; } } diff --git a/API/Data/Repositories/TagRepository.cs b/API/Data/Repositories/TagRepository.cs index 772957aa9a..ef7f2ad437 100644 --- a/API/Data/Repositories/TagRepository.cs +++ b/API/Data/Repositories/TagRepository.cs @@ -50,13 +50,13 @@ public async Task FindByNameAsync(string tagName) public async Task RemoveAllTagNoLongerAssociated(bool removeExternal = false) { - var TagsWithNoConnections = await _context.Tag + var tagsWithNoConnections = await _context.Tag .Include(p => p.SeriesMetadatas) .Include(p => p.Chapters) .Where(p => p.SeriesMetadatas.Count == 0 && p.Chapters.Count == 0 && p.ExternalTag == removeExternal) .ToListAsync(); - _context.Tag.RemoveRange(TagsWithNoConnections); + _context.Tag.RemoveRange(tagsWithNoConnections); await _context.SaveChangesAsync(); } @@ -67,6 +67,8 @@ public async Task> GetAllTagDtosForLibrariesAsync(IList libra .Where(s => libraryIds.Contains(s.LibraryId)) .SelectMany(s => s.Metadata.Tags) .Distinct() + .OrderBy(t => t.Title) + .AsNoTracking() .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); } @@ -80,6 +82,7 @@ public async Task> GetAllTagDtosAsync() { return await _context.Tag .AsNoTracking() + .OrderBy(t => t.Title) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); } diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index 138ef15b80..b926abe9c3 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.Linq; using System.Threading.Tasks; using API.Constants; @@ -20,7 +21,8 @@ public enum AppUserIncludes Progress = 2, Bookmarks = 4, ReadingLists = 8, - Ratings = 16 + Ratings = 16, + UserPreferences = 32 } public interface IUserRepository @@ -29,7 +31,9 @@ public interface IUserRepository void Update(AppUserPreferences preferences); void Update(AppUserBookmark bookmark); public void Delete(AppUser user); - Task> GetMembersAsync(); + void Delete(AppUserBookmark bookmark); + Task> GetEmailConfirmedMemberDtosAsync(); + Task> GetPendingMemberDtosAsync(); Task> GetAdminUsersAsync(); Task> GetNonAdminUsersAsync(); Task IsUserAdminAsync(AppUser user); @@ -48,6 +52,9 @@ public interface IUserRepository Task GetUserIdByUsernameAsync(string username); Task GetUserWithReadingListsByUsernameAsync(string username); Task> GetAllBookmarksByIds(IList bookmarkIds); + Task GetUserByEmailAsync(string email); + Task> GetAllUsers(); + } public class UserRepository : IUserRepository @@ -83,6 +90,11 @@ public void Delete(AppUser user) _context.AppUser.Remove(user); } + public void Delete(AppUserBookmark bookmark) + { + _context.AppUserBookmark.Remove(bookmark); + } + /// /// A one stop shop to get a tracked AppUser instance with any number of JOINs generated by passing bitwise flags. /// @@ -156,6 +168,13 @@ private static IQueryable AddIncludesToQuery(IQueryable query, query = query.Include(u => u.Ratings); } + if (includeFlags.HasFlag(AppUserIncludes.UserPreferences)) + { + query = query.Include(u => u.UserPreferences); + } + + + return query; } @@ -198,6 +217,16 @@ public async Task> GetAllBookmarksByIds(IList bookma .ToListAsync(); } + public async Task GetUserByEmailAsync(string email) + { + return await _context.AppUser.SingleOrDefaultAsync(u => u.Email.ToLower().Equals(email.ToLower())); + } + + public async Task> GetAllUsers() + { + return await _context.AppUser.ToListAsync(); + } + public async Task> GetAdminUsersAsync() { return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole); @@ -280,9 +309,38 @@ public async Task GetUserIdByApiKeyAsync(string apiKey) } - public async Task> GetMembersAsync() + public async Task> GetEmailConfirmedMemberDtosAsync() + { + return await _context.Users + .Where(u => u.EmailConfirmed) + .Include(x => x.Libraries) + .Include(r => r.UserRoles) + .ThenInclude(r => r.Role) + .OrderBy(u => u.UserName) + .Select(u => new MemberDto + { + Id = u.Id, + Username = u.UserName, + Email = u.Email, + Created = u.Created, + LastActive = u.LastActive, + Roles = u.UserRoles.Select(r => r.Role.Name).ToList(), + Libraries = u.Libraries.Select(l => new LibraryDto + { + Name = l.Name, + Type = l.Type, + LastScanned = l.LastScanned, + Folders = l.Folders.Select(x => x.Path).ToList() + }).ToList() + }) + .AsNoTracking() + .ToListAsync(); + } + + public async Task> GetPendingMemberDtosAsync() { return await _context.Users + .Where(u => !u.EmailConfirmed) .Include(x => x.Libraries) .Include(r => r.UserRoles) .ThenInclude(r => r.Role) @@ -291,6 +349,7 @@ public async Task> GetMembersAsync() { Id = u.Id, Username = u.UserName, + Email = u.Email, Created = u.Created, LastActive = u.LastActive, Roles = u.UserRoles.Select(r => r.Role.Name).ToList(), @@ -305,4 +364,14 @@ public async Task> GetMembersAsync() .AsNoTracking() .ToListAsync(); } + + public async Task ValidateUserExists(string username) + { + if (await _userManager.Users.AnyAsync(x => x.NormalizedUserName == username.ToUpper())) + { + throw new ValidationException("Username is taken."); + } + + return true; + } } diff --git a/API/Data/Repositories/VolumeRepository.cs b/API/Data/Repositories/VolumeRepository.cs index e63b469e6e..6ab2ca1138 100644 --- a/API/Data/Repositories/VolumeRepository.cs +++ b/API/Data/Repositories/VolumeRepository.cs @@ -1,7 +1,6 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using API.Comparators; using API.DTOs; using API.Entities; using API.Extensions; diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index b7590e168b..3dd8ecc5fa 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -60,6 +60,7 @@ public static async Task SeedSettings(DataContext context, IDirectoryService dir new () {Key = ServerSettingKey.InstallId, Value = HashUtil.AnonymousToken()}, new () {Key = ServerSettingKey.InstallVersion, Value = BuildInfo.Version.ToString()}, new () {Key = ServerSettingKey.BookmarkDirectory, Value = directoryService.BookmarkDirectory}, + new () {Key = ServerSettingKey.EmailServiceUrl, Value = EmailService.DefaultApiUrl}, }; foreach (var defaultSetting in DefaultSettings) @@ -92,12 +93,9 @@ public static async Task SeedUserApiKeys(DataContext context) await context.Database.EnsureCreatedAsync(); var users = await context.AppUser.ToListAsync(); - foreach (var user in users) + foreach (var user in users.Where(user => string.IsNullOrEmpty(user.ApiKey))) { - if (string.IsNullOrEmpty(user.ApiKey)) - { - user.ApiKey = HashUtil.ApiKey(); - } + user.ApiKey = HashUtil.ApiKey(); } await context.SaveChangesAsync(); } diff --git a/API/Entities/Enums/AgeRating.cs b/API/Entities/Enums/AgeRating.cs index ddb288ee1b..ed9deac256 100644 --- a/API/Entities/Enums/AgeRating.cs +++ b/API/Entities/Enums/AgeRating.cs @@ -21,6 +21,7 @@ public enum AgeRating [Description("Everyone 10+")] Everyone10Plus = 5, [Description("PG")] + // ReSharper disable once InconsistentNaming PG = 6, [Description("Kids to Adults")] KidsToAdults = 7, diff --git a/API/Entities/Enums/PublicationStatus.cs b/API/Entities/Enums/PublicationStatus.cs index 4d8124391d..69f700fc6a 100644 --- a/API/Entities/Enums/PublicationStatus.cs +++ b/API/Entities/Enums/PublicationStatus.cs @@ -7,7 +7,7 @@ public enum PublicationStatus /// /// Default Status. Publication is currently in progress /// - [Description("On Going")] + [Description("Ongoing")] OnGoing = 0, /// /// Series is on temp or indefinite Hiatus diff --git a/API/Entities/Enums/ServerSettingKey.cs b/API/Entities/Enums/ServerSettingKey.cs index 80484d693a..1a1ab80733 100644 --- a/API/Entities/Enums/ServerSettingKey.cs +++ b/API/Entities/Enums/ServerSettingKey.cs @@ -47,6 +47,7 @@ public enum ServerSettingKey /// /// Is Authentication needed for non-admin accounts /// + /// Deprecated. This is no longer used v0.5.1+. Assume Authentication is always in effect [Description("EnableAuthentication")] EnableAuthentication = 8, /// @@ -70,6 +71,10 @@ public enum ServerSettingKey /// [Description("BookmarkDirectory")] BookmarkDirectory = 12, - + /// + /// If SMTP is enabled on the server + /// + [Description("CustomEmailService")] + EmailServiceUrl = 13, } } diff --git a/API/Entities/Metadata/SeriesMetadata.cs b/API/Entities/Metadata/SeriesMetadata.cs index 54ea8ccc0a..81fcba0903 100644 --- a/API/Entities/Metadata/SeriesMetadata.cs +++ b/API/Entities/Metadata/SeriesMetadata.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using API.Entities.Enums; using API.Entities.Interfaces; diff --git a/API/Entities/Series.cs b/API/Entities/Series.cs index a532028bb1..77a011d533 100644 --- a/API/Entities/Series.cs +++ b/API/Entities/Series.cs @@ -31,7 +31,13 @@ public class Series : IEntityDate /// Original Name on disk. Not exposed to UI. /// public string OriginalName { get; set; } + /// + /// Time of creation + /// public DateTime Created { get; set; } + /// + /// Whenever a modification occurs. Ie) New volumes, removed volumes, title update, etc + /// public DateTime LastModified { get; set; } /// /// Absolute path to the (managed) image file diff --git a/API/Errors/ApiException.cs b/API/Errors/ApiException.cs index ce1792f72b..1d570e8ff5 100644 --- a/API/Errors/ApiException.cs +++ b/API/Errors/ApiException.cs @@ -13,4 +13,4 @@ public ApiException(int status, string message = null, string details = null) Details = details; } } -} \ No newline at end of file +} diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index fd0c5f5ca8..102a7e107d 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -37,6 +37,12 @@ public static void AddApplicationServices(this IServiceCollection services, ICon services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); diff --git a/API/Extensions/HttpExtensions.cs b/API/Extensions/HttpExtensions.cs index 68655f43da..f0b3d53997 100644 --- a/API/Extensions/HttpExtensions.cs +++ b/API/Extensions/HttpExtensions.cs @@ -1,6 +1,5 @@ using System.IO; using System.Linq; -using System.Runtime.Intrinsics.Arm; using System.Security.Cryptography; using System.Text; using System.Text.Json; diff --git a/API/Extensions/IdentityServiceExtensions.cs b/API/Extensions/IdentityServiceExtensions.cs index 1d0638e672..16404949bc 100644 --- a/API/Extensions/IdentityServiceExtensions.cs +++ b/API/Extensions/IdentityServiceExtensions.cs @@ -24,13 +24,17 @@ public static IServiceCollection AddIdentityServices(this IServiceCollection ser opt.Password.RequireUppercase = false; opt.Password.RequireNonAlphanumeric = false; opt.Password.RequiredLength = 6; + + opt.SignIn.RequireConfirmedEmail = true; }) + .AddTokenProvider>(TokenOptions.DefaultProvider) .AddRoles() .AddRoleManager>() .AddSignInManager>() .AddRoleValidator>() .AddEntityFrameworkStores(); + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { @@ -39,7 +43,8 @@ public static IServiceCollection AddIdentityServices(this IServiceCollection ser ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["TokenKey"])), ValidateIssuer = false, - ValidateAudience = false + ValidateAudience = false, + ValidIssuer = "Kavita" }; options.Events = new JwtBearerEvents() @@ -62,6 +67,7 @@ public static IServiceCollection AddIdentityServices(this IServiceCollection ser { opt.AddPolicy("RequireAdminRole", policy => policy.RequireRole(PolicyConstants.AdminRole)); opt.AddPolicy("RequireDownloadRole", policy => policy.RequireRole(PolicyConstants.DownloadRole, PolicyConstants.AdminRole)); + opt.AddPolicy("RequireChangePasswordRole", policy => policy.RequireRole(PolicyConstants.ChangePasswordRole, PolicyConstants.AdminRole)); }); return services; diff --git a/API/Extensions/ParserInfoListExtensions.cs b/API/Extensions/ParserInfoListExtensions.cs index 31a65c819e..1bca8787b5 100644 --- a/API/Extensions/ParserInfoListExtensions.cs +++ b/API/Extensions/ParserInfoListExtensions.cs @@ -1,7 +1,6 @@ using System.Collections.Generic; using System.Linq; using API.Entities; -using API.Entities.Enums; using API.Parser; namespace API.Extensions @@ -30,16 +29,5 @@ public static bool HasInfo(this IList infos, Chapter chapter) return chapter.IsSpecial ? infos.Any(v => v.Filename == chapter.Range) : infos.Any(v => v.Chapters == chapter.Range); } - - // /// - // /// Returns the MangaFormat that is common to all the files. Unknown if files are mixed (should never happen) or no infos - // /// - // /// - // /// - // public static MangaFormat GetFormat(this IList infos) - // { - // if (infos.Count == 0) return MangaFormat.Unknown; - // return infos.DistinctBy(x => x.Format).First().Format; - // } } } diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index 0b3f891612..1c2426ae46 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -5,6 +5,7 @@ using API.DTOs.Metadata; using API.DTOs.Reader; using API.DTOs.ReadingLists; +using API.DTOs.Search; using API.DTOs.Settings; using API.Entities; using API.Entities.Enums; diff --git a/API/Helpers/Converters/CronConverter.cs b/API/Helpers/Converters/CronConverter.cs index 32df46753c..4f8305e016 100644 --- a/API/Helpers/Converters/CronConverter.cs +++ b/API/Helpers/Converters/CronConverter.cs @@ -25,17 +25,5 @@ public static string ConvertToCronNotation(string source) return destination; } - - // public static string ConvertFromCronNotation(string cronNotation) - // { - // var destination = string.Empty; - // destination = cronNotation.ToLower() switch - // { - // "0 0 31 2 *" => "disabled", - // _ => destination - // }; - // - // return destination; - // } } } diff --git a/API/Helpers/Converters/ServerSettingConverter.cs b/API/Helpers/Converters/ServerSettingConverter.cs index 50a8390104..31ea46d4bf 100644 --- a/API/Helpers/Converters/ServerSettingConverter.cs +++ b/API/Helpers/Converters/ServerSettingConverter.cs @@ -36,15 +36,15 @@ public ServerSettingDto Convert(IEnumerable source, ServerSetting case ServerSettingKey.EnableOpds: destination.EnableOpds = bool.Parse(row.Value); break; - case ServerSettingKey.EnableAuthentication: - destination.EnableAuthentication = bool.Parse(row.Value); - break; case ServerSettingKey.BaseUrl: destination.BaseUrl = row.Value; break; case ServerSettingKey.BookmarkDirectory: destination.BookmarksDirectory = row.Value; break; + case ServerSettingKey.EmailServiceUrl: + destination.EmailServiceUrl = row.Value; + break; } } diff --git a/API/Helpers/ParserInfoHelpers.cs b/API/Helpers/ParserInfoHelpers.cs index 48421cd70e..a97601a43d 100644 --- a/API/Helpers/ParserInfoHelpers.cs +++ b/API/Helpers/ParserInfoHelpers.cs @@ -1,7 +1,6 @@ using System.Collections.Generic; using API.Entities; using API.Entities.Enums; -using API.Extensions; using API.Parser; using API.Services.Tasks.Scanner; diff --git a/API/Parser/Parser.cs b/API/Parser/Parser.cs index c17290c5b4..45a1e77571 100644 --- a/API/Parser/Parser.cs +++ b/API/Parser/Parser.cs @@ -3,7 +3,6 @@ using System.Linq; using System.Text.RegularExpressions; using API.Entities.Enums; -using API.Services; namespace API.Parser { @@ -484,7 +483,7 @@ public static class Parser { // All Keywords, does not account for checking if contains volume/chapter identification. Parser.Parse() will handle. new Regex( - @"(?Specials?|OneShot|One\-Shot|Extra(?:(\sChapter)?[^\S])|Book \d.+?|Compendium \d.+?|Omnibus \d.+?|[_\s\-]TPB[_\s\-]|FCBD \d.+?|Absolute \d.+?|Preview \d.+?|Art Collection|Side(\s|_)Stories|Bonus|Hors Série|(\W|_|-)HS(\W|_|-)|(\W|_|-)THS(\W|_|-))", + @"(?Specials?|OneShot|One\-Shot|\d.+?(\W|_|-)Annual|Annual(\W|_|-)\d.+?|Extra(?:(\sChapter)?[^\S])|Book \d.+?|Compendium \d.+?|Omnibus \d.+?|[_\s\-]TPB[_\s\-]|FCBD \d.+?|Absolute \d.+?|Preview \d.+?|Art Collection|Side(\s|_)Stories|Bonus|Hors Série|(\W|_|-)HS(\W|_|-)|(\W|_|-)THS(\W|_|-))", MatchOptions, RegexTimeout), }; @@ -660,20 +659,17 @@ public static string ParseComicVolume(string filename) private static string FormatValue(string value, bool hasPart) { - if (!value.Contains("-")) + if (!value.Contains('-')) { return RemoveLeadingZeroes(hasPart ? AddChapterPart(value) : value); } var tokens = value.Split("-"); var from = RemoveLeadingZeroes(tokens[0]); - if (tokens.Length == 2) - { - var to = RemoveLeadingZeroes(hasPart ? AddChapterPart(tokens[1]) : tokens[1]); - return $"{@from}-{to}"; - } + if (tokens.Length != 2) return from; - return @from; + var to = RemoveLeadingZeroes(hasPart ? AddChapterPart(tokens[1]) : tokens[1]); + return $"{from}-{to}"; } public static string ParseChapter(string filename) @@ -697,7 +693,7 @@ public static string ParseChapter(string filename) private static string AddChapterPart(string value) { - if (value.Contains(".")) + if (value.Contains('.')) { return value; } @@ -877,13 +873,10 @@ private static string RemoveReleaseGroup(string title) /// A zero padded number public static string PadZeros(string number) { - if (number.Contains("-")) - { - var tokens = number.Split("-"); - return $"{PerformPadding(tokens[0])}-{PerformPadding(tokens[1])}"; - } + if (!number.Contains('-')) return PerformPadding(number); - return PerformPadding(number); + var tokens = number.Split("-"); + return $"{PerformPadding(tokens[0])}-{PerformPadding(tokens[1])}"; } private static string PerformPadding(string number) @@ -926,6 +919,25 @@ public static bool IsXml(string filePath) return XmlRegex.IsMatch(Path.GetExtension(filePath)); } + + public static float MaximumNumberFromRange(string range) + { + try + { + if (!Regex.IsMatch(range, @"^[\d-.]+$")) + { + return (float) 0.0; + } + + var tokens = range.Replace("_", string.Empty).Split("-"); + return tokens.Max(float.Parse); + } + catch + { + return (float) 0.0; + } + } + public static float MinimumNumberFromRange(string range) { try @@ -946,7 +958,8 @@ public static float MinimumNumberFromRange(string range) public static string Normalize(string name) { - return NormalizeRegex.Replace(name, string.Empty).ToLower(); + var normalized = NormalizeRegex.Replace(name, string.Empty).ToLower(); + return string.IsNullOrEmpty(normalized) ? name : normalized; } diff --git a/API/Program.cs b/API/Program.cs index 4ed8ce56a9..3a0d9ab25f 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -8,7 +8,6 @@ using API.Entities; using API.Entities.Enums; using API.Services; -using API.Services.Tasks; using Kavita.Common; using Kavita.Common.EnvironmentInfo; using Microsoft.AspNetCore.Hosting; @@ -37,7 +36,7 @@ public static async Task Main(string[] args) var directoryService = new DirectoryService(null, new FileSystem()); - MigrateConfigFiles.Migrate(isDocker, directoryService); + //MigrateConfigFiles.Migrate(isDocker, directoryService); // Before anything, check if JWT has been generated properly or if user still has default if (!Configuration.CheckIfJwtTokenSet() && @@ -62,7 +61,15 @@ public static async Task Main(string[] args) if (pendingMigrations.Any()) { logger.LogInformation("Performing backup as migrations are needed. Backup will be kavita.db in temp folder"); - directoryService.CopyFileToDirectory(directoryService.FileSystem.Path.Join(directoryService.ConfigDirectory, "kavita.db"), directoryService.TempDirectory); + var migrationDirectory = await GetMigrationDirectory(context, directoryService); + directoryService.ExistOrCreate(migrationDirectory); + + if (!directoryService.FileSystem.File.Exists( + directoryService.FileSystem.Path.Join(migrationDirectory, "kavita.db"))) + { + directoryService.CopyFileToDirectory(directoryService.FileSystem.Path.Join(directoryService.ConfigDirectory, "kavita.db"), migrationDirectory); + logger.LogInformation("Database backed up to {MigrationDirectory}", migrationDirectory); + } } await context.Database.MigrateAsync(); @@ -82,12 +89,42 @@ public static async Task Main(string[] args) catch (Exception ex) { var logger = services.GetRequiredService>(); - logger.LogCritical(ex, "An error occurred during migration"); + var context = services.GetRequiredService(); + var migrationDirectory = await GetMigrationDirectory(context, directoryService); + + logger.LogCritical(ex, "A migration failed during startup. Restoring backup from {MigrationDirectory} and exiting", migrationDirectory); + directoryService.CopyFileToDirectory(directoryService.FileSystem.Path.Join(migrationDirectory, "kavita.db"), directoryService.ConfigDirectory); + + return; } await host.RunAsync(); } + private static async Task GetMigrationDirectory(DataContext context, IDirectoryService directoryService) + { + string currentVersion = null; + try + { + currentVersion = + (await context.ServerSetting.SingleOrDefaultAsync(s => + s.Key == ServerSettingKey.InstallVersion))?.Value; + } + catch + { + // ignored + } + + if (string.IsNullOrEmpty(currentVersion)) + { + currentVersion = "vUnknown"; + } + + var migrationDirectory = directoryService.FileSystem.Path.Join(directoryService.TempDirectory, + "migration", currentVersion); + return migrationDirectory; + } + private static IHostBuilder CreateHostBuilder(string[] args) => Host.CreateDefaultBuilder(args) .ConfigureAppConfiguration((hostingContext, config) => diff --git a/API/Services/AccountService.cs b/API/Services/AccountService.cs index 0591770ecf..62f5386fbd 100644 --- a/API/Services/AccountService.cs +++ b/API/Services/AccountService.cs @@ -1,9 +1,12 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using API.Data; using API.Entities; using API.Errors; using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace API.Services @@ -11,30 +14,29 @@ namespace API.Services public interface IAccountService { Task> ChangeUserPassword(AppUser user, string newPassword); + Task> ValidatePassword(AppUser user, string password); + Task> ValidateUsername(string username); + Task> ValidateEmail(string email); } public class AccountService : IAccountService { private readonly UserManager _userManager; private readonly ILogger _logger; + private readonly IUnitOfWork _unitOfWork; public const string DefaultPassword = "[k.2@RZ!mxCQkJzE"; - public AccountService(UserManager userManager, ILogger logger) + public AccountService(UserManager userManager, ILogger logger, IUnitOfWork unitOfWork) { _userManager = userManager; _logger = logger; + _unitOfWork = unitOfWork; } public async Task> ChangeUserPassword(AppUser user, string newPassword) { - foreach (var validator in _userManager.PasswordValidators) - { - var validationResult = await validator.ValidateAsync(_userManager, user, newPassword); - if (!validationResult.Succeeded) - { - return validationResult.Errors.Select(e => new ApiException(400, e.Code, e.Description)); - } - } + var passwordValidationIssues = (await ValidatePassword(user, newPassword)).ToList(); + if (passwordValidationIssues.Any()) return passwordValidationIssues; var result = await _userManager.RemovePasswordAsync(user); if (!result.Succeeded) @@ -53,5 +55,42 @@ public async Task> ChangeUserPassword(AppUser user, st return new List(); } + + public async Task> ValidatePassword(AppUser user, string password) + { + foreach (var validator in _userManager.PasswordValidators) + { + var validationResult = await validator.ValidateAsync(_userManager, user, password); + if (!validationResult.Succeeded) + { + return validationResult.Errors.Select(e => new ApiException(400, e.Code, e.Description)); + } + } + + return Array.Empty(); + } + public async Task> ValidateUsername(string username) + { + if (await _userManager.Users.AnyAsync(x => x.NormalizedUserName == username.ToUpper())) + { + return new List() + { + new ApiException(400, "Username is already taken") + }; + } + + return Array.Empty(); + } + + public async Task> ValidateEmail(string email) + { + var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(email); + if (user == null) return Array.Empty(); + + return new List() + { + new ApiException(400, "Email is already registered") + }; + } } } diff --git a/API/Services/ArchiveService.cs b/API/Services/ArchiveService.cs index 11042ed341..db098aa0f9 100644 --- a/API/Services/ArchiveService.cs +++ b/API/Services/ArchiveService.cs @@ -7,7 +7,6 @@ using System.Threading.Tasks; using System.Xml.Serialization; using API.Archive; -using API.Comparators; using API.Data.Metadata; using API.Extensions; using API.Services.Tasks; @@ -322,27 +321,13 @@ public bool IsValidArchive(string archivePath) return false; } - - private static ComicInfo FindComicInfoXml(IEnumerable entries) + private static bool ValidComicInfoArchiveEntry(string fullName, string name) { - foreach (var entry in entries) - { - var filename = Path.GetFileNameWithoutExtension(entry.Key).ToLower(); - if (filename.EndsWith(ComicInfoFilename) - && !filename.StartsWith(Parser.Parser.MacOsMetadataFileStartsWith) - && !Parser.Parser.HasBlacklistedFolderInPath(entry.Key) - && Parser.Parser.IsXml(entry.Key)) - { - using var ms = entry.OpenEntryStream(); - - var serializer = new XmlSerializer(typeof(ComicInfo)); - var info = (ComicInfo) serializer.Deserialize(ms); - return info; - } - } - - - return null; + var filenameWithoutExtension = Path.GetFileNameWithoutExtension(name).ToLower(); + return !Parser.Parser.HasBlacklistedFolderInPath(fullName) + && filenameWithoutExtension.Equals(ComicInfoFilename, StringComparison.InvariantCultureIgnoreCase) + && !filenameWithoutExtension.StartsWith(Parser.Parser.MacOsMetadataFileStartsWith) + && Parser.Parser.IsXml(name); } /// @@ -364,12 +349,8 @@ private static ComicInfo FindComicInfoXml(IEnumerable entries) case ArchiveLibrary.Default: { using var archive = ZipFile.OpenRead(archivePath); - var entry = archive.Entries.FirstOrDefault(x => - !Parser.Parser.HasBlacklistedFolderInPath(x.FullName) - && Path.GetFileNameWithoutExtension(x.Name)?.ToLower() == ComicInfoFilename - && !Path.GetFileNameWithoutExtension(x.Name) - .StartsWith(Parser.Parser.MacOsMetadataFileStartsWith) - && Parser.Parser.IsXml(x.FullName)); + + var entry = archive.Entries.FirstOrDefault(x => ValidComicInfoArchiveEntry(x.FullName, x.Name)); if (entry != null) { using var stream = entry.Open(); @@ -384,20 +365,19 @@ private static ComicInfo FindComicInfoXml(IEnumerable entries) case ArchiveLibrary.SharpCompress: { using var archive = ArchiveFactory.Open(archivePath); - var info = FindComicInfoXml(archive.Entries.Where(entry => !entry.IsDirectory - && !Parser.Parser - .HasBlacklistedFolderInPath( - Path.GetDirectoryName( - entry.Key) ?? string.Empty) - && !Path - .GetFileNameWithoutExtension( - entry.Key).StartsWith(Parser - .Parser - .MacOsMetadataFileStartsWith) - && Parser.Parser.IsXml(entry.Key))); - ComicInfo.CleanComicInfo(info); - - return info; + var entry = archive.Entries.FirstOrDefault(entry => + ValidComicInfoArchiveEntry(Path.GetDirectoryName(entry.Key), entry.Key)); + + if (entry != null) + { + using var stream = entry.OpenEntryStream(); + var serializer = new XmlSerializer(typeof(ComicInfo)); + var info = (ComicInfo) serializer.Deserialize(stream); + ComicInfo.CleanComicInfo(info); + return info; + } + + break; } case ArchiveLibrary.NotSupported: _logger.LogWarning("[GetComicInfo] This archive cannot be read: {ArchivePath}", archivePath); diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index 871b7dd32c..9531aa785d 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -171,7 +171,7 @@ public async Task ScopeStyles(string stylesheetHtml, string apiBase, str stylesheetHtml = stylesheetHtml.Insert(0, importBuilder.ToString()); - EscapeCSSImportReferences(ref stylesheetHtml, apiBase, prepend); + EscapeCssImportReferences(ref stylesheetHtml, apiBase, prepend); EscapeFontFamilyReferences(ref stylesheetHtml, apiBase, prepend); @@ -200,7 +200,7 @@ public async Task ScopeStyles(string stylesheetHtml, string apiBase, str return RemoveWhiteSpaceFromStylesheets(stylesheet.ToCss()); } - private static void EscapeCSSImportReferences(ref string stylesheetHtml, string apiBase, string prepend) + private static void EscapeCssImportReferences(ref string stylesheetHtml, string apiBase, string prepend) { foreach (Match match in Parser.Parser.CssImportUrlRegex.Matches(stylesheetHtml)) { @@ -384,9 +384,11 @@ public ComicInfo GetComicInfo(string filePath) Writer = string.Join(",", epubBook.Schema.Package.Metadata.Creators.Select(c => Parser.Parser.CleanAuthor(c.Creator))), Publisher = string.Join(",", epubBook.Schema.Package.Metadata.Publishers), Month = !string.IsNullOrEmpty(publicationDate) ? DateTime.Parse(publicationDate).Month : 0, + Day = !string.IsNullOrEmpty(publicationDate) ? DateTime.Parse(publicationDate).Day : 0, Year = !string.IsNullOrEmpty(publicationDate) ? DateTime.Parse(publicationDate).Year : 0, Title = epubBook.Title, Genre = string.Join(",", epubBook.Schema.Package.Metadata.Subjects.Select(s => s.ToLower().Trim())), + LanguageISO = epubBook.Schema.Package.Metadata.Languages.FirstOrDefault() ?? string.Empty }; // Parse tags not exposed via Library @@ -457,6 +459,11 @@ public static string EscapeTags(string content) return content; } + /// + /// Removes the leading ../ + /// + /// + /// public static string CleanContentKeys(string key) { return key.Replace("../", string.Empty); diff --git a/API/Services/BookmarkService.cs b/API/Services/BookmarkService.cs new file mode 100644 index 0000000000..7acea6ad89 --- /dev/null +++ b/API/Services/BookmarkService.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using API.Data; +using API.DTOs.Reader; +using API.Entities; +using API.Entities.Enums; +using Microsoft.Extensions.Logging; + +namespace API.Services; + +public interface IBookmarkService +{ + Task DeleteBookmarkFiles(IEnumerable bookmarks); + Task BookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto, string imageToBookmark); + Task RemoveBookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto); +} + +public class BookmarkService : IBookmarkService +{ + private readonly ILogger _logger; + private readonly IUnitOfWork _unitOfWork; + private readonly IDirectoryService _directoryService; + + public BookmarkService(ILogger logger, IUnitOfWork unitOfWork, IDirectoryService directoryService) + { + _logger = logger; + _unitOfWork = unitOfWork; + _directoryService = directoryService; + } + + /// + /// Deletes the files associated with the list of Bookmarks passed. Will clean up empty folders. + /// + /// + public async Task DeleteBookmarkFiles(IEnumerable bookmarks) + { + var bookmarkDirectory = + (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value; + + var bookmarkFilesToDelete = bookmarks.Select(b => Parser.Parser.NormalizePath( + _directoryService.FileSystem.Path.Join(bookmarkDirectory, + b.FileName))).ToList(); + + if (bookmarkFilesToDelete.Count == 0) return; + + _directoryService.DeleteFiles(bookmarkFilesToDelete); + + // Delete any leftover folders + foreach (var directory in _directoryService.FileSystem.Directory.GetDirectories(bookmarkDirectory, "", SearchOption.AllDirectories)) + { + if (_directoryService.FileSystem.Directory.GetFiles(directory, "", SearchOption.AllDirectories).Length == 0 && + _directoryService.FileSystem.Directory.GetDirectories(directory).Length == 0) + { + _directoryService.FileSystem.Directory.Delete(directory, false); + } + } + } + /// + /// Creates a new entry in the AppUserBookmarks and copies an image to BookmarkDirectory. + /// + /// An AppUser object with Bookmarks populated + /// + /// Full path to the cached image that is going to be copied + /// If the save to DB and copy was successful + public async Task BookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto, string imageToBookmark) + { + try + { + var userBookmark = + await _unitOfWork.UserRepository.GetBookmarkForPage(bookmarkDto.Page, bookmarkDto.ChapterId, userWithBookmarks.Id); + + if (userBookmark != null) + { + _logger.LogError("Bookmark already exists for Series {SeriesId}, Volume {VolumeId}, Chapter {ChapterId}, Page {PageNum}", bookmarkDto.SeriesId, bookmarkDto.VolumeId, bookmarkDto.ChapterId, bookmarkDto.Page); + return false; + } + + var fileInfo = new FileInfo(imageToBookmark); + var bookmarkDirectory = + (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value; + var targetFolderStem = BookmarkStem(userWithBookmarks.Id, bookmarkDto.SeriesId, bookmarkDto.ChapterId); + var targetFilepath = Path.Join(bookmarkDirectory, targetFolderStem); + + userWithBookmarks.Bookmarks ??= new List(); + userWithBookmarks.Bookmarks.Add(new AppUserBookmark() + { + Page = bookmarkDto.Page, + VolumeId = bookmarkDto.VolumeId, + SeriesId = bookmarkDto.SeriesId, + ChapterId = bookmarkDto.ChapterId, + FileName = Path.Join(targetFolderStem, fileInfo.Name) + }); + _directoryService.CopyFileToDirectory(imageToBookmark, targetFilepath); + _unitOfWork.UserRepository.Update(userWithBookmarks); + await _unitOfWork.CommitAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception when saving bookmark"); + await _unitOfWork.RollbackAsync(); + return false; + } + + return true; + } + + /// + /// Removes the Bookmark entity and the file from BookmarkDirectory + /// + /// + /// + /// + public async Task RemoveBookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto) + { + if (userWithBookmarks.Bookmarks == null) return true; + try + { + var bookmarkToDelete = userWithBookmarks.Bookmarks.SingleOrDefault(x => + x.ChapterId == bookmarkDto.ChapterId && x.AppUserId == userWithBookmarks.Id && x.Page == bookmarkDto.Page && + x.SeriesId == bookmarkDto.SeriesId); + + if (bookmarkToDelete != null) + { + await DeleteBookmarkFiles(new[] {bookmarkToDelete}); + _unitOfWork.UserRepository.Delete(bookmarkToDelete); + } + + await _unitOfWork.CommitAsync(); + } + catch (Exception) + { + await _unitOfWork.RollbackAsync(); + return false; + } + + return true; + } + + private static string BookmarkStem(int userId, int seriesId, int chapterId) + { + return Path.Join($"{userId}", $"{seriesId}", $"{chapterId}"); + } +} diff --git a/API/Services/CacheService.cs b/API/Services/CacheService.cs index 0b6c4aa0d9..c5396f4ed8 100644 --- a/API/Services/CacheService.cs +++ b/API/Services/CacheService.cs @@ -1,6 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; diff --git a/API/Services/DirectoryService.cs b/API/Services/DirectoryService.cs index bf3c01d255..0edf51ffcc 100644 --- a/API/Services/DirectoryService.cs +++ b/API/Services/DirectoryService.cs @@ -1,5 +1,4 @@ using System; -using System.Collections; using System.Collections.Generic; using System.Collections.Immutable; using System.IO; @@ -7,7 +6,6 @@ using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; -using API.Comparators; using API.Extensions; using Microsoft.Extensions.Logging; @@ -22,7 +20,7 @@ public interface IDirectoryService string TempDirectory { get; } string ConfigDirectory { get; } /// - /// Original BookmarkDirectory. Only used for resetting directory. Use for actual path. + /// Original BookmarkDirectory. Only used for resetting directory. Use for actual path. /// string BookmarkDirectory { get; } /// @@ -198,11 +196,10 @@ public void CopyFileToDirectory(string fullFilePath, string targetDirectory) try { var fileInfo = FileSystem.FileInfo.FromFileName(fullFilePath); - if (fileInfo.Exists) - { - ExistOrCreate(targetDirectory); - fileInfo.CopyTo(FileSystem.Path.Join(targetDirectory, fileInfo.Name), true); - } + if (!fileInfo.Exists) return; + + ExistOrCreate(targetDirectory); + fileInfo.CopyTo(FileSystem.Path.Join(targetDirectory, fileInfo.Name), true); } catch (Exception ex) { diff --git a/API/Services/EmailService.cs b/API/Services/EmailService.cs new file mode 100644 index 0000000000..08d00d29d4 --- /dev/null +++ b/API/Services/EmailService.cs @@ -0,0 +1,141 @@ +using System; +using System.Threading.Tasks; +using API.Data; +using API.DTOs.Email; +using API.Entities.Enums; +using Flurl.Http; +using Kavita.Common; +using Kavita.Common.EnvironmentInfo; +using Kavita.Common.Helpers; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace API.Services; + +public interface IEmailService +{ + Task SendConfirmationEmail(ConfirmationEmailDto data); + Task CheckIfAccessible(string host); + Task SendMigrationEmail(EmailMigrationDto data); + Task SendPasswordResetEmail(PasswordResetEmailDto data); + Task TestConnectivity(string emailUrl); +} + +public class EmailService : IEmailService +{ + private readonly ILogger _logger; + private readonly IUnitOfWork _unitOfWork; + + /// + /// This is used to initially set or reset the ServerSettingKey. Do not access from the code, access via UnitOfWork + /// + public const string DefaultApiUrl = "https://email.kavitareader.com"; + + public EmailService(ILogger logger, IUnitOfWork unitOfWork) + { + _logger = logger; + _unitOfWork = unitOfWork; + + FlurlHttp.ConfigureClient(DefaultApiUrl, cli => + cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); + } + + public async Task TestConnectivity(string emailUrl) + { + // FlurlHttp.ConfigureClient(emailUrl, cli => + // cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); + + var result = new EmailTestResultDto(); + try + { + result.Successful = await SendEmailWithGet(emailUrl + "/api/email/test"); + } + catch (KavitaException ex) + { + result.Successful = false; + result.ErrorMessage = ex.Message; + } + + return result; + } + + public async Task SendConfirmationEmail(ConfirmationEmailDto data) + { + var emailLink = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl)).Value; + var success = await SendEmailWithPost(emailLink + "/api/email/confirm", data); + if (!success) + { + _logger.LogError("There was a critical error sending Confirmation email"); + } + } + + public async Task CheckIfAccessible(string host) + { + // This is the only exception for using the default because we need an external service to check if the server is accessible for emails + return await SendEmailWithGet(DefaultApiUrl + "/api/email/reachable?host=" + host); + } + + public async Task SendMigrationEmail(EmailMigrationDto data) + { + var emailLink = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl)).Value; + return await SendEmailWithPost(emailLink + "/api/email/email-migration", data); + } + + public async Task SendPasswordResetEmail(PasswordResetEmailDto data) + { + var emailLink = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl)).Value; + return await SendEmailWithPost(emailLink + "/api/email/email-password-reset", data); + } + + private static async Task SendEmailWithGet(string url) + { + try + { + var response = await (url) + .WithHeader("Accept", "application/json") + .WithHeader("User-Agent", "Kavita") + .WithHeader("x-api-key", "MsnvA2DfQqxSK5jh") + .WithHeader("x-kavita-version", BuildInfo.Version) + .WithHeader("Content-Type", "application/json") + .WithTimeout(TimeSpan.FromSeconds(30)) + .GetStringAsync(); + + if (!string.IsNullOrEmpty(response) && bool.Parse(response)) + { + return true; + } + } + catch (Exception ex) + { + throw new KavitaException(ex.Message); + } + return false; + } + + + private static async Task SendEmailWithPost(string url, object data) + { + try + { + var response = await (url) + .WithHeader("Accept", "application/json") + .WithHeader("User-Agent", "Kavita") + .WithHeader("x-api-key", "MsnvA2DfQqxSK5jh") + .WithHeader("x-kavita-version", BuildInfo.Version) + .WithHeader("Content-Type", "application/json") + .WithTimeout(TimeSpan.FromSeconds(30)) + .PostJsonAsync(data); + + if (response.StatusCode != StatusCodes.Status200OK) + { + return false; + } + } + catch (Exception) + { + return false; + } + return true; + } + +} diff --git a/API/Services/ImageService.cs b/API/Services/ImageService.cs index 29a528e718..cc852b5bb3 100644 --- a/API/Services/ImageService.cs +++ b/API/Services/ImageService.cs @@ -14,6 +14,7 @@ public interface IImageService /// Creates a Thumbnail version of a base64 image /// /// base64 encoded image + /// /// File name with extension of the file. This will always write to string CreateThumbnailFromBase64(string encodedImage, string fileName); @@ -88,7 +89,7 @@ public string WriteCoverThumbnail(Stream stream, string fileName, string outputD try { _directoryService.FileSystem.File.Delete(_directoryService.FileSystem.Path.Join(outputDirectory, filename)); - } catch (Exception ex) {/* Swallow exception */} + } catch (Exception) {/* Swallow exception */} thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(outputDirectory, filename)); return filename; } diff --git a/API/Services/MetadataService.cs b/API/Services/MetadataService.cs index b6d45c77ed..75513193db 100644 --- a/API/Services/MetadataService.cs +++ b/API/Services/MetadataService.cs @@ -1,16 +1,12 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.IO; using System.Linq; using System.Threading.Tasks; using API.Comparators; using API.Data; -using API.Data.Metadata; using API.Data.Repositories; -using API.Data.Scanner; using API.Entities; -using API.Entities.Enums; using API.Extensions; using API.Helpers; using API.SignalR; @@ -61,7 +57,7 @@ public MetadataService(IUnitOfWork unitOfWork, ILogger logger, /// /// /// Force updating cover image even if underlying file has not been modified or chapter already has a cover image - private bool UpdateChapterCoverImage(Chapter chapter, bool forceUpdate) + private async Task UpdateChapterCoverImage(Chapter chapter, bool forceUpdate) { var firstFile = chapter.Files.OrderBy(x => x.Chapter).FirstOrDefault(); @@ -70,8 +66,9 @@ private bool UpdateChapterCoverImage(Chapter chapter, bool forceUpdate) if (firstFile == null) return false; - _logger.LogDebug("[MetadataService] Generating cover image for {File}", firstFile?.FilePath); + _logger.LogDebug("[MetadataService] Generating cover image for {File}", firstFile.FilePath); chapter.CoverImage = _readingItemService.GetCoverImage(firstFile.FilePath, ImageService.GetChapterFormat(chapter.Id, chapter.VolumeId), firstFile.Format); + await _messageHub.Clients.All.SendAsync(SignalREvents.CoverUpdate, MessageFactory.CoverUpdateEvent(chapter.Id, "chapter")); return true; } @@ -89,7 +86,7 @@ private void UpdateChapterLastModified(Chapter chapter, bool forceUpdate) /// /// /// Force updating cover image even if underlying file has not been modified or chapter already has a cover image - private bool UpdateVolumeCoverImage(Volume volume, bool forceUpdate) + private async Task UpdateVolumeCoverImage(Volume volume, bool forceUpdate) { // We need to check if Volume coverImage matches first chapters if forceUpdate is false if (volume == null || !_cacheHelper.ShouldUpdateCoverImage( @@ -101,6 +98,8 @@ private bool UpdateVolumeCoverImage(Volume volume, bool forceUpdate) if (firstChapter == null) return false; volume.CoverImage = firstChapter.CoverImage; + await _messageHub.Clients.All.SendAsync(SignalREvents.CoverUpdate, MessageFactory.CoverUpdateEvent(volume.Id, "volume")); + return true; } @@ -109,7 +108,7 @@ private bool UpdateVolumeCoverImage(Volume volume, bool forceUpdate) /// /// /// Force updating cover image even if underlying file has not been modified or chapter already has a cover image - private void UpdateSeriesCoverImage(Series series, bool forceUpdate) + private async Task UpdateSeriesCoverImage(Series series, bool forceUpdate) { if (series == null) return; @@ -136,6 +135,7 @@ private void UpdateSeriesCoverImage(Series series, bool forceUpdate) } } series.CoverImage = firstCover?.CoverImage ?? coverImage; + await _messageHub.Clients.All.SendAsync(SignalREvents.CoverUpdate, MessageFactory.CoverUpdateEvent(series.Id, "series")); } @@ -144,7 +144,7 @@ private void UpdateSeriesCoverImage(Series series, bool forceUpdate) /// /// /// - private void ProcessSeriesMetadataUpdate(Series series, ICollection allPeople, ICollection allGenres, ICollection allTags, bool forceUpdate) + private async Task ProcessSeriesMetadataUpdate(Series series, bool forceUpdate) { _logger.LogDebug("[MetadataService] Processing series {SeriesName}", series.OriginalName); try @@ -157,7 +157,7 @@ private void ProcessSeriesMetadataUpdate(Series series, ICollection allP var index = 0; foreach (var chapter in volume.Chapters) { - var chapterUpdated = UpdateChapterCoverImage(chapter, forceUpdate); + var chapterUpdated = await UpdateChapterCoverImage(chapter, forceUpdate); // If cover was update, either the file has changed or first scan and we should force a metadata update UpdateChapterLastModified(chapter, forceUpdate || chapterUpdated); if (index == 0 && chapterUpdated) @@ -168,7 +168,7 @@ private void ProcessSeriesMetadataUpdate(Series series, ICollection allP index++; } - var volumeUpdated = UpdateVolumeCoverImage(volume, firstChapterUpdated || forceUpdate); + var volumeUpdated = await UpdateVolumeCoverImage(volume, firstChapterUpdated || forceUpdate); if (volumeIndex == 0 && volumeUpdated) { firstVolumeUpdated = true; @@ -176,7 +176,7 @@ private void ProcessSeriesMetadataUpdate(Series series, ICollection allP volumeIndex++; } - UpdateSeriesCoverImage(series, firstVolumeUpdated || forceUpdate); + await UpdateSeriesCoverImage(series, firstVolumeUpdated || forceUpdate); } catch (Exception ex) { @@ -220,17 +220,12 @@ await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress, }); _logger.LogDebug("[MetadataService] Fetched {SeriesCount} series for refresh", nonLibrarySeries.Count); - var allPeople = await _unitOfWork.PersonRepository.GetAllPeople(); - var allGenres = await _unitOfWork.GenreRepository.GetAllGenresAsync(); - var allTags = await _unitOfWork.TagRepository.GetAllTagsAsync(); - - var seriesIndex = 0; foreach (var series in nonLibrarySeries) { try { - ProcessSeriesMetadataUpdate(series, allPeople, allGenres, allTags, forceUpdate); + await ProcessSeriesMetadataUpdate(series, forceUpdate); } catch (Exception ex) { @@ -245,10 +240,7 @@ await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress, } await _unitOfWork.CommitAsync(); - foreach (var series in nonLibrarySeries) - { - await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadata, MessageFactory.RefreshMetadataEvent(library.Id, series.Id)); - } + _logger.LogInformation( "[MetadataService] Processed {SeriesStart} - {SeriesEnd} out of {TotalSeries} series in {ElapsedScanTime} milliseconds for {LibraryName}", chunk * chunkInfo.ChunkSize, (chunk * chunkInfo.ChunkSize) + nonLibrarySeries.Count, chunkInfo.TotalSize, stopwatch.ElapsedMilliseconds, library.Name); @@ -270,64 +262,6 @@ private async Task RemoveAbandonedMetadataKeys() await _unitOfWork.GenreRepository.RemoveAllGenreNoLongerAssociated(); } - // TODO: I can probably refactor RefreshMetadata and RefreshMetadataForSeries to be the same by utilizing chunk size of 1, so most of the code can be the same. - private async Task PerformScan(Library library, bool forceUpdate, Action action) - { - var chunkInfo = await _unitOfWork.SeriesRepository.GetChunkInfo(library.Id); - var stopwatch = Stopwatch.StartNew(); - var totalTime = 0L; - _logger.LogInformation("[MetadataService] Refreshing Library {LibraryName}. Total Items: {TotalSize}. Total Chunks: {TotalChunks} with {ChunkSize} size", library.Name, chunkInfo.TotalSize, chunkInfo.TotalChunks, chunkInfo.ChunkSize); - await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress, - MessageFactory.RefreshMetadataProgressEvent(library.Id, 0F)); - - for (var chunk = 1; chunk <= chunkInfo.TotalChunks; chunk++) - { - if (chunkInfo.TotalChunks == 0) continue; - totalTime += stopwatch.ElapsedMilliseconds; - stopwatch.Restart(); - - action(chunk, chunkInfo); - - // _logger.LogInformation("[MetadataService] Processing chunk {ChunkNumber} / {TotalChunks} with size {ChunkSize}. Series ({SeriesStart} - {SeriesEnd}", - // chunk, chunkInfo.TotalChunks, chunkInfo.ChunkSize, chunk * chunkInfo.ChunkSize, (chunk + 1) * chunkInfo.ChunkSize); - // var nonLibrarySeries = await _unitOfWork.SeriesRepository.GetFullSeriesForLibraryIdAsync(library.Id, - // new UserParams() - // { - // PageNumber = chunk, - // PageSize = chunkInfo.ChunkSize - // }); - // _logger.LogDebug("[MetadataService] Fetched {SeriesCount} series for refresh", nonLibrarySeries.Count); - // - // var chapterIds = await _unitOfWork.SeriesRepository.GetChapterIdWithSeriesIdForSeriesAsync(nonLibrarySeries.Select(s => s.Id).ToArray()); - // var allPeople = await _unitOfWork.PersonRepository.GetAllPeople(); - // var allGenres = await _unitOfWork.GenreRepository.GetAllGenres(); - // - // - // var seriesIndex = 0; - // foreach (var series in nonLibrarySeries) - // { - // try - // { - // ProcessSeriesMetadataUpdate(series, chapterIds, allPeople, allGenres, forceUpdate); - // } - // catch (Exception ex) - // { - // _logger.LogError(ex, "[MetadataService] There was an exception during metadata refresh for {SeriesName}", series.Name); - // } - // var index = chunk * seriesIndex; - // var progress = Math.Max(0F, Math.Min(1F, index * 1F / chunkInfo.TotalSize)); - // - // await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress, - // MessageFactory.RefreshMetadataProgressEvent(library.Id, progress)); - // seriesIndex++; - // } - - await _unitOfWork.CommitAsync(); - } - } - - - /// /// Refreshes Metadata for a Series. Will always force updates. /// @@ -346,21 +280,17 @@ public async Task RefreshMetadataForSeries(int libraryId, int seriesId, bool for await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress, MessageFactory.RefreshMetadataProgressEvent(libraryId, 0F)); - var allPeople = await _unitOfWork.PersonRepository.GetAllPeople(); - var allGenres = await _unitOfWork.GenreRepository.GetAllGenresAsync(); - var allTags = await _unitOfWork.TagRepository.GetAllTagsAsync(); - - ProcessSeriesMetadataUpdate(series, allPeople, allGenres, allTags, forceUpdate); - - await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress, - MessageFactory.RefreshMetadataProgressEvent(libraryId, 1F)); + await ProcessSeriesMetadataUpdate(series, forceUpdate); - if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync()) + if (_unitOfWork.HasChanges()) { - await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadata, MessageFactory.RefreshMetadataEvent(series.LibraryId, series.Id)); + await _unitOfWork.CommitAsync(); } + await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress, + MessageFactory.RefreshMetadataProgressEvent(libraryId, 1F)); + await RemoveAbandonedMetadataKeys(); _logger.LogInformation("[MetadataService] Updated metadata for {SeriesName} in {ElapsedMilliseconds} milliseconds", series.Name, sw.ElapsedMilliseconds); diff --git a/API/Services/ReaderService.cs b/API/Services/ReaderService.cs index b9862cf054..36a98317b8 100644 --- a/API/Services/ReaderService.cs +++ b/API/Services/ReaderService.cs @@ -22,23 +22,22 @@ public interface IReaderService Task CapPageToChapter(int chapterId, int page); Task GetNextChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId); Task GetPrevChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId); + Task GetContinuePoint(int seriesId, int userId); + Task MarkChaptersUntilAsRead(AppUser user, int seriesId, float chapterNumber); + Task MarkVolumesUntilAsRead(AppUser user, int seriesId, int volumeNumber); } public class ReaderService : IReaderService { private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; - private readonly IDirectoryService _directoryService; - private readonly ICacheService _cacheService; private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer(); private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst(); - public ReaderService(IUnitOfWork unitOfWork, ILogger logger, IDirectoryService directoryService, ICacheService cacheService) + public ReaderService(IUnitOfWork unitOfWork, ILogger logger) { _unitOfWork = unitOfWork; _logger = logger; - _directoryService = directoryService; - _cacheService = cacheService; } public static string FormatBookmarkFolderPath(string baseDirectory, int userId, int seriesId, int chapterId) @@ -176,6 +175,7 @@ public async Task SaveReadingProgress(ProgressDto progressDto, int userId) _unitOfWork.AppUserProgressRepository.Update(userProgress); } + if (!_unitOfWork.HasChanges()) return true; if (await _unitOfWork.CommitAsync()) { return true; @@ -216,7 +216,7 @@ public async Task CapPageToChapter(int chapterId, int page) /// Tries to find the next logical Chapter /// /// - /// V1 → V2 → V3 chapter 0 → V3 chapter 10 → SP 01 → SP 02 + /// V1 → V2 → V3 chapter 0 → V3 chapter 10 → V0 chapter 1 -> V0 chapter 2 -> SP 01 → SP 02 /// /// /// @@ -232,7 +232,7 @@ public async Task GetNextChapterIdAsync(int seriesId, int volumeId, int cur if (currentVolume.Number == 0) { // Handle specials by sorting on their Filename aka Range - var chapterId = GetNextChapterId(currentVolume.Chapters.OrderByNatural(x => x.Range), currentChapter.Number); + var chapterId = GetNextChapterId(currentVolume.Chapters.OrderByNatural(x => x.Range), currentChapter.Range, dto => dto.Range); if (chapterId > 0) return chapterId; } @@ -242,8 +242,10 @@ public async Task GetNextChapterIdAsync(int seriesId, int volumeId, int cur { // Handle Chapters within current Volume // In this case, i need 0 first because 0 represents a full volume file. - var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting), currentChapter.Number); + var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting), + currentChapter.Range, dto => dto.Range); if (chapterId > 0) return chapterId; + } if (volume.Number != currentVolume.Number + 1) continue; @@ -257,9 +259,26 @@ public async Task GetNextChapterIdAsync(int seriesId, int volumeId, int cur } var firstChapter = chapters.FirstOrDefault(); + if (firstChapter == null) break; + var isSpecial = firstChapter.IsSpecial || currentChapter.IsSpecial; + if (isSpecial) + { + var chapterId = GetNextChapterId(volume.Chapters.OrderByNatural(x => x.Number), + currentChapter.Range, dto => dto.Range); + if (chapterId > 0) return chapterId; + } else if (double.Parse(firstChapter.Number) > double.Parse(currentChapter.Number)) return firstChapter.Id; + } + + // If we are the last volume and we didn't find any next volume, loop back to volume 0 and give the first chapter + // This has an added problem that it will loop up to the beginning always + // Should I change this to Max number? volumes.LastOrDefault()?.Number -> volumes.Max(v => v.Number) + if (currentVolume.Number != 0 && currentVolume.Number == volumes.LastOrDefault()?.Number && volumes.Count > 1) + { + var chapterVolume = volumes.FirstOrDefault(); + if (chapterVolume?.Number != 0) return -1; + var firstChapter = chapterVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparer).FirstOrDefault(); if (firstChapter == null) return -1; return firstChapter.Id; - } return -1; @@ -268,7 +287,7 @@ public async Task GetNextChapterIdAsync(int seriesId, int volumeId, int cur /// Tries to find the prev logical Chapter /// /// - /// V1 ← V2 ← V3 chapter 0 ← V3 chapter 10 ← SP 01 ← SP 02 + /// V1 ← V2 ← V3 chapter 0 ← V3 chapter 10 ← V0 chapter 1 ← V0 chapter 2 ← SP 01 ← SP 02 /// /// /// @@ -283,7 +302,7 @@ public async Task GetPrevChapterIdAsync(int seriesId, int volumeId, int cur if (currentVolume.Number == 0) { - var chapterId = GetNextChapterId(currentVolume.Chapters.OrderByNatural(x => x.Range).Reverse(), currentChapter.Number); + var chapterId = GetNextChapterId(currentVolume.Chapters.OrderByNatural(x => x.Range).Reverse(), currentChapter.Number, dto => dto.Number); if (chapterId > 0) return chapterId; } @@ -291,7 +310,8 @@ public async Task GetPrevChapterIdAsync(int seriesId, int volumeId, int cur { if (volume.Number == currentVolume.Number) { - var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting).Reverse(), currentChapter.Number); + var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting).Reverse(), + currentChapter.Number, dto => dto.Number); if (chapterId > 0) return chapterId; } if (volume.Number == currentVolume.Number - 1) @@ -302,11 +322,46 @@ public async Task GetPrevChapterIdAsync(int seriesId, int volumeId, int cur return lastChapter.Id; } } + + var lastVolume = volumes.OrderBy(v => v.Number).LastOrDefault(); + if (currentVolume.Number == 0 && currentVolume.Number != lastVolume?.Number && lastVolume?.Chapters.Count > 1) + { + var lastChapter = lastVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting).LastOrDefault(); + if (lastChapter == null) return -1; + return lastChapter.Id; + } + + return -1; } + public async Task GetContinuePoint(int seriesId, int userId) + { + // Loop through all chapters that are not in volume 0 + var volumes = (await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId)).ToList(); + + var nonSpecialChapters = volumes + .Where(v => v.Number != 0) + .SelectMany(v => v.Chapters) + .OrderBy(c => float.Parse(c.Number)) + .ToList(); + + var currentlyReadingChapter = nonSpecialChapters.FirstOrDefault(chapter => chapter.PagesRead < chapter.Pages); + + + if (currentlyReadingChapter != null) return currentlyReadingChapter; + + // Check if there are any specials + var volume = volumes.SingleOrDefault(v => v.Number == 0); + if (volume == null) return nonSpecialChapters.First(); + + var chapters = volume.Chapters.OrderBy(c => float.Parse(c.Number)).ToList(); + + return chapters.FirstOrDefault(chapter => chapter.PagesRead < chapter.Pages) ?? chapters.First(); + } + - private static int GetNextChapterId(IEnumerable chapters, string currentChapterNumber) + private static int GetNextChapterId(IEnumerable chapters, string currentChapterNumber, Func accessor) { var next = false; var chaptersList = chapters.ToList(); @@ -316,11 +371,38 @@ private static int GetNextChapterId(IEnumerable chapters, string cur { return chapter.Id; } - if (currentChapterNumber.Equals(chapter.Number)) next = true; + + var chapterNum = accessor(chapter); + if (currentChapterNumber.Equals(chapterNum)) next = true; } return -1; } + /// + /// Marks every chapter that is sorted below the passed number as Read. This will not mark any specials as read or Volumes with a single 0 chapter. + /// + /// + /// + /// + public async Task MarkChaptersUntilAsRead(AppUser user, int seriesId, float chapterNumber) + { + var volumes = await _unitOfWork.VolumeRepository.GetVolumesForSeriesAsync(new List() { seriesId }, true); + foreach (var volume in volumes.OrderBy(v => v.Number)) + { + var chapters = volume.Chapters + .OrderBy(c => float.Parse(c.Number)) + .Where(c => !c.IsSpecial && Parser.Parser.MaximumNumberFromRange(c.Range) <= chapterNumber && Parser.Parser.MaximumNumberFromRange(c.Range) > 0.0); + MarkChaptersAsRead(user, volume.SeriesId, chapters); + } + } + public async Task MarkVolumesUntilAsRead(AppUser user, int seriesId, int volumeNumber) + { + var volumes = await _unitOfWork.VolumeRepository.GetVolumesForSeriesAsync(new List() { seriesId }, true); + foreach (var volume in volumes.OrderBy(v => v.Number).Where(v => v.Number <= volumeNumber)) + { + MarkChaptersAsRead(user, volume.SeriesId, volume.Chapters); + } + } } diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index 9f91bf75c9..6c1d914cf8 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -35,7 +35,6 @@ public class TaskScheduler : ITaskScheduler private readonly IStatsService _statsService; private readonly IVersionUpdaterService _versionUpdaterService; - private readonly IDirectoryService _directoryService; public static BackgroundJobServer Client => new BackgroundJobServer(); private static readonly Random Rnd = new Random(); @@ -43,8 +42,7 @@ public class TaskScheduler : ITaskScheduler public TaskScheduler(ICacheService cacheService, ILogger logger, IScannerService scannerService, IUnitOfWork unitOfWork, IMetadataService metadataService, IBackupService backupService, - ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService, - IDirectoryService directoryService) + ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService) { _cacheService = cacheService; _logger = logger; @@ -55,7 +53,6 @@ public TaskScheduler(ICacheService cacheService, ILogger logger, _cleanupService = cleanupService; _statsService = statsService; _versionUpdaterService = versionUpdaterService; - _directoryService = directoryService; } public async Task ScheduleTasks() diff --git a/API/Services/Tasks/CleanupService.cs b/API/Services/Tasks/CleanupService.cs index d31e50a22c..fbb87ecd58 100644 --- a/API/Services/Tasks/CleanupService.cs +++ b/API/Services/Tasks/CleanupService.cs @@ -1,5 +1,4 @@ using System; -using System.IO; using System.Linq; using System.Threading.Tasks; using API.Data; @@ -66,8 +65,8 @@ public async Task Cleanup() await SendProgress(0.7F); await DeleteTagCoverImages(); await SendProgress(0.8F); - _logger.LogInformation("Cleaning old bookmarks"); - await CleanupBookmarks(); + //_logger.LogInformation("Cleaning old bookmarks"); + //await CleanupBookmarks(); await SendProgress(1F); _logger.LogInformation("Cleanup finished"); } @@ -172,31 +171,35 @@ public async Task CleanupBackups() /// /// Removes all files in the BookmarkDirectory that don't currently have bookmarks in the Database /// - public async Task CleanupBookmarks() + public Task CleanupBookmarks() { + // This is disabled for now while we test and validate a new method of deleting bookmarks + return Task.CompletedTask; // Search all files in bookmarks/ except bookmark files and delete those - var bookmarkDirectory = - (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value; - var allBookmarkFiles = _directoryService.GetFiles(bookmarkDirectory, searchOption: SearchOption.AllDirectories).Select(Parser.Parser.NormalizePath); - var bookmarks = (await _unitOfWork.UserRepository.GetAllBookmarksAsync()) - .Select(b => Parser.Parser.NormalizePath(_directoryService.FileSystem.Path.Join(bookmarkDirectory, - b.FileName))); - - - var filesToDelete = allBookmarkFiles.ToList().Except(bookmarks).ToList(); - _logger.LogDebug("[Bookmarks] Bookmark cleanup wants to delete {Count} files", filesToDelete.Count()); - - _directoryService.DeleteFiles(filesToDelete); - - // Clear all empty directories - foreach (var directory in _directoryService.FileSystem.Directory.GetDirectories(bookmarkDirectory)) - { - if (_directoryService.FileSystem.Directory.GetFiles(directory).Length == 0 && - _directoryService.FileSystem.Directory.GetDirectories(directory).Length == 0) - { - _directoryService.FileSystem.Directory.Delete(directory, false); - } - } + // var bookmarkDirectory = + // (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value; + // var allBookmarkFiles = _directoryService.GetFiles(bookmarkDirectory, searchOption: SearchOption.AllDirectories).Select(Parser.Parser.NormalizePath); + // var bookmarks = (await _unitOfWork.UserRepository.GetAllBookmarksAsync()) + // .Select(b => Parser.Parser.NormalizePath(_directoryService.FileSystem.Path.Join(bookmarkDirectory, + // b.FileName))); + // + // + // var filesToDelete = allBookmarkFiles.AsEnumerable().Except(bookmarks).ToList(); + // _logger.LogDebug("[Bookmarks] Bookmark cleanup wants to delete {Count} files", filesToDelete.Count); + // + // if (filesToDelete.Count == 0) return; + // + // _directoryService.DeleteFiles(filesToDelete); + // + // // Clear all empty directories + // foreach (var directory in _directoryService.FileSystem.Directory.GetDirectories(bookmarkDirectory, "", SearchOption.AllDirectories)) + // { + // if (_directoryService.FileSystem.Directory.GetFiles(directory, "", SearchOption.AllDirectories).Length == 0 && + // _directoryService.FileSystem.Directory.GetDirectories(directory).Length == 0) + // { + // _directoryService.FileSystem.Directory.Delete(directory, false); + // } + // } } } } diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index 7c6a51f2ca..86b8198194 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -250,6 +250,7 @@ await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress, var scanner = new ParseScannedFiles(_logger, _directoryService, _readingItemService); var series = scanner.ScanLibrariesForSeries(library.Type, library.Folders.Select(fp => fp.Path), out var totalFiles, out var scanElapsedTime); + _logger.LogInformation("[ScannerService] Finished file scan. Updating database"); foreach (var folderPath in library.Folders) { @@ -379,6 +380,11 @@ await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryError, await _messageHub.Clients.All.SendAsync(SignalREvents.SeriesRemoved, MessageFactory.SeriesRemovedEvent(missing.Id, missing.Name, library.Id)); } + foreach (var series in librarySeries) + { + await _messageHub.Clients.All.SendAsync(SignalREvents.ScanSeries, MessageFactory.ScanSeriesEvent(series.Id, series.Name)); + } + var progress = Math.Max(0, Math.Min(1, ((chunk + 1F) * chunkInfo.ChunkSize) / chunkInfo.TotalSize)); await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress, MessageFactory.ScanLibraryProgressEvent(library.Id, progress)); @@ -569,7 +575,7 @@ private static void UpdateSeriesMetadata(Series series, ICollection allP PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Translator).Select(p => p.Name), PersonRole.Translator, person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person)); - TagHelper.UpdateTag(allTags, chapter.Tags.Select(t => t.Title), false, (tag, added) => + TagHelper.UpdateTag(allTags, chapter.Tags.Select(t => t.Title), false, (tag, _) => TagHelper.AddTagIfNotExists(series.Metadata.Tags, tag)); GenreHelper.UpdateGenre(allGenres, chapter.Genres.Select(t => t.Title), false, genre => @@ -821,7 +827,7 @@ private void UpdateChapterFromComicInfo(Chapter chapter, ICollection all // Remove all tags that aren't matching between chapter tags and metadata TagHelper.KeepOnlySameTagBetweenLists(chapter.Tags, tags.Select(t => DbFactory.Tag(t, false)).ToList()); TagHelper.UpdateTag(allTags, tags, false, - (tag, added) => + (tag, _) => { chapter.Tags.Add(tag); }); diff --git a/API/Services/Tasks/StatsService.cs b/API/Services/Tasks/StatsService.cs index 1b9f25593d..298b8f2b7d 100644 --- a/API/Services/Tasks/StatsService.cs +++ b/API/Services/Tasks/StatsService.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Net.Http; using System.Runtime.InteropServices; using System.Threading.Tasks; @@ -7,6 +8,7 @@ using API.Entities.Enums; using Flurl.Http; using Kavita.Common.EnvironmentInfo; +using Kavita.Common.Helpers; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; @@ -104,7 +106,9 @@ public async Task GetServerInfo() KavitaVersion = BuildInfo.Version.ToString(), DotnetVersion = Environment.Version.ToString(), IsDocker = new OsInfo(Array.Empty()).IsDocker, - NumOfCores = Math.Max(Environment.ProcessorCount, 1) + NumOfCores = Math.Max(Environment.ProcessorCount, 1), + HasBookmarks = (await _unitOfWork.UserRepository.GetAllBookmarksAsync()).Any(), + NumberOfLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).Count() }; return serverInfo; diff --git a/API/Services/Tasks/VersionUpdaterService.cs b/API/Services/Tasks/VersionUpdaterService.cs index 1781110513..255d0b1054 100644 --- a/API/Services/Tasks/VersionUpdaterService.cs +++ b/API/Services/Tasks/VersionUpdaterService.cs @@ -1,14 +1,13 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Net.Http; using System.Threading.Tasks; using API.DTOs.Update; using API.SignalR; using API.SignalR.Presence; using Flurl.Http; -using Flurl.Http.Configuration; using Kavita.Common.EnvironmentInfo; +using Kavita.Common.Helpers; using MarkdownDeep; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Hosting; @@ -44,15 +43,6 @@ internal class GithubReleaseMetadata public string Published_At { get; init; } } -public class UntrustedCertClientFactory : DefaultHttpClientFactory -{ - public override HttpMessageHandler CreateMessageHandler() { - return new HttpClientHandler { - ServerCertificateCustomValidationCallback = (_, _, _, _) => true - }; - } -} - public interface IVersionUpdaterService { Task CheckForUpdate(); @@ -67,8 +57,8 @@ public class VersionUpdaterService : IVersionUpdaterService private readonly IPresenceTracker _tracker; private readonly Markdown _markdown = new MarkdownDeep.Markdown(); #pragma warning disable S1075 - private static readonly string GithubLatestReleasesUrl = "https://api.github.com/repos/Kareadita/Kavita/releases/latest"; - private static readonly string GithubAllReleasesUrl = "https://api.github.com/repos/Kareadita/Kavita/releases"; + private const string GithubLatestReleasesUrl = "https://api.github.com/repos/Kareadita/Kavita/releases/latest"; + private const string GithubAllReleasesUrl = "https://api.github.com/repos/Kareadita/Kavita/releases"; #pragma warning restore S1075 public VersionUpdaterService(ILogger logger, IHubContext messageHub, IPresenceTracker tracker) @@ -86,10 +76,12 @@ public VersionUpdaterService(ILogger logger, IHubContext< /// /// Fetches the latest release from Github /// - public async Task CheckForUpdate() + /// Latest update or null if current version is greater than latest update + public async Task CheckForUpdate() { var update = await GetGithubRelease(); - return CreateDto(update); + var dto = CreateDto(update); + return new Version(dto.UpdateVersion) <= new Version(dto.CurrentVersion) ? null : dto; } public async Task> GetAllReleases() diff --git a/API/Services/TokenService.cs b/API/Services/TokenService.cs index 8145b330e4..4b734e8b96 100644 --- a/API/Services/TokenService.cs +++ b/API/Services/TokenService.cs @@ -5,6 +5,7 @@ using System.Security.Claims; using System.Text; using System.Threading.Tasks; +using API.DTOs.Account; using API.Entities; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Configuration; @@ -17,6 +18,8 @@ namespace API.Services; public interface ITokenService { Task CreateToken(AppUser user); + Task ValidateRefreshToken(TokenRequestDto request); + Task CreateRefreshToken(AppUser user); } public class TokenService : ITokenService @@ -47,7 +50,7 @@ public async Task CreateToken(AppUser user) var tokenDescriptor = new SecurityTokenDescriptor() { Subject = new ClaimsIdentity(claims), - Expires = DateTime.Now.AddDays(7), + Expires = DateTime.Now.AddDays(14), SigningCredentials = creds }; @@ -56,4 +59,33 @@ public async Task CreateToken(AppUser user) return tokenHandler.WriteToken(token); } + + public async Task CreateRefreshToken(AppUser user) + { + await _userManager.RemoveAuthenticationTokenAsync(user, TokenOptions.DefaultProvider, "RefreshToken"); + var refreshToken = await _userManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, "RefreshToken"); + await _userManager.SetAuthenticationTokenAsync(user, TokenOptions.DefaultProvider, "RefreshToken", refreshToken); + return refreshToken; + } + + public async Task ValidateRefreshToken(TokenRequestDto request) + { + var tokenHandler = new JwtSecurityTokenHandler(); + var tokenContent = tokenHandler.ReadJwtToken(request.Token); + var username = tokenContent.Claims.FirstOrDefault(q => q.Type == JwtRegisteredClaimNames.NameId)?.Value; + var user = await _userManager.FindByNameAsync(username); + var isValid = await _userManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, "RefreshToken", request.RefreshToken); + if (isValid) + { + return new TokenRequestDto() + { + Token = await CreateToken(user), + RefreshToken = await CreateRefreshToken(user) + }; + } + + await _userManager.UpdateSecurityStampAsync(user); + + return null; + } } diff --git a/API/SignalR/MessageFactory.cs b/API/SignalR/MessageFactory.cs index 25262430a4..bf7c649bfe 100644 --- a/API/SignalR/MessageFactory.cs +++ b/API/SignalR/MessageFactory.cs @@ -75,20 +75,6 @@ public static SignalRMessage RefreshMetadataProgressEvent(int libraryId, float p } - - public static SignalRMessage RefreshMetadataEvent(int libraryId, int seriesId) - { - return new SignalRMessage() - { - Name = SignalREvents.RefreshMetadata, - Body = new - { - SeriesId = seriesId, - LibraryId = libraryId - } - }; - } - public static SignalRMessage BackupDatabaseProgressEvent(float progress) { return new SignalRMessage() @@ -161,5 +147,18 @@ public static SignalRMessage DownloadProgressEvent(string username, string downl } }; } + + public static SignalRMessage CoverUpdateEvent(int id, string entityType) + { + return new SignalRMessage() + { + Name = SignalREvents.CoverUpdate, + Body = new + { + Id = id, + EntityType = entityType, + } + }; + } } } diff --git a/API/SignalR/SignalREvents.cs b/API/SignalR/SignalREvents.cs index 15590f4266..1da613455f 100644 --- a/API/SignalR/SignalREvents.cs +++ b/API/SignalR/SignalREvents.cs @@ -2,12 +2,14 @@ { public static class SignalREvents { + /// + /// An update is available for the Kavita instance + /// public const string UpdateAvailable = "UpdateAvailable"; - public const string ScanSeries = "ScanSeries"; /// - /// Event during Refresh Metadata for cover image change + /// Used to tell when a scan series completes /// - public const string RefreshMetadata = "RefreshMetadata"; + public const string ScanSeries = "ScanSeries"; /// /// Event sent out during Refresh Metadata for progress tracking /// @@ -48,6 +50,9 @@ public static class SignalREvents /// Event sent out during downloading of files /// public const string DownloadProgress = "DownloadProgress"; - + /// + /// A cover was updated + /// + public const string CoverUpdate = "CoverUpdate"; } } diff --git a/API/Startup.cs b/API/Startup.cs index 00bfdd589b..2f9ad133bf 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -5,6 +5,7 @@ using System.Net; using System.Net.Sockets; using System.Threading.Tasks; +using API.Constants; using API.Data; using API.Entities; using API.Extensions; @@ -24,7 +25,6 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.ResponseCompression; using Microsoft.AspNetCore.StaticFiles; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -143,45 +143,15 @@ public void Configure(IApplicationBuilder app, IBackgroundJobClient backgroundJo Task.Run(async () => { // Apply all migrations on startup - // If we have pending migrations, make a backup first - //var isDocker = new OsInfo(Array.Empty()).IsDocker; var logger = serviceProvider.GetRequiredService>(); - var context = serviceProvider.GetRequiredService(); - // var pendingMigrations = await context.Database.GetPendingMigrationsAsync(); - // if (pendingMigrations.Any()) - // { - // logger.LogInformation("Performing backup as migrations are needed"); - // await backupService.BackupDatabase(); - // } - // - // await context.Database.MigrateAsync(); - // var roleManager = serviceProvider.GetRequiredService>(); - // - // await Seed.SeedRoles(roleManager); - // await Seed.SeedSettings(context, directoryService); - // await Seed.SeedUserApiKeys(context); + var userManager = serviceProvider.GetRequiredService>(); + await MigrateBookmarks.Migrate(directoryService, unitOfWork, logger, cacheService); - var requiresCoverImageMigration = !Directory.Exists(directoryService.CoverImageDirectory); - try - { - // If this is a new install, tables wont exist yet - if (requiresCoverImageMigration) - { - MigrateCoverImages.ExtractToImages(context, directoryService, imageService); - } - } - catch (Exception) - { - requiresCoverImageMigration = false; - } - - if (requiresCoverImageMigration) - { - await MigrateCoverImages.UpdateDatabaseWithImages(context, directoryService); - } + // Only run this if we are upgrading + await MigrateChangePasswordRoles.Migrate(unitOfWork, userManager); }).GetAwaiter() .GetResult(); } diff --git a/API/config/appsettings.Development.json b/API/config/appsettings.Development.json index ac17075928..7401b734b1 100644 --- a/API/config/appsettings.Development.json +++ b/API/config/appsettings.Development.json @@ -15,7 +15,7 @@ "Path": "config//logs/kavita.log", "Append": "True", "FileSizeLimitBytes": 26214400, - "MaxRollingFiles": 2 + "MaxRollingFiles": 1 } }, "Port": 5000 diff --git a/Dockerfile b/Dockerfile index 82fd49132d..11db76ef8f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,7 +20,7 @@ COPY --from=copytask /files/wwwroot /kavita/wwwroot #Installs program dependencies RUN apt-get update \ - && apt-get install -y libicu-dev libssl1.1 libgdiplus \ + && apt-get install -y libicu-dev libssl1.1 libgdiplus curl\ && rm -rf /var/lib/apt/lists/* COPY entrypoint.sh /entrypoint.sh @@ -29,5 +29,7 @@ EXPOSE 5000 WORKDIR /kavita +HEALTHCHECK --interval=300s --timeout=15s --start-period=30s --retries=3 CMD curl --fail http://localhost:5000 || exit 1 + ENTRYPOINT [ "/bin/bash" ] CMD ["/entrypoint.sh"] diff --git a/Kavita.Common/AppSettingsConfig.cs b/Kavita.Common/AppSettingsConfig.cs deleted file mode 100644 index c7718b2305..0000000000 --- a/Kavita.Common/AppSettingsConfig.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Kavita.Common -{ - public class AppSettingsConfig - { - - } -} diff --git a/Kavita.Common/Configuration.cs b/Kavita.Common/Configuration.cs index 7593ae84a6..55aa99598f 100644 --- a/Kavita.Common/Configuration.cs +++ b/Kavita.Common/Configuration.cs @@ -329,7 +329,7 @@ private static string GetDatabasePath(string filePath) } /// - /// This should NEVER be called except by + /// This should NEVER be called except by MigrateConfigFiles /// /// /// diff --git a/Kavita.Common/Helpers/UntrustedCertClientFactory.cs b/Kavita.Common/Helpers/UntrustedCertClientFactory.cs new file mode 100644 index 0000000000..6ddb2a9f33 --- /dev/null +++ b/Kavita.Common/Helpers/UntrustedCertClientFactory.cs @@ -0,0 +1,13 @@ +using System.Net.Http; +using Flurl.Http.Configuration; + +namespace Kavita.Common.Helpers; + +public class UntrustedCertClientFactory : DefaultHttpClientFactory +{ + public override HttpMessageHandler CreateMessageHandler() { + return new HttpClientHandler { + ServerCertificateCustomValidationCallback = (_, _, _, _) => true + }; + } +} diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index 8285453bb1..91a6ab754c 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -4,11 +4,12 @@ net6.0 kavitareader.com Kavita - 0.5.0.0 + 0.5.1.0 en + diff --git a/Kavita.Email/Kavita.Email.csproj b/Kavita.Email/Kavita.Email.csproj new file mode 100644 index 0000000000..5a95578901 --- /dev/null +++ b/Kavita.Email/Kavita.Email.csproj @@ -0,0 +1,22 @@ + + + + net6.0 + enable + enable + + + + + + + + + + + + + + + + diff --git a/Logo/hosting-sponsor.png b/Logo/hosting-sponsor.png new file mode 100644 index 0000000000..81c0b3d786 Binary files /dev/null and b/Logo/hosting-sponsor.png differ diff --git a/README.md b/README.md index d32b48c262..55570e1928 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ your reading collection with your friends and family! - [x] First class responsive readers that work great on any device (phone, tablet, desktop) - [x] Dark and Light themes - [ ] Provide hooks into metadata providers to fetch metadata for Comics, Manga, and Books -- [ ] Metadata should allow for collections, want to read integration from 3rd party services, genres. +- [x] Metadata should allow for collections, want to read integration from 3rd party services, genres. - [x] Ability to manage users, access, and ratings - [ ] Ability to sync ratings and reviews to external services - [x] Fully Accessible with active accessibility audits @@ -116,8 +116,12 @@ Thank you to [ JetBrains](http: * [ Rider](http://www.jetbrains.com/rider/) * [ dotTrace](http://www.jetbrains.com/dottrace/) +## Palace-Designs +We would like to extend a big thank you to [](https://www.palace-designs.com/) who hosts our infrastructure pro-bono. + + ### License * [GNU GPL v3](http://www.gnu.org/licenses/gpl.html) -* Copyright 2020-2021 +* Copyright 2020-2022 diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index 8a71fcd8b5..68c606f646 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -3255,11 +3255,6 @@ "integrity": "sha1-l6ERlkmyEa0zaR2fn0hqjsn74KM=", "dev": true }, - "angular-ng-autocomplete": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/angular-ng-autocomplete/-/angular-ng-autocomplete-2.0.5.tgz", - "integrity": "sha512-mYALrzwc5eoFR5xz/diup5GDsxqXp3L707P4CkiBl5l01fKej0nyIDTQ+xXtZUK3spXIyfuOX0ypa9wTrgCP5A==" - }, "ansi-colors": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", @@ -10645,9 +10640,33 @@ "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==" + "version": "2.6.7", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", + "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "requires": { + "whatwg-url": "^5.0.0" + }, + "dependencies": { + "tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=" + }, + "webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=" + }, + "whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", + "requires": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + } + } }, "node-forge": { "version": "0.10.0", diff --git a/UI/Web/package.json b/UI/Web/package.json index c54eee7134..26fb10c7f7 100644 --- a/UI/Web/package.json +++ b/UI/Web/package.json @@ -32,7 +32,6 @@ "@ngx-lite/nav-drawer": "^0.4.6", "@ngx-lite/util": "0.0.0", "@types/file-saver": "^2.0.1", - "angular-ng-autocomplete": "^2.0.5", "bootstrap": "^4.5.0", "bowser": "^2.11.0", "file-saver": "^2.0.5", diff --git a/UI/Web/src/app/_interceptors/error.interceptor.ts b/UI/Web/src/app/_interceptors/error.interceptor.ts index a924b6b2ed..2e4b50acdb 100644 --- a/UI/Web/src/app/_interceptors/error.interceptor.ts +++ b/UI/Web/src/app/_interceptors/error.interceptor.ts @@ -62,9 +62,15 @@ export class ErrorInterceptor implements HttpInterceptor { if (Array.isArray(error.error)) { const modalStateErrors: any[] = []; if (error.error.length > 0 && error.error[0].hasOwnProperty('message')) { - error.error.forEach((issue: {status: string, details: string, message: string}) => { - modalStateErrors.push(issue.details); - }); + if (error.error[0].details === null) { + error.error.forEach((issue: {status: string, details: string, message: string}) => { + modalStateErrors.push(issue.message); + }); + } else { + error.error.forEach((issue: {status: string, details: string, message: string}) => { + modalStateErrors.push(issue.details); + }); + } } else { error.error.forEach((issue: {code: string, description: string}) => { modalStateErrors.push(issue.description); @@ -83,6 +89,10 @@ export class ErrorInterceptor implements HttpInterceptor { } else { console.error('error:', error); if (error.statusText === 'Bad Request') { + if (error.error instanceof Blob) { + this.toastr.error('There was an issue downloading this file or you do not have permissions', error.status); + return; + } this.toastr.error(error.error, error.status); } else { this.toastr.error(error.statusText === 'OK' ? error.error : error.statusText, error.status); @@ -101,7 +111,13 @@ export class ErrorInterceptor implements HttpInterceptor { console.log('500 error: ', error); } this.toastr.error(err.message); - } else { + } else if (error.hasOwnProperty('message') && error.message.trim() !== '') { + if (error.message != 'User is not authenticated') { + console.log('500 error: ', error); + } + this.toastr.error(error.message); + } + else { this.toastr.error('There was an unknown critical error.'); console.error('500 error:', error); } diff --git a/UI/Web/src/app/_models/events/cover-update-event.ts b/UI/Web/src/app/_models/events/cover-update-event.ts new file mode 100644 index 0000000000..54d55049f9 --- /dev/null +++ b/UI/Web/src/app/_models/events/cover-update-event.ts @@ -0,0 +1,7 @@ +/** + * Represents a generic cover update event. Id is used based on entityType + */ +export interface CoverUpdateEvent { + id: number; + entityType: 'series' | 'chapter' | 'volume' | 'collectionTag'; +} \ No newline at end of file diff --git a/UI/Web/src/app/_models/in-progress-chapter.ts b/UI/Web/src/app/_models/in-progress-chapter.ts deleted file mode 100644 index 57e75b0edb..0000000000 --- a/UI/Web/src/app/_models/in-progress-chapter.ts +++ /dev/null @@ -1,13 +0,0 @@ -export interface InProgressChapter { - id: number; - range: string; - number: string; - pages: number; - volumeId: number; - pagesRead: number; - seriesId: number; - seriesName: string; - coverImage: string; - libraryId: number; - libraryName: string; -} diff --git a/UI/Web/src/app/_models/member.ts b/UI/Web/src/app/_models/member.ts index 0b8f174230..874dba535d 100644 --- a/UI/Web/src/app/_models/member.ts +++ b/UI/Web/src/app/_models/member.ts @@ -1,10 +1,12 @@ import { Library } from './library'; export interface Member { + id: number; username: string; + email: string; lastActive: string; // datetime created: string; // datetime - isAdmin: boolean; + //isAdmin: boolean; roles: string[]; libraries: Library[]; } \ No newline at end of file diff --git a/UI/Web/src/app/_models/recently-added-item.ts b/UI/Web/src/app/_models/recently-added-item.ts new file mode 100644 index 0000000000..4c44474a83 --- /dev/null +++ b/UI/Web/src/app/_models/recently-added-item.ts @@ -0,0 +1,13 @@ +import { LibraryType } from "./library"; + +export interface RecentlyAddedItem { + seriesId: number; + seriesName: string; + created: string; + title: string; + libraryId: number; + libraryType: LibraryType; + volumeId: number; + chapterId: number; + id: number; // This is UI only, sent from backend but has no relation to any entity +} \ No newline at end of file diff --git a/UI/Web/src/app/_models/search/search-result-group.ts b/UI/Web/src/app/_models/search/search-result-group.ts new file mode 100644 index 0000000000..377593669b --- /dev/null +++ b/UI/Web/src/app/_models/search/search-result-group.ts @@ -0,0 +1,23 @@ +import { Library } from "../library"; +import { SearchResult } from "../search-result"; +import { Tag } from "../tag"; + +export class SearchResultGroup { + libraries: Array = []; + series: Array = []; + collections: Array = []; + readingLists: Array = []; + persons: Array = []; + genres: Array = []; + tags: Array = []; + + reset() { + this.libraries = []; + this.series = []; + this.collections = []; + this.readingLists = []; + this.persons = []; + this.genres = []; + this.tags = []; + } +} \ No newline at end of file diff --git a/UI/Web/src/app/_models/series-filter.ts b/UI/Web/src/app/_models/series-filter.ts index 068054a27b..a370266f5d 100644 --- a/UI/Web/src/app/_models/series-filter.ts +++ b/UI/Web/src/app/_models/series-filter.ts @@ -12,6 +12,7 @@ export interface SeriesFilter { readStatus: ReadStatus; genres: Array; writers: Array; + artists: Array; penciller: Array; inker: Array; colorist: Array; @@ -68,4 +69,10 @@ export const mangaFormatFilters = [ value: MangaFormat.ARCHIVE, selected: false } -]; \ No newline at end of file +]; + +export interface FilterEvent { + filter: SeriesFilter; + isFirst: boolean; +} + diff --git a/UI/Web/src/app/_models/series-group.ts b/UI/Web/src/app/_models/series-group.ts new file mode 100644 index 0000000000..657890c25d --- /dev/null +++ b/UI/Web/src/app/_models/series-group.ts @@ -0,0 +1,14 @@ +import { LibraryType } from "./library"; + +export interface SeriesGroup { + seriesId: number; + seriesName: string; + created: string; + title: string; + libraryId: number; + libraryType: LibraryType; + volumeId: number; + chapterId: number; + id: number; // This is UI only, sent from backend but has no relation to any entity + count: number; +} \ No newline at end of file diff --git a/UI/Web/src/app/_models/user.ts b/UI/Web/src/app/_models/user.ts index 74b7913a0e..626e56a5fc 100644 --- a/UI/Web/src/app/_models/user.ts +++ b/UI/Web/src/app/_models/user.ts @@ -4,6 +4,7 @@ import { Preferences } from './preferences/preferences'; export interface User { username: string; token: string; + refreshToken: string; roles: string[]; preferences: Preferences; apiKey: string; diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index d3006815ae..0d5ee5ed15 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -1,6 +1,6 @@ import { HttpClient } from '@angular/common/http'; import { Injectable, OnDestroy } from '@angular/core'; -import { Observable, ReplaySubject, Subject } from 'rxjs'; +import { Observable, of, ReplaySubject, Subject } from 'rxjs'; import { map, takeUntil } from 'rxjs/operators'; import { environment } from 'src/environments/environment'; import { Preferences } from '../_models/preferences/preferences'; @@ -22,6 +22,11 @@ export class AccountService implements OnDestroy { private currentUserSource = new ReplaySubject(1); currentUser$ = this.currentUserSource.asObservable(); + /** + * SetTimeout handler for keeping track of refresh token call + */ + private refreshTokenTimeout: ReturnType | undefined; + private readonly onDestroy = new Subject(); constructor(private httpClient: HttpClient, private router: Router, @@ -36,6 +41,10 @@ export class AccountService implements OnDestroy { return user && user.roles.includes('Admin'); } + hasChangePasswordRole(user: User) { + return user && user.roles.includes('Change Password'); + } + hasDownloadRole(user: User) { return user && user.roles.includes('Download'); } @@ -44,7 +53,7 @@ export class AccountService implements OnDestroy { return this.httpClient.get(this.baseUrl + 'account/roles'); } - login(model: any): Observable { + login(model: {username: string, password: string}): Observable { return this.httpClient.post(this.baseUrl + 'account/login', model).pipe( map((response: User) => { const user = response; @@ -69,22 +78,30 @@ export class AccountService implements OnDestroy { this.currentUserSource.next(user); this.currentUser = user; + if (this.currentUser !== undefined) { + this.startRefreshTokenTimer(); + } else { + this.stopRefreshTokenTimer(); + } } logout() { localStorage.removeItem(this.userKey); this.currentUserSource.next(undefined); this.currentUser = undefined; + this.stopRefreshTokenTimer(); // Upon logout, perform redirection this.router.navigateByUrl('/login'); this.messageHub.stopHubConnection(); } - register(model: {username: string, password: string, isAdmin?: boolean}) { - if (!model.hasOwnProperty('isAdmin')) { - model.isAdmin = false; - } + /** + * Registers the first admin on the account. Only used for that. All other registrations must occur through invite + * @param model + * @returns + */ + register(model: {username: string, password: string, email: string}) { return this.httpClient.post(this.baseUrl + 'account/register', model).pipe( map((user: User) => { return user; @@ -93,14 +110,46 @@ export class AccountService implements OnDestroy { ); } + migrateUser(model: {email: string, username: string, password: string, sendEmail: boolean}) { + return this.httpClient.post(this.baseUrl + 'account/migrate-email', model, {responseType: 'text' as 'json'}); + } + + confirmMigrationEmail(model: {email: string, token: string}) { + return this.httpClient.post(this.baseUrl + 'account/confirm-migration-email', model); + } + + resendConfirmationEmail(userId: number) { + return this.httpClient.post(this.baseUrl + 'account/resend-confirmation-email?userId=' + userId, {}, {responseType: 'text' as 'json'}); + } + + inviteUser(model: {email: string, roles: Array, libraries: Array, sendEmail: boolean}) { + return this.httpClient.post(this.baseUrl + 'account/invite', model, {responseType: 'text' as 'json'}); + } + + confirmEmail(model: {email: string, username: string, password: string, token: string}) { + return this.httpClient.post(this.baseUrl + 'account/confirm-email', model); + } + getDecodedToken(token: string) { return JSON.parse(atob(token.split('.')[1])); } + requestResetPasswordEmail(email: string) { + return this.httpClient.post(this.baseUrl + 'account/forgot-password?email=' + encodeURIComponent(email), {}, {responseType: 'text' as 'json'}); + } + + confirmResetPasswordEmail(model: {email: string, token: string, password: string}) { + 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'}); } + update(model: {email: string, roles: Array, libraries: Array, userId: number}) { + return this.httpClient.post(this.baseUrl + 'account/update', model); + } + updatePreferences(userPreferences: Preferences) { return this.httpClient.post(this.baseUrl + 'users/update-preferences', userPreferences).pipe(map(settings => { if (this.currentUser !== undefined || this.currentUser != null) { @@ -135,8 +184,45 @@ export class AccountService implements OnDestroy { } return key; })); + } - + private refreshToken() { + if (this.currentUser === null || this.currentUser === undefined) return of(); + + return this.httpClient.post<{token: string, refreshToken: string}>(this.baseUrl + 'account/refresh-token', {token: this.currentUser.token, refreshToken: this.currentUser.refreshToken}).pipe(map(user => { + if (this.currentUser) { + this.currentUser.token = user.token; + this.currentUser.refreshToken = user.refreshToken; + } + + this.currentUserSource.next(this.currentUser); + this.startRefreshTokenTimer(); + return user; + })); + } + + private startRefreshTokenTimer() { + if (this.currentUser === null || this.currentUser === undefined) return; + + if (this.refreshTokenTimeout !== undefined) { + this.stopRefreshTokenTimer(); + } + + const jwtToken = JSON.parse(atob(this.currentUser.token.split('.')[1])); + // set a timeout to refresh the token a minute before it expires + const expires = new Date(jwtToken.exp * 1000); + const timeout = expires.getTime() - Date.now() - (60 * 1000); + this.refreshTokenTimeout = setTimeout(() => this.refreshToken().subscribe(() => { + console.log('Token Refreshed'); + }), timeout); } + private stopRefreshTokenTimer() { + if (this.refreshTokenTimeout !== undefined) { + clearTimeout(this.refreshTokenTimeout); + } + } + + + } diff --git a/UI/Web/src/app/_services/action.service.ts b/UI/Web/src/app/_services/action.service.ts index 66615388c4..8ec3b396dd 100644 --- a/UI/Web/src/app/_services/action.service.ts +++ b/UI/Web/src/app/_services/action.service.ts @@ -149,7 +149,7 @@ export class ActionService implements OnDestroy { } this.seriesService.refreshMetadata(series).pipe(take(1)).subscribe((res: any) => { - this.toastr.success('Refresh started for ' + series.name); + this.toastr.success('Refresh covers queued for ' + series.name); if (callback) { callback(series); } @@ -214,7 +214,7 @@ export class ActionService implements OnDestroy { * @param callback Optional callback to perform actions after API completes */ markChapterAsUnread(seriesId: number, chapter: Chapter, callback?: ChapterActionCallback) { - this.readerService.saveProgress(seriesId, chapter.volumeId, chapter.id, chapter.pages).pipe(take(1)).subscribe(results => { + this.readerService.saveProgress(seriesId, chapter.volumeId, chapter.id, 0).pipe(take(1)).subscribe(results => { chapter.pagesRead = 0; this.toastr.success('Marked as unread'); if (callback) { @@ -375,7 +375,7 @@ export class ActionService implements OnDestroy { */ addMultipleSeriesToCollectionTag(series: Array, callback?: VoidActionCallback) { if (this.collectionModalRef != null) { return; } - this.collectionModalRef = this.modalService.open(BulkAddToCollectionComponent, { scrollable: true, size: 'md' }); + this.collectionModalRef = this.modalService.open(BulkAddToCollectionComponent, { scrollable: true, size: 'md', windowClass: 'collection' }); this.collectionModalRef.componentInstance.seriesIds = series.map(v => v.id); this.collectionModalRef.componentInstance.title = 'New Collection'; diff --git a/UI/Web/src/app/_services/image.service.ts b/UI/Web/src/app/_services/image.service.ts index 4cb21c2998..7b76539ab2 100644 --- a/UI/Web/src/app/_services/image.service.ts +++ b/UI/Web/src/app/_services/image.service.ts @@ -2,6 +2,7 @@ import { Injectable, OnDestroy } from '@angular/core'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; import { environment } from 'src/environments/environment'; +import { RecentlyAddedItem } from '../_models/recently-added-item'; import { AccountService } from './account.service'; import { NavService } from './nav.service'; @@ -41,6 +42,25 @@ export class ImageService implements OnDestroy { this.onDestroy.complete(); } + getRecentlyAddedItem(item: RecentlyAddedItem) { + if (item.chapterId === 0) { + return this.getVolumeCoverImage(item.volumeId); + } + return this.getChapterCoverImage(item.chapterId); + } + + /** + * Returns the entity type from a cover image url. Undefied if not applicable + * @param url + * @returns + */ + getEntityTypeFromUrl(url: string) { + if (url.indexOf('?') < 0) return undefined; + const part = url.split('?')[1]; + const equalIndex = part.indexOf('='); + return part.substring(0, equalIndex).replace('Id', ''); + } + getVolumeCoverImage(volumeId: number) { return this.baseUrl + 'image/volume-cover?volumeId=' + volumeId; } @@ -71,7 +91,7 @@ export class ImageService implements OnDestroy { * @returns Url with a random parameter attached */ randomize(url: string) { - const r = Math.random() * 100 + 1; + const r = Math.round(Math.random() * 100 + 1); if (url.indexOf('&random') >= 0) { return url + 1; } diff --git a/UI/Web/src/app/_services/library.service.ts b/UI/Web/src/app/_services/library.service.ts index 2c0b06f1fa..7aea516f09 100644 --- a/UI/Web/src/app/_services/library.service.ts +++ b/UI/Web/src/app/_services/library.service.ts @@ -5,6 +5,7 @@ import { map, take } from 'rxjs/operators'; import { environment } from 'src/environments/environment'; import { Library, LibraryType } from '../_models/library'; import { SearchResult } from '../_models/search-result'; +import { SearchResultGroup } from '../_models/search/search-result-group'; @Injectable({ @@ -34,6 +35,21 @@ export class LibraryService { })); } + getLibraryName(libraryId: number) { + if (this.libraryNames != undefined && this.libraryNames.hasOwnProperty(libraryId)) { + return of(this.libraryNames[libraryId]); + } + return this.httpClient.get(this.baseUrl + 'library').pipe(map(l => { + this.libraryNames = {}; + l.forEach(lib => { + if (this.libraryNames !== undefined) { + this.libraryNames[lib.id] = lib.name; + } + }); + return this.libraryNames[libraryId]; + })); + } + listDirectories(rootPath: string) { let query = ''; if (rootPath !== undefined && rootPath.length > 0) { @@ -91,9 +107,9 @@ export class LibraryService { search(term: string) { if (term === '') { - return of([]); + return of(new SearchResultGroup()); } - return this.httpClient.get(this.baseUrl + 'library/search?queryString=' + encodeURIComponent(term)); + return this.httpClient.get(this.baseUrl + 'library/search?queryString=' + encodeURIComponent(term)); } } diff --git a/UI/Web/src/app/_services/member.service.ts b/UI/Web/src/app/_services/member.service.ts index 3e59347f7c..2c28db2cc5 100644 --- a/UI/Web/src/app/_services/member.service.ts +++ b/UI/Web/src/app/_services/member.service.ts @@ -25,7 +25,7 @@ export class MemberService { } deleteMember(username: string) { - return this.httpClient.delete(this.baseUrl + 'users/delete-user?username=' + username); + return this.httpClient.delete(this.baseUrl + 'users/delete-user?username=' + encodeURIComponent(username)); } hasLibraryAccess(libraryId: number) { @@ -36,7 +36,8 @@ export class MemberService { return this.httpClient.get(this.baseUrl + 'users/has-reading-progress?libraryId=' + librayId); } - updateMemberRoles(username: string, roles: string[]) { - return this.httpClient.post(this.baseUrl + 'account/update-rbs', {username, roles}); + + getPendingInvites() { + return this.httpClient.get>(this.baseUrl + 'users/pending'); } } diff --git a/UI/Web/src/app/_services/message-hub.service.ts b/UI/Web/src/app/_services/message-hub.service.ts index 259f2a4c14..7547bf2439 100644 --- a/UI/Web/src/app/_services/message-hub.service.ts +++ b/UI/Web/src/app/_services/message-hub.service.ts @@ -15,7 +15,6 @@ import { User } from '../_models/user'; export enum EVENTS { UpdateAvailable = 'UpdateAvailable', ScanSeries = 'ScanSeries', - RefreshMetadata = 'RefreshMetadata', RefreshMetadataProgress = 'RefreshMetadataProgress', SeriesAdded = 'SeriesAdded', SeriesRemoved = 'SeriesRemoved', @@ -25,7 +24,11 @@ export enum EVENTS { ScanLibraryError = 'ScanLibraryError', BackupDatabaseProgress = 'BackupDatabaseProgress', CleanupProgress = 'CleanupProgress', - DownloadProgress = 'DownloadProgress' + DownloadProgress = 'DownloadProgress', + /** + * A cover is updated + */ + CoverUpdate = 'CoverUpdate' } export interface Message { @@ -49,7 +52,6 @@ export class MessageHubService { public scanSeries: EventEmitter = new EventEmitter(); public scanLibrary: EventEmitter = new EventEmitter(); // TODO: Refactor this name to be generic public seriesAdded: EventEmitter = new EventEmitter(); - public refreshMetadata: EventEmitter = new EventEmitter(); isAdmin: boolean = false; @@ -143,10 +145,6 @@ export class MessageHubService { payload: resp.body }); this.seriesAdded.emit(resp.body); - // Don't show the toast when user has reader open - if (this.isAdmin && this.router.url.match(/\d+\/manga|book\/\d+/gi) !== null) { - this.toastr.info('Series ' + (resp.body as SeriesAddedEvent).seriesName + ' added'); - } }); this.hubConnection.on(EVENTS.SeriesRemoved, resp => { @@ -156,12 +154,19 @@ export class MessageHubService { }); }); - this.hubConnection.on(EVENTS.RefreshMetadata, resp => { + // this.hubConnection.on(EVENTS.RefreshMetadata, resp => { + // this.messagesSource.next({ + // event: EVENTS.RefreshMetadata, + // payload: resp.body + // }); + // this.refreshMetadata.emit(resp.body); // TODO: Remove this + // }); + + this.hubConnection.on(EVENTS.CoverUpdate, resp => { this.messagesSource.next({ - event: EVENTS.RefreshMetadata, + event: EVENTS.CoverUpdate, payload: resp.body }); - this.refreshMetadata.emit(resp.body); }); this.hubConnection.on(EVENTS.UpdateAvailable, resp => { diff --git a/UI/Web/src/app/_services/metadata.service.ts b/UI/Web/src/app/_services/metadata.service.ts index 4d64a79204..97d39624b5 100644 --- a/UI/Web/src/app/_services/metadata.service.ts +++ b/UI/Web/src/app/_services/metadata.service.ts @@ -42,7 +42,7 @@ export class MetadataService { if (libraries != undefined && libraries.length > 0) { method += '?libraryIds=' + libraries.join(','); } - return this.httpClient.get>(this.baseUrl + method);; + return this.httpClient.get>(this.baseUrl + method); } getAllPublicationStatus(libraries?: Array) { @@ -50,7 +50,7 @@ export class MetadataService { if (libraries != undefined && libraries.length > 0) { method += '?libraryIds=' + libraries.join(','); } - return this.httpClient.get>(this.baseUrl + method);; + return this.httpClient.get>(this.baseUrl + method); } getAllTags(libraries?: Array) { @@ -58,7 +58,7 @@ export class MetadataService { if (libraries != undefined && libraries.length > 0) { method += '?libraryIds=' + libraries.join(','); } - return this.httpClient.get>(this.baseUrl + method);; + return this.httpClient.get>(this.baseUrl + method); } getAllGenres(libraries?: Array) { @@ -66,7 +66,7 @@ export class MetadataService { if (libraries != undefined && libraries.length > 0) { method += '?libraryIds=' + libraries.join(','); } - return this.httpClient.get(this.baseUrl + method); + return this.httpClient.get>(this.baseUrl + method); } getAllLanguages(libraries?: Array) { @@ -74,7 +74,7 @@ export class MetadataService { if (libraries != undefined && libraries.length > 0) { method += '?libraryIds=' + libraries.join(','); } - return this.httpClient.get(this.baseUrl + method); + return this.httpClient.get>(this.baseUrl + method); } getAllPeople(libraries?: Array) { @@ -82,6 +82,6 @@ export class MetadataService { if (libraries != undefined && libraries.length > 0) { method += '?libraryIds=' + libraries.join(','); } - return this.httpClient.get(this.baseUrl + method); + return this.httpClient.get>(this.baseUrl + method); } } diff --git a/UI/Web/src/app/_services/reader.service.ts b/UI/Web/src/app/_services/reader.service.ts index 16c2ea6563..c1db7b1cdf 100644 --- a/UI/Web/src/app/_services/reader.service.ts +++ b/UI/Web/src/app/_services/reader.service.ts @@ -103,33 +103,12 @@ export class ReaderService { return this.httpClient.get(this.baseUrl + 'reader/prev-chapter?seriesId=' + seriesId + '&volumeId=' + volumeId + '¤tChapterId=' + currentChapterId); } - getCurrentChapter(volumes: Array): Chapter { - let currentlyReadingChapter: Chapter | undefined = undefined; - const chapters = volumes.filter(v => v.number !== 0).map(v => v.chapters || []).flat().sort(this.utilityService.sortChapters); - - for (const c of chapters) { - if (c.pagesRead < c.pages) { - currentlyReadingChapter = c; - break; - } - } - - if (currentlyReadingChapter === undefined) { - // Check if there are specials we can load: - const specials = volumes.filter(v => v.number === 0).map(v => v.chapters || []).flat().sort(this.utilityService.sortChapters); - for (const c of specials) { - if (c.pagesRead < c.pages) { - currentlyReadingChapter = c; - break; - } - } - if (currentlyReadingChapter === undefined) { - // Default to first chapter - currentlyReadingChapter = chapters[0]; - } - } + hasSeriesProgress(seriesId: number) { + return this.httpClient.get(this.baseUrl + 'reader/has-progress?seriesId=' + seriesId); + } - return currentlyReadingChapter; + getCurrentChapter(seriesId: number) { + return this.httpClient.get(this.baseUrl + 'reader/continue-point?seriesId=' + seriesId); } /** diff --git a/UI/Web/src/app/_services/series.service.ts b/UI/Web/src/app/_services/series.service.ts index af02b8e88a..c73b9f2c08 100644 --- a/UI/Web/src/app/_services/series.service.ts +++ b/UI/Web/src/app/_services/series.service.ts @@ -5,10 +5,11 @@ import { map } from 'rxjs/operators'; import { environment } from 'src/environments/environment'; import { Chapter } from '../_models/chapter'; import { CollectionTag } from '../_models/collection-tag'; -import { InProgressChapter } from '../_models/in-progress-chapter'; import { PaginatedResult } from '../_models/pagination'; +import { RecentlyAddedItem } from '../_models/recently-added-item'; import { Series } from '../_models/series'; -import { ReadStatus, SeriesFilter } from '../_models/series-filter'; +import { SeriesFilter } from '../_models/series-filter'; +import { SeriesGroup } from '../_models/series-group'; import { SeriesMetadata } from '../_models/series-metadata'; import { Volume } from '../_models/volume'; import { ImageService } from './image.service'; @@ -123,6 +124,13 @@ export class SeriesService { ); } + getRecentlyUpdatedSeries() { + return this.httpClient.post(this.baseUrl + 'series/recently-updated-series', {}); + } + getRecentlyAddedChapters() { + return this.httpClient.post(this.baseUrl + 'series/recently-added-chapters', {}); + } + getOnDeck(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) { const data = this.createSeriesFilter(filter); @@ -135,9 +143,6 @@ export class SeriesService { })); } - getContinueReading(libraryId: number = 0) { - return this.httpClient.get(this.baseUrl + 'series/continue-reading?libraryId=' + libraryId); - } refreshMetadata(series: Series) { return this.httpClient.post(this.baseUrl + 'series/refresh-metadata', {libraryId: series.libraryId, seriesId: series.id}); @@ -193,6 +198,7 @@ export class SeriesService { libraries: [], genres: [], writers: [], + artists: [], penciller: [], inker: [], colorist: [], diff --git a/UI/Web/src/app/_services/server.service.ts b/UI/Web/src/app/_services/server.service.ts index 4538d17db8..fcc94435c4 100644 --- a/UI/Web/src/app/_services/server.service.ts +++ b/UI/Web/src/app/_services/server.service.ts @@ -36,4 +36,8 @@ export class ServerService { getChangelog() { return this.httpClient.get(this.baseUrl + 'server/changelog', {}); } + + isServerAccessible() { + return this.httpClient.get(this.baseUrl + 'server/accessible'); + } } diff --git a/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.ts b/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.ts index 2885c40de6..82e7b5791a 100644 --- a/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.ts +++ b/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.ts @@ -77,6 +77,7 @@ export class DirectoryPickerComponent implements OnInit { loadChildren(path: string) { this.libraryService.listDirectories(path).subscribe(folders => { + this.filterQuery = ''; this.folders = folders; }, err => { // If there was an error, pop off last directory added to stack diff --git a/UI/Web/src/app/admin/_modals/edit-rbs-modal/edit-rbs-modal.component.html b/UI/Web/src/app/admin/_modals/edit-rbs-modal/edit-rbs-modal.component.html deleted file mode 100644 index 2051953842..0000000000 --- a/UI/Web/src/app/admin/_modals/edit-rbs-modal/edit-rbs-modal.component.html +++ /dev/null @@ -1,23 +0,0 @@ - - - - diff --git a/UI/Web/src/app/admin/_modals/library-access-modal/library-access-modal.component.ts b/UI/Web/src/app/admin/_modals/library-access-modal/library-access-modal.component.ts index 4c7791b4dc..50023b3e7b 100644 --- a/UI/Web/src/app/admin/_modals/library-access-modal/library-access-modal.component.ts +++ b/UI/Web/src/app/admin/_modals/library-access-modal/library-access-modal.component.ts @@ -21,7 +21,6 @@ export class LibraryAccessModalComponent implements OnInit { isLoading: boolean = false; get hasSomeSelected() { - console.log(this.selections != null && this.selections.hasSomeSelected()); return this.selections != null && this.selections.hasSomeSelected(); } diff --git a/UI/Web/src/app/admin/_models/server-settings.ts b/UI/Web/src/app/admin/_models/server-settings.ts index 1f5b398e95..101434a432 100644 --- a/UI/Web/src/app/admin/_models/server-settings.ts +++ b/UI/Web/src/app/admin/_models/server-settings.ts @@ -6,7 +6,7 @@ export interface ServerSettings { port: number; allowStatCollection: boolean; enableOpds: boolean; - enableAuthentication: boolean; baseUrl: string; bookmarksDirectory: string; + emailServiceUrl: string; } diff --git a/UI/Web/src/app/admin/admin.module.ts b/UI/Web/src/app/admin/admin.module.ts index 5ffadf3a1b..8baed6a005 100644 --- a/UI/Web/src/app/admin/admin.module.ts +++ b/UI/Web/src/app/admin/admin.module.ts @@ -12,10 +12,13 @@ import { DirectoryPickerComponent } from './_modals/directory-picker/directory-p import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { ResetPasswordModalComponent } from './_modals/reset-password-modal/reset-password-modal.component'; import { ManageSettingsComponent } from './manage-settings/manage-settings.component'; -import { EditRbsModalComponent } from './_modals/edit-rbs-modal/edit-rbs-modal.component'; import { ManageSystemComponent } from './manage-system/manage-system.component'; import { ChangelogComponent } from './changelog/changelog.component'; import { PipeModule } from '../pipe/pipe.module'; +import { InviteUserComponent } from './invite-user/invite-user.component'; +import { RoleSelectorComponent } from './role-selector/role-selector.component'; +import { LibrarySelectorComponent } from './library-selector/library-selector.component'; +import { EditUserComponent } from './edit-user/edit-user.component'; @@ -30,9 +33,12 @@ import { PipeModule } from '../pipe/pipe.module'; DirectoryPickerComponent, ResetPasswordModalComponent, ManageSettingsComponent, - EditRbsModalComponent, ManageSystemComponent, ChangelogComponent, + InviteUserComponent, + RoleSelectorComponent, + LibrarySelectorComponent, + EditUserComponent, ], imports: [ CommonModule, diff --git a/UI/Web/src/app/admin/edit-user/edit-user.component.html b/UI/Web/src/app/admin/edit-user/edit-user.component.html new file mode 100644 index 0000000000..db7af4508d --- /dev/null +++ b/UI/Web/src/app/admin/edit-user/edit-user.component.html @@ -0,0 +1,58 @@ + + + diff --git a/UI/Web/src/app/admin/_modals/edit-rbs-modal/edit-rbs-modal.component.scss b/UI/Web/src/app/admin/edit-user/edit-user.component.scss similarity index 100% rename from UI/Web/src/app/admin/_modals/edit-rbs-modal/edit-rbs-modal.component.scss rename to UI/Web/src/app/admin/edit-user/edit-user.component.scss diff --git a/UI/Web/src/app/admin/edit-user/edit-user.component.ts b/UI/Web/src/app/admin/edit-user/edit-user.component.ts new file mode 100644 index 0000000000..ceec62caee --- /dev/null +++ b/UI/Web/src/app/admin/edit-user/edit-user.component.ts @@ -0,0 +1,62 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { FormGroup, FormControl, Validators } from '@angular/forms'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { ConfirmService } from 'src/app/shared/confirm.service'; +import { Library } from 'src/app/_models/library'; +import { Member } from 'src/app/_models/member'; +import { AccountService } from 'src/app/_services/account.service'; +import { ServerService } from 'src/app/_services/server.service'; + +// TODO: Rename this to EditUserModal +@Component({ + selector: 'app-edit-user', + templateUrl: './edit-user.component.html', + styleUrls: ['./edit-user.component.scss'] +}) +export class EditUserComponent implements OnInit { + + @Input() member!: Member; + + selectedRoles: Array = []; + selectedLibraries: Array = []; + isSaving: boolean = false; + + userForm: FormGroup = new FormGroup({}); + + public get email() { return this.userForm.get('email'); } + public get username() { return this.userForm.get('username'); } + public get password() { return this.userForm.get('password'); } + + constructor(public modal: NgbActiveModal, private accountService: AccountService, private serverService: ServerService, + private confirmService: ConfirmService) { } + + ngOnInit(): void { + this.userForm.addControl('email', new FormControl(this.member.email, [Validators.required, Validators.email])); + this.userForm.addControl('username', new FormControl(this.member.username, [Validators.required])); + + this.userForm.get('email')?.disable(); + } + + updateRoleSelection(roles: Array) { + this.selectedRoles = roles; + } + + updateLibrarySelection(libraries: Array) { + this.selectedLibraries = libraries.map(l => l.id); + } + + close() { + this.modal.close(false); + } + + save() { + const model = this.userForm.getRawValue(); + model.userId = this.member.id; + model.roles = this.selectedRoles; + model.libraries = this.selectedLibraries; + this.accountService.update(model).subscribe(() => { + this.modal.close(true); + }); + } + +} diff --git a/UI/Web/src/app/admin/invite-user/invite-user.component.html b/UI/Web/src/app/admin/invite-user/invite-user.component.html new file mode 100644 index 0000000000..034420235a --- /dev/null +++ b/UI/Web/src/app/admin/invite-user/invite-user.component.html @@ -0,0 +1,56 @@ + + + diff --git a/UI/Web/src/app/admin/invite-user/invite-user.component.scss b/UI/Web/src/app/admin/invite-user/invite-user.component.scss new file mode 100644 index 0000000000..8d24606591 --- /dev/null +++ b/UI/Web/src/app/admin/invite-user/invite-user.component.scss @@ -0,0 +1,5 @@ +.email-link { + word-break: break-all; + margin-bottom: 15px; + display: block; +} \ No newline at end of file diff --git a/UI/Web/src/app/admin/invite-user/invite-user.component.ts b/UI/Web/src/app/admin/invite-user/invite-user.component.ts new file mode 100644 index 0000000000..cc525957fd --- /dev/null +++ b/UI/Web/src/app/admin/invite-user/invite-user.component.ts @@ -0,0 +1,81 @@ +import { Component, OnInit } from '@angular/core'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { ToastrService } from 'ngx-toastr'; +import { ConfirmService } from 'src/app/shared/confirm.service'; +import { Library } from 'src/app/_models/library'; +import { AccountService } from 'src/app/_services/account.service'; +import { ServerService } from 'src/app/_services/server.service'; + +@Component({ + selector: 'app-invite-user', + templateUrl: './invite-user.component.html', + styleUrls: ['./invite-user.component.scss'] +}) +export class InviteUserComponent implements OnInit { + + /** + * Maintains if the backend is sending an email + */ + isSending: boolean = false; + inviteForm: FormGroup = new FormGroup({}); + /** + * If a user would be able to load this server up externally + */ + accessible: boolean = true; + checkedAccessibility: boolean = false; + selectedRoles: Array = []; + selectedLibraries: Array = []; + emailLink: string = ''; + + public get email() { return this.inviteForm.get('email'); } + + constructor(public modal: NgbActiveModal, private accountService: AccountService, private serverService: ServerService, + private confirmService: ConfirmService, private toastr: ToastrService) { } + + ngOnInit(): void { + this.inviteForm.addControl('email', new FormControl('', [Validators.required])); + + this.serverService.isServerAccessible().subscribe(async (accessibile) => { + if (!accessibile) { + await this.confirmService.alert('This server is not accessible outside the network. You cannot invite via Email. You wil be given a link to finish registration with instead.'); + this.accessible = accessibile; + } + this.checkedAccessibility = true; + }); + } + + close() { + this.modal.close(false); + } + + invite() { + + this.isSending = true; + const email = this.inviteForm.get('email')?.value; + this.accountService.inviteUser({ + email, + libraries: this.selectedLibraries, + roles: this.selectedRoles, + sendEmail: this.accessible + }).subscribe(emailLink => { + this.emailLink = emailLink; + this.isSending = false; + if (this.accessible) { + this.toastr.info('Email sent to ' + email); + this.modal.close(true); + } + }, err => { + this.isSending = false; + }); + } + + updateRoleSelection(roles: Array) { + this.selectedRoles = roles; + } + + updateLibrarySelection(libraries: Array) { + this.selectedLibraries = libraries.map(l => l.id); + } + +} diff --git a/UI/Web/src/app/admin/library-selector/library-selector.component.html b/UI/Web/src/app/admin/library-selector/library-selector.component.html new file mode 100644 index 0000000000..132ac1750d --- /dev/null +++ b/UI/Web/src/app/admin/library-selector/library-selector.component.html @@ -0,0 +1,20 @@ +

Libraries

+
+
+ + +
+
    +
  • +
    + + +
    +
  • +
  • + There are no libraries setup yet. +
  • +
+
\ No newline at end of file diff --git a/UI/Web/src/app/admin/library-selector/library-selector.component.scss b/UI/Web/src/app/admin/library-selector/library-selector.component.scss new file mode 100644 index 0000000000..3f2adc8d1d --- /dev/null +++ b/UI/Web/src/app/admin/library-selector/library-selector.component.scss @@ -0,0 +1,3 @@ +.list-group-item { + border: none; +} \ No newline at end of file diff --git a/UI/Web/src/app/admin/library-selector/library-selector.component.ts b/UI/Web/src/app/admin/library-selector/library-selector.component.ts new file mode 100644 index 0000000000..c7172ced6d --- /dev/null +++ b/UI/Web/src/app/admin/library-selector/library-selector.component.ts @@ -0,0 +1,71 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { FormBuilder } from '@angular/forms'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { SelectionModel } from 'src/app/typeahead/typeahead.component'; +import { Library } from 'src/app/_models/library'; +import { Member } from 'src/app/_models/member'; +import { LibraryService } from 'src/app/_services/library.service'; + +@Component({ + selector: 'app-library-selector', + templateUrl: './library-selector.component.html', + styleUrls: ['./library-selector.component.scss'] +}) +export class LibrarySelectorComponent implements OnInit { + + @Input() member: Member | undefined; + @Output() selected: EventEmitter> = new EventEmitter>(); + + allLibraries: Library[] = []; + selectedLibraries: Array<{selected: boolean, data: Library}> = []; + selections!: SelectionModel; + selectAll: boolean = false; + isLoading: boolean = false; + + get hasSomeSelected() { + return this.selections != null && this.selections.hasSomeSelected(); + } + + constructor(private libraryService: LibraryService, private fb: FormBuilder) { } + + ngOnInit(): void { + this.libraryService.getLibraries().subscribe(libs => { + this.allLibraries = libs; + this.setupSelections(); + }); + } + + + setupSelections() { + this.selections = new SelectionModel(false, this.allLibraries); + this.isLoading = false; + + // If a member is passed in, then auto-select their libraries + if (this.member !== undefined) { + this.member.libraries.forEach(lib => { + this.selections.toggle(lib, true, (a, b) => a.name === b.name); + }); + this.selectAll = this.selections.selected().length === this.allLibraries.length; + this.selected.emit(this.selections.selected()); + } + } + + toggleAll() { + this.selectAll = !this.selectAll; + this.allLibraries.forEach(s => this.selections.toggle(s, this.selectAll)); + this.selected.emit(this.selections.selected()); + } + + handleSelection(item: Library) { + this.selections.toggle(item); + const numberOfSelected = this.selections.selected().length; + if (numberOfSelected == 0) { + this.selectAll = false; + } else if (numberOfSelected == this.selectedLibraries.length) { + this.selectAll = true; + } + + this.selected.emit(this.selections.selected()); + } + +} diff --git a/UI/Web/src/app/admin/manage-settings/manage-settings.component.html b/UI/Web/src/app/admin/manage-settings/manage-settings.component.html index ecda0b4acb..344fc374f9 100644 --- a/UI/Web/src/app/admin/manage-settings/manage-settings.component.html +++ b/UI/Web/src/app/admin/manage-settings/manage-settings.component.html @@ -64,16 +64,30 @@ - + +

Email Services (SMTP)

+

Kavita comes out of the box with an email service to power flows like invite user, forgot password, etc. Emails sent via our service are deleted immediately. You can use your own + email service. Set the url of the email service and use the Test button to ensure it works. At any time you can reset to ours. There is no way to disable emails althought confirmation links will always + be saved to logs. +

- -

By disabling authentication, all non-admin users will be able to login by just their username. No password will be required to authenticate.

-
- - +   + Use fully qualified url of the email service. Do not include ending slash. + +
+ +
+ + +
+

Reoccuring Tasks

  diff --git a/UI/Web/src/app/admin/manage-settings/manage-settings.component.ts b/UI/Web/src/app/admin/manage-settings/manage-settings.component.ts index eadfe5b715..5de5a0ab59 100644 --- a/UI/Web/src/app/admin/manage-settings/manage-settings.component.ts +++ b/UI/Web/src/app/admin/manage-settings/manage-settings.component.ts @@ -4,10 +4,11 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { ToastrService } from 'ngx-toastr'; import { take } from 'rxjs/operators'; import { ConfirmService } from 'src/app/shared/confirm.service'; -import { SettingsService } from '../settings.service'; +import { EmailTestResult, SettingsService } from '../settings.service'; import { DirectoryPickerComponent, DirectoryPickerResult } from '../_modals/directory-picker/directory-picker.component'; import { ServerSettings } from '../_models/server-settings'; + @Component({ selector: 'app-manage-settings', templateUrl: './manage-settings.component.html', @@ -40,8 +41,8 @@ export class ManageSettingsComponent implements OnInit { this.settingsForm.addControl('loggingLevel', new FormControl(this.serverSettings.loggingLevel, [Validators.required])); this.settingsForm.addControl('allowStatCollection', new FormControl(this.serverSettings.allowStatCollection, [Validators.required])); this.settingsForm.addControl('enableOpds', new FormControl(this.serverSettings.enableOpds, [Validators.required])); - this.settingsForm.addControl('enableAuthentication', new FormControl(this.serverSettings.enableAuthentication, [Validators.required])); this.settingsForm.addControl('baseUrl', new FormControl(this.serverSettings.baseUrl, [Validators.required])); + this.settingsForm.addControl('emailServiceUrl', new FormControl(this.serverSettings.emailServiceUrl, [Validators.required])); }); } @@ -54,29 +55,17 @@ export class ManageSettingsComponent implements OnInit { this.settingsForm.get('loggingLevel')?.setValue(this.serverSettings.loggingLevel); this.settingsForm.get('allowStatCollection')?.setValue(this.serverSettings.allowStatCollection); this.settingsForm.get('enableOpds')?.setValue(this.serverSettings.enableOpds); - this.settingsForm.get('enableAuthentication')?.setValue(this.serverSettings.enableAuthentication); this.settingsForm.get('baseUrl')?.setValue(this.serverSettings.baseUrl); + this.settingsForm.get('emailServiceUrl')?.setValue(this.serverSettings.emailServiceUrl); } async saveSettings() { const modelSettings = this.settingsForm.value; - if (this.settingsForm.get('enableAuthentication')?.dirty && this.settingsForm.get('enableAuthentication')?.value === false) { - if (!await this.confirmService.confirm('Disabling Authentication opens your server up to unauthorized access and possible hacking. Are you sure you want to continue with this?')) { - return; - } - } - - const informUserAfterAuthenticationEnabled = this.settingsForm.get('enableAuthentication')?.dirty && this.settingsForm.get('enableAuthentication')?.value && !this.serverSettings.enableAuthentication; - this.settingsService.updateServerSettings(modelSettings).pipe(take(1)).subscribe(async (settings: ServerSettings) => { this.serverSettings = settings; this.resetForm(); this.toastr.success('Server settings updated'); - - if (informUserAfterAuthenticationEnabled) { - await this.confirmService.alert('You have just re-enabled authentication. All non-admin users have been re-assigned a password of "[k.2@RZ!mxCQkJzE". This is a publicly known password. Please change their users passwords or request them to.'); - } }, (err: any) => { console.error('error: ', err); }); @@ -104,4 +93,28 @@ export class ManageSettingsComponent implements OnInit { }); } + resetEmailServiceUrl() { + this.settingsService.resetEmailServerSettings().pipe(take(1)).subscribe(async (settings: ServerSettings) => { + this.serverSettings.emailServiceUrl = settings.emailServiceUrl; + this.resetForm(); + this.toastr.success('Email Service Reset'); + }, (err: any) => { + console.error('error: ', err); + }); + } + + testEmailServiceUrl() { + this.settingsService.testEmailServerSettings(this.settingsForm.get('emailServiceUrl')?.value || '').pipe(take(1)).subscribe(async (result: EmailTestResult) => { + if (result.successful) { + this.toastr.success('Email Service Url validated'); + } else { + this.toastr.error('Email Service Url did not respond. ' + result.errorMessage); + } + + }, (err: any) => { + console.error('error: ', err); + }); + + } + } 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 5b6ce57b98..8256f7f79f 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 @@ -1,19 +1,53 @@
-
-

Users

-
-
-
    + +
    +

    Pending Invites

    +
    +
    +
      +
    • +
      +

      + {{invite.username | titlecase}} +
      + + +
      +

      + +
      Invited: {{invite.created | date: 'short'}}
      +
      +
    • +
    • +
      + +
      +
    • +
    • + There are no invited Users +
    • +
    +
    + + + +

    Active Users

    +
    • - {{member.username | titlecase}} (You) + + {{member.username | titlecase}} + + + (You) +
      - +

      Last Active: @@ -22,16 +56,12 @@

      {{member.lastActive | date: 'short'}}

      -
      Sharing: {{formatLibraries(member)}}
      +
      Sharing: {{formatLibraries(member)}}
      Roles: None {{role}} -
    • @@ -44,7 +74,4 @@

      There are no other users.

    - - -
\ 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 c1982f130d..632e39208a 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 @@ -5,13 +5,14 @@ import { MemberService } from 'src/app/_services/member.service'; import { Member } from 'src/app/_models/member'; import { User } from 'src/app/_models/user'; import { AccountService } from 'src/app/_services/account.service'; -import { LibraryAccessModalComponent } from '../_modals/library-access-modal/library-access-modal.component'; 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 { Subject } from 'rxjs'; import { MessageHubService } from 'src/app/_services/message-hub.service'; +import { InviteUserComponent } from '../invite-user/invite-user.component'; +import { EditUserComponent } from '../edit-user/edit-user.component'; +import { ServerService } from 'src/app/_services/server.service'; @Component({ selector: 'app-manage-users', @@ -21,10 +22,8 @@ import { MessageHubService } from 'src/app/_services/message-hub.service'; export class ManageUsersComponent implements OnInit, OnDestroy { members: Member[] = []; + pendingInvites: Member[] = []; loggedInUsername = ''; - - // Create User functionality - createMemberToggle = false; loadingMembers = false; private onDestroy = new Subject(); @@ -34,7 +33,8 @@ export class ManageUsersComponent implements OnInit, OnDestroy { private modalService: NgbModal, private toastr: ToastrService, private confirmService: ConfirmService, - public messageHub: MessageHubService) { + public messageHub: MessageHubService, + private serverService: ServerService) { this.accountService.currentUser$.pipe(take(1)).subscribe((user: User) => { this.loggedInUsername = user.username; }); @@ -43,6 +43,8 @@ export class ManageUsersComponent implements OnInit, OnDestroy { ngOnInit(): void { this.loadMembers(); + + this.loadPendingInvites(); } ngOnDestroy() { @@ -69,44 +71,69 @@ export class ManageUsersComponent implements OnInit, OnDestroy { }); } - canEditMember(member: Member): boolean { - return this.loggedInUsername !== member.username; - } + loadPendingInvites() { + this.memberService.getPendingInvites().subscribe(members => { + this.pendingInvites = members; + // Show logged in user at the top of the list + this.pendingInvites.sort((a: Member, b: Member) => { + if (a.username === this.loggedInUsername) return 1; + if (b.username === this.loggedInUsername) return 1; - createMember() { - this.createMemberToggle = true; + const nameA = a.username.toUpperCase(); + const nameB = b.username.toUpperCase(); + if (nameA < nameB) return -1; + if (nameA > nameB) return 1; + return 0; + }) + }); } - onMemberCreated(createdUser: User | null) { - this.createMemberToggle = false; - this.loadMembers(); + canEditMember(member: Member): boolean { + return this.loggedInUsername !== member.username; } - openEditLibraryAccess(member: Member) { - const modalRef = this.modalService.open(LibraryAccessModalComponent); + openEditUser(member: Member) { + const modalRef = this.modalService.open(EditUserComponent, {size: 'lg'}); modalRef.componentInstance.member = member; modalRef.closed.subscribe(() => { this.loadMembers(); }); } + async deleteUser(member: Member) { if (await this.confirmService.confirm('Are you sure you want to delete this user?')) { this.memberService.deleteMember(member.username).subscribe(() => { this.loadMembers(); + this.loadPendingInvites(); this.toastr.success(member.username + ' has been deleted.'); }); } } - openEditRole(member: Member) { - const modalRef = this.modalService.open(EditRbsModalComponent); - modalRef.componentInstance.member = member; - modalRef.closed.subscribe((updatedMember: Member) => { - if (updatedMember !== undefined) { - member = updatedMember; + inviteUser() { + const modalRef = this.modalService.open(InviteUserComponent, {size: 'lg'}); + modalRef.closed.subscribe((successful: boolean) => { + if (successful) { + this.loadPendingInvites(); } - }) + }); + } + + resendEmail(member: Member) { + + this.serverService.isServerAccessible().subscribe(canAccess => { + this.accountService.resendConfirmationEmail(member.id).subscribe(async (email) => { + if (canAccess) { + this.toastr.info('Email sent to ' + member.username); + return; + } + await this.confirmService.alert( + 'Please click this link to confirm your email. You must confirm to be able to login. You may need to log out of the current account before clicking.
' + email + ''); + + }); + }); + } updatePassword(member: Member) { @@ -129,4 +156,5 @@ export class ManageUsersComponent implements OnInit, OnDestroy { getRoles(member: Member) { return member.roles.filter(item => item != 'Pleb'); } + } diff --git a/UI/Web/src/app/admin/role-selector/role-selector.component.html b/UI/Web/src/app/admin/role-selector/role-selector.component.html new file mode 100644 index 0000000000..1eb806aab7 --- /dev/null +++ b/UI/Web/src/app/admin/role-selector/role-selector.component.html @@ -0,0 +1,10 @@ +

Roles

+
    +
  • +
    + + +
    +
  • +
\ No newline at end of file diff --git a/UI/Web/src/app/admin/role-selector/role-selector.component.scss b/UI/Web/src/app/admin/role-selector/role-selector.component.scss new file mode 100644 index 0000000000..3f2adc8d1d --- /dev/null +++ b/UI/Web/src/app/admin/role-selector/role-selector.component.scss @@ -0,0 +1,3 @@ +.list-group-item { + border: none; +} \ No newline at end of file diff --git a/UI/Web/src/app/admin/_modals/edit-rbs-modal/edit-rbs-modal.component.ts b/UI/Web/src/app/admin/role-selector/role-selector.component.ts similarity index 51% rename from UI/Web/src/app/admin/_modals/edit-rbs-modal/edit-rbs-modal.component.ts rename to UI/Web/src/app/admin/role-selector/role-selector.component.ts index ff63461525..ed00eaecda 100644 --- a/UI/Web/src/app/admin/_modals/edit-rbs-modal/edit-rbs-modal.component.ts +++ b/UI/Web/src/app/admin/role-selector/role-selector.component.ts @@ -1,17 +1,23 @@ -import { Component, Input, OnInit } from '@angular/core'; +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; 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-edit-rbs-modal', - templateUrl: './edit-rbs-modal.component.html', - styleUrls: ['./edit-rbs-modal.component.scss'] + selector: 'app-role-selector', + templateUrl: './role-selector.component.html', + styleUrls: ['./role-selector.component.scss'] }) -export class EditRbsModalComponent implements OnInit { +export class RoleSelectorComponent implements OnInit { @Input() member: Member | undefined; + /** + * Allows the selection of Admin role + */ + @Input() allowAdmin: boolean = false; + @Output() selected: EventEmitter = new EventEmitter(); + allRoles: string[] = []; selectedRoles: Array<{selected: boolean, data: string}> = []; @@ -19,45 +25,20 @@ export class EditRbsModalComponent implements OnInit { ngOnInit(): void { this.accountService.getRoles().subscribe(roles => { - roles = roles.filter(item => item != 'Admin' && item != 'Pleb'); // Do not allow the user to modify Account RBS + let bannedRoles = ['Pleb']; + if (!this.allowAdmin) { + bannedRoles.push('Admin'); + } + roles = roles.filter(item => !bannedRoles.includes(item)); this.allRoles = roles; this.selectedRoles = roles.map(item => { return {selected: false, data: item}; }); - this.preselect(); + this.selected.emit(this.selectedRoles.filter(item => item.selected).map(item => item.data)); }); } - close() { - this.modal.close(undefined); - } - - save() { - if (this.member?.username === undefined) { - return; - } - - const selectedRoles = this.selectedRoles.filter(item => item.selected).map(item => item.data); - this.memberService.updateMemberRoles(this.member?.username, selectedRoles).subscribe(() => { - if (this.member) { - this.member.roles = selectedRoles; - this.modal.close(this.member); - return; - } - this.modal.close(undefined); - }); - } - - reset() { - this.selectedRoles = this.allRoles.map(item => { - return {selected: false, data: item}; - }); - - - this.preselect(); - } - preselect() { if (this.member !== undefined) { this.member.roles.forEach(role => { @@ -69,4 +50,8 @@ export class EditRbsModalComponent implements OnInit { } } + handleModelUpdate() { + this.selected.emit(this.selectedRoles.filter(item => item.selected).map(item => item.data)); + } + } diff --git a/UI/Web/src/app/admin/settings.service.ts b/UI/Web/src/app/admin/settings.service.ts index 646fde0875..ab29e4f868 100644 --- a/UI/Web/src/app/admin/settings.service.ts +++ b/UI/Web/src/app/admin/settings.service.ts @@ -1,9 +1,16 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { map } from 'rxjs/operators'; import { environment } from 'src/environments/environment'; import { ServerSettings } from './_models/server-settings'; +/** + * Used only for the Test Email Service call + */ +export interface EmailTestResult { + successful: boolean; + errorMessage: string; +} + @Injectable({ providedIn: 'root' }) @@ -25,6 +32,14 @@ export class SettingsService { return this.http.post(this.baseUrl + 'settings/reset', {}); } + resetEmailServerSettings() { + return this.http.post(this.baseUrl + 'settings/reset-email-url', {}); + } + + testEmailServerSettings(emailUrl: string) { + return this.http.post(this.baseUrl + 'settings/test-email-url', {url: emailUrl}); + } + getTaskFrequencies() { return this.http.get(this.baseUrl + 'settings/task-frequencies'); } @@ -40,10 +55,4 @@ export class SettingsService { getOpdsEnabled() { return this.http.get(this.baseUrl + 'settings/opds-enabled', {responseType: 'text' as 'json'}); } - - getAuthenticationEnabled() { - return this.http.get(this.baseUrl + 'settings/authentication-enabled', {responseType: 'text' as 'json'}).pipe(map((res: string) => { - return res === 'true'; - })); - } } diff --git a/UI/Web/src/app/all-series/all-series.component.ts b/UI/Web/src/app/all-series/all-series.component.ts index 51a37fc590..11fa904f6e 100644 --- a/UI/Web/src/app/all-series/all-series.component.ts +++ b/UI/Web/src/app/all-series/all-series.component.ts @@ -1,16 +1,16 @@ import { Component, HostListener, OnDestroy, OnInit } from '@angular/core'; import { Title } from '@angular/platform-browser'; -import { Router } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { Subject } from 'rxjs'; import { take, debounceTime, takeUntil } from 'rxjs/operators'; import { BulkSelectionService } from '../cards/bulk-selection.service'; import { FilterSettings } from '../cards/card-detail-layout/card-detail-layout.component'; -import { KEY_CODES } from '../shared/_services/utility.service'; +import { KEY_CODES, UtilityService } from '../shared/_services/utility.service'; import { SeriesAddedEvent } from '../_models/events/series-added-event'; import { Library } from '../_models/library'; import { Pagination } from '../_models/pagination'; import { Series } from '../_models/series'; -import { SeriesFilter } from '../_models/series-filter'; +import { FilterEvent, SeriesFilter } from '../_models/series-filter'; import { ActionItem, Action } from '../_services/action-factory.service'; import { ActionService } from '../_services/action.service'; import { MessageHubService } from '../_services/message-hub.service'; @@ -70,14 +70,15 @@ export class AllSeriesComponent implements OnInit, OnDestroy { constructor(private router: Router, private seriesService: SeriesService, private titleService: Title, private actionService: ActionService, - public bulkSelectionService: BulkSelectionService, private hubService: MessageHubService) { + public bulkSelectionService: BulkSelectionService, private hubService: MessageHubService, + private utilityService: UtilityService, private route: ActivatedRoute) { this.router.routeReuseStrategy.shouldReuseRoute = () => false; this.titleService.setTitle('Kavita - All Series'); this.pagination = {currentPage: 0, itemsPerPage: 30, totalItems: 0, totalPages: 1}; - - this.loadPage(); + + [this.filterSettings.presets, this.filterSettings.openByDefault] = this.utilityService.filterPresetsFromUrl(this.route.snapshot, this.seriesService.createSeriesFilter()); } ngOnInit(): void { @@ -105,9 +106,9 @@ export class AllSeriesComponent implements OnInit, OnDestroy { } } - updateFilter(data: SeriesFilter) { - this.filter = data; - if (this.pagination !== undefined && this.pagination !== null) { + updateFilter(data: FilterEvent) { + this.filter = data.filter; + if (this.pagination !== undefined && this.pagination !== null && !data.isFirst) { this.pagination.currentPage = 1; this.onPageChange(this.pagination); } else { @@ -116,11 +117,10 @@ export class AllSeriesComponent implements OnInit, OnDestroy { } loadPage() { - const page = this.getPage(); - if (page != null) { - this.pagination.currentPage = parseInt(page, 10); + // The filter is out of sync with the presets from typeaheads on first load but syncs afterwards + if (this.filter == undefined) { + this.filter = this.seriesService.createSeriesFilter(); } - this.loadingSeries = true; this.seriesService.getAllSeries(this.pagination?.currentPage, this.pagination?.itemsPerPage, this.filter).pipe(take(1)).subscribe(series => { this.series = series.result; diff --git a/UI/Web/src/app/app-routing.module.ts b/UI/Web/src/app/app-routing.module.ts index 7f8bd88f78..87eb0cfba8 100644 --- a/UI/Web/src/app/app-routing.module.ts +++ b/UI/Web/src/app/app-routing.module.ts @@ -65,7 +65,11 @@ const routes: Routes = [ ] }, - {path: 'login', component: UserLoginComponent}, + { + path: 'registration', + loadChildren: () => import('../app/registration/registration.module').then(m => m.RegistrationModule) + }, + {path: 'login', component: UserLoginComponent}, // TODO: move this to registration module {path: 'no-connection', component: NotConnectedComponent}, {path: '**', component: UserLoginComponent, pathMatch: 'full'} ]; diff --git a/UI/Web/src/app/app.component.ts b/UI/Web/src/app/app.component.ts index c4a8cd8638..1378a292a5 100644 --- a/UI/Web/src/app/app.component.ts +++ b/UI/Web/src/app/app.component.ts @@ -40,7 +40,6 @@ export class AppComponent implements OnInit { setCurrentUser() { const user = this.accountService.getUserFromLocalStorage(); - this.accountService.setCurrentUser(user); if (user) { diff --git a/UI/Web/src/app/app.module.ts b/UI/Web/src/app/app.module.ts index 89be774531..c013a3de5c 100644 --- a/UI/Web/src/app/app.module.ts +++ b/UI/Web/src/app/app.module.ts @@ -18,7 +18,6 @@ import { SharedModule } from './shared/shared.module'; import { LibraryDetailComponent } from './library-detail/library-detail.component'; import { SeriesDetailComponent } from './series-detail/series-detail.component'; import { NotConnectedComponent } from './not-connected/not-connected.component'; -import { AutocompleteLibModule } from 'angular-ng-autocomplete'; import { ReviewSeriesModalComponent } from './_modals/review-series-modal/review-series-modal.component'; import { CarouselModule } from './carousel/carousel.module'; @@ -36,6 +35,8 @@ import { PersonRolePipe } from './person-role.pipe'; import { SeriesMetadataDetailComponent } from './series-metadata-detail/series-metadata-detail.component'; import { AllSeriesComponent } from './all-series/all-series.component'; import { PublicationStatusPipe } from './publication-status.pipe'; +import { RegistrationModule } from './registration/registration.module'; +import { GroupedTypeaheadComponent } from './grouped-typeahead/grouped-typeahead.component'; @NgModule({ @@ -56,6 +57,7 @@ import { PublicationStatusPipe } from './publication-status.pipe'; PublicationStatusPipe, SeriesMetadataDetailComponent, AllSeriesComponent, + GroupedTypeaheadComponent, ], imports: [ HttpClientModule, @@ -66,7 +68,6 @@ import { PublicationStatusPipe } from './publication-status.pipe'; FormsModule, // EditCollection Modal NgbDropdownModule, // Nav - AutocompleteLibModule, // Nav NgbPopoverModule, // Nav Events toggle NgbRatingModule, // Series Detail NgbNavModule, @@ -80,6 +81,7 @@ import { PublicationStatusPipe } from './publication-status.pipe'; CardsModule, CollectionsModule, ReadingListModule, + RegistrationModule, ToastrModule.forRoot({ positionClass: 'toast-bottom-right', diff --git a/UI/Web/src/app/book-reader/book-reader/book-reader.component.html b/UI/Web/src/app/book-reader/book-reader/book-reader.component.html index 44e05dd861..4d9669345e 100644 --- a/UI/Web/src/app/book-reader/book-reader/book-reader.component.html +++ b/UI/Web/src/app/book-reader/book-reader/book-reader.component.html @@ -117,33 +117,40 @@

Table of Contents

[innerHtml]="page" *ngIf="page !== undefined">
-
+
-
-
{{bookTitle}} (Incognito Mode)
+
+ +
+ Loading book... +
+
+ + {{bookTitle}} + (Incognito Mode) + +
diff --git a/UI/Web/src/app/book-reader/book-reader/book-reader.component.scss b/UI/Web/src/app/book-reader/book-reader/book-reader.component.scss index 12823f9961..1d70b604d0 100644 --- a/UI/Web/src/app/book-reader/book-reader/book-reader.component.scss +++ b/UI/Web/src/app/book-reader/book-reader/book-reader.component.scss @@ -202,9 +202,22 @@ $primary-color: #0062cc; .right { position: fixed; - right: 0px; + right: 0px; // with scrollbar: 17px top: 0px; - width: 20%; + width: 20%; // with scrollbar: 18% + height: 100%; + z-index: 2; + cursor: pointer; + opacity: 0; + background: transparent; +} + +// This class pushes the click area to the left a bit to let users click the scrollbar +.right-with-scrollbar { + position: fixed; + right: 17px; + top: 0px; + width: 18%; height: 100%; z-index: 2; cursor: pointer; diff --git a/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts b/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts index 74120972e7..0e474968a0 100644 --- a/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts +++ b/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts @@ -43,6 +43,15 @@ const TOP_OFFSET = -50 * 1.5; // px the sticky header takes up const CHAPTER_ID_NOT_FETCHED = -2; const CHAPTER_ID_DOESNT_EXIST = -1; +/** + * Styles that should be applied on the top level book-content tag + */ +const pageLevelStyles = ['margin-left', 'margin-right', 'font-size']; +/** + * Styles that should be applied on every element within book-content tag + */ +const elementLevelStyles = ['line-height', 'font-family']; + @Component({ selector: 'app-book-reader', templateUrl: './book-reader.component.html', @@ -235,6 +244,13 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } } + get IsNextChapter(): boolean { + return this.pageNum + 1 >= this.maxPages; + } + get IsPrevChapter(): boolean { + return this.pageNum === 0; + } + get drawerBackgroundColor() { return this.darkMode ? '#010409': '#fff'; } @@ -344,12 +360,24 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.lastSeenScrollPartPath = path; } - if (this.lastSeenScrollPartPath !== '' && !this.incognitoMode) { - this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, this.pageNum, this.lastSeenScrollPartPath).pipe(take(1)).subscribe(() => {/* No operation */}); + if (this.lastSeenScrollPartPath !== '') { + this.saveProgress(); } }); } + saveProgress() { + let tempPageNum = this.pageNum; + if (this.pageNum == this.maxPages - 1) { + tempPageNum = this.pageNum + 1; + } + + if (!this.incognitoMode) { + this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, tempPageNum, this.lastSeenScrollPartPath).pipe(take(1)).subscribe(() => {/* No operation */}); + } + + } + ngOnDestroy(): void { const bodyNode = this.document.querySelector('body'); if (bodyNode !== undefined && bodyNode !== null && this.originalBodyColor !== undefined) { @@ -450,9 +478,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { if (this.pageNum >= this.maxPages) { this.pageNum = this.maxPages - 1; - if (!this.incognitoMode) { - this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, this.pageNum).pipe(take(1)).subscribe(() => {/* No operation */}); - } + this.saveProgress(); } this.readerService.getNextChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).pipe(take(1)).subscribe(chapterId => { @@ -494,6 +520,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { event.preventDefault(); } else if (event.key === KEY_CODES.G) { this.goToPage(); + } else if (event.key === KEY_CODES.F) { + this.toggleFullscreen() } } @@ -587,7 +615,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { let margin = '15%'; if (windowWidth <= 700) { - margin = '0%'; + margin = '5%'; } if (this.user) { if (windowWidth > 700) { @@ -702,9 +730,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { loadPage(part?: string | undefined, scrollTop?: number | undefined) { this.isLoading = true; - if (!this.incognitoMode) { - this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, this.pageNum).pipe(take(1)).subscribe(() => {/* No operation */}); - } + this.saveProgress(); this.bookService.getBookPage(this.chapterId, this.pageNum).pipe(take(1)).subscribe(content => { this.page = this.domSanitizer.bypassSecurityTrustHtml(content); @@ -755,8 +781,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { setPageNum(pageNum: number) { if (pageNum < 0) { this.pageNum = 0; - } else if (pageNum >= this.maxPages) { - this.pageNum = this.maxPages - 1; + } else if (pageNum >= this.maxPages - 1) { // This case handles when we are using the pager to move to the next volume/chapter, the pageNum will get incremented past maxPages // NOTE: I made a change where I removed - 1 in comparison, it's breaking page progress + this.pageNum = this.maxPages; // } else { this.pageNum = pageNum; } @@ -785,6 +811,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { prevPage() { const oldPageNum = this.pageNum; + if (this.readingDirection === ReadingDirection.LeftToRight) { this.setPageNum(this.pageNum - 1); } else { @@ -807,18 +834,21 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { event.stopPropagation(); event.preventDefault(); } - const oldPageNum = this.pageNum; + if (oldPageNum + 1 === this.maxPages) { + // Move to next volume/chapter automatically + this.loadNextChapter(); + return; + } + + if (this.readingDirection === ReadingDirection.LeftToRight) { this.setPageNum(this.pageNum + 1); } else { this.setPageNum(this.pageNum - 1); } - if (oldPageNum + 1 === this.maxPages) { - // Move to next volume/chapter automatically - this.loadNextChapter(); - } + if (oldPageNum === this.pageNum) { return; } @@ -875,31 +905,41 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.updateReaderStyles(); } + /** + * Applies styles onto the html of the book page + */ updateReaderStyles() { - if (this.readingHtml != undefined && this.readingHtml.nativeElement) { - Object.entries(this.pageStyles).forEach(item => { - if (item[1] == '100%' || item[1] == '0px' || item[1] == 'inherit') { - // Remove the style or skip - this.renderer.removeStyle(this.readingHtml.nativeElement, item[0]); - return; - } - this.renderer.setStyle(this.readingHtml.nativeElement, item[0], item[1], RendererStyleFlags2.Important); - }); + if (this.readingHtml === undefined || !this.readingHtml.nativeElement) return; - for(let i = 0; i < this.readingHtml.nativeElement.children.length; i++) { - const elem = this.readingHtml.nativeElement.children.item(i); - if (elem?.tagName === 'STYLE') continue; - Object.entries(this.pageStyles).forEach(item => { - if (item[1] == '100%' || item[1] == '0px' || item[1] == 'inherit') { - // Remove the style or skip - this.renderer.removeStyle(elem, item[0]); - return; - } - this.renderer.setStyle(elem, item[0], item[1], RendererStyleFlags2.Important); - }); - + // Line Height must be placed on each element in the page + + // Apply page level overrides + Object.entries(this.pageStyles).forEach(item => { + if (item[1] == '100%' || item[1] == '0px' || item[1] == 'inherit') { + // Remove the style or skip + this.renderer.removeStyle(this.readingHtml.nativeElement, item[0]); + return; } + if (pageLevelStyles.includes(item[0])) { + this.renderer.setStyle(this.readingHtml.nativeElement, item[0], item[1], RendererStyleFlags2.Important); + } + }); + + const individualElementStyles = Object.entries(this.pageStyles).filter(item => elementLevelStyles.includes(item[0])); + for(let i = 0; i < this.readingHtml.nativeElement.children.length; i++) { + const elem = this.readingHtml.nativeElement.children.item(i); + if (elem?.tagName === 'STYLE') continue; + individualElementStyles.forEach(item => { + if (item[1] == '100%' || item[1] == '0px' || item[1] == 'inherit') { + // Remove the style or skip + this.renderer.removeStyle(elem, item[0]); + return; + } + this.renderer.setStyle(elem, item[0], item[1], RendererStyleFlags2.Important); + }); + } + } @@ -1042,7 +1082,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { const newRoute = this.readerService.getNextChapterUrl(this.router.url, this.chapterId, this.incognitoMode, this.readingListMode, this.readingListId); window.history.replaceState({}, '', newRoute); this.toastr.info('Incognito mode is off. Progress will now start being tracked.'); - this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, this.pageNum).pipe(take(1)).subscribe(() => {/* No operation */}); + this.saveProgress(); } toggleFullscreen() { diff --git a/UI/Web/src/app/cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component.scss b/UI/Web/src/app/cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component.scss index 91847160a5..1cfe40c07a 100644 --- a/UI/Web/src/app/cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component.scss +++ b/UI/Web/src/app/cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component.scss @@ -4,4 +4,17 @@ .clickable:hover, .clickable:focus { background-color: lightgreen; -} \ No newline at end of file +} + +.collection { + overflow: auto; + .modal-body { + height: calc(100vh - 235px); + min-height: 150px; + .list-group { + overflow: auto; + height: calc(100vh - 355px); + min-height: 32px; + } + } +} diff --git a/UI/Web/src/app/cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component.ts b/UI/Web/src/app/cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component.ts index 13641550b2..be28e41a30 100644 --- a/UI/Web/src/app/cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component.ts +++ b/UI/Web/src/app/cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component.ts @@ -1,4 +1,4 @@ -import { Component, ElementRef, Input, OnInit, ViewChild } from '@angular/core'; +import { Component, ElementRef, Input, OnInit, ViewChild, ViewEncapsulation } from '@angular/core'; import { FormGroup, FormControl } from '@angular/forms'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { ToastrService } from 'ngx-toastr'; @@ -9,6 +9,7 @@ import { CollectionTagService } from 'src/app/_services/collection-tag.service'; @Component({ selector: 'app-bulk-add-to-collection', templateUrl: './bulk-add-to-collection.component.html', + encapsulation: ViewEncapsulation.None, // This is needed as per the bootstrap modal documentation to get styles to work. styleUrls: ['./bulk-add-to-collection.component.scss'] }) export class BulkAddToCollectionComponent implements OnInit { diff --git a/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.html b/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.html index 8bdaf57318..4bc78fb09f 100644 --- a/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.html +++ b/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.html @@ -40,7 +40,7 @@

{{utilityService.formatChapterName(l
  • - +
    diff --git a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html index ef3c2a3cc0..a81b3bbffd 100644 --- a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html +++ b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html @@ -104,7 +104,7 @@

    Volumes

    • - +
      Volume {{volume.name}}
      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 ff11237979..c6b1e74526 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 @@ -2,7 +2,7 @@ import { Component, Input, OnDestroy, OnInit } from '@angular/core'; import { FormBuilder, FormControl, FormGroup } from '@angular/forms'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { forkJoin, Subject } from 'rxjs'; -import { takeUntil } from 'rxjs/operators'; +import { map, takeUntil } from 'rxjs/operators'; import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service'; import { TypeaheadSettings } from 'src/app/typeahead/typeahead-settings'; import { Chapter } from 'src/app/_models/chapter'; @@ -120,13 +120,15 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { this.settings.id = 'collections'; this.settings.unique = true; this.settings.addIfNonExisting = true; - this.settings.fetchFn = (filter: string) => this.fetchCollectionTags(filter); + this.settings.fetchFn = (filter: string) => this.fetchCollectionTags(filter).pipe(map(items => this.settings.compareFn(items, filter))); this.settings.addTransformFn = ((title: string) => { return {id: 0, title: title, promoted: false, coverImage: '', summary: '', coverImageLocked: false }; }); this.settings.compareFn = (options: CollectionTag[], filter: string) => { - const f = filter.toLowerCase(); - return options.filter(m => m.title.toLowerCase() === f); + return options.filter(m => this.utilityService.filter(m.title, filter)); + } + this.settings.singleCompareFn = (a: CollectionTag, b: CollectionTag) => { + return a.id == b.id; } } diff --git a/UI/Web/src/app/cards/bookmark/bookmark.component.html b/UI/Web/src/app/cards/bookmark/bookmark.component.html index 005d703a6c..7ef42efa6e 100644 --- a/UI/Web/src/app/cards/bookmark/bookmark.component.html +++ b/UI/Web/src/app/cards/bookmark/bookmark.component.html @@ -1,6 +1,5 @@
      - +
      diff --git a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html index 7ecb8d71ab..19626e2965 100644 --- a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html +++ b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html @@ -5,16 +5,16 @@

        {{header}}  - {{pagination.totalItems}}

      - -
      +
      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 1bece3ff62..a84d1ba2e4 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 @@ -14,7 +14,7 @@ import { Language } from 'src/app/_models/metadata/language'; import { PublicationStatusDto } from 'src/app/_models/metadata/publication-status-dto'; import { Pagination } from 'src/app/_models/pagination'; import { Person, PersonRole } from 'src/app/_models/person'; -import { FilterItem, mangaFormatFilters, SeriesFilter, SortField } from 'src/app/_models/series-filter'; +import { FilterEvent, FilterItem, mangaFormatFilters, SeriesFilter, SortField } from 'src/app/_models/series-filter'; import { Tag } from 'src/app/_models/tag'; import { ActionItem } from 'src/app/_services/action-factory.service'; import { CollectionTagService } from 'src/app/_services/collection-tag.service'; @@ -57,6 +57,10 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy { @Input() isLoading: boolean = false; @Input() items: any[] = []; @Input() pagination!: Pagination; + /** + * Should filtering be shown on the page + */ + @Input() filteringDisabled: boolean = false; /** * Any actions to exist on the header for the parent collection (library, collection) */ @@ -65,7 +69,7 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy { @Input() filterSettings!: FilterSettings; @Output() itemClicked: EventEmitter = new EventEmitter(); @Output() pageChange: EventEmitter = new EventEmitter(); - @Output() applyFilter: EventEmitter = new EventEmitter(); + @Output() applyFilter: EventEmitter = new EventEmitter(); @ContentChild('cardItem') itemTemplate!: TemplateRef; @@ -95,6 +99,7 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy { updateApplied: number = 0; + private onDestory: Subject = new Subject(); get PersonRole(): typeof PersonRole { @@ -194,10 +199,13 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy { this.formatSettings.id = 'format'; this.formatSettings.unique = true; this.formatSettings.addIfNonExisting = false; - this.formatSettings.fetchFn = (filter: string) => of(mangaFormatFilters); + this.formatSettings.fetchFn = (filter: string) => of(mangaFormatFilters).pipe(map(items => this.formatSettings.compareFn(items, filter))); this.formatSettings.compareFn = (options: FilterItem[], filter: string) => { - const f = filter.toLowerCase(); - return options.filter(m => m.title.toLowerCase() === f); + return options.filter(m => this.utilityService.filter(m.title, filter)); + } + + this.formatSettings.singleCompareFn = (a: FilterItem, b: FilterItem) => { + return a.title == b.title; } if (this.filterSettings.presets?.formats && this.filterSettings.presets?.formats.length > 0) { @@ -214,11 +222,14 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy { this.librarySettings.unique = true; this.librarySettings.addIfNonExisting = false; this.librarySettings.fetchFn = (filter: string) => { - return this.libraryService.getLibrariesForMember(); + return this.libraryService.getLibrariesForMember() + .pipe(map(items => this.librarySettings.compareFn(items, filter))); }; this.librarySettings.compareFn = (options: Library[], filter: string) => { - const f = filter.toLowerCase(); - return options.filter(m => m.name.toLowerCase() === f); + return options.filter(m => this.utilityService.filter(m.name, filter)); + } + this.librarySettings.singleCompareFn = (a: Library, b: Library) => { + return a.name == b.name; } if (this.filterSettings.presets?.libraries && this.filterSettings.presets?.libraries.length > 0) { @@ -238,11 +249,14 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy { this.genreSettings.unique = true; this.genreSettings.addIfNonExisting = false; this.genreSettings.fetchFn = (filter: string) => { - return this.metadataService.getAllGenres(this.filter.libraries); + return this.metadataService.getAllGenres(this.filter.libraries) + .pipe(map(items => this.genreSettings.compareFn(items, filter))); }; this.genreSettings.compareFn = (options: Genre[], filter: string) => { - const f = filter.toLowerCase(); - return options.filter(m => m.title.toLowerCase() === f); + return options.filter(m => this.utilityService.filter(m.title, filter)); + } + this.genreSettings.singleCompareFn = (a: Genre, b: Genre) => { + return a.title == b.title; } if (this.filterSettings.presets?.genres && this.filterSettings.presets?.genres.length > 0) { @@ -261,12 +275,15 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy { this.ageRatingSettings.id = 'age-rating'; this.ageRatingSettings.unique = true; this.ageRatingSettings.addIfNonExisting = false; - this.ageRatingSettings.fetchFn = (filter: string) => { - return this.metadataService.getAllAgeRatings(this.filter.libraries); - }; + this.ageRatingSettings.fetchFn = (filter: string) => this.metadataService.getAllAgeRatings(this.filter.libraries) + .pipe(map(items => this.ageRatingSettings.compareFn(items, filter))); + this.ageRatingSettings.compareFn = (options: AgeRatingDto[], filter: string) => { - const f = filter.toLowerCase(); - return options.filter(m => m.title.toLowerCase() === f && this.utilityService.filter(m.title, filter)); + return options.filter(m => this.utilityService.filter(m.title, filter)); + } + + this.ageRatingSettings.singleCompareFn = (a: AgeRatingDto, b: AgeRatingDto) => { + return a.title == b.title; } if (this.filterSettings.presets?.ageRating && this.filterSettings.presets?.ageRating.length > 0) { @@ -285,12 +302,15 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy { this.publicationStatusSettings.id = 'publication-status'; this.publicationStatusSettings.unique = true; this.publicationStatusSettings.addIfNonExisting = false; - this.publicationStatusSettings.fetchFn = (filter: string) => { - return this.metadataService.getAllPublicationStatus(this.filter.libraries); - }; + this.publicationStatusSettings.fetchFn = (filter: string) => this.metadataService.getAllPublicationStatus(this.filter.libraries) + .pipe(map(items => this.publicationStatusSettings.compareFn(items, filter))); + this.publicationStatusSettings.compareFn = (options: PublicationStatusDto[], filter: string) => { - const f = filter.toLowerCase(); - return options.filter(m => m.title.toLowerCase() === f && this.utilityService.filter(m.title, filter)); + return options.filter(m => this.utilityService.filter(m.title, filter)); + } + + this.publicationStatusSettings.singleCompareFn = (a: PublicationStatusDto, b: PublicationStatusDto) => { + return a.title == b.title; } if (this.filterSettings.presets?.publicationStatus && this.filterSettings.presets?.publicationStatus.length > 0) { @@ -309,12 +329,14 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy { this.tagsSettings.id = 'tags'; this.tagsSettings.unique = true; this.tagsSettings.addIfNonExisting = false; - this.tagsSettings.fetchFn = (filter: string) => { - return this.metadataService.getAllTags(this.filter.libraries); - }; this.tagsSettings.compareFn = (options: Tag[], filter: string) => { - const f = filter.toLowerCase(); - return options.filter(m => m.title.toLowerCase() === f && this.utilityService.filter(m.title, filter)); + return options.filter(m => this.utilityService.filter(m.title, filter)); + } + this.tagsSettings.fetchFn = (filter: string) => this.metadataService.getAllTags(this.filter.libraries) + .pipe(map(items => this.tagsSettings.compareFn(items, filter))); + + this.tagsSettings.singleCompareFn = (a: Tag, b: Tag) => { + return a.id == b.id; } if (this.filterSettings.presets?.tags && this.filterSettings.presets?.tags.length > 0) { @@ -333,12 +355,14 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy { this.languageSettings.id = 'languages'; this.languageSettings.unique = true; this.languageSettings.addIfNonExisting = false; - this.languageSettings.fetchFn = (filter: string) => { - return this.metadataService.getAllLanguages(this.filter.libraries); - }; this.languageSettings.compareFn = (options: Language[], filter: string) => { - const f = filter.toLowerCase(); - return options.filter(m => m.title.toLowerCase() === f && this.utilityService.filter(m.title, filter)); + return options.filter(m => this.utilityService.filter(m.title, filter)); + } + this.languageSettings.fetchFn = (filter: string) => this.metadataService.getAllLanguages(this.filter.libraries) + .pipe(map(items => this.languageSettings.compareFn(items, filter))); + + this.languageSettings.singleCompareFn = (a: Language, b: Language) => { + return a.isoCode == b.isoCode; } if (this.filterSettings.presets?.languages && this.filterSettings.presets?.languages.length > 0) { @@ -357,12 +381,14 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy { this.collectionSettings.id = 'collections'; this.collectionSettings.unique = true; this.collectionSettings.addIfNonExisting = false; - this.collectionSettings.fetchFn = (filter: string) => { - return this.collectionTagService.allTags(); - }; this.collectionSettings.compareFn = (options: CollectionTag[], filter: string) => { - const f = filter.toLowerCase(); - return options.filter(m => m.title.toLowerCase() === f); + return options.filter(m => this.utilityService.filter(m.title, filter)); + } + this.collectionSettings.fetchFn = (filter: string) => this.collectionTagService.allTags() + .pipe(map(items => this.collectionSettings.compareFn(items, filter))); + + this.collectionSettings.singleCompareFn = (a: CollectionTag, b: CollectionTag) => { + return a.id == b.id; } if (this.filterSettings.presets?.collectionTags && this.filterSettings.presets?.collectionTags.length > 0) { @@ -427,11 +453,14 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy { personSettings.addIfNonExisting = false; personSettings.id = id; personSettings.compareFn = (options: Person[], filter: string) => { - const f = filter.toLowerCase(); - return options.filter(m => m.name.toLowerCase() === f); + return options.filter(m => this.utilityService.filter(m.name, filter)); + } + + personSettings.singleCompareFn = (a: Person, b: Person) => { + return a.name == b.name && a.role == b.role; } personSettings.fetchFn = (filter: string) => { - return this.fetchPeople(role, filter); + return this.fetchPeople(role, filter).pipe(map(items => personSettings.compareFn(items, filter))); }; return personSettings; } @@ -566,7 +595,7 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy { } apply() { - this.applyFilter.emit(this.filter); + this.applyFilter.emit({filter: this.filter, isFirst: this.updateApplied === 0}); this.updateApplied++; } diff --git a/UI/Web/src/app/cards/card-item/card-item.component.html b/UI/Web/src/app/cards/card-item/card-item.component.html index c096aac5b7..aafe5b88a1 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.html +++ b/UI/Web/src/app/cards/card-item/card-item.component.html @@ -1,9 +1,12 @@
      - - + + + + + + +

      @@ -22,6 +25,10 @@
      + +
      + {{count}} +
      @@ -38,6 +45,7 @@
      + {{subtitle}} {{libraryName | sentenceCase}}
      \ No newline at end of file diff --git a/UI/Web/src/app/cards/card-item/card-item.component.scss b/UI/Web/src/app/cards/card-item/card-item.component.scss index d1c81c1552..f6dd70bdd5 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.scss +++ b/UI/Web/src/app/cards/card-item/card-item.component.scss @@ -118,6 +118,12 @@ $image-width: 160px; } z-index: 10; + + .count { + top: 5px; + right: 10px; + position: absolute; + } } .card-actions { diff --git a/UI/Web/src/app/cards/card-item/card-item.component.ts b/UI/Web/src/app/cards/card-item/card-item.component.ts index 6788881a3a..fc98739c71 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.ts +++ b/UI/Web/src/app/cards/card-item/card-item.component.ts @@ -9,6 +9,7 @@ import { Chapter } from 'src/app/_models/chapter'; import { CollectionTag } from 'src/app/_models/collection-tag'; import { MangaFormat } from 'src/app/_models/manga-format'; import { PageBookmark } from 'src/app/_models/page-bookmark'; +import { RecentlyAddedItem } from 'src/app/_models/recently-added-item'; import { Series } from 'src/app/_models/series'; import { Volume } from 'src/app/_models/volume'; import { Action, ActionItem } from 'src/app/_services/action-factory.service'; @@ -31,6 +32,10 @@ export class CardItemComponent implements OnInit, OnDestroy { * Name of the card */ @Input() title = ''; + /** + * Shows below the title. Defaults to not visible + */ + @Input() subtitle = ''; /** * Any actions to perform on the card */ @@ -50,7 +55,7 @@ export class CardItemComponent implements OnInit, OnDestroy { /** * This is the entity we are representing. It will be returned if an action is executed. */ - @Input() entity!: Series | Volume | Chapter | CollectionTag | PageBookmark; + @Input() entity!: Series | Volume | Chapter | CollectionTag | PageBookmark | RecentlyAddedItem; /** * If the entity is selected or not. */ @@ -59,6 +64,14 @@ export class CardItemComponent implements OnInit, OnDestroy { * If the entity should show selection code */ @Input() allowSelection: boolean = false; + /** + * This will supress the cannot read archive warning when total pages is 0 + */ + @Input() supressArchiveWarning: boolean = false; + /** + * The number of updates/items within the card. If less than 2, will not be shown. + */ + @Input() count: number = 0; /** * Event emitted when item is clicked */ @@ -72,10 +85,6 @@ export class CardItemComponent implements OnInit, OnDestroy { */ libraryName: string | undefined = undefined; libraryId: number | undefined = undefined; - /** - * This will supress the cannot read archive warning when total pages is 0 - */ - supressArchiveWarning: boolean = false; /** * Format of the entity (only applies to Series) */ @@ -110,12 +119,15 @@ export class CardItemComponent implements OnInit, OnDestroy { } if (this.supressLibraryLink === false) { - this.libraryService.getLibraryNames().pipe(takeUntil(this.onDestroy)).subscribe(names => { - if (this.entity !== undefined && this.entity.hasOwnProperty('libraryId')) { - this.libraryId = (this.entity as Series).libraryId; - this.libraryName = names[this.libraryId]; - } - }); + if (this.entity !== undefined && this.entity.hasOwnProperty('libraryId')) { + this.libraryId = (this.entity as Series).libraryId; + } + + if (this.libraryId !== undefined && this.libraryId > 0) { + this.libraryService.getLibraryName(this.libraryId).pipe(takeUntil(this.onDestroy)).subscribe(name => { + this.libraryName = name; + }); + } } this.format = (this.entity as Series).format; diff --git a/UI/Web/src/app/cards/chapter-metadata-detail/chapter-metadata-detail.component.html b/UI/Web/src/app/cards/chapter-metadata-detail/chapter-metadata-detail.component.html index 24d7b0aed9..0642250572 100644 --- a/UI/Web/src/app/cards/chapter-metadata-detail/chapter-metadata-detail.component.html +++ b/UI/Web/src/app/cards/chapter-metadata-detail/chapter-metadata-detail.component.html @@ -4,7 +4,11 @@ - +
      +
      + Id: {{chapter.id}} +
      +
      @@ -28,7 +32,7 @@
      • - +
        diff --git a/UI/Web/src/app/cards/cover-image-chooser/cover-image-chooser.component.html b/UI/Web/src/app/cards/cover-image-chooser/cover-image-chooser.component.html index e9a772613c..24a0703b94 100644 --- a/UI/Web/src/app/cards/cover-image-chooser/cover-image-chooser.component.html +++ b/UI/Web/src/app/cards/cover-image-chooser/cover-image-chooser.component.html @@ -53,10 +53,10 @@
        - +
        - +
        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 8966cc1be6..c9acf96d3c 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 @@ -61,13 +61,7 @@ export class SeriesCardComponent implements OnInit, OnChanges, OnDestroy { ngOnInit(): void { if (this.data) { - this.imageUrl = this.imageService.randomize(this.imageService.getSeriesCoverImage(this.data.id)); - - this.hubService.refreshMetadata.pipe(takeWhile(event => event.libraryId === this.libraryId), takeUntil(this.onDestroy)).subscribe((event: RefreshMetadataEvent) => { - if (this.data.id === event.seriesId) { - this.imageUrl = this.imageService.randomize(this.imageService.getSeriesCoverImage(this.data.id)); - } - }); + this.imageUrl = this.imageService.getSeriesCoverImage(this.data.id); } } diff --git a/UI/Web/src/app/collections/collection-detail/collection-detail.component.html b/UI/Web/src/app/collections/collection-detail/collection-detail.component.html index 0e8516cf8e..1d33472eae 100644 --- a/UI/Web/src/app/collections/collection-detail/collection-detail.component.html +++ b/UI/Web/src/app/collections/collection-detail/collection-detail.component.html @@ -1,12 +1,12 @@
        - +

        + {{collectionTag.title}}

        diff --git a/UI/Web/src/app/collections/collection-detail/collection-detail.component.ts b/UI/Web/src/app/collections/collection-detail/collection-detail.component.ts index 02f8974517..5eebc5bd3e 100644 --- a/UI/Web/src/app/collections/collection-detail/collection-detail.component.ts +++ b/UI/Web/src/app/collections/collection-detail/collection-detail.component.ts @@ -13,7 +13,7 @@ import { CollectionTag } from 'src/app/_models/collection-tag'; import { SeriesAddedToCollectionEvent } from 'src/app/_models/events/series-added-to-collection-event'; import { Pagination } from 'src/app/_models/pagination'; import { Series } from 'src/app/_models/series'; -import { SeriesFilter } from 'src/app/_models/series-filter'; +import { FilterEvent, SeriesFilter } from 'src/app/_models/series-filter'; import { AccountService } from 'src/app/_services/account.service'; import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/action-factory.service'; import { ActionService } from 'src/app/_services/action.service'; @@ -175,9 +175,9 @@ export class CollectionDetailComponent implements OnInit, OnDestroy { }); } - updateFilter(data: SeriesFilter) { - this.filter = data; - if (this.seriesPagination !== undefined && this.seriesPagination !== null) { + updateFilter(data: FilterEvent) { + this.filter = data.filter; + if (this.seriesPagination !== undefined && this.seriesPagination !== null && !data.isFirst) { this.seriesPagination.currentPage = 1; this.onPageChange(this.seriesPagination); } else { diff --git a/UI/Web/src/app/grouped-typeahead/grouped-typeahead.component.html b/UI/Web/src/app/grouped-typeahead/grouped-typeahead.component.html new file mode 100644 index 0000000000..565ead3730 --- /dev/null +++ b/UI/Web/src/app/grouped-typeahead/grouped-typeahead.component.html @@ -0,0 +1,99 @@ +
        +
        +
        + +
        + Loading... +
        + +
        +
        + + +
        diff --git a/UI/Web/src/app/grouped-typeahead/grouped-typeahead.component.scss b/UI/Web/src/app/grouped-typeahead/grouped-typeahead.component.scss new file mode 100644 index 0000000000..a6bbd01f08 --- /dev/null +++ b/UI/Web/src/app/grouped-typeahead/grouped-typeahead.component.scss @@ -0,0 +1,198 @@ +@use "../../theme/colors"; +form { + max-height: 38px; +} + +input { + width: 15px; + opacity: 1; + position: relative; + left: 4px; + border: none; +} + +.search-result img { + width: 100% !important; +} + + +.typeahead-input { + border: 1px solid transparent; + border-radius: 4px; + padding: 0px 6px; + display: inline-block; + overflow: hidden; + position: relative; + z-index: 1; + box-sizing: border-box; + box-shadow: none; + cursor: text; + background-color: #fff; + min-height: 38px; + transition-property: all; + transition-duration: 0.3s; + display: block; + + + .close { + cursor: pointer; + position: absolute; + top: 7px; + right: 10px; + } + + @media only screen and (max-width:650px) { + .close { + top: 50%; + transform: translate(0, -60%); + } + } + + + input { + outline: 0 !important; + border-radius: .28571429rem; + display: inline-block !important; + padding: 0px !important; + min-height: 0px !important; + max-width: 100% !important; + margin: 0px !important; + text-indent: 0 !important; + line-height: inherit !important; + box-shadow: none !important; + width: 300px; + transition-property: all; + transition-duration: 0.3s; + display: block; + } + + input:focus-visible { + width: calc(100vw - 400px); + } + + input:empty { + padding-top: 6px !important; + } +} + +.typeahead-input.focused { + width: 100%; + border-color: #ccc; +} + +/* small devices (phones, 650px and down) */ +@media only screen and (max-width:650px) { + .typeahead-input { + width: 120px; + } + + input { + width: 100% + } + + input:focus-visible { + width: 100% !important; + } +} + +::ng-deep .bg-dark .typeahead-input { + color: #efefef; + background-color: colors.$dark-bg-color; +} + +// Causes bleedover +::ng-deep .bg-dark .dropdown .list-group-item.hover { + background-color: colors.$dark-hover-color; +} + + +.dropdown { + width: 100vw; + height: calc(100vh - 57px); //header offset + background: rgba(0,0,0,0.5); + position: fixed; + justify-content: center; + left: 0; + overflow-y: auto; + display: flex; + flex-wrap: wrap; + margin-top: 10px; +} + +.list-group { + max-width: 600px; + z-index:1000; + overflow-y: auto; + overflow-x: hidden; + display: block; + flex: auto; + max-height: calc(100vh - 58px); + height: fit-content; +} + +.list-group.results { + max-height: unset; +} + +@media only screen and (max-width: 600px) { + .list-group { + max-width: unset; + } +} + +.list-group-item { + padding: 5px 10px; +} + + +li { + list-style: none; + border-radius: 0px !important; + margin: 0 !important; +} + +ul ul { + border-radius: 0px !important; +} + +.list-group-item { + cursor: pointer; +} + +::ng-deep .bg-dark { + & .section-header { + + background: colors.$dark-item-accent-bg; + cursor: default; + } + + & .section-header:hover { + background-color: colors.$dark-item-accent-bg !important; + } +} + +::ng-deep .bg-light { + & .section-header { + + background: colors.$white-item-accent-bg; + cursor: default; + } + + & .section-header:hover, .list-group-item.section-header:hover { + background: colors.$white-item-accent-bg !important; + } + + & .list-group-item:hover { + background-color: colors.$primary-color !important; + } + + +} + +.spinner-border { + position: absolute; + right: 10px; + margin: auto; + cursor: pointer; + top: 30%; +} diff --git a/UI/Web/src/app/grouped-typeahead/grouped-typeahead.component.ts b/UI/Web/src/app/grouped-typeahead/grouped-typeahead.component.ts new file mode 100644 index 0000000000..7110e8d02a --- /dev/null +++ b/UI/Web/src/app/grouped-typeahead/grouped-typeahead.component.ts @@ -0,0 +1,181 @@ +import { DOCUMENT } from '@angular/common'; +import { Component, ContentChild, ElementRef, EventEmitter, HostListener, Inject, Input, OnDestroy, OnInit, Output, Renderer2, TemplateRef, ViewChild } from '@angular/core'; +import { FormControl, FormGroup } from '@angular/forms'; +import { BehaviorSubject, Subject } from 'rxjs'; +import { debounceTime, takeUntil } from 'rxjs/operators'; +import { KEY_CODES } from '../shared/_services/utility.service'; +import { SearchResultGroup } from '../_models/search/search-result-group'; + +@Component({ + selector: 'app-grouped-typeahead', + templateUrl: './grouped-typeahead.component.html', + styleUrls: ['./grouped-typeahead.component.scss'] +}) +export class GroupedTypeaheadComponent implements OnInit, OnDestroy { + /** + * Unique id to tie with a label element + */ + @Input() id: string = 'grouped-typeahead'; + /** + * Minimum number of characters in input to trigger a search + */ + @Input() minQueryLength: number = 0; + /** + * Initial value of the search model + */ + @Input() initialValue: string = ''; + @Input() grouppedData: SearchResultGroup = new SearchResultGroup(); + /** + * Placeholder for the input + */ + @Input() placeholder: string = ''; + /** + * Number of milliseconds after typing before triggering inputChanged for data fetching + */ + @Input() debounceTime: number = 200; + /** + * Emits when the input changes from user interaction + */ + @Output() inputChanged: EventEmitter = new EventEmitter(); + /** + * Emits when something is clicked/selected + */ + @Output() selected: EventEmitter = new EventEmitter(); + /** + * Emits an event when the field is cleared + */ + @Output() clearField: EventEmitter = new EventEmitter(); + /** + * Emits when a change in the search field looses/gains focus + */ + @Output() focusChanged: EventEmitter = new EventEmitter(); + + @ViewChild('input') inputElem!: ElementRef; + @ContentChild('itemTemplate') itemTemplate!: TemplateRef; + @ContentChild('seriesTemplate') seriesTemplate: TemplateRef | undefined; + @ContentChild('collectionTemplate') collectionTemplate: TemplateRef | undefined; + @ContentChild('tagTemplate') tagTemplate: TemplateRef | undefined; + @ContentChild('personTemplate') personTemplate: TemplateRef | undefined; + @ContentChild('genreTemplate') genreTemplate!: TemplateRef; + @ContentChild('noResultsTemplate') noResultsTemplate!: TemplateRef; + @ContentChild('libraryTemplate') libraryTemplate!: TemplateRef; + @ContentChild('readingListTemplate') readingListTemplate!: TemplateRef; + + + hasFocus: boolean = false; + isLoading: boolean = false; + typeaheadForm: FormGroup = new FormGroup({}); + + prevSearchTerm: string = ''; + + private onDestroy: Subject = new Subject(); + + get searchTerm() { + return this.typeaheadForm.get('typeahead')?.value || ''; + } + + get hasData() { + return this.grouppedData.persons.length || this.grouppedData.collections.length || this.grouppedData.series.length || this.grouppedData.persons.length || this.grouppedData.tags.length || this.grouppedData.genres.length; + } + + + constructor() { } + + @HostListener('window:click', ['$event']) + handleDocumentClick(event: any) { + this.close(); + } + + @HostListener('window:keydown', ['$event']) + handleKeyPress(event: KeyboardEvent) { + if (!this.hasFocus) { return; } + + switch(event.key) { + case KEY_CODES.ESC_KEY: + this.close(); + event.stopPropagation(); + break; + default: + break; + } + } + + ngOnInit(): void { + this.typeaheadForm.addControl('typeahead', new FormControl(this.initialValue, [])); + + this.typeaheadForm.valueChanges.pipe(debounceTime(this.debounceTime), takeUntil(this.onDestroy)).subscribe(change => { + const value = this.typeaheadForm.get('typeahead')?.value; + + if (value != undefined && value != '' && !this.hasFocus) { + this.hasFocus = true; + } + + if (value != undefined && value.length >= this.minQueryLength) { + + if (this.prevSearchTerm === value) return; + this.inputChanged.emit(value); + this.prevSearchTerm = value; + } + }); + } + + ngOnDestroy(): void { + this.onDestroy.next(); + this.onDestroy.complete(); + } + + onInputFocus(event: any) { + if (event) { + event.stopPropagation(); + event.preventDefault(); + } + + this.openDropdown(); + return this.hasFocus; + } + + openDropdown() { + setTimeout(() => { + const model = this.typeaheadForm.get('typeahead'); + if (model) { + model.setValue(model.value); + } + }); + } + + handleResultlick(item: any) { + this.selected.emit(item); + } + + resetField() { + this.prevSearchTerm = ''; + this.typeaheadForm.get('typeahead')?.setValue(this.initialValue); + this.clearField.emit(); + } + + + close(event?: FocusEvent) { + if (event) { + // If the user is tabbing out of the input field, check if there are results first before closing + if (this.hasData) { + return; + } + } + if (this.searchTerm === '') { + this.resetField(); + } + this.hasFocus = false; + this.focusChanged.emit(this.hasFocus); + } + + open(event?: FocusEvent) { + this.hasFocus = true; + this.focusChanged.emit(this.hasFocus); + } + + public clear() { + this.prevSearchTerm = ''; + this.typeaheadForm.get('typeahead')?.setValue(this.initialValue); + } + +} diff --git a/UI/Web/src/app/library-detail/library-detail.component.ts b/UI/Web/src/app/library-detail/library-detail.component.ts index 13fc0e885c..ab465bf9e7 100644 --- a/UI/Web/src/app/library-detail/library-detail.component.ts +++ b/UI/Web/src/app/library-detail/library-detail.component.ts @@ -10,7 +10,7 @@ import { SeriesAddedEvent } from '../_models/events/series-added-event'; import { Library } from '../_models/library'; import { Pagination } from '../_models/pagination'; import { Series } from '../_models/series'; -import { SeriesFilter } from '../_models/series-filter'; +import { FilterEvent, SeriesFilter } from '../_models/series-filter'; import { Action, ActionFactoryService, ActionItem } from '../_services/action-factory.service'; import { ActionService } from '../_services/action.service'; import { LibraryService } from '../_services/library.service'; @@ -138,9 +138,10 @@ export class LibraryDetailComponent implements OnInit, OnDestroy { } } - updateFilter(data: SeriesFilter) { - this.filter = data; - if (this.pagination !== undefined && this.pagination !== null) { + updateFilter(event: FilterEvent) { + this.filter = event.filter; + const page = this.getPage(); + if (page === undefined || page === null || !event.isFirst) { this.pagination.currentPage = 1; this.onPageChange(this.pagination); } else { diff --git a/UI/Web/src/app/library/library.component.html b/UI/Web/src/app/library/library.component.html index 52c6427437..144907d685 100644 --- a/UI/Web/src/app/library/library.component.html +++ b/UI/Web/src/app/library/library.component.html @@ -11,9 +11,24 @@ - + + - + + + + + + + + + + + + + diff --git a/UI/Web/src/app/library/library.component.ts b/UI/Web/src/app/library/library.component.ts index 83ff53e805..975eaf30c9 100644 --- a/UI/Web/src/app/library/library.component.ts +++ b/UI/Web/src/app/library/library.component.ts @@ -1,13 +1,15 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { Title } from '@angular/platform-browser'; import { Router } from '@angular/router'; -import { Subject } from 'rxjs'; -import { take, takeUntil } from 'rxjs/operators'; +import { ReplaySubject, Subject } from 'rxjs'; +import { debounceTime, take, takeUntil } from 'rxjs/operators'; +import { RefreshMetadataEvent } from '../_models/events/refresh-metadata-event'; import { SeriesAddedEvent } from '../_models/events/series-added-event'; import { SeriesRemovedEvent } from '../_models/events/series-removed-event'; -import { InProgressChapter } from '../_models/in-progress-chapter'; import { Library } from '../_models/library'; +import { RecentlyAddedItem } from '../_models/recently-added-item'; import { Series } from '../_models/series'; +import { SeriesGroup } from '../_models/series-group'; import { User } from '../_models/user'; import { AccountService } from '../_services/account.service'; import { ImageService } from '../_services/image.service'; @@ -27,13 +29,17 @@ export class LibraryComponent implements OnInit, OnDestroy { isLoading = false; isAdmin = false; - recentlyAdded: Series[] = []; + recentlyUpdatedSeries: SeriesGroup[] = []; + recentlyAddedChapters: RecentlyAddedItem[] = []; inProgress: Series[] = []; - continueReading: InProgressChapter[] = []; + recentlyAddedSeries: Series[] = []; private readonly onDestroy = new Subject(); - seriesTrackBy = (index: number, item: any) => `${item.name}_${item.pagesRead}`; + /** + * We use this Replay subject to slow the amount of times we reload the UI + */ + private loadRecentlyAdded$: ReplaySubject = new ReplaySubject(); constructor(public accountService: AccountService, private libraryService: LibraryService, private seriesService: SeriesService, private router: Router, @@ -42,15 +48,24 @@ export class LibraryComponent implements OnInit, OnDestroy { this.messageHub.messages$.pipe(takeUntil(this.onDestroy)).subscribe(res => { if (res.event === EVENTS.SeriesAdded) { const seriesAddedEvent = res.payload as SeriesAddedEvent; + this.seriesService.getSeries(seriesAddedEvent.seriesId).subscribe(series => { - this.recentlyAdded.unshift(series); + this.recentlyAddedSeries.unshift(series); }); } else if (res.event === EVENTS.SeriesRemoved) { const seriesRemovedEvent = res.payload as SeriesRemovedEvent; - this.recentlyAdded = this.recentlyAdded.filter(item => item.id != seriesRemovedEvent.seriesId); + this.inProgress = this.inProgress.filter(item => item.id != seriesRemovedEvent.seriesId); + this.recentlyAddedSeries = this.recentlyAddedSeries.filter(item => item.id != seriesRemovedEvent.seriesId); + this.recentlyUpdatedSeries = this.recentlyUpdatedSeries.filter(item => item.seriesId != seriesRemovedEvent.seriesId); + this.recentlyAddedChapters = this.recentlyAddedChapters.filter(item => item.seriesId != seriesRemovedEvent.seriesId); + } else if (res.event === EVENTS.ScanSeries) { + // We don't have events for when series are updated, but we do get events when a scan update occurs. Refresh recentlyAdded at that time. + this.loadRecentlyAdded$.next(); } }); + + this.loadRecentlyAdded$.pipe(debounceTime(1000), takeUntil(this.onDestroy)).subscribe(() => this.loadRecentlyAdded()); } ngOnInit(): void { @@ -74,8 +89,9 @@ export class LibraryComponent implements OnInit, OnDestroy { } reloadSeries() { - this.loadRecentlyAdded(); this.loadOnDeck(); + this.loadRecentlyAdded(); + this.loadRecentlyAddedSeries(); } reloadInProgress(series: Series | boolean) { @@ -97,12 +113,27 @@ export class LibraryComponent implements OnInit, OnDestroy { }); } + loadRecentlyAddedSeries() { + this.seriesService.getRecentlyAdded().pipe(takeUntil(this.onDestroy)).subscribe((updatedSeries) => { + this.recentlyAddedSeries = updatedSeries.result; + }); + } + + loadRecentlyAdded() { - this.seriesService.getRecentlyAdded(0, 0, 20).pipe(takeUntil(this.onDestroy)).subscribe(updatedSeries => { - this.recentlyAdded = updatedSeries.result; + this.seriesService.getRecentlyUpdatedSeries().pipe(takeUntil(this.onDestroy)).subscribe(updatedSeries => { + this.recentlyUpdatedSeries = updatedSeries; + }); + + this.seriesService.getRecentlyAddedChapters().pipe(takeUntil(this.onDestroy)).subscribe(updatedSeries => { + this.recentlyAddedChapters = updatedSeries; }); } + handleRecentlyAddedChapterClick(item: RecentlyAddedItem) { + this.router.navigate(['library', item.libraryId, 'series', item.seriesId]); + } + handleSectionClick(sectionTitle: string) { if (sectionTitle.toLowerCase() === 'collections') { this.router.navigate(['collections']); diff --git a/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.html b/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.html index a2c1f61e12..1f8477c624 100644 --- a/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.html +++ b/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.html @@ -4,7 +4,7 @@ Is Scrolling: {{isScrollingForwards() ? 'Forwards' : 'Backwards'}} {{this.isScrolling}} All Images Loaded: {{this.allImagesLoaded}} Prefetched {{minPageLoaded}}-{{maxPageLoaded}} - Pages: {{pageNum}} / {{totalPages}} + Pages: {{pageNum}} / {{totalPages - 1}} At Top: {{atTop}} At Bottom: {{atBottom}} Total Height: {{getTotalHeight()}} @@ -27,7 +27,7 @@
        image diff --git a/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.scss b/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.scss index c723bbcb20..cb34773a4c 100644 --- a/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.scss +++ b/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.scss @@ -6,6 +6,10 @@ border: 2px solid red; } +.full-opacity { + opacity: 0; +} + .spacer { width: 100%; height: 300px; diff --git a/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.ts b/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.ts index fd9d011066..652e386cf7 100644 --- a/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.ts +++ b/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.ts @@ -61,7 +61,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { @Output() loadNextChapter: EventEmitter = new EventEmitter(); @Output() loadPrevChapter: EventEmitter = new EventEmitter(); - @Input() goToPage: ReplaySubject = new ReplaySubject(); + @Input() goToPage: BehaviorSubject | undefined; @Input() bookmarkPage: ReplaySubject = new ReplaySubject(); @Input() fullscreenToggled: ReplaySubject = new ReplaySubject(); @@ -121,10 +121,18 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { * Keeps track of the previous scrolling height for restoring scroll position after we inject spacer block */ previousScrollHeightMinusTop: number = 0; + /** + * Tracks the first load, until all the initial prefetched images are loaded. We use this to reduce opacity so images can load without jerk. + */ + initFinished: boolean = false; /** * Debug mode. Will show extra information. Use bitwise (|) operators between different modes to enable different output */ debugMode: DEBUG_MODES = DEBUG_MODES.None; + /** + * Debug mode. Will filter out any messages in here so they don't hit the log + */ + debugLogFilter: Array = ['[PREFETCH]', '[Intersection]', '[Visibility]', '[Image Load]']; get minPageLoaded() { return Math.min(...Object.values(this.imagesLoaded)); @@ -135,7 +143,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { } get areImagesWiderThanWindow() { - let [innerWidth, _] = this.getInnerDimensions(); + let [_, innerWidth] = this.getInnerDimensions(); return this.webtoonImageWidth > (innerWidth || document.documentElement.clientWidth); } @@ -173,18 +181,18 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { fromEvent(this.isFullscreenMode ? this.readerElemRef.nativeElement : window, 'scroll') .pipe(debounceTime(20), takeUntil(this.onDestroy)) .subscribe((event) => this.handleScrollEvent(event)); - - } ngOnInit(): void { this.initScrollHandler(); + this.recalculateImageWidth(); + if (this.goToPage) { this.goToPage.pipe(takeUntil(this.onDestroy)).subscribe(page => { - this.debugLog('[GoToPage] jump has occured from ' + this.pageNum + ' to ' + page); const isSamePage = this.pageNum === page; if (isSamePage) { return; } + this.debugLog('[GoToPage] jump has occured from ' + this.pageNum + ' to ' + page); if (this.pageNum < page) { this.scrollingDirection = PAGING_DIRECTION.FORWARD; @@ -212,14 +220,18 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { this.fullscreenToggled.pipe(takeUntil(this.onDestroy)).subscribe(isFullscreen => { this.debugLog('[FullScreen] Fullscreen mode: ', isFullscreen); this.isFullscreenMode = isFullscreen; - const [innerWidth, _] = this.getInnerDimensions(); - this.webtoonImageWidth = innerWidth || document.documentElement.clientWidth || document.body.clientWidth; + this.recalculateImageWidth(); this.initScrollHandler(); this.setPageNum(this.pageNum, true); }); } } + recalculateImageWidth() { + const [_, innerWidth] = this.getInnerDimensions(); + this.webtoonImageWidth = innerWidth || document.documentElement.clientWidth || document.body.clientWidth; + } + getVerticalOffset() { const reader = this.isFullscreenMode ? this.readerElemRef.nativeElement : window; @@ -252,10 +264,6 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { } this.prevScrollPosition = verticalOffset; - console.log('CurrentPageElem: ', this.currentPageElem); - if (this.currentPageElem != null) { - console.log('Element Visible: ', this.isElementVisible(this.currentPageElem)); - } if (this.isScrolling && this.currentPageElem != null && this.isElementVisible(this.currentPageElem)) { this.debugLog('[Scroll] Image is visible from scroll, isScrolling is now false'); this.isScrolling = false; @@ -336,6 +344,10 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { } + /** + * + * @returns Height, Width + */ getInnerDimensions() { let innerHeight = window.innerHeight; let innerWidth = window.innerWidth; @@ -356,15 +368,12 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { isElementVisible(elem: Element) { if (elem === null || elem === undefined) { return false; } + this.debugLog('[Visibility] Checking if Page ' + elem.getAttribute('id') + ' is visible'); // NOTE: This will say an element is visible if it is 1 px offscreen on top var rect = elem.getBoundingClientRect(); let [innerHeight, innerWidth] = this.getInnerDimensions(); - - console.log('innerHeight: ', innerHeight); - console.log('innerWidth: ', innerWidth); - return (rect.bottom >= 0 && rect.right >= 0 && rect.top <= (innerHeight || document.documentElement.clientHeight) && @@ -399,8 +408,8 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { initWebtoonReader() { - const [innerWidth, _] = this.getInnerDimensions(); - this.webtoonImageWidth = innerWidth || document.documentElement.clientWidth || document.body.clientWidth; + this.initFinished = false; + this.recalculateImageWidth(); this.imagesLoaded = {}; this.webtoonImages.next([]); this.atBottom = false; @@ -437,11 +446,14 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { .filter((img: any) => !img.complete) .map((img: any) => new Promise(resolve => { img.onload = img.onerror = resolve; }))) .then(() => { + this.debugLog('[Initialization] All images have loaded from initial prefetch, initFinished = true'); this.debugLog('[Image Load] ! Loaded current page !', this.pageNum); this.currentPageElem = document.querySelector('img#page-' + this.pageNum); - + // There needs to be a bit of time before we scroll if (this.currentPageElem && !this.isElementVisible(this.currentPageElem)) { this.scrollToCurrentPage(); + } else { + this.initFinished = true; } this.allImagesLoaded = true; @@ -471,8 +483,8 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { * @param scrollToPage Optional (default false) parameter to trigger scrolling to the newly set page */ setPageNum(pageNum: number, scrollToPage: boolean = false) { - if (pageNum > this.totalPages) { - pageNum = this.totalPages; + if (pageNum >= this.totalPages) { + pageNum = this.totalPages - 1; } else if (pageNum < 0) { pageNum = 0; } @@ -482,9 +494,6 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { this.prefetchWebtoonImages(); if (scrollToPage) { - const currentImage = document.querySelector('img#page-' + this.pageNum); - if (currentImage === null) return; - this.debugLog('[GoToPage] Scrolling to page', this.pageNum); this.scrollToCurrentPage(); } } @@ -499,6 +508,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { scrollToCurrentPage() { this.currentPageElem = document.querySelector('img#page-' + this.pageNum); if (!this.currentPageElem) { return; } + this.debugLog('[GoToPage] Scrolling to page', this.pageNum); // Update prevScrollPosition, so the next scroll event properly calculates direction this.prevScrollPosition = this.currentPageElem.getBoundingClientRect().top; @@ -508,6 +518,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { if (this.currentPageElem) { this.debugLog('[Scroll] Scrolling to page ', this.pageNum); this.currentPageElem.scrollIntoView({behavior: 'smooth'}); + this.initFinished = true; } }, 600); } @@ -540,7 +551,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { attachIntersectionObserverElem(elem: HTMLImageElement) { if (elem !== null) { this.intersectionObserver.observe(elem); - this.debugLog('Attached Intersection Observer to page', this.readerService.imageUrlToPageNum(elem.src)); + this.debugLog('[Intersection] Attached Intersection Observer to page', this.readerService.imageUrlToPageNum(elem.src)); } else { console.error('Could not attach observer on elem'); // This never happens } @@ -610,6 +621,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { debugLog(message: string, extraData?: any) { if (!(this.debugMode & DEBUG_MODES.Logs)) return; + if (this.debugLogFilter.filter(str => message.replace('\t', '').startsWith(str)).length > 0) return; if (extraData !== undefined) { console.log(message, extraData); } else { diff --git a/UI/Web/src/app/manga-reader/manga-reader.component.html b/UI/Web/src/app/manga-reader/manga-reader.component.html index ca95853bcf..4adc3ec04a 100644 --- a/UI/Web/src/app/manga-reader/manga-reader.component.html +++ b/UI/Web/src/app/manga-reader/manga-reader.component.html @@ -27,7 +27,7 @@ -
        +
        = new ReplaySubject(); + goToPageEvent!: BehaviorSubject; + /** * An event emiter when a bookmark on a page change occurs. Used soley by the webtoon reader. */ @@ -221,6 +222,10 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { * Library Type used for rendering chapter or issue */ libraryType: LibraryType = LibraryType.Manga; + /** + * Used for webtoon reader. When loading pages or data, this will disable the reader + */ + inSetup: boolean = true; private readonly onDestroy = new Subject(); @@ -400,6 +405,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.goToPage(parseInt(goToPageNum.trim(), 10)); } else if (event.key === KEY_CODES.B) { this.bookmarkPage(); + } else if (event.key === KEY_CODES.F) { + this.toggleFullscreen() } } @@ -422,6 +429,13 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.nextChapterPrefetched = false; this.pageNum = 0; this.pagingDirection = PAGING_DIRECTION.FORWARD; + this.inSetup = true; + + if (this.goToPageEvent) { + // There was a bug where goToPage was emitting old values into infinite scroller between chapter loads. We explicity clear it out between loads + // and we use a BehaviourSubject to ensure only latest value is sent + this.goToPageEvent.complete(); + } forkJoin({ progress: this.readerService.getProgress(this.chapterId), @@ -443,6 +457,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { page = this.maxPages - 1; } this.setPageNum(page); + this.goToPageEvent = new BehaviorSubject(this.pageNum); + @@ -451,11 +467,14 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { newOptions.ceil = this.maxPages - 1; // We -1 so that the slider UI shows us hitting the end, since visually we +1 everything. this.pageOptions = newOptions; + // TODO: Move this into ChapterInfo this.libraryService.getLibraryType(results.chapterInfo.libraryId).pipe(take(1)).subscribe(type => { this.libraryType = type; this.updateTitle(results.chapterInfo, type); }); + this.inSetup = false; + // From bookmarks, create map of pages to make lookup time O(1) @@ -1017,7 +1036,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.setPageNum(page); this.refreshSlider.emit(); - this.goToPageEvent.next(page); + this.goToPageEvent.next(page); this.render(); } diff --git a/UI/Web/src/app/nav-events-toggle/nav-events-toggle.component.html b/UI/Web/src/app/nav-events-toggle/nav-events-toggle.component.html index 69be1191ff..4ff6c86438 100644 --- a/UI/Web/src/app/nav-events-toggle/nav-events-toggle.component.html +++ b/UI/Web/src/app/nav-events-toggle/nav-events-toggle.component.html @@ -1,4 +1,4 @@ - + -
        + +
        + +
        - + + + + - + + +
        \ No newline at end of file diff --git a/UI/Web/src/app/nav-header/nav-header.component.scss b/UI/Web/src/app/nav-header/nav-header.component.scss index c0d170b06e..1ff9cc3f09 100644 --- a/UI/Web/src/app/nav-header/nav-header.component.scss +++ b/UI/Web/src/app/nav-header/nav-header.component.scss @@ -3,10 +3,41 @@ $primary-color: white; $bg-color: rgb(22, 27, 34); +.btn:focus, .btn:hover { + box-shadow: 0 0 0 0.1rem rgba(255, 255, 255, 1); +} + .navbar { background-color: $bg-color; } +/* small devices (phones, 650px and down) */ +@media only screen and (max-width:650px) { //370 + .navbar-nav { + width: 0; + } +} + +// On Really small screens, hide the server settings wheel and show it in nav +.xs-only { + display: none; +} +.not-xs-only { + display: inherit; +} +@media only screen and (max-width:300px) { + .xs-only { + display: inherit; + } + .not-xs-only { + display: none; + } +} + +.nav-item.dropdown { + position: unset; +} + .navbar-brand { font-family: "Spartan", sans-serif; font-weight: bold; @@ -28,7 +59,6 @@ $bg-color: rgb(22, 27, 34); .ng-autocomplete { margin-bottom: 0px; - max-width: 400px; } .primary-text { @@ -41,16 +71,19 @@ $bg-color: rgb(22, 27, 34); margin-top: 5px; } -@include media-breakpoint-down(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px)) { - .ng-autocomplete { - width: 100%; // 232px +.form-inline .form-group { + width: 100%; +} + +@media (min-width: 576px) { + .form-inline .form-group { + width: 100%; } } - -/* Extra small devices (phones, 300px and down) */ -@media only screen and (max-width: 300px) { //370 + +@include media-breakpoint-down(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px)) { .ng-autocomplete { - max-width: 120px; + width: 100%; // 232px } } diff --git a/UI/Web/src/app/nav-header/nav-header.component.ts b/UI/Web/src/app/nav-header/nav-header.component.ts index b2f7896e14..3ec02889d5 100644 --- a/UI/Web/src/app/nav-header/nav-header.component.ts +++ b/UI/Web/src/app/nav-header/nav-header.component.ts @@ -3,9 +3,13 @@ import { Component, HostListener, Inject, OnDestroy, OnInit, ViewChild } from '@ import { Router } from '@angular/router'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; -import { isTemplateSpan } from 'typescript'; import { ScrollService } from '../scroll.service'; +import { CollectionTag } from '../_models/collection-tag'; +import { Library } from '../_models/library'; +import { PersonRole } from '../_models/person'; +import { ReadingList } from '../_models/reading-list'; import { SearchResult } from '../_models/search-result'; +import { SearchResultGroup } from '../_models/search/search-result-group'; import { AccountService } from '../_services/account.service'; import { ImageService } from '../_services/image.service'; import { LibraryService } from '../_services/library.service'; @@ -23,7 +27,7 @@ export class NavHeaderComponent implements OnInit, OnDestroy { isLoading = false; debounceTime = 300; imageStyles = {width: '24px', 'margin-top': '5px'}; - searchResults: SearchResult[] = []; + searchResults: SearchResultGroup = new SearchResultGroup(); searchTerm = ''; customFilter: (items: SearchResult[], query: string) => SearchResult[] = (items: SearchResult[], query: string) => { const normalizedQuery = query.trim().toLowerCase(); @@ -38,6 +42,7 @@ export class NavHeaderComponent implements OnInit, OnDestroy { backToTopNeeded = false; + searchFocused: boolean = false; private readonly onDestroy = new Subject(); constructor(public accountService: AccountService, private router: Router, public navService: NavService, @@ -78,31 +83,104 @@ export class NavHeaderComponent implements OnInit, OnDestroy { } moveFocus() { - document.getElementById('content')?.focus(); + this.document.getElementById('content')?.focus(); } + + onChangeSearch(val: string) { this.isLoading = true; this.searchTerm = val.trim(); - this.libraryService.search(val).pipe(takeUntil(this.onDestroy)).subscribe(results => { + + this.libraryService.search(val.trim()).pipe(takeUntil(this.onDestroy)).subscribe(results => { this.searchResults = results; this.isLoading = false; }, err => { - this.searchResults = []; + this.searchResults.reset(); this.isLoading = false; this.searchTerm = ''; }); } - clickSearchResult(item: SearchResult) { - const libraryId = item.libraryId; - const seriesId = item.seriesId; + goTo(queryParamName: string, filter: any) { + let params: any = {}; + params[queryParamName] = filter; + params['page'] = 1; + this.clearSearch(); + this.router.navigate(['all-series'], {queryParams: params}); + } + + goToPerson(role: PersonRole, filter: any) { + // TODO: Move this to utility service + this.clearSearch(); + switch(role) { + case PersonRole.Writer: + this.goTo('writers', filter); + break; + case PersonRole.Artist: + this.goTo('artists', filter); + break; + case PersonRole.Character: + this.goTo('character', filter); + break; + case PersonRole.Colorist: + this.goTo('colorist', filter); + break; + case PersonRole.Editor: + this.goTo('editor', filter); + break; + case PersonRole.Inker: + this.goTo('inker', filter); + break; + case PersonRole.CoverArtist: + this.goTo('coverArtists', filter); + break; + case PersonRole.Inker: + this.goTo('inker', filter); + break; + case PersonRole.Letterer: + this.goTo('letterer', filter); + break; + case PersonRole.Penciller: + this.goTo('penciller', filter); + break; + case PersonRole.Publisher: + this.goTo('publisher', filter); + break; + case PersonRole.Translator: + this.goTo('translator', filter); + break; + } + } + + clearSearch() { this.searchViewRef.clear(); - this.searchResults = []; this.searchTerm = ''; + this.searchResults = new SearchResultGroup(); + } + + clickSeriesSearchResult(item: SearchResult) { + this.clearSearch(); + const libraryId = item.libraryId; + const seriesId = item.seriesId; this.router.navigate(['library', libraryId, 'series', seriesId]); } + clickLibraryResult(item: Library) { + this.router.navigate(['library', item.id]); + } + + clickCollectionSearchResult(item: CollectionTag) { + this.clearSearch(); + this.router.navigate(['collections', item.id]); + } + + clickReadingListSearchResult(item: ReadingList) { + this.clearSearch(); + this.router.navigate(['lists', item.id]); + } + + scrollToTop() { window.scroll({ top: 0, @@ -110,5 +188,10 @@ export class NavHeaderComponent implements OnInit, OnDestroy { }); } + focusUpdate(searchFocused: boolean) { + this.searchFocused = searchFocused + return searchFocused; + } + } diff --git a/UI/Web/src/app/on-deck/on-deck.component.ts b/UI/Web/src/app/on-deck/on-deck.component.ts index 0d8d23ee59..a7bcbe5f15 100644 --- a/UI/Web/src/app/on-deck/on-deck.component.ts +++ b/UI/Web/src/app/on-deck/on-deck.component.ts @@ -7,7 +7,7 @@ import { FilterSettings } from '../cards/card-detail-layout/card-detail-layout.c import { KEY_CODES } from '../shared/_services/utility.service'; import { Pagination } from '../_models/pagination'; import { Series } from '../_models/series'; -import { SeriesFilter} from '../_models/series-filter'; +import { FilterEvent, SeriesFilter} from '../_models/series-filter'; import { Action } from '../_services/action-factory.service'; import { ActionService } from '../_services/action.service'; import { SeriesService } from '../_services/series.service'; @@ -63,9 +63,10 @@ export class OnDeckComponent implements OnInit { this.loadPage(); } - updateFilter(data: SeriesFilter) { - this.filter = data; - if (this.pagination !== undefined && this.pagination !== null) { + updateFilter(event: FilterEvent) { + this.filter = event.filter; + const page = this.getPage(); + if (page === undefined || page === null || !event.isFirst) { this.pagination.currentPage = 1; this.onPageChange(this.pagination); } else { diff --git a/UI/Web/src/app/person-role.pipe.ts b/UI/Web/src/app/person-role.pipe.ts index 69190fcf64..9559e4ad6a 100644 --- a/UI/Web/src/app/person-role.pipe.ts +++ b/UI/Web/src/app/person-role.pipe.ts @@ -11,7 +11,7 @@ export class PersonRolePipe implements PipeTransform { case PersonRole.Artist: return 'Artist'; case PersonRole.Character: return 'Character'; case PersonRole.Colorist: return 'Colorist'; - case PersonRole.CoverArtist: return 'CoverArtist'; + case PersonRole.CoverArtist: return 'Cover Artist'; case PersonRole.Editor: return 'Editor'; case PersonRole.Inker: return 'Inker'; case PersonRole.Letterer: return 'Letterer'; diff --git a/UI/Web/src/app/publication-status.pipe.ts b/UI/Web/src/app/publication-status.pipe.ts index 6aa7516cac..d3beecacdb 100644 --- a/UI/Web/src/app/publication-status.pipe.ts +++ b/UI/Web/src/app/publication-status.pipe.ts @@ -8,7 +8,7 @@ export class PublicationStatusPipe implements PipeTransform { transform(value: PublicationStatus): string { switch (value) { - case PublicationStatus.OnGoing: return 'On Going'; + case PublicationStatus.OnGoing: return 'Ongoing'; case PublicationStatus.Hiatus: return 'Hiatus'; case PublicationStatus.Completed: return 'Completed'; diff --git a/UI/Web/src/app/reading-list/reading-list-detail/reading-list-detail.component.html b/UI/Web/src/app/reading-list/reading-list-detail/reading-list-detail.component.html index bfe2e5d09e..09f74709d8 100644 --- a/UI/Web/src/app/reading-list/reading-list-detail/reading-list-detail.component.html +++ b/UI/Web/src/app/reading-list/reading-list-detail/reading-list-detail.component.html @@ -1,4 +1,4 @@ -
        +
        @@ -49,8 +49,7 @@

        - +
        {{formatTitle(item)}}  diff --git a/UI/Web/src/app/reading-list/reading-lists/reading-lists.component.html b/UI/Web/src/app/reading-list/reading-lists/reading-lists.component.html index 96837060cd..70ec078ae7 100644 --- a/UI/Web/src/app/reading-list/reading-lists/reading-lists.component.html +++ b/UI/Web/src/app/reading-list/reading-lists/reading-lists.component.html @@ -3,6 +3,7 @@ [items]="lists" [actions]="actions" [pagination]="pagination" + [filteringDisabled]="true" (pageChange)="onPageChange($event)" > diff --git a/UI/Web/src/app/recently-added/recently-added.component.ts b/UI/Web/src/app/recently-added/recently-added.component.ts index f7b6f146a2..c7ec27d58b 100644 --- a/UI/Web/src/app/recently-added/recently-added.component.ts +++ b/UI/Web/src/app/recently-added/recently-added.component.ts @@ -9,7 +9,7 @@ import { KEY_CODES } from '../shared/_services/utility.service'; import { SeriesAddedEvent } from '../_models/events/series-added-event'; import { Pagination } from '../_models/pagination'; import { Series } from '../_models/series'; -import { SeriesFilter } from '../_models/series-filter'; +import { FilterEvent, SeriesFilter } from '../_models/series-filter'; import { Action } from '../_services/action-factory.service'; import { ActionService } from '../_services/action.service'; import { MessageHubService } from '../_services/message-hub.service'; @@ -23,6 +23,7 @@ import { SeriesService } from '../_services/series.service'; templateUrl: './recently-added.component.html', styleUrls: ['./recently-added.component.scss'] }) + export class RecentlyAddedComponent implements OnInit, OnDestroy { isLoading: boolean = true; @@ -81,9 +82,10 @@ export class RecentlyAddedComponent implements OnInit, OnDestroy { this.loadPage(); } - applyFilter(data: SeriesFilter) { - this.filter = data; - if (this.pagination !== undefined && this.pagination !== null) { + applyFilter(event: FilterEvent) { + this.filter = event.filter; + const page = this.getPage(); + if (page === undefined || page === null || !event.isFirst) { this.pagination.currentPage = 1; this.onPageChange(this.pagination); } else { diff --git a/UI/Web/src/app/register-member/register-member.component.html b/UI/Web/src/app/register-member/register-member.component.html deleted file mode 100644 index 5b757f889a..0000000000 --- a/UI/Web/src/app/register-member/register-member.component.html +++ /dev/null @@ -1,32 +0,0 @@ - -
        -

        Errors:

        -
          -
        • {{error}}
        • -
        -
        -
        -
        - - -
        - -
        -   - - Password must be between 6 and 32 characters in length - - - -
        - -
        - - -
        - -
        - - -
        -
        diff --git a/UI/Web/src/app/register-member/register-member.component.scss b/UI/Web/src/app/register-member/register-member.component.scss deleted file mode 100644 index 5fc352dbbc..0000000000 --- a/UI/Web/src/app/register-member/register-member.component.scss +++ /dev/null @@ -1,18 +0,0 @@ -.alt { - background-color: #424c72; - border-color: #444f75; -} - -.alt:hover { - background-color: #3b4466; -} - -.alt:focus { - background-color: #343c59; - box-shadow: 0 0 0 0.2rem rgb(68 79 117 / 50%); -} - -input { - background-color: #fff !important; - color: black !important; -} \ No newline at end of file diff --git a/UI/Web/src/app/register-member/register-member.component.ts b/UI/Web/src/app/register-member/register-member.component.ts deleted file mode 100644 index 8a705700c3..0000000000 --- a/UI/Web/src/app/register-member/register-member.component.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; -import { FormGroup, FormControl, Validators } from '@angular/forms'; -import { take } from 'rxjs/operators'; -import { AccountService } from 'src/app/_services/account.service'; -import { SettingsService } from '../admin/settings.service'; -import { User } from '../_models/user'; - -@Component({ - selector: 'app-register-member', - templateUrl: './register-member.component.html', - styleUrls: ['./register-member.component.scss'] -}) -export class RegisterMemberComponent implements OnInit { - - @Input() firstTimeFlow = false; - /** - * Emits the new user created. - */ - @Output() created = new EventEmitter(); - - adminExists = false; - authDisabled: boolean = false; - registerForm: FormGroup = new FormGroup({ - username: new FormControl('', [Validators.required]), - password: new FormControl('', []), - isAdmin: new FormControl(false, []) - }); - errors: string[] = []; - - constructor(private accountService: AccountService, private settingsService: SettingsService) { - } - - ngOnInit(): void { - this.settingsService.getAuthenticationEnabled().pipe(take(1)).subscribe(authEnabled => { - this.authDisabled = !authEnabled; - }); - if (this.firstTimeFlow) { - this.registerForm.get('isAdmin')?.setValue(true); - } - } - - register() { - this.accountService.register(this.registerForm.value).subscribe(user => { - this.created.emit(user); - }, err => { - this.errors = err; - }); - } - - cancel() { - this.created.emit(null); - } - -} diff --git a/UI/Web/src/app/registration/add-email-to-account-migration-modal/add-email-to-account-migration-modal.component.html b/UI/Web/src/app/registration/add-email-to-account-migration-modal/add-email-to-account-migration-modal.component.html new file mode 100644 index 0000000000..a7ed816852 --- /dev/null +++ b/UI/Web/src/app/registration/add-email-to-account-migration-modal/add-email-to-account-migration-modal.component.html @@ -0,0 +1,58 @@ + + + diff --git a/UI/Web/src/app/registration/add-email-to-account-migration-modal/add-email-to-account-migration-modal.component.scss b/UI/Web/src/app/registration/add-email-to-account-migration-modal/add-email-to-account-migration-modal.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/UI/Web/src/app/registration/add-email-to-account-migration-modal/add-email-to-account-migration-modal.component.ts b/UI/Web/src/app/registration/add-email-to-account-migration-modal/add-email-to-account-migration-modal.component.ts new file mode 100644 index 0000000000..4899f67b75 --- /dev/null +++ b/UI/Web/src/app/registration/add-email-to-account-migration-modal/add-email-to-account-migration-modal.component.ts @@ -0,0 +1,66 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; +import { SafeUrl } from '@angular/platform-browser'; +import { ActivatedRoute, Router } from '@angular/router'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { ToastrService } from 'ngx-toastr'; +import { ConfirmService } from 'src/app/shared/confirm.service'; +import { AccountService } from 'src/app/_services/account.service'; +import { MemberService } from 'src/app/_services/member.service'; +import { ServerService } from 'src/app/_services/server.service'; + +@Component({ + selector: 'app-add-email-to-account-migration-modal', + templateUrl: './add-email-to-account-migration-modal.component.html', + styleUrls: ['./add-email-to-account-migration-modal.component.scss'] +}) +export class AddEmailToAccountMigrationModalComponent implements OnInit { + + @Input() username!: string; + @Input() password!: string; + + isSaving: boolean = false; + registerForm: FormGroup = new FormGroup({}); + emailLink: string = ''; + emailLinkUrl: SafeUrl | undefined; + error: string = ''; + + constructor(private accountService: AccountService, private modal: NgbActiveModal, + private serverService: ServerService, private confirmService: ConfirmService) { + } + + ngOnInit(): void { + this.registerForm.addControl('username', new FormControl(this.username, [Validators.required])); + this.registerForm.addControl('email', new FormControl('', [Validators.required, Validators.email])); + this.registerForm.addControl('password', new FormControl(this.password, [Validators.required])); + } + + close() { + this.modal.close(false); + } + + save() { + this.serverService.isServerAccessible().subscribe(canAccess => { + const model = this.registerForm.getRawValue(); + model.sendEmail = canAccess; + this.accountService.migrateUser(model).subscribe(async (email) => { + console.log(email); + if (!canAccess) { + // Display the email to the user + this.emailLink = email; + await this.confirmService.alert('Please click this link to confirm your email. You must confirm to be able to login. The link is in your logs. You may need to log out of the current account before clicking.
        ' + this.emailLink + ''); + this.modal.close(true); + } else { + await this.confirmService.alert('Please check your email (or logs under "Email Link") for the confirmation link. You must confirm to be able to login.'); + this.modal.close(true); + } + }, err => { + this.error = err; + }); + }); + + } + + + +} diff --git a/UI/Web/src/app/registration/confirm-email/confirm-email.component.html b/UI/Web/src/app/registration/confirm-email/confirm-email.component.html new file mode 100644 index 0000000000..9b0d2fcb45 --- /dev/null +++ b/UI/Web/src/app/registration/confirm-email/confirm-email.component.html @@ -0,0 +1,58 @@ + + +

        Register

        + +

        Complete the form to complete your registration

        +
        +

        Errors:

        +
          +
        • {{error}}
        • +
        +
        +
        +
        + + +
        +
        + This field is required +
        +
        +
        + +
        + + +
        +
        + This field is required +
        +
        + This must be a valid email address +
        +
        +
        + +
        +   + + Password must be between 6 and 32 characters in length + + + +
        +
        + This field is required +
        +
        + Password must be between 6 and 32 characters in length +
        +
        +
        + +
        + +
        +
        +
        +
        diff --git a/UI/Web/src/app/registration/confirm-email/confirm-email.component.scss b/UI/Web/src/app/registration/confirm-email/confirm-email.component.scss new file mode 100644 index 0000000000..b3a96fcced --- /dev/null +++ b/UI/Web/src/app/registration/confirm-email/confirm-email.component.scss @@ -0,0 +1,4 @@ +input { + background-color: #fff !important; + color: black; +} \ No newline at end of file diff --git a/UI/Web/src/app/registration/confirm-email/confirm-email.component.ts b/UI/Web/src/app/registration/confirm-email/confirm-email.component.ts new file mode 100644 index 0000000000..96b5faf1e3 --- /dev/null +++ b/UI/Web/src/app/registration/confirm-email/confirm-email.component.ts @@ -0,0 +1,61 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ToastrService } from 'ngx-toastr'; +import { AccountService } from 'src/app/_services/account.service'; + +@Component({ + selector: 'app-confirm-email', + templateUrl: './confirm-email.component.html', + styleUrls: ['./confirm-email.component.scss'] +}) +export class ConfirmEmailComponent implements OnInit { + + + /** + * Email token used for validating + */ + token: string = ''; + + registerForm: FormGroup = new FormGroup({ + email: new FormControl('', [Validators.required, Validators.email]), + username: new FormControl('', [Validators.required]), + password: new FormControl('', [Validators.required, Validators.maxLength(32), Validators.minLength(6)]), + }); + + /** + * Validation errors from API + */ + errors: Array = []; + + + constructor(private route: ActivatedRoute, private router: Router, private accountService: AccountService, private toastr: ToastrService) { + + const token = this.route.snapshot.queryParamMap.get('token'); + const email = this.route.snapshot.queryParamMap.get('email'); + if (token == undefined || token === '' || token === null) { + // This is not a valid url, redirect to login + this.toastr.error('Invalid confirmation email'); + this.router.navigateByUrl('login'); + return; + } + this.token = token; + this.registerForm.get('email')?.setValue(email || ''); + } + + ngOnInit(): void { + } + + submit() { + let model = this.registerForm.getRawValue(); + model.token = this.token; + this.accountService.confirmEmail(model).subscribe((user) => { + this.toastr.success('Account registration complete'); + this.router.navigateByUrl('login'); + }, err => { + console.log('error: ', err); + this.errors = err; + }); + } + +} diff --git a/UI/Web/src/app/registration/confirm-migration-email/confirm-migration-email.component.html b/UI/Web/src/app/registration/confirm-migration-email/confirm-migration-email.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/UI/Web/src/app/registration/confirm-migration-email/confirm-migration-email.component.scss b/UI/Web/src/app/registration/confirm-migration-email/confirm-migration-email.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/UI/Web/src/app/registration/confirm-migration-email/confirm-migration-email.component.ts b/UI/Web/src/app/registration/confirm-migration-email/confirm-migration-email.component.ts new file mode 100644 index 0000000000..554dd9d308 --- /dev/null +++ b/UI/Web/src/app/registration/confirm-migration-email/confirm-migration-email.component.ts @@ -0,0 +1,34 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ToastrService } from 'ngx-toastr'; +import { AccountService } from 'src/app/_services/account.service'; + +@Component({ + selector: 'app-confirm-migration-email', + templateUrl: './confirm-migration-email.component.html', + styleUrls: ['./confirm-migration-email.component.scss'] +}) +export class ConfirmMigrationEmailComponent implements OnInit { + + constructor(private route: ActivatedRoute, private router: Router, private accountService: AccountService, private toastr: ToastrService) { + + const token = this.route.snapshot.queryParamMap.get('token'); + const email = this.route.snapshot.queryParamMap.get('email'); + if (token === undefined || token === '' || token === null || email === undefined || email === '' || email === null) { + // This is not a valid url, redirect to login + this.toastr.error('Invalid confirmation email'); + this.router.navigateByUrl('login'); + return; + } + this.accountService.confirmMigrationEmail({token: token, email}).subscribe((user) => { + this.toastr.success('Account migration complete'); + this.router.navigateByUrl('login'); + }); + + } + + + ngOnInit(): void { + } + +} diff --git a/UI/Web/src/app/registration/confirm-reset-password/confirm-reset-password.component.html b/UI/Web/src/app/registration/confirm-reset-password/confirm-reset-password.component.html new file mode 100644 index 0000000000..9046a1fbe3 --- /dev/null +++ b/UI/Web/src/app/registration/confirm-reset-password/confirm-reset-password.component.html @@ -0,0 +1,28 @@ + +

        Password Reset

        + +

        Enter the email of your account. We will send you an email

        +
        +
        +   + + Password must be between 6 and 32 characters in length + + + +
        +
        + This field is required +
        +
        + Password must be between 6 and 32 characters in length +
        +
        +
        + +
        + +
        +
        +
        +
        diff --git a/UI/Web/src/app/registration/confirm-reset-password/confirm-reset-password.component.scss b/UI/Web/src/app/registration/confirm-reset-password/confirm-reset-password.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/UI/Web/src/app/registration/confirm-reset-password/confirm-reset-password.component.ts b/UI/Web/src/app/registration/confirm-reset-password/confirm-reset-password.component.ts new file mode 100644 index 0000000000..2fdcbc910c --- /dev/null +++ b/UI/Web/src/app/registration/confirm-reset-password/confirm-reset-password.component.ts @@ -0,0 +1,50 @@ +import { Component, OnInit } from '@angular/core'; +import { FormGroup, FormControl, Validators } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ToastrService } from 'ngx-toastr'; +import { AccountService } from 'src/app/_services/account.service'; + +@Component({ + selector: 'app-confirm-reset-password', + templateUrl: './confirm-reset-password.component.html', + styleUrls: ['./confirm-reset-password.component.scss'] +}) +export class ConfirmResetPasswordComponent implements OnInit { + + token: string = ''; + registerForm: FormGroup = new FormGroup({ + email: new FormControl('', [Validators.required, Validators.email]), + password: new FormControl('', [Validators.required, Validators.maxLength(32), Validators.minLength(6)]), + }); + + constructor(private route: ActivatedRoute, private router: Router, private accountService: AccountService, private toastr: ToastrService) { + const token = this.route.snapshot.queryParamMap.get('token'); + const email = this.route.snapshot.queryParamMap.get('email'); + if (token == undefined || token === '' || token === null) { + // This is not a valid url, redirect to login + this.toastr.error('Invalid reset password url'); + this.router.navigateByUrl('login'); + return; + } + + this.token = token; + this.registerForm.get('email')?.setValue(email); + + } + + ngOnInit(): void { + } + + submit() { + const model = this.registerForm.getRawValue(); + model.token = this.token; + this.accountService.confirmResetPasswordEmail(model).subscribe(() => { + this.toastr.success("Password reset"); + this.router.navigateByUrl('login'); + }, err => { + console.log(err); + }); + } + + +} diff --git a/UI/Web/src/app/registration/register/register.component.html b/UI/Web/src/app/registration/register/register.component.html new file mode 100644 index 0000000000..c87826e687 --- /dev/null +++ b/UI/Web/src/app/registration/register/register.component.html @@ -0,0 +1,51 @@ + +

        Register

        + +

        Complete the form to register an admin account

        +
        +
        + + +
        +
        + This field is required +
        +
        +
        + +
        + + +
        +
        + This field is required +
        +
        + This must be a valid email address +
        +
        +
        + +
        +   + + Password must be between 6 and 32 characters in length + + + +
        +
        + This field is required +
        +
        + Password must be between 6 and 32 characters in length +
        +
        +
        + +
        + +
        +
        +
        +
        diff --git a/UI/Web/src/app/registration/register/register.component.scss b/UI/Web/src/app/registration/register/register.component.scss new file mode 100644 index 0000000000..b3a96fcced --- /dev/null +++ b/UI/Web/src/app/registration/register/register.component.scss @@ -0,0 +1,4 @@ +input { + background-color: #fff !important; + color: black; +} \ No newline at end of file diff --git a/UI/Web/src/app/registration/register/register.component.ts b/UI/Web/src/app/registration/register/register.component.ts new file mode 100644 index 0000000000..76ac3d87dd --- /dev/null +++ b/UI/Web/src/app/registration/register/register.component.ts @@ -0,0 +1,44 @@ +import { Component, OnInit } from '@angular/core'; +import { FormGroup, FormControl, Validators } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ToastrService } from 'ngx-toastr'; +import { take } from 'rxjs/operators'; +import { AccountService } from 'src/app/_services/account.service'; +import { MemberService } from 'src/app/_services/member.service'; + +/** + * This is exclusivly used to register the first user on the server and nothing else + */ +@Component({ + selector: 'app-register', + templateUrl: './register.component.html', + styleUrls: ['./register.component.scss'] +}) +export class RegisterComponent implements OnInit { + + registerForm: FormGroup = new FormGroup({ + email: new FormControl('', [Validators.required, Validators.email]), + username: new FormControl('', [Validators.required]), + password: new FormControl('', [Validators.required, Validators.maxLength(32), Validators.minLength(6)]), + }); + + constructor(private route: ActivatedRoute, private router: Router, private accountService: AccountService, private toastr: ToastrService, private memberService: MemberService) { + this.memberService.adminExists().pipe(take(1)).subscribe(adminExists => { + if (adminExists) { + this.router.navigateByUrl('login'); + } + }); + } + + ngOnInit(): void { + } + + submit() { + const model = this.registerForm.getRawValue(); + this.accountService.register(model).subscribe((user) => { + this.toastr.success('Account registration complete'); + this.router.navigateByUrl('login'); + }); + } + +} diff --git a/UI/Web/src/app/registration/registration.module.ts b/UI/Web/src/app/registration/registration.module.ts new file mode 100644 index 0000000000..873c512f74 --- /dev/null +++ b/UI/Web/src/app/registration/registration.module.ts @@ -0,0 +1,36 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ConfirmEmailComponent } from './confirm-email/confirm-email.component'; +import { RegistrationRoutingModule } from './registration.router.module'; +import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; +import { ReactiveFormsModule } from '@angular/forms'; +import { SplashContainerComponent } from './splash-container/splash-container.component'; +import { RegisterComponent } from './register/register.component'; +import { AddEmailToAccountMigrationModalComponent } from './add-email-to-account-migration-modal/add-email-to-account-migration-modal.component'; +import { ConfirmMigrationEmailComponent } from './confirm-migration-email/confirm-migration-email.component'; +import { ResetPasswordComponent } from './reset-password/reset-password.component'; +import { ConfirmResetPasswordComponent } from './confirm-reset-password/confirm-reset-password.component'; + + + +@NgModule({ + declarations: [ + ConfirmEmailComponent, + SplashContainerComponent, + RegisterComponent, + AddEmailToAccountMigrationModalComponent, + ConfirmMigrationEmailComponent, + ResetPasswordComponent, + ConfirmResetPasswordComponent + ], + imports: [ + CommonModule, + RegistrationRoutingModule, + NgbTooltipModule, + ReactiveFormsModule + ], + exports: [ + SplashContainerComponent + ] +}) +export class RegistrationModule { } diff --git a/UI/Web/src/app/registration/registration.router.module.ts b/UI/Web/src/app/registration/registration.router.module.ts new file mode 100644 index 0000000000..f87951c253 --- /dev/null +++ b/UI/Web/src/app/registration/registration.router.module.ts @@ -0,0 +1,37 @@ +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; +import { ConfirmEmailComponent } from './confirm-email/confirm-email.component'; +import { ConfirmMigrationEmailComponent } from './confirm-migration-email/confirm-migration-email.component'; +import { ConfirmResetPasswordComponent } from './confirm-reset-password/confirm-reset-password.component'; +import { RegisterComponent } from './register/register.component'; +import { ResetPasswordComponent } from './reset-password/reset-password.component'; + +const routes: Routes = [ + { + path: 'confirm-email', + component: ConfirmEmailComponent, + }, + { + path: 'confirm-migration-email', + component: ConfirmMigrationEmailComponent, + }, + { + path: 'register', + component: RegisterComponent, + }, + { + path: 'reset-password', + component: ResetPasswordComponent + }, + { + path: 'confirm-reset-password', + component: ConfirmResetPasswordComponent + } +]; + + +@NgModule({ + imports: [RouterModule.forChild(routes), ], + exports: [RouterModule] +}) +export class RegistrationRoutingModule { } diff --git a/UI/Web/src/app/registration/reset-password/reset-password.component.html b/UI/Web/src/app/registration/reset-password/reset-password.component.html new file mode 100644 index 0000000000..ed3eaedc80 --- /dev/null +++ b/UI/Web/src/app/registration/reset-password/reset-password.component.html @@ -0,0 +1,24 @@ + +

        Password Reset

        + +

        Enter the email of your account. We will send you an email

        +
        +
        + + +
        +
        + This field is required +
        +
        + This must be a valid email address +
        +
        +
        + +
        + +
        +
        +
        +
        diff --git a/UI/Web/src/app/registration/reset-password/reset-password.component.scss b/UI/Web/src/app/registration/reset-password/reset-password.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/UI/Web/src/app/registration/reset-password/reset-password.component.ts b/UI/Web/src/app/registration/reset-password/reset-password.component.ts new file mode 100644 index 0000000000..4080b4f63d --- /dev/null +++ b/UI/Web/src/app/registration/reset-password/reset-password.component.ts @@ -0,0 +1,31 @@ +import { Component, OnInit } from '@angular/core'; +import { FormGroup, FormControl, Validators } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ToastrService } from 'ngx-toastr'; +import { AccountService } from 'src/app/_services/account.service'; + +@Component({ + selector: 'app-reset-password', + templateUrl: './reset-password.component.html', + styleUrls: ['./reset-password.component.scss'] +}) +export class ResetPasswordComponent implements OnInit { + + registerForm: FormGroup = new FormGroup({ + email: new FormControl('', [Validators.required, Validators.email]), + }); + + constructor(private route: ActivatedRoute, private router: Router, private accountService: AccountService, private toastr: ToastrService) {} + + ngOnInit(): void { + } + + submit() { + const model = this.registerForm.get('email')?.value; + this.accountService.requestResetPasswordEmail(model).subscribe((resp: string) => { + this.toastr.info(resp); + this.router.navigateByUrl('login'); + }); + } + +} diff --git a/UI/Web/src/app/registration/splash-container/splash-container.component.html b/UI/Web/src/app/registration/splash-container/splash-container.component.html new file mode 100644 index 0000000000..04ec3d8c80 --- /dev/null +++ b/UI/Web/src/app/registration/splash-container/splash-container.component.html @@ -0,0 +1,20 @@ + \ No newline at end of file diff --git a/UI/Web/src/app/registration/splash-container/splash-container.component.scss b/UI/Web/src/app/registration/splash-container/splash-container.component.scss new file mode 100644 index 0000000000..40255e6004 --- /dev/null +++ b/UI/Web/src/app/registration/splash-container/splash-container.component.scss @@ -0,0 +1,92 @@ +@use "../../../theme/colors"; + + + +.login { + display: flex; + align-items: center; + justify-content: center; + margin-top: -61px; // To offset the navbar + height: calc(100vh); + min-height: 289px; + position: relative; + width: 100vw; + max-width: 100vw; + + + &::before { + content: ""; + background-image: url('../../../assets/images/login-bg.jpg'); + background-size: cover; + position: absolute; + top: 0; + right: 0; + bottom: 0; + opacity: 0.1; + width: 100%; + } + + .logo-container { + .logo { + display:inline-block; + height: 50px; + } + } + + .row { + margin-top: 10vh; + } + + .card { + background-color: colors.$primary-color; + color: #fff; + min-width: 300px; + + &:focus { + border: 2px solid white; + + } + + + .card-title { + font-family: 'Spartan', sans-serif; + font-weight: bold; + display: inline-block; + vertical-align: middle; + width: 280px; + } + + .card-text { + font-family: "EBGaramond", "Helvetica Neue", sans-serif; + } + + .alt { + background-color: #424c72; + border-color: #444f75; + } + + .alt:hover { + background-color: #3b4466; + } + + .alt:focus { + background-color: #343c59; + box-shadow: 0 0 0 0.2rem rgb(68 79 117 / 50%); + } + } + + ::ng-deep input { + background-color: #fff !important; + color: black; + } + + ::ng-deep a { + color: white; + } +} + +.invalid-feedback { + display: inline-block; + color: #343c59; +} + diff --git a/UI/Web/src/app/registration/splash-container/splash-container.component.ts b/UI/Web/src/app/registration/splash-container/splash-container.component.ts new file mode 100644 index 0000000000..1be131f785 --- /dev/null +++ b/UI/Web/src/app/registration/splash-container/splash-container.component.ts @@ -0,0 +1,15 @@ +import { Component, OnInit } from '@angular/core'; + +@Component({ + selector: 'app-splash-container', + templateUrl: './splash-container.component.html', + styleUrls: ['./splash-container.component.scss'] +}) +export class SplashContainerComponent implements OnInit { + + constructor() { } + + ngOnInit(): void { + } + +} diff --git a/UI/Web/src/app/series-detail/series-detail.component.html b/UI/Web/src/app/series-detail/series-detail.component.html index a5ec40dd42..668f7baa63 100644 --- a/UI/Web/src/app/series-detail/series-detail.component.html +++ b/UI/Web/src/app/series-detail/series-detail.component.html @@ -1,8 +1,7 @@
        - +
        @@ -62,8 +61,8 @@

        -