diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000..02cbdf152 --- /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 000000000..ec4bb386b --- /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 6673f3f00..52585bed1 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 1ee566816..bc03f54c7 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 ef12331cc..98e83eb00 100644 --- a/API.Benchmark/ParserBenchmarks.cs +++ b/API.Benchmark/ParserBenchmarks.cs @@ -29,37 +29,26 @@ namespace API.Benchmark 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 618a8b93c..c5d2d18e1 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 fc5b5b8ca..c00ade1e8 100644 --- a/API.Tests/Extensions/SeriesExtensionsTests.cs +++ b/API.Tests/Extensions/SeriesExtensionsTests.cs @@ -5,7 +5,6 @@ using API.Entities.Metadata; 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 9d1024ff0..723742bc6 100644 --- a/API.Tests/Helpers/CacheHelperTests.cs +++ b/API.Tests/Helpers/CacheHelperTests.cs @@ -5,8 +5,6 @@ using System.IO.Abstractions.TestingHelpers; 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 7b55df108..25b807c32 100644 --- a/API.Tests/Helpers/EntityFactory.cs +++ b/API.Tests/Helpers/EntityFactory.cs @@ -35,7 +35,7 @@ namespace API.Tests.Helpers }; } - 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 @@ namespace API.Tests.Helpers Range = range, Number = API.Parser.Parser.MinimumNumberFromRange(range) + string.Empty, Files = files ?? new List(), - Pages = 0, + Pages = pageCount, }; } diff --git a/API.Tests/Parser/ComicParserTests.cs b/API.Tests/Parser/ComicParserTests.cs index fe0cd0961..73f7cede4 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 @@ namespace API.Tests.Parser [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 fe4dd5e42..171e582cb 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 5068b39b3..02cd81aa4 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 @@ namespace API.Tests.Parser 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 526abde3e..7de8bb2bf 100644 --- a/API.Tests/Services/ArchiveServiceTests.cs +++ b/API.Tests/Services/ArchiveServiceTests.cs @@ -257,6 +257,17 @@ namespace API.Tests.Services 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 000000000..5f862d35f --- /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 419dd4126..44cba64a4 100644 --- a/API.Tests/Services/CleanupServiceTests.cs +++ b/API.Tests/Services/CleanupServiceTests.cs @@ -61,8 +61,6 @@ public class CleanupServiceTests return connection; } - public void Dispose() => _connection.Dispose(); - private async Task SeedDb() { await _context.Database.MigrateAsync(); @@ -364,70 +362,142 @@ public class CleanupServiceTests #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 bdbb7a238..391b4eac4 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 fd55143a1..e3b0b498f 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.Entities.Enums; 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 @@ public class ParseScannedFilesTests 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 940bc2ebe..05d076b3f 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; 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 @@ public class ReaderServiceTests return connection; } - public void Dispose() => _connection.Dispose(); - private async Task SeedDb() { await _context.Database.MigrateAsync(); @@ -152,10 +147,7 @@ public class ReaderServiceTests 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 class ReaderServiceTests 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 class ReaderServiceTests 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 class ReaderServiceTests 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 class ReaderServiceTests 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 class ReaderServiceTests 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 class ReaderServiceTests 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 class ReaderServiceTests } [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 class ReaderServiceTests 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 class ReaderServiceTests 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 class ReaderServiceTests 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 class ReaderServiceTests 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 class ReaderServiceTests 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); @@ -740,6 +862,153 @@ public class ReaderServiceTests Assert.Equal("B.cbz", actualChapter.Range); } + [Fact] + public async Task GetPrevChapterIdAsync_ShouldFindNoPrevChapterFromVolume() + { + 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 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("1", false, new List()), + EntityFactory.CreateChapter("2", false, 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_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() { @@ -774,10 +1043,7 @@ public class ReaderServiceTests 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); @@ -786,29 +1052,509 @@ public class ReaderServiceTests 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 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 + #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 + + } diff --git a/API.Tests/Services/ScannerServiceTests.cs b/API.Tests/Services/ScannerServiceTests.cs index b78c6be35..280fe5c10 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 000000000..53182a168 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 fbf15067e..42e0d1107 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 389a87eee..8e8a2bc5d 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 @@ /// 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 3d3a93f85..77b14ec21 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -3,18 +3,25 @@ using System.Collections.Generic; 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 @@ namespace API.Controllers 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 @@ namespace API.Controllers _logger = logger; _mapper = mapper; _accountService = accountService; + _emailService = emailService; + _environment = environment; } /// @@ -59,7 +70,7 @@ namespace API.Controllers _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 @@ namespace API.Controllers } /// - /// 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 user = new AppUser() { - 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) - { - _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 @@ namespace API.Controllers 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 @@ namespace API.Controllers 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.LogDebug("User {UserName} is logging in with authentication disabled", loginDto.Username); - loginDto.Password = AccountService.DefaultPassword; + _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) + { + 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 @@ namespace API.Controllers 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,45 +223,6 @@ namespace API.Controllers f => (string) f.GetValue(null)).Values.ToList(); } - /// - /// Sets the given roles to the user. - /// - /// - /// - [HttpPost("update-rbs")] - public async Task UpdateRoles(UpdateRbsDto updateRbsDto) - { - 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)) - { - return BadRequest("Invalid Roles"); - } - - var existingRoles = (await _userManager.GetRolesAsync(user)) - .Where(s => s != PolicyConstants.AdminRole && s != PolicyConstants.PlebRole) - .ToList(); - - // 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) - { - await _unitOfWork.RollbackAsync(); - return BadRequest("Something went wrong, unable to update user's roles"); - } - if ((await _userManager.RemoveFromRolesAsync(user, rolesToRemove)).Succeeded) - { - return Ok(); - } - - await _unitOfWork.RollbackAsync(); - return BadRequest("Something went wrong, unable to update user's roles"); - - } /// /// Resets the API Key assigned with a user @@ -271,5 +244,421 @@ namespace API.Controllers return BadRequest("Something went wrong, unable to reset key"); } + + /// + /// 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)) + { + // 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(); + } + + 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("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"); + } + + + /// + /// Will send user a link to update their password to their email or prompt them if not accessible + /// + /// + /// + [AllowAnonymous] + [HttpPost("forgot-password")] + public async Task> ForgotPassword([FromQuery] string email) + { + 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"); + } + + 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"); + } + + 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 + { + 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."); + } + + + 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 473640df7..89b2d3de4 100644 --- a/API/Controllers/BookController.cs +++ b/API/Controllers/BookController.cs @@ -3,14 +3,12 @@ using System.Collections.Generic; 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 @@ namespace API.Controllers 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 @@ namespace API.Controllers 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 @@ namespace API.Controllers 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 @@ namespace API.Controllers 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 9f297273f..89921d5f2 100644 --- a/API/Controllers/CollectionController.cs +++ b/API/Controllers/CollectionController.cs @@ -6,8 +6,10 @@ using API.Data; 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 @@ namespace API.Controllers 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 @@ namespace API.Controllers { 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 c253fb9ee..bb84138b2 100644 --- a/API/Controllers/DownloadController.cs +++ b/API/Controllers/DownloadController.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; 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.Services; 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 @@ namespace API.Controllers 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 @@ namespace API.Controllers } } + 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 @@ namespace API.Controllers [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 @@ namespace API.Controllers 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 @@ namespace API.Controllers [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 9cdd06158..3084ab352 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; 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 @@ namespace API.Controllers } [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 4aa13691f..d3e0806ed 100644 --- a/API/Controllers/MetadataController.cs +++ b/API/Controllers/MetadataController.cs @@ -111,7 +111,7 @@ public class MetadataController : BaseApiController { Title = t.ToDescription(), Value = t - })); + }).OrderBy(t => t.Title)); } /// diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index 9b7e87d62..21e184e33 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -10,6 +10,7 @@ using API.DTOs; 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 class OpdsController : BaseApiController 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 class OpdsController : BaseApiController 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 @@ public class OpdsController : BaseApiController 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 5f2d99ba3..b6162bb3a 100644 --- a/API/Controllers/PluginController.cs +++ b/API/Controllers/PluginController.cs @@ -32,6 +32,7 @@ namespace API.Controllers // 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 3028d1fee..a71d73e42 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -8,7 +8,6 @@ using API.Data.Repositories; 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 @@ namespace API.Controllers 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 @@ namespace API.Controllers _readerService = readerService; _directoryService = directoryService; _cleanupService = cleanupService; + _bookmarkService = bookmarkService; } /// @@ -356,6 +357,64 @@ namespace API.Controllers 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 @@ namespace API.Controllers 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 @@ namespace API.Controllers { try { - await _cleanupService.CleanupBookmarks(); + await _bookmarkService.DeleteBookmarkFiles(bookmarksToRemove); } catch (Exception ex) { @@ -456,49 +516,17 @@ namespace API.Controllers { // 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 + if (await _bookmarkService.BookmarkPage(user, bookmarkDto, path)) { - 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) - { - await _unitOfWork.RollbackAsync(); - return BadRequest("Could not save bookmark"); + return Ok(); } - return Ok(); + return BadRequest("Could not save bookmark"); } /// @@ -510,24 +538,11 @@ namespace API.Controllers 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 9391105cb..34a7e47b8 100644 --- a/API/Controllers/ReadingListController.cs +++ b/API/Controllers/ReadingListController.cs @@ -164,12 +164,15 @@ namespace API.Controllers 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 @@ namespace API.Controllers } /// - /// 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 ad2faeb0f..397109c09 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; 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 @@ namespace API.Controllers } [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 @@ namespace API.Controllers 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 45fb22ce5..7f5c16b0b 100644 --- a/API/Controllers/ServerController.cs +++ b/API/Controllers/ServerController.cs @@ -24,24 +24,24 @@ namespace API.Controllers 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 @@ namespace API.Controllers } } + /// + /// Checks for updates, if no updates that are > current version installed, returns null + /// [HttpGet("check-update")] public async Task> CheckForUpdates() { @@ -119,5 +122,16 @@ namespace API.Controllers { 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 76b30acf8..d42b775b2 100644 --- a/API/Controllers/SettingsController.cs +++ b/API/Controllers/SettingsController.cs @@ -4,14 +4,17 @@ using System.IO; 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 @@ namespace API.Controllers 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 @@ namespace API.Controllers 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 @@ namespace API.Controllers // 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 @@ namespace API.Controllers } - 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 @@ namespace API.Controllers 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 @@ namespace API.Controllers { 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 @@ namespace API.Controllers 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 7662fdf95..dd6e975ab 100644 --- a/API/Controllers/UsersController.cs +++ b/API/Controllers/UsersController.cs @@ -36,22 +36,17 @@ namespace API.Controllers [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 000000000..225835796 --- /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 000000000..07e0aa1ca --- /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 000000000..603508ac4 --- /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 000000000..04c9c1103 --- /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 000000000..aa947d5d1 --- /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 000000000..508e0c75c --- /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 000000000..f3afb98a5 --- /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 6a4effe16..10956b529 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 000000000..a64d92f91 --- /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 000000000..e7a941405 --- /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 000000000..a41a6027d --- /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 000000000..503a9c5e3 --- /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 000000000..dba9d05f0 --- /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 863e66e6e..fba9a7493 100644 --- a/API/DTOs/Filtering/FilterDto.cs +++ b/API/DTOs/Filtering/FilterDto.cs @@ -80,7 +80,7 @@ namespace API.DTOs.Filtering /// /// 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 e2452fdc1..eeb786714 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 000000000..9795da16e --- /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 88a16aa7c..8215cebc2 100644 --- a/API/DTOs/MemberDto.cs +++ b/API/DTOs/MemberDto.cs @@ -4,15 +4,16 @@ using System.Collections.Generic; 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 68d1fce40..9db676cc5 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 @@ namespace API.DTOs /// 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 000000000..6c7df8b4d --- /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 1bf598f5d..95814b88f 100644 --- a/API/DTOs/RegisterDto.cs +++ b/API/DTOs/RegisterDto.cs @@ -7,8 +7,9 @@ namespace API.DTOs [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 6d7ba9f58..328ff7a1f 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 000000000..b21209dca --- /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 4004c65b1..03f853d33 100644 --- a/API/DTOs/Settings/ServerSettingDTO.cs +++ b/API/DTOs/Settings/ServerSettingDTO.cs @@ -23,11 +23,6 @@ namespace API.DTOs.Settings /// 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 @@ namespace API.DTOs.Settings /// /// 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 46a8c9ae1..9176a81ff 100644 --- a/API/DTOs/Stats/ServerInfoDto.cs +++ b/API/DTOs/Stats/ServerInfoDto.cs @@ -8,5 +8,7 @@ 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 d9c232578..7a7a234e7 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 d5b3434f7..3952243fb 100644 --- a/API/Data/DbFactory.cs +++ b/API/Data/DbFactory.cs @@ -61,6 +61,11 @@ namespace API.Data }; } + 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 cc7154f93..0f213d848 100644 --- a/API/Data/Metadata/ComicInfo.cs +++ b/API/Data/Metadata/ComicInfo.cs @@ -103,17 +103,6 @@ namespace API.Data.Metadata 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 043b3e0a4..294acc57a 100644 --- a/API/Data/MigrateBookmarks.cs +++ b/API/Data/MigrateBookmarks.cs @@ -9,13 +9,13 @@ using Microsoft.Extensions.Logging; 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 000000000..d9a07ab24 --- /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 37fc68693..d9799aa22 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 class AppUserProgressRepository : IAppUserProgressRepository .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 05f2052f4..c2d5db2af 100644 --- a/API/Data/Repositories/GenreRepository.cs +++ b/API/Data/Repositories/GenreRepository.cs @@ -67,6 +67,7 @@ public class GenreRepository : IGenreRepository .Where(s => 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 26fc517a2..4a3681de3 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 class LibraryRepository : ILibraryRepository .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 71ec69639..371558459 100644 --- a/API/Data/Repositories/PersonRepository.cs +++ b/API/Data/Repositories/PersonRepository.cs @@ -66,6 +66,8 @@ public class PersonRepository : IPersonRepository .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 class PersonRepository : IPersonRepository 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 e8ffa9e16..0c8caa2e1 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -8,6 +8,8 @@ using API.DTOs; 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 @@ using Microsoft.EntityFrameworkCore; 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 class SeriesRepository : ISeriesRepository .CountAsync() > 1; } + public async Task> GetSeriesForLibraryIdAsync(int libraryId) { return await _context.Series @@ -209,15 +233,18 @@ public class SeriesRepository : ISeriesRepository .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 @@ public class SeriesRepository : ISeriesRepository }; } - 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,17 +297,56 @@ public class SeriesRepository : ISeriesRepository .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) { var series = await _context.Series.Where(x => x.Id == seriesId) @@ -277,9 +359,6 @@ public class SeriesRepository : ISeriesRepository 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 class SeriesRepository : ISeriesRepository } /// - /// 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 @@ public class SeriesRepository : ISeriesRepository 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 @@ public class SeriesRepository : ISeriesRepository && (!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 @@ public class SeriesRepository : ISeriesRepository ) .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 class SeriesRepository : ISeriesRepository var ret = await _context.Series .Where(s => libraryIds.Contains(s.LibraryId)) .Select(s => s.Metadata.Language) + .AsNoTracking() .Distinct() .ToListAsync(); @@ -786,7 +863,9 @@ public class SeriesRepository : ISeriesRepository { Title = CultureInfo.GetCultureInfo(s).DisplayName, IsoCode = s - }).ToList(); + }) + .OrderBy(s => s.Title) + .ToList(); } public async Task> GetAllPublicationStatusesDtosForLibrariesAsync(List libraryIds) @@ -800,6 +879,154 @@ public class SeriesRepository : ISeriesRepository 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 772957aa9..ef7f2ad43 100644 --- a/API/Data/Repositories/TagRepository.cs +++ b/API/Data/Repositories/TagRepository.cs @@ -50,13 +50,13 @@ public class TagRepository : ITagRepository 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 class TagRepository : ITagRepository .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 class TagRepository : ITagRepository { 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 138ef15b8..b926abe9c 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 class UserRepository : IUserRepository _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 @@ public class UserRepository : IUserRepository query = query.Include(u => u.Ratings); } + if (includeFlags.HasFlag(AppUserIncludes.UserPreferences)) + { + query = query.Include(u => u.UserPreferences); + } + + + return query; } @@ -198,6 +217,16 @@ public class UserRepository : IUserRepository .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,10 @@ public class UserRepository : IUserRepository } - 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) @@ -291,6 +321,7 @@ public class UserRepository : IUserRepository { 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 +336,42 @@ public class UserRepository : IUserRepository .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) + .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 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 e63b469e6..6ab2ca113 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 b7590e168..3dd8ecc5f 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -60,6 +60,7 @@ namespace API.Data 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 @@ namespace API.Data 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 ddb288ee1..ed9deac25 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 4d8124391..69f700fc6 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 80484d693..1a1ab8073 100644 --- a/API/Entities/Enums/ServerSettingKey.cs +++ b/API/Entities/Enums/ServerSettingKey.cs @@ -47,6 +47,7 @@ namespace API.Entities.Enums /// /// 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 @@ namespace API.Entities.Enums /// [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 54ea8ccc0..81fcba090 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 a532028bb..77a011d53 100644 --- a/API/Entities/Series.cs +++ b/API/Entities/Series.cs @@ -31,7 +31,13 @@ namespace API.Entities /// 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 ce1792f72..1d570e8ff 100644 --- a/API/Errors/ApiException.cs +++ b/API/Errors/ApiException.cs @@ -13,4 +13,4 @@ Details = details; } } -} \ No newline at end of file +} diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index fd0c5f5ca..102a7e107 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -37,6 +37,12 @@ namespace API.Extensions 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 68655f43d..f0b3d5399 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 1d0638e67..16404949b 100644 --- a/API/Extensions/IdentityServiceExtensions.cs +++ b/API/Extensions/IdentityServiceExtensions.cs @@ -24,13 +24,17 @@ namespace API.Extensions 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 @@ namespace API.Extensions 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 @@ namespace API.Extensions { 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 31a65c819..1bca8787b 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 @@ namespace API.Extensions 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 0b3f89161..1c2426ae4 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -5,6 +5,7 @@ using API.DTOs.CollectionTags; 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 32df46753..4f8305e01 100644 --- a/API/Helpers/Converters/CronConverter.cs +++ b/API/Helpers/Converters/CronConverter.cs @@ -25,17 +25,5 @@ namespace API.Helpers.Converters 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 50a839010..31ea46d4b 100644 --- a/API/Helpers/Converters/ServerSettingConverter.cs +++ b/API/Helpers/Converters/ServerSettingConverter.cs @@ -36,15 +36,15 @@ namespace API.Helpers.Converters 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 48421cd70..a97601a43 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 c17290c5b..45a1e7757 100644 --- a/API/Parser/Parser.cs +++ b/API/Parser/Parser.cs @@ -3,7 +3,6 @@ using System.IO; using System.Linq; using System.Text.RegularExpressions; using API.Entities.Enums; -using API.Services; namespace API.Parser { @@ -484,7 +483,7 @@ namespace API.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 @@ namespace API.Parser 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 @@ namespace API.Parser private static string AddChapterPart(string value) { - if (value.Contains(".")) + if (value.Contains('.')) { return value; } @@ -877,13 +873,10 @@ namespace API.Parser /// 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 @@ namespace API.Parser 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 @@ namespace API.Parser 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 4ed8ce56a..3a0d9ab25 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -8,7 +8,6 @@ using API.Data; 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 @@ namespace API 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 @@ namespace API 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 @@ namespace API 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 0591770ec..62f5386fb 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 @@ namespace API.Services 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 11042ed34..db098aa0f 100644 --- a/API/Services/ArchiveService.cs +++ b/API/Services/ArchiveService.cs @@ -7,7 +7,6 @@ using System.Linq; 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 @@ namespace API.Services 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 @@ namespace API.Services 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 @@ namespace API.Services 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); + var entry = archive.Entries.FirstOrDefault(entry => + ValidComicInfoArchiveEntry(Path.GetDirectoryName(entry.Key), entry.Key)); - return info; + 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 871b7dd32..9531aa785 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -171,7 +171,7 @@ namespace API.Services 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 @@ namespace API.Services 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 @@ namespace API.Services 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 @@ namespace API.Services 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 000000000..7acea6ad8 --- /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 0b6c4aa0d..c5396f4ed 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 bf3c01d25..0edf51ffc 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.IO.Abstractions; 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 @@ namespace API.Services 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 @@ namespace API.Services 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 000000000..08d00d29d --- /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 29a528e71..cc852b5bb 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 class ImageService : IImageService 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 b6d45c77e..75513193d 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 class MetadataService : IMetadataService /// /// /// 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 @@ public class MetadataService : IMetadataService 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 @@ public class MetadataService : IMetadataService /// /// /// 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 @@ public class MetadataService : IMetadataService 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 @@ public class MetadataService : IMetadataService /// /// /// 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 @@ public class MetadataService : IMetadataService } } series.CoverImage = firstCover?.CoverImage ?? coverImage; + await _messageHub.Clients.All.SendAsync(SignalREvents.CoverUpdate, MessageFactory.CoverUpdateEvent(series.Id, "series")); } @@ -144,7 +144,7 @@ public class MetadataService : IMetadataService /// /// /// - 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 @@ public class MetadataService : IMetadataService 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 @@ public class MetadataService : IMetadataService index++; } - var volumeUpdated = UpdateVolumeCoverImage(volume, firstChapterUpdated || forceUpdate); + var volumeUpdated = await UpdateVolumeCoverImage(volume, firstChapterUpdated || forceUpdate); if (volumeIndex == 0 && volumeUpdated) { firstVolumeUpdated = true; @@ -176,7 +176,7 @@ public class MetadataService : IMetadataService volumeIndex++; } - UpdateSeriesCoverImage(series, firstVolumeUpdated || forceUpdate); + await UpdateSeriesCoverImage(series, firstVolumeUpdated || forceUpdate); } catch (Exception ex) { @@ -220,17 +220,12 @@ public class MetadataService : IMetadataService }); _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 @@ public class MetadataService : IMetadataService } 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 @@ public class MetadataService : IMetadataService 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 class MetadataService : IMetadataService 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(); + await ProcessSeriesMetadataUpdate(series, forceUpdate); - ProcessSeriesMetadataUpdate(series, allPeople, allGenres, allTags, forceUpdate); + + if (_unitOfWork.HasChanges()) + { + await _unitOfWork.CommitAsync(); + } await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress, MessageFactory.RefreshMetadataProgressEvent(libraryId, 1F)); - - if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync()) - { - await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadata, MessageFactory.RefreshMetadataEvent(series.LibraryId, series.Id)); - } - 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 b9862cf05..36a98317b 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 class ReaderService : IReaderService _unitOfWork.AppUserProgressRepository.Update(userProgress); } + if (!_unitOfWork.HasChanges()) return true; if (await _unitOfWork.CommitAsync()) { return true; @@ -216,7 +216,7 @@ public class ReaderService : IReaderService /// 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 class ReaderService : IReaderService 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 class ReaderService : IReaderService { // 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 class ReaderService : IReaderService } 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 class ReaderService : IReaderService /// 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 class ReaderService : IReaderService 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 class ReaderService : IReaderService { 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 class ReaderService : IReaderService 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(); - private static int GetNextChapterId(IEnumerable chapters, string currentChapterNumber) + 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, Func accessor) { var next = false; var chaptersList = chapters.ToList(); @@ -316,11 +371,38 @@ public class ReaderService : IReaderService { 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 9f91bf75c..6c1d914cf 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 class TaskScheduler : ITaskScheduler _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 d31e50a22..fbb87ecd5 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 @@ namespace API.Services.Tasks 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 @@ namespace API.Services.Tasks /// /// 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 7c6a51f2c..86b819819 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -250,6 +250,7 @@ public class ScannerService : IScannerService 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 @@ public class ScannerService : IScannerService 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 @@ public class ScannerService : IScannerService 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 @@ public class ScannerService : IScannerService // 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 1b9f25593..298b8f2b7 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.DTOs.Stats; 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 class StatsService : IStatsService 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 178111051..255d0b105 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 class VersionUpdaterService : IVersionUpdaterService /// /// 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 8145b330e..4b734e8b9 100644 --- a/API/Services/TokenService.cs +++ b/API/Services/TokenService.cs @@ -5,6 +5,7 @@ using System.Linq; 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 class TokenService : ITokenService 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 class TokenService : ITokenService 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 25262430a..bf7c649bf 100644 --- a/API/SignalR/MessageFactory.cs +++ b/API/SignalR/MessageFactory.cs @@ -75,20 +75,6 @@ namespace API.SignalR } - - 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 @@ namespace API.SignalR } }; } + + 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 15590f426..1da613455 100644 --- a/API/SignalR/SignalREvents.cs +++ b/API/SignalR/SignalREvents.cs @@ -2,12 +2,14 @@ { public static class SignalREvents { - public const string UpdateAvailable = "UpdateAvailable"; - public const string ScanSeries = "ScanSeries"; /// - /// Event during Refresh Metadata for cover image change + /// An update is available for the Kavita instance /// - public const string RefreshMetadata = "RefreshMetadata"; + public const string UpdateAvailable = "UpdateAvailable"; + /// + /// Used to tell when a scan series completes + /// + public const string ScanSeries = "ScanSeries"; /// /// Event sent out during Refresh Metadata for progress tracking /// @@ -48,6 +50,9 @@ /// 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 00bfdd589..2f9ad133b 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -5,6 +5,7 @@ using System.Linq; 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.HttpOverrides; 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 @@ namespace API 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 ac1707592..7401b734b 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 82fd49132..11db76ef8 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 c7718b230..000000000 --- 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 7593ae84a..55aa99598 100644 --- a/Kavita.Common/Configuration.cs +++ b/Kavita.Common/Configuration.cs @@ -329,7 +329,7 @@ namespace Kavita.Common } /// - /// 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 000000000..6ddb2a9f3 --- /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 8285453bb..91a6ab754 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 000000000..5a9557890 --- /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 000000000..81c0b3d78 Binary files /dev/null and b/Logo/hosting-sponsor.png differ diff --git a/README.md b/README.md index d32b48c26..55570e192 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 8a71fcd8b..68c606f64 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 c54eee713..26fb10c7f 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 a924b6b2e..2e4b50acd 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 000000000..54d55049f --- /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 57e75b0ed..000000000 --- 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 0b8f17423..874dba535 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 000000000..4c44474a8 --- /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 000000000..377593669 --- /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 068054a27..a370266f5 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 000000000..657890c25 --- /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 74b7913a0..626e56a5f 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 d3006815a..0d5ee5ed1 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 66615388c..8ec3b396d 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 4cb21c299..7b76539ab 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 2c0b06f1f..7aea516f0 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 3e59347f7..2c28db2cc 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 259f2a4c1..7547bf243 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 4d64a7920..97d39624b 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 16c2ea656..c1db7b1cd 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); + hasSeriesProgress(seriesId: number) { + return this.httpClient.get(this.baseUrl + 'reader/has-progress?seriesId=' + seriesId); + } - 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]; - } - } - - 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 af02b8e88..c73b9f2c0 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 4538d17db..fcc94435c 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 2885c40de..82e7b5791 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 205195384..000000000 --- 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 4c7791b4d..50023b3e7 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 1f5b398e9..101434a43 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 5ffadf3a1..8baed6a00 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 000000000..db7af4508 --- /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 000000000..ceec62cae --- /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 000000000..034420235 --- /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 000000000..8d2460659 --- /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 000000000..cc525957f --- /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 000000000..132ac1750 --- /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 000000000..3f2adc8d1 --- /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 000000000..c7172ced6 --- /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 ecda0b4ac..344fc374f 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 eadfe5b71..5de5a0ab5 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 5b6ce57b9..8256f7f79 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 c1982f130..632e39208 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 { }); } + 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; + + const nameA = a.username.toUpperCase(); + const nameB = b.username.toUpperCase(); + if (nameA < nameB) return -1; + if (nameA > nameB) return 1; + return 0; + }) + }); + } + canEditMember(member: Member): boolean { return this.loggedInUsername !== member.username; } - createMember() { - this.createMemberToggle = true; - } - - onMemberCreated(createdUser: User | null) { - this.createMemberToggle = false; - this.loadMembers(); - } - - 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 000000000..1eb806aab --- /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 000000000..3f2adc8d1 --- /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 ff6346152..ed00eaecd 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 646fde087..ab29e4f86 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 51a37fc59..11fa904f6 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 7f8bd88f7..87eb0cfba 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 c4a8cd863..1378a292a 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 89be77453..c013a3de5 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 44e05dd86..4d9669345 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 @@ [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 12823f996..1d70b604d 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 74120972e..0e474968a 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 91847160a..1cfe40c07 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 13641550b..be28e41a3 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 8bdaf5731..4bc78fb09 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 @@
  • - +
    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 ef3c2a3cc..a81b3bbff 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 @@
    • - +
      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 ff1123797..c6b1e7452 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 005d703a6..7ef42efa6 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 7ecb8d71a..19626e296 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 1bece3ff6..a84d1ba2e 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 c096aac5b..aafe5b88a 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 d1c81c155..f6dd70bdd 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 6788881a3..fc98739c7 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 24d7b0aed..064225057 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 e9a772613..24a0703b9 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 8966cc1be..c9acf96d3 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 0e8516cf8..1d33472ea 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 02f897451..5eebc5bd3 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 000000000..565ead373 --- /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 000000000..a6bbd01f0 --- /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 000000000..7110e8d02 --- /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 13fc0e885..ab465bf9e 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 52c642743..144907d68 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 83ff53e80..975eaf30c 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 { }); } - loadRecentlyAdded() { - this.seriesService.getRecentlyAdded(0, 0, 20).pipe(takeUntil(this.onDestroy)).subscribe(updatedSeries => { - this.recentlyAdded = updatedSeries.result; + loadRecentlyAddedSeries() { + this.seriesService.getRecentlyAdded().pipe(takeUntil(this.onDestroy)).subscribe((updatedSeries) => { + this.recentlyAddedSeries = updatedSeries.result; }); } + + loadRecentlyAdded() { + 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 a2c1f61e1..1f8477c62 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 c723bbcb2..cb34773a4 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 fd9d01106..652e386cf 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 ca95853bc..4adc3ec04 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 69be1191f..4ff6c8643 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 c0d170b06..1ff9cc3f0 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,18 +71,21 @@ $bg-color: rgb(22, 27, 34); margin-top: 5px; } +.form-inline .form-group { + width: 100%; +} + +@media (min-width: 576px) { + .form-inline .form-group { + width: 100%; + } +} + @include media-breakpoint-down(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px)) { .ng-autocomplete { width: 100%; // 232px } } - -/* Extra small devices (phones, 300px and down) */ -@media only screen and (max-width: 300px) { //370 - .ng-autocomplete { - max-width: 120px; - } -} .scroll-to-top:hover { animation: MoveUpDown 1s linear infinite; 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 b2f7896e1..3ec02889d 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) { + 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.searchTerm = ''; + this.searchResults = new SearchResultGroup(); + } + + clickSeriesSearchResult(item: SearchResult) { + this.clearSearch(); const libraryId = item.libraryId; const seriesId = item.seriesId; - this.searchViewRef.clear(); - this.searchResults = []; - this.searchTerm = ''; 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 0d8d23ee5..a7bcbe5f1 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 69190fcf6..9559e4ad6 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 6aa7516ca..d3beecacd 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 bfe2e5d09..09f74709d 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 96837060c..70ec078ae 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 f7b6f146a..c7ec27d58 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 5b757f889..000000000 --- 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 5fc352dbb..000000000 --- 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 8a705700c..000000000 --- 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 000000000..a7ed81685 --- /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 000000000..e69de29bb 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 000000000..4899f67b7 --- /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 000000000..9b0d2fcb4 --- /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 000000000..b3a96fcce --- /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 000000000..96b5faf1e --- /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 000000000..e69de29bb 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 000000000..e69de29bb 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 000000000..554dd9d30 --- /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 000000000..9046a1fbe --- /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 000000000..e69de29bb 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 000000000..2fdcbc910 --- /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 000000000..c87826e68 --- /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 000000000..b3a96fcce --- /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 000000000..76ac3d87d --- /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 000000000..873c512f7 --- /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 000000000..f87951c25 --- /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 000000000..ed3eaedc8 --- /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 000000000..e69de29bb 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 000000000..4080b4f63 --- /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 000000000..04ec3d8c8 --- /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 000000000..40255e600 --- /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 000000000..1be131f78 --- /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 a5ec40dd4..668f7baa6 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 @@
        -