Merged v0.5.1 develop into main.

This commit is contained in:
Joseph Milazzo 2022-02-11 09:25:26 -06:00
commit 150479e755
256 changed files with 6898 additions and 1833 deletions

103
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@ -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

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@ -0,0 +1 @@
blank_issues_enabled: false

View File

@ -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:

3
.gitignore vendored
View File

@ -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/

View File

@ -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()
{

View File

@ -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;

View File

@ -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

View File

@ -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;

View File

@ -35,7 +35,7 @@ namespace API.Tests.Helpers
};
}
public static Chapter CreateChapter(string range, bool isSpecial, List<MangaFile> files = null)
public static Chapter CreateChapter(string range, bool isSpecial, List<MangaFile> 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<MangaFile>(),
Pages = 0,
Pages = pageCount,
};
}

View File

@ -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)));

View File

@ -1,6 +1,4 @@
using System.Collections.Generic;
using API.Entities.Enums;
using API.Parser;
using Xunit;
using Xunit.Abstractions;

View File

@ -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)
{

View File

@ -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

View File

@ -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<IMapper>(), null);
}
#region Setup
private static DbConnection CreateInMemoryDatabase()
{
var connection = new SqliteConnection("Filename=:memory:");
connection.Open();
return connection;
}
private async Task<bool> SeedDb()
{
await _context.Database.MigrateAsync();
var filesystem = CreateFileSystem();
await Seed.SeedSettings(_context, new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), 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<FolderPath>()
{
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<Volume>()
{
new Volume()
{
Chapters = new List<Chapter>()
{
new Chapter()
{
}
}
}
}
});
_context.AppUser.Add(new AppUser()
{
UserName = "Joe"
});
await _context.SaveChangesAsync();
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
var bookmarkService = new BookmarkService(Substitute.For<ILogger<BookmarkService>>(), _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<Volume>()
{
new Volume()
{
Chapters = new List<Chapter>()
{
new Chapter()
{
}
}
}
}
});
_context.AppUser.Add(new AppUser()
{
UserName = "Joe",
Bookmarks = new List<AppUserBookmark>()
{
new AppUserBookmark()
{
Page = 1,
ChapterId = 1,
FileName = $"1/1/0001.jpg",
SeriesId = 1,
VolumeId = 1
}
}
});
await _context.SaveChangesAsync();
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
var bookmarkService = new BookmarkService(Substitute.For<ILogger<BookmarkService>>(), _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<Volume>()
{
new Volume()
{
Chapters = new List<Chapter>()
{
new Chapter()
{
}
}
}
}
});
_context.AppUser.Add(new AppUser()
{
UserName = "Joe",
Bookmarks = new List<AppUserBookmark>()
{
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<ILogger<DirectoryService>>(), filesystem);
var bookmarkService = new BookmarkService(Substitute.For<ILogger<BookmarkService>>(), _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
}

View File

@ -61,8 +61,6 @@ public class CleanupServiceTests
return connection;
}
public void Dispose() => _connection.Dispose();
private async Task<bool> 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<Volume>()
{
new Volume()
{
Chapters = new List<Chapter>()
{
new Chapter()
{
}
}
}
}
});
await _context.SaveChangesAsync();
_context.AppUser.Add(new AppUser()
{
Bookmarks = new List<AppUserBookmark>()
{
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<ILogger<DirectoryService>>(), 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<Volume>()
// {
// new Volume()
// {
// Chapters = new List<Chapter>()
// {
// new Chapter()
// {
//
// }
// }
// }
// }
// });
//
// await _context.SaveChangesAsync();
//
// _context.AppUser.Add(new AppUser()
// {
// Bookmarks = new List<AppUserBookmark>()
// {
// 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<ILogger<DirectoryService>>(), 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<Volume>()
// {
// new Volume()
// {
// Chapters = new List<Chapter>()
// {
// new Chapter()
// {
//
// }
// }
// }
// }
// });
//
// await _context.SaveChangesAsync();
//
// _context.AppUser.Add(new AppUser()
// {
// Bookmarks = new List<AppUserBookmark>()
// {
// 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<ILogger<DirectoryService>>(), 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
}

View File

@ -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;

View File

@ -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<bool> SeedDb()
{
await _context.Database.MigrateAsync();

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -14,6 +14,7 @@
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
<DocumentationFile>bin\Debug\API.xml</DocumentationFile>
<NoWarn>1701;1702;1591</NoWarn>
</PropertyGroup>
<PropertyGroup>
@ -49,17 +50,17 @@
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="6.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="6.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="6.0.1" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.0">
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="2.2.0" />
<PackageReference Include="NetVips" Version="2.1.0" />
<PackageReference Include="NetVips.Native" Version="8.12.1" />
<PackageReference Include="NetVips.Native" Version="8.12.2" />
<PackageReference Include="NReco.Logging.File" Version="1.1.2" />
<PackageReference Include="SharpCompress" Version="0.30.1" />
<PackageReference Include="SixLabors.ImageSharp" Version="1.0.4" />
@ -100,6 +101,9 @@
<Compile Remove="logs\**" />
<Compile Remove="temp\**" />
<Compile Remove="covers\**" />
<Compile Remove="DTOs\Email\SmtpConfig.cs" />
<Compile Remove="DTOs\Email\EmailOptionsDto.cs" />
<Compile Remove="Helpers\Converters\SmtpConverter.cs" />
</ItemGroup>
<ItemGroup>

View File

@ -1,4 +1,6 @@
namespace API.Constants
using System.Collections.Immutable;
namespace API.Constants
{
/// <summary>
/// Role-based Security
@ -17,5 +19,12 @@
/// Used to give a user ability to download files from the server
/// </summary>
public const string DownloadRole = "Download";
/// <summary>
/// Used to give a user ability to change their own password
/// </summary>
public const string ChangePasswordRole = "Change Password";
public static readonly ImmutableArray<string> ValidRoles =
ImmutableArray.Create(AdminRole, PlebRole, DownloadRole, ChangePasswordRole);
}
}

View File

@ -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<AccountController> _logger;
private readonly IMapper _mapper;
private readonly IAccountService _accountService;
private readonly IEmailService _emailService;
private readonly IHostEnvironment _environment;
/// <inheritdoc />
public AccountController(UserManager<AppUser> userManager,
SignInManager<AppUser> signInManager,
ITokenService tokenService, IUnitOfWork unitOfWork,
ILogger<AccountController> 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;
}
/// <summary>
@ -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
}
/// <summary>
/// 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
/// </summary>
/// <param name="registerDto"></param>
/// <returns></returns>
[HttpPost("register")]
public async Task<ActionResult<UserDto>> Register(RegisterDto registerDto)
public async Task<ActionResult<UserDto>> 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<AppUser>(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<AppUser>();
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<UserPreferencesDto>(user.UserPreferences)
};
@ -152,6 +140,7 @@ namespace API.Controllers
return BadRequest("Something went wrong when registering user");
}
/// <summary>
/// Perform a login. Will send JWT Token of the logged in user back.
/// </summary>
@ -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<UserPreferencesDto>(user.UserPreferences)
};
}
[HttpPost("refresh-token")]
public async Task<ActionResult<TokenRequestDto>> RefreshToken([FromBody] TokenRequestDto tokenRequestDto)
{
var token = await _tokenService.ValidateRefreshToken(tokenRequestDto);
if (token == null)
{
return Unauthorized(new { message = "Invalid token" });
}
return Ok(token);
}
/// <summary>
/// Get All Roles back. See <see cref="PolicyConstants"/>
/// </summary>
@ -211,45 +223,6 @@ namespace API.Controllers
f => (string) f.GetValue(null)).Values.ToList();
}
/// <summary>
/// Sets the given roles to the user.
/// </summary>
/// <param name="updateRbsDto"></param>
/// <returns></returns>
[HttpPost("update-rbs")]
public async Task<ActionResult> 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");
}
/// <summary>
/// Resets the API Key assigned with a user
@ -271,5 +244,421 @@ namespace API.Controllers
return BadRequest("Something went wrong, unable to reset key");
}
/// <summary>
/// Update the user account. This can only affect Username, Email (will require confirming), Roles, and Library access.
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("update")]
public async Task<ActionResult> 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<Library> 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<AppUser>();
lib.AppUsers.Remove(user);
}
libraries = (await _unitOfWork.LibraryRepository.GetLibraryForIdsAsync(dto.Libraries)).ToList();
}
foreach (var lib in libraries)
{
lib.AppUsers ??= new List<AppUser>();
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<ActionResult<string>> 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<Library> 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<AppUser>();
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<ActionResult<UserDto>> ConfirmEmail(ConfirmEmailDto dto)
{
var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email);
// Validate Password and Username
var validationErrors = new List<ApiException>();
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<UserPreferencesDto>(user.UserPreferences)
};
}
[AllowAnonymous]
[HttpPost("confirm-password-reset")]
public async Task<ActionResult<string>> 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");
}
/// <summary>
/// Will send user a link to update their password to their email or prompt them if not accessible
/// </summary>
/// <param name="email"></param>
/// <returns></returns>
[AllowAnonymous]
[HttpPost("forgot-password")]
public async Task<ActionResult<string>> 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<ActionResult<UserDto>> 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<UserPreferencesDto>(user.UserPreferences)
};
}
[HttpPost("resend-confirmation-email")]
public async Task<ActionResult<string>> 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;
}
/// <summary>
/// This is similar to invite. Essentially we authenticate the user's password then go through invite email flow
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[AllowAnonymous]
[HttpPost("migrate-email")]
public async Task<ActionResult<string>> 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<bool> 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;
}
}
}

View File

@ -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}");
}
/// <summary>
/// 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
/// </summary>
/// <remarks>This is essentially building the table of contents</remarks>
/// <param name="chapterId"></param>
/// <returns></returns>
[HttpGet("{chapterId}/chapters")]
public async Task<ActionResult<ICollection<BookChapterItem>>> 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)

View File

@ -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> _messageHub;
/// <inheritdoc />
public CollectionController(IUnitOfWork unitOfWork)
public CollectionController(IUnitOfWork unitOfWork, IHubContext<MessageHub> messageHub)
{
_unitOfWork = unitOfWork;
_messageHub = messageHub;
}
/// <summary>
@ -51,7 +55,7 @@ namespace API.Controllers
public async Task<IEnumerable<CollectionTagDto>> 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);
}

View File

@ -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> _messageHub;
private readonly NumericComparer _numericComparer;
private readonly UserManager<AppUser> _userManager;
private readonly ILogger<DownloadController> _logger;
private const string DefaultContentType = "application/octet-stream";
public DownloadController(IUnitOfWork unitOfWork, IArchiveService archiveService, IDirectoryService directoryService,
ICacheService cacheService, IDownloadService downloadService, IHubContext<MessageHub> messageHub)
IDownloadService downloadService, IHubContext<MessageHub> messageHub, UserManager<AppUser> userManager, ILogger<DownloadController> 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<ActionResult> 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<bool> 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<ActionResult> GetFirstFileDownload(IEnumerable<MangaFile> files)
{
var (bytes, contentType, fileDownloadName) = await _downloadService.GetFirstFileDownload(files);
@ -88,6 +100,7 @@ namespace API.Controllers
[HttpGet("chapter")]
public async Task<ActionResult> 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<ActionResult> DownloadFiles(ICollection<MangaFile> 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<ActionResult> 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<ActionResult> 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);
}
}

View File

@ -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<ActionResult<IEnumerable<SearchResultDto>>> Search(string queryString)
public async Task<ActionResult<SearchResultGroupDto>> 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);
}

View File

@ -111,7 +111,7 @@ public class MetadataController : BaseApiController
{
Title = t.ToDescription(),
Value = t
}));
}).OrderBy(t => t.Title));
}
/// <summary>

View File

@ -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<FeedLink>()
{
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<FeedLink>()
{
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<SeriesDto> list, string href)
{
var url = href;
if (href.Contains("?"))
if (href.Contains('?'))
{
url += "&amp;";
}

View File

@ -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

View File

@ -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;
/// <inheritdoc />
public ReaderController(ICacheService cacheService,
IUnitOfWork unitOfWork, ILogger<ReaderController> 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;
}
/// <summary>
@ -356,6 +357,64 @@ namespace API.Controllers
return BadRequest("Could not save progress");
}
/// <summary>
/// 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.
/// </summary>
/// <returns></returns>
[HttpGet("continue-point")]
public async Task<ActionResult<ChapterDto>> GetContinuePoint(int seriesId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(await _readerService.GetContinuePoint(seriesId, userId));
}
/// <summary>
/// Returns if the user has reading progress on the Series
/// </summary>
/// <param name="seriesId"></param>
/// <returns></returns>
[HttpGet("has-progress")]
public async Task<ActionResult<ChapterDto>> HasProgress(int seriesId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.AppUserProgressRepository.HasAnyProgressOnSeriesAsync(seriesId, userId));
}
/// <summary>
/// Marks every chapter that is sorted below the passed number as Read. This will not mark any specials as read.
/// </summary>
/// <remarks>This is built for Tachiyomi and is not expected to be called by any other place</remarks>
/// <returns></returns>
[HttpPost("mark-chapter-until-as-read")]
public async Task<ActionResult<bool>> MarkChaptersUntilAsRead(int seriesId, float chapterNumber)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress);
user.Progresses ??= new List<AppUserProgress>();
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);
}
/// <summary>
/// Returns a list of bookmarked pages for a given Chapter
/// </summary>
@ -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<AppUserBookmark>();
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");
}
/// <summary>
@ -510,24 +538,11 @@ namespace API.Controllers
public async Task<ActionResult> 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");

View File

@ -164,12 +164,15 @@ namespace API.Controllers
public async Task<ActionResult> 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
}
/// <summary>
/// Update the properites (title, summary) of a reading list
/// Update the properties (title, summary) of a reading list
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>

View File

@ -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<ActionResult<VolumeDto>> GetChapter(int chapterId)
public async Task<ActionResult<ChapterDto>> 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<ActionResult<IEnumerable<RecentlyAddedItemDto>>> GetRecentlyAddedChapters()
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.SeriesRepository.GetRecentlyUpdatedSeries(userId));
}
[HttpPost("recently-added-chapters")]
public async Task<ActionResult<IEnumerable<RecentlyAddedItemDto>>> GetRecentlyAddedChaptersAlt()
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.SeriesRepository.GetRecentlyAddedChapters(userId));
}
[HttpPost("all")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetAllSeries(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0)
{

View File

@ -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<ServerController> 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;
}
/// <summary>
@ -108,6 +108,9 @@ namespace API.Controllers
}
}
/// <summary>
/// Checks for updates, if no updates that are > current version installed, returns null
/// </summary>
[HttpGet("check-update")]
public async Task<ActionResult<UpdateNotificationDto>> CheckForUpdates()
{
@ -119,5 +122,16 @@ namespace API.Controllers
{
return Ok(await _versionUpdaterService.GetAllReleases());
}
/// <summary>
/// Is this server accessible to the outside net
/// </summary>
/// <returns></returns>
[HttpGet("accessible")]
[AllowAnonymous]
public async Task<ActionResult<bool>> IsServerAccessible()
{
return await _emailService.CheckIfAccessible(Request.Host.ToString());
}
}
}

View File

@ -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<SettingsController> _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<SettingsController> 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<ServerSettingDto>(Seed.DefaultSettings));
}
/// <summary>
/// Resets the email service url
/// </summary>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("reset-email-url")]
public async Task<ActionResult<ServerSettingDto>> 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<ActionResult<EmailTestResultDto>> TestEmailServiceUrl(TestEmailDto dto)
{
return Ok(await _emailService.TestConnectivity(dto.Url));
}
[Authorize(Policy = "RequireAdminRole")]
[HttpPost]
public async Task<ActionResult<ServerSettingDto>> 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<ActionResult<bool>> GetAuthenticationEnabled()
{
var settingsDto = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
return Ok(settingsDto.EnableAuthentication);
}
}
}

View File

@ -36,22 +36,17 @@ namespace API.Controllers
[HttpGet]
public async Task<ActionResult<IEnumerable<MemberDto>>> GetUsers()
{
return Ok(await _unitOfWork.UserRepository.GetMembersAsync());
return Ok(await _unitOfWork.UserRepository.GetEmailConfirmedMemberDtosAsync());
}
[AllowAnonymous]
[HttpGet("names")]
public async Task<ActionResult<IEnumerable<MemberDto>>> GetUserNames()
[Authorize(Policy = "RequireAdminRole")]
[HttpGet("pending")]
public async Task<ActionResult<IEnumerable<MemberDto>>> 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<ActionResult<bool>> HasReadingProgress(int libraryId)
{

View File

@ -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; }
}

View File

@ -0,0 +1,7 @@
namespace API.DTOs.Account;
public class ConfirmMigrationEmailDto
{
public string Email { get; set; }
public string Token { get; set; }
}

View File

@ -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; }
}

View File

@ -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; }
/// <summary>
/// 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.
/// </summary>
public ICollection<string> Roles { get; init; }
/// <summary>
/// A list of libraries to grant access to
/// </summary>
public IList<int> Libraries { get; init; }
public bool SendEmail { get; init; } = true;
}

View File

@ -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; }
}

View File

@ -0,0 +1,7 @@
namespace API.DTOs.Account;
public class TokenRequestDto
{
public string Token { get; init; }
public string RefreshToken { get; init; }
}

View File

@ -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; }
/// <summary>
/// This field will not result in any change to the User model. Changing email is not supported.
/// </summary>
public string Email { get; set; }
/// <summary>
/// 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.
/// </summary>
public IList<string> Roles { get; init; }
/// <summary>
/// A list of libraries to grant access to
/// </summary>
public IList<int> Libraries { get; init; }
}

View File

@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using API.DTOs.Metadata;
using API.Entities;
namespace API.DTOs
{

View File

@ -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; }
/// <summary>
/// InstallId of this Kavita Instance
/// </summary>
public string InstallId { get; init; }
}

View File

@ -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; }
/// <summary>
/// InstallId of this Kavita Instance
/// </summary>
public string InstallId { get; init; }
}

View File

@ -0,0 +1,10 @@
namespace API.DTOs.Email;
/// <summary>
/// Represents if Test Email Service URL was successful or not and if any error occured
/// </summary>
public class EmailTestResultDto
{
public bool Successful { get; set; }
public string ErrorMessage { get; set; }
}

View File

@ -0,0 +1,11 @@
namespace API.DTOs.Email;
public class PasswordResetEmailDto
{
public string EmailAddress { get; init; }
public string ServerConfirmationLink { get; init; }
/// <summary>
/// InstallId of this Kavita Instance
/// </summary>
public string InstallId { get; init; }
}

View File

@ -0,0 +1,6 @@
namespace API.DTOs.Email;
public class TestEmailDto
{
public string Url { get; set; }
}

View File

@ -80,7 +80,7 @@ namespace API.DTOs.Filtering
/// <summary>
/// Sorting Options for a query. Defaults to null, which uses the queries natural sorting order
/// </summary>
public SortOptions SortOptions { get; init; } = null;
public SortOptions SortOptions { get; set; } = null;
/// <summary>
/// Age Ratings. Empty list will return everything back
/// </summary>

View File

@ -1,6 +1,4 @@
using System;
namespace API.DTOs.Filtering;
namespace API.DTOs.Filtering;
/// <summary>
/// Represents the Reading Status. This is a flag and allows multiple statues

View File

@ -0,0 +1,32 @@
using System;
using API.Entities.Enums;
namespace API.DTOs;
/// <summary>
/// This is a representation of a Series with some amount of underlying files within it. This is used for Recently Updated Series section
/// </summary>
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; }
/// <summary>
/// Chapter Id if this is a chapter. Not guaranteed to be set.
/// </summary>
public int ChapterId { get; set; } = 0;
/// <summary>
/// Volume Id if this is a chapter. Not guaranteed to be set.
/// </summary>
public int VolumeId { get; set; } = 0;
/// <summary>
/// This is used only on the UI. It is just index of being added.
/// </summary>
public int Id { get; set; }
public MangaFormat Format { get; set; }
/// <summary>
/// Number of items that are updated. This provides a sort of grouping when multiple chapters are added per Volume/Series
/// </summary>
public int Count { get; set; }
}

View File

@ -4,15 +4,16 @@ using System.Collections.Generic;
namespace API.DTOs
{
/// <summary>
/// Represents a member of a Kavita server.
/// Represents a member of a Kavita server.
/// </summary>
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<LibraryDto> Libraries { get; init; }
public IEnumerable<string> Roles { get; init; }
}
}
}

View File

@ -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
/// </summary>
public int Page { get; set; }
public ICollection<BookChapterItem> Children { get; set; }
public ICollection<BookChapterItem> Children { get; set; }
}
}
}

View File

@ -0,0 +1,34 @@
using System;
using API.Entities.Enums;
namespace API.DTOs;
/// <summary>
/// A mesh of data for Recently added volume/chapters
/// </summary>
public class RecentlyAddedItemDto
{
public string SeriesName { get; set; }
public int SeriesId { get; set; }
public int LibraryId { get; set; }
public LibraryType LibraryType { get; set; }
/// <summary>
/// This will automatically map to Volume X, Chapter Y, etc.
/// </summary>
public string Title { get; set; }
public DateTime Created { get; set; }
/// <summary>
/// Chapter Id if this is a chapter. Not guaranteed to be set.
/// </summary>
public int ChapterId { get; set; } = 0;
/// <summary>
/// Volume Id if this is a chapter. Not guaranteed to be set.
/// </summary>
public int VolumeId { get; set; } = 0;
/// <summary>
/// This is used only on the UI. It is just index of being added.
/// </summary>
public int Id { get; set; }
public MangaFormat Format { get; set; }
}

View File

@ -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; }
}
}

View File

@ -1,6 +1,6 @@
using API.Entities.Enums;
namespace API.DTOs
namespace API.DTOs.Search
{
public class SearchResultDto
{

View File

@ -0,0 +1,21 @@
using System.Collections.Generic;
using API.DTOs.CollectionTags;
using API.DTOs.Metadata;
using API.DTOs.ReadingLists;
namespace API.DTOs.Search;
/// <summary>
/// Represents all Search results for a query
/// </summary>
public class SearchResultGroupDto
{
public IEnumerable<LibraryDto> Libraries { get; set; }
public IEnumerable<SearchResultDto> Series { get; set; }
public IEnumerable<CollectionTagDto> Collections { get; set; }
public IEnumerable<ReadingListDto> ReadingLists { get; set; }
public IEnumerable<PersonDto> Persons { get; set; }
public IEnumerable<GenreTagDto> Genres { get; set; }
public IEnumerable<TagDto> Tags { get; set; }
}

View File

@ -23,11 +23,6 @@ namespace API.DTOs.Settings
/// Enables OPDS connections to be made to the server.
/// </summary>
public bool EnableOpds { get; set; }
/// <summary>
/// Enables Authentication on the server. Defaults to true.
/// </summary>
public bool EnableAuthentication { get; set; }
/// <summary>
/// Base Url for the kavita. Requires restart to take effect.
/// </summary>
@ -37,5 +32,10 @@ namespace API.DTOs.Settings
/// </summary>
/// <remarks>If null or empty string, will default back to default install setting aka <see cref="DirectoryService.BookmarkDirectory"/></remarks>
public string BookmarksDirectory { get; set; }
/// <summary>
/// Email service to use for the invite user flow, forgot password, etc.
/// </summary>
/// <remarks>If null or empty string, will default back to default install setting aka <see cref="EmailService.DefaultApiUrl"/></remarks>
public string EmailServiceUrl { get; set; }
}
}

View File

@ -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; }
}
}

View File

@ -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; }
}

View File

@ -61,6 +61,11 @@ namespace API.Data
};
}
public static SeriesMetadata SeriesMetadata(ComicInfo info)
{
return SeriesMetadata(Array.Empty<CollectionTag>());
}
public static SeriesMetadata SeriesMetadata(ICollection<CollectionTag> collectionTags)
{
return new SeriesMetadata()

View File

@ -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;
// }
// }
}

View File

@ -9,13 +9,13 @@ using Microsoft.Extensions.Logging;
namespace API.Data;
/// <summary>
/// Responsible to migrate existing bookmarks to files
/// Responsible to migrate existing bookmarks to files. Introduced in v0.4.9.27
/// </summary>
public static class MigrateBookmarks
{
private static readonly Version VersionBookmarksChanged = new Version(0, 4, 9, 27);
/// <summary>
/// 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.
/// </summary>
/// <remarks>Bookmark directory is configurable. This will always use the default bookmark directory.</remarks>
/// <param name="directoryService"></param>

View File

@ -0,0 +1,30 @@
using System.Threading.Tasks;
using API.Constants;
using API.Entities;
using Microsoft.AspNetCore.Identity;
namespace API.Data;
/// <summary>
/// New role introduced in v0.5.1. Adds the role to all users.
/// </summary>
public static class MigrateChangePasswordRoles
{
/// <summary>
/// Will not run if any users have the ChangePassword role already
/// </summary>
/// <param name="unitOfWork"></param>
/// <param name="userManager"></param>
public static async Task Migrate(IUnitOfWork unitOfWork, UserManager<AppUser> 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);
}
}
}

View File

@ -12,6 +12,7 @@ public interface IAppUserProgressRepository
Task<int> CleanupAbandonedChapters();
Task<bool> UserHasProgress(LibraryType libraryType, int userId);
Task<AppUserProgress> GetUserProgressAsync(int chapterId, int userId);
Task<bool> HasAnyProgressOnSeriesAsync(int seriesId, int userId);
}
public class AppUserProgressRepository : IAppUserProgressRepository
@ -76,6 +77,12 @@ public class AppUserProgressRepository : IAppUserProgressRepository
.AnyAsync();
}
public async Task<bool> HasAnyProgressOnSeriesAsync(int seriesId, int userId)
{
return await _context.AppUserProgresses
.AnyAsync(aup => aup.PagesRead > 0 && aup.AppUserId == userId && aup.SeriesId == seriesId);
}
public async Task<AppUserProgress> GetUserProgressAsync(int chapterId, int userId)
{
return await _context.AppUserProgresses

View File

@ -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<GenreTagDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}

View File

@ -36,6 +36,7 @@ public interface ILibraryRepository
Task<bool> DeleteLibrary(int libraryId);
Task<IEnumerable<Library>> GetLibrariesForUserIdAsync(int userId);
Task<LibraryType> GetLibraryTypeAsync(int libraryId);
Task<IEnumerable<Library>> GetLibraryForIdsAsync(IList<int> libraryIds);
}
public class LibraryRepository : ILibraryRepository
@ -108,6 +109,13 @@ public class LibraryRepository : ILibraryRepository
.SingleAsync();
}
public async Task<IEnumerable<Library>> GetLibraryForIdsAsync(IList<int> libraryIds)
{
return await _context.Library
.Where(x => libraryIds.Contains(x.Id))
.ToListAsync();
}
public async Task<IEnumerable<LibraryDto>> GetLibraryDtosAsync()
{
return await _context.Library

View File

@ -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<PersonDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
@ -74,6 +76,7 @@ public class PersonRepository : IPersonRepository
public async Task<IList<Person>> GetAllPeople()
{
return await _context.Person
.OrderBy(p => p.Name)
.ToListAsync();
}
}

View File

@ -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
/// <summary>
/// Does not add user information like progress, ratings, etc.
/// </summary>
/// <param name="userId"></param>
/// <param name="isAdmin"></param>
/// <param name="libraryIds"></param>
/// <param name="searchQuery">Series name to search for</param>
/// <param name="searchQuery"></param>
/// <returns></returns>
Task<IEnumerable<SearchResultDto>> SearchSeries(int[] libraryIds, string searchQuery);
Task<SearchResultGroupDto> SearchSeries(int userId, bool isAdmin, int[] libraryIds, string searchQuery);
Task<IEnumerable<Series>> GetSeriesForLibraryIdAsync(int libraryId);
Task<SeriesDto> GetSeriesDtoByIdAsync(int seriesId, int userId);
Task<bool> DeleteSeriesAsync(int seriesId);
@ -73,6 +94,8 @@ public interface ISeriesRepository
Task<IList<AgeRatingDto>> GetAllAgeRatingsDtosForLibrariesAsync(List<int> libraryIds);
Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync(List<int> libraryIds);
Task<IList<PublicationStatusDto>> GetAllPublicationStatusesDtosForLibrariesAsync(List<int> libraryIds);
Task<IList<GroupedSeriesDto>> GetRecentlyUpdatedSeries(int userId);
Task<IList<RecentlyAddedItemDto>> GetRecentlyAddedChapters(int userId);
}
public class SeriesRepository : ISeriesRepository
@ -124,6 +147,7 @@ public class SeriesRepository : ISeriesRepository
.CountAsync() > 1;
}
public async Task<IEnumerable<Series>> GetSeriesForLibraryIdAsync(int libraryId)
{
return await _context.Series
@ -209,15 +233,18 @@ public class SeriesRepository : ISeriesRepository
.SingleOrDefaultAsync();
}
/// <summary>
/// Gets all series
/// </summary>
/// <param name="libraryId"></param>
/// <param name="userId"></param>
/// <param name="userParams"></param>
/// <param name="filter"></param>
/// <returns></returns>
public async Task<PagedList<SeriesDto>> 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<SeriesDto>(_mapper.ConfigurationProvider)
.AsSplitQuery()
@ -244,9 +271,25 @@ public class SeriesRepository : ISeriesRepository
};
}
public async Task<IEnumerable<SearchResultDto>> SearchSeries(int[] libraryIds, string searchQuery)
public async Task<SearchResultGroupDto> 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<LibraryDto>(_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<SearchResultDto>(_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<ReadingListDto>(_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<CollectionTagDto>(_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<PersonDto>(_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<GenreTagDto>(_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<TagDto>(_mapper.ConfigurationProvider)
.ToListAsync();
return result;
}
public async Task<SeriesDto> 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<bool> DeleteSeriesAsync(int seriesId)
{
var series = await _context.Series.Where(s => s.Id == seriesId).SingleOrDefaultAsync();
@ -345,7 +424,7 @@ public class SeriesRepository : ISeriesRepository
}
/// <summary>
/// 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
/// </summary>
/// <param name="seriesIds"></param>
/// <returns></returns>
@ -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<IList<PublicationStatusDto>> GetAllPublicationStatusesDtosForLibrariesAsync(List<int> 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";
}
}
/// <summary>
/// Show all recently added chapters. Provide some mapping for chapter 0 -> Volume 1
/// </summary>
/// <param name="userId"></param>
/// <returns></returns>
public async Task<IList<RecentlyAddedItemDto>> GetRecentlyAddedChapters(int userId)
{
var ret = await GetRecentlyAddedChaptersQuery(userId);
var items = new List<RecentlyAddedItemDto>();
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;
}
/// <summary>
/// Return recently updated series, regardless of read progress, and group the number of volume or chapters added.
/// </summary>
/// <param name="userId">Used to ensure user has access to libraries</param>
/// <returns></returns>
public async Task<IList<GroupedSeriesDto>> GetRecentlyUpdatedSeries(int userId)
{
var ret = await GetRecentlyAddedChaptersQuery(userId, 150);
var seriesMap = new Dictionary<string, GroupedSeriesDto>();
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<List<RecentlyAddedSeries>> 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;
}
}

View File

@ -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<TagDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
@ -80,6 +82,7 @@ public class TagRepository : ITagRepository
{
return await _context.Tag
.AsNoTracking()
.OrderBy(t => t.Title)
.ProjectTo<TagDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}

View File

@ -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<IEnumerable<MemberDto>> GetMembersAsync();
void Delete(AppUserBookmark bookmark);
Task<IEnumerable<MemberDto>> GetEmailConfirmedMemberDtosAsync();
Task<IEnumerable<MemberDto>> GetPendingMemberDtosAsync();
Task<IEnumerable<AppUser>> GetAdminUsersAsync();
Task<IEnumerable<AppUser>> GetNonAdminUsersAsync();
Task<bool> IsUserAdminAsync(AppUser user);
@ -48,6 +52,9 @@ public interface IUserRepository
Task<int> GetUserIdByUsernameAsync(string username);
Task<AppUser> GetUserWithReadingListsByUsernameAsync(string username);
Task<IList<AppUserBookmark>> GetAllBookmarksByIds(IList<int> bookmarkIds);
Task<AppUser> GetUserByEmailAsync(string email);
Task<IEnumerable<AppUser>> 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);
}
/// <summary>
/// A one stop shop to get a tracked AppUser instance with any number of JOINs generated by passing bitwise flags.
/// </summary>
@ -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<AppUser> GetUserByEmailAsync(string email)
{
return await _context.AppUser.SingleOrDefaultAsync(u => u.Email.ToLower().Equals(email.ToLower()));
}
public async Task<IEnumerable<AppUser>> GetAllUsers()
{
return await _context.AppUser.ToListAsync();
}
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
{
return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole);
@ -280,9 +309,10 @@ public class UserRepository : IUserRepository
}
public async Task<IEnumerable<MemberDto>> GetMembersAsync()
public async Task<IEnumerable<MemberDto>> 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<IEnumerable<MemberDto>> 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<bool> ValidateUserExists(string username)
{
if (await _userManager.Users.AnyAsync(x => x.NormalizedUserName == username.ToUpper()))
{
throw new ValidationException("Username is taken.");
}
return true;
}
}

View File

@ -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;

View File

@ -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();
}

View File

@ -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,

View File

@ -7,7 +7,7 @@ public enum PublicationStatus
/// <summary>
/// Default Status. Publication is currently in progress
/// </summary>
[Description("On Going")]
[Description("Ongoing")]
OnGoing = 0,
/// <summary>
/// Series is on temp or indefinite Hiatus

View File

@ -47,6 +47,7 @@ namespace API.Entities.Enums
/// <summary>
/// Is Authentication needed for non-admin accounts
/// </summary>
/// <remarks>Deprecated. This is no longer used v0.5.1+. Assume Authentication is always in effect</remarks>
[Description("EnableAuthentication")]
EnableAuthentication = 8,
/// <summary>
@ -70,6 +71,10 @@ namespace API.Entities.Enums
/// </summary>
[Description("BookmarkDirectory")]
BookmarkDirectory = 12,
/// <summary>
/// If SMTP is enabled on the server
/// </summary>
[Description("CustomEmailService")]
EmailServiceUrl = 13,
}
}

View File

@ -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;

View File

@ -31,7 +31,13 @@ namespace API.Entities
/// Original Name on disk. Not exposed to UI.
/// </summary>
public string OriginalName { get; set; }
/// <summary>
/// Time of creation
/// </summary>
public DateTime Created { get; set; }
/// <summary>
/// Whenever a modification occurs. Ie) New volumes, removed volumes, title update, etc
/// </summary>
public DateTime LastModified { get; set; }
/// <summary>
/// Absolute path to the (managed) image file

View File

@ -13,4 +13,4 @@
Details = details;
}
}
}
}

View File

@ -37,6 +37,12 @@ namespace API.Extensions
services.AddScoped<IReaderService, ReaderService>();
services.AddScoped<IReadingItemService, ReadingItemService>();
services.AddScoped<IAccountService, AccountService>();
services.AddScoped<IEmailService, EmailService>();
services.AddScoped<IBookmarkService, BookmarkService>();
services.AddScoped<IFileSystem, FileSystem>();
services.AddScoped<IFileService, FileService>();
services.AddScoped<ICacheHelper, CacheHelper>();
services.AddScoped<IFileSystem, FileSystem>();

View File

@ -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;

View File

@ -24,13 +24,17 @@ namespace API.Extensions
opt.Password.RequireUppercase = false;
opt.Password.RequireNonAlphanumeric = false;
opt.Password.RequiredLength = 6;
opt.SignIn.RequireConfirmedEmail = true;
})
.AddTokenProvider<DataProtectorTokenProvider<AppUser>>(TokenOptions.DefaultProvider)
.AddRoles<AppRole>()
.AddRoleManager<RoleManager<AppRole>>()
.AddSignInManager<SignInManager<AppUser>>()
.AddRoleValidator<RoleValidator<AppRole>>()
.AddEntityFrameworkStores<DataContext>();
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;

View File

@ -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);
}
// /// <summary>
// /// Returns the MangaFormat that is common to all the files. Unknown if files are mixed (should never happen) or no infos
// /// </summary>
// /// <param name="infos"></param>
// /// <returns></returns>
// public static MangaFormat GetFormat(this IList<ParserInfo> infos)
// {
// if (infos.Count == 0) return MangaFormat.Unknown;
// return infos.DistinctBy(x => x.Format).First().Format;
// }
}
}

View File

@ -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;

View File

@ -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;
// }
}
}

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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(
@"(?<Special>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|_|-))",
@"(?<Special>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
/// <returns>A zero padded number</returns>
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;
}

View File

@ -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<ILogger<Program>>();
logger.LogCritical(ex, "An error occurred during migration");
var context = services.GetRequiredService<DataContext>();
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<string> 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) =>

View File

@ -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<IEnumerable<ApiException>> ChangeUserPassword(AppUser user, string newPassword);
Task<IEnumerable<ApiException>> ValidatePassword(AppUser user, string password);
Task<IEnumerable<ApiException>> ValidateUsername(string username);
Task<IEnumerable<ApiException>> ValidateEmail(string email);
}
public class AccountService : IAccountService
{
private readonly UserManager<AppUser> _userManager;
private readonly ILogger<AccountService> _logger;
private readonly IUnitOfWork _unitOfWork;
public const string DefaultPassword = "[k.2@RZ!mxCQkJzE";
public AccountService(UserManager<AppUser> userManager, ILogger<AccountService> logger)
public AccountService(UserManager<AppUser> userManager, ILogger<AccountService> logger, IUnitOfWork unitOfWork)
{
_userManager = userManager;
_logger = logger;
_unitOfWork = unitOfWork;
}
public async Task<IEnumerable<ApiException>> 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<ApiException>();
}
public async Task<IEnumerable<ApiException>> 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<ApiException>();
}
public async Task<IEnumerable<ApiException>> ValidateUsername(string username)
{
if (await _userManager.Users.AnyAsync(x => x.NormalizedUserName == username.ToUpper()))
{
return new List<ApiException>()
{
new ApiException(400, "Username is already taken")
};
}
return Array.Empty<ApiException>();
}
public async Task<IEnumerable<ApiException>> ValidateEmail(string email)
{
var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(email);
if (user == null) return Array.Empty<ApiException>();
return new List<ApiException>()
{
new ApiException(400, "Email is already registered")
};
}
}
}

View File

@ -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<IArchiveEntry> 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);
}
/// <summary>
@ -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);

View File

@ -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;
}
/// <summary>
/// Removes the leading ../
/// </summary>
/// <param name="key"></param>
/// <returns></returns>
public static string CleanContentKeys(string key)
{
return key.Replace("../", string.Empty);

View File

@ -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<AppUserBookmark> bookmarks);
Task<bool> BookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto, string imageToBookmark);
Task<bool> RemoveBookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto);
}
public class BookmarkService : IBookmarkService
{
private readonly ILogger<BookmarkService> _logger;
private readonly IUnitOfWork _unitOfWork;
private readonly IDirectoryService _directoryService;
public BookmarkService(ILogger<BookmarkService> logger, IUnitOfWork unitOfWork, IDirectoryService directoryService)
{
_logger = logger;
_unitOfWork = unitOfWork;
_directoryService = directoryService;
}
/// <summary>
/// Deletes the files associated with the list of Bookmarks passed. Will clean up empty folders.
/// </summary>
/// <param name="bookmarks"></param>
public async Task DeleteBookmarkFiles(IEnumerable<AppUserBookmark> 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);
}
}
}
/// <summary>
/// Creates a new entry in the AppUserBookmarks and copies an image to BookmarkDirectory.
/// </summary>
/// <param name="userWithBookmarks">An AppUser object with Bookmarks populated</param>
/// <param name="bookmarkDto"></param>
/// <param name="imageToBookmark">Full path to the cached image that is going to be copied</param>
/// <returns>If the save to DB and copy was successful</returns>
public async Task<bool> 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<AppUserBookmark>();
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;
}
/// <summary>
/// Removes the Bookmark entity and the file from BookmarkDirectory
/// </summary>
/// <param name="userWithBookmarks"></param>
/// <param name="bookmarkDto"></param>
/// <returns></returns>
public async Task<bool> 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}");
}
}

View File

@ -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;

View File

@ -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; }
/// <summary>
/// Original BookmarkDirectory. Only used for resetting directory. Use <see cref="ServerSettings.BackupDirectory"/> for actual path.
/// Original BookmarkDirectory. Only used for resetting directory. Use <see cref="ServerSettingKey.BackupDirectory"/> for actual path.
/// </summary>
string BookmarkDirectory { get; }
/// <summary>
@ -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)
{

View File

@ -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<bool> CheckIfAccessible(string host);
Task<bool> SendMigrationEmail(EmailMigrationDto data);
Task<bool> SendPasswordResetEmail(PasswordResetEmailDto data);
Task<EmailTestResultDto> TestConnectivity(string emailUrl);
}
public class EmailService : IEmailService
{
private readonly ILogger<EmailService> _logger;
private readonly IUnitOfWork _unitOfWork;
/// <summary>
/// This is used to initially set or reset the ServerSettingKey. Do not access from the code, access via UnitOfWork
/// </summary>
public const string DefaultApiUrl = "https://email.kavitareader.com";
public EmailService(ILogger<EmailService> logger, IUnitOfWork unitOfWork)
{
_logger = logger;
_unitOfWork = unitOfWork;
FlurlHttp.ConfigureClient(DefaultApiUrl, cli =>
cli.Settings.HttpClientFactory = new UntrustedCertClientFactory());
}
public async Task<EmailTestResultDto> 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<bool> 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<bool> SendMigrationEmail(EmailMigrationDto data)
{
var emailLink = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl)).Value;
return await SendEmailWithPost(emailLink + "/api/email/email-migration", data);
}
public async Task<bool> 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<bool> 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<bool> 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;
}
}

View File

@ -14,6 +14,7 @@ public interface IImageService
/// Creates a Thumbnail version of a base64 image
/// </summary>
/// <param name="encodedImage">base64 encoded image</param>
/// <param name="fileName"></param>
/// <returns>File name with extension of the file. This will always write to <see cref="DirectoryService.CoverImageDirectory"/></returns>
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;
}

View File

@ -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
/// </summary>
/// <param name="chapter"></param>
/// <param name="forceUpdate">Force updating cover image even if underlying file has not been modified or chapter already has a cover image</param>
private bool UpdateChapterCoverImage(Chapter chapter, bool forceUpdate)
private async Task<bool> 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
/// </summary>
/// <param name="volume"></param>
/// <param name="forceUpdate">Force updating cover image even if underlying file has not been modified or chapter already has a cover image</param>
private bool UpdateVolumeCoverImage(Volume volume, bool forceUpdate)
private async Task<bool> 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
/// </summary>
/// <param name="series"></param>
/// <param name="forceUpdate">Force updating cover image even if underlying file has not been modified or chapter already has a cover image</param>
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
/// </summary>
/// <param name="series"></param>
/// <param name="forceUpdate"></param>
private void ProcessSeriesMetadataUpdate(Series series, ICollection<Person> allPeople, ICollection<Genre> allGenres, ICollection<Tag> 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<int, Chunk> 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();
}
}
/// <summary>
/// Refreshes Metadata for a Series. Will always force updates.
/// </summary>
@ -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);

View File

@ -22,23 +22,22 @@ public interface IReaderService
Task<int> CapPageToChapter(int chapterId, int page);
Task<int> GetNextChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId);
Task<int> GetPrevChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId);
Task<ChapterDto> 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<ReaderService> _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<ReaderService> logger, IDirectoryService directoryService, ICacheService cacheService)
public ReaderService(IUnitOfWork unitOfWork, ILogger<ReaderService> 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
/// </summary>
/// <example>
/// 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
/// </example>
/// <param name="seriesId"></param>
/// <param name="volumeId"></param>
@ -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
/// </summary>
/// <example>
/// 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
/// </example>
/// <param name="seriesId"></param>
/// <param name="volumeId"></param>
@ -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<ChapterDto> 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<ChapterDto> 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<ChapterDto> chapters, string currentChapterNumber, Func<ChapterDto, string> 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;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="user"></param>
/// <param name="seriesId"></param>
/// <param name="chapterNumber"></param>
public async Task MarkChaptersUntilAsRead(AppUser user, int seriesId, float chapterNumber)
{
var volumes = await _unitOfWork.VolumeRepository.GetVolumesForSeriesAsync(new List<int>() { 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<int>() { seriesId }, true);
foreach (var volume in volumes.OrderBy(v => v.Number).Where(v => v.Number <= volumeNumber))
{
MarkChaptersAsRead(user, volume.SeriesId, volume.Chapters);
}
}
}

Some files were not shown because too many files have changed in this diff Show More