mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
Merged v0.5.1 develop into main.
This commit is contained in:
commit
150479e755
103
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
103
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal 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
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
122
.github/workflows/sonar-scan.yml
vendored
122
.github/workflows/sonar-scan.yml
vendored
@ -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
3
.gitignore
vendored
@ -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/
|
||||
|
@ -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()
|
||||
{
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
|
||||
};
|
||||
}
|
||||
|
@ -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)));
|
||||
|
@ -1,6 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using API.Entities.Enums;
|
||||
using API.Parser;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
|
@ -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)
|
||||
{
|
||||
|
@ -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
|
||||
|
337
API.Tests/Services/BookmarkServiceTests.cs
Normal file
337
API.Tests/Services/BookmarkServiceTests.cs
Normal 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
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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
@ -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
|
||||
|
Binary file not shown.
@ -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>
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -111,7 +111,7 @@ public class MetadataController : BaseApiController
|
||||
{
|
||||
Title = t.ToDescription(),
|
||||
Value = t
|
||||
}));
|
||||
}).OrderBy(t => t.Title));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
@ -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 += "&";
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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");
|
||||
|
@ -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>
|
||||
|
@ -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)
|
||||
{
|
||||
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
{
|
||||
|
16
API/DTOs/Account/ConfirmEmailDto.cs
Normal file
16
API/DTOs/Account/ConfirmEmailDto.cs
Normal 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; }
|
||||
}
|
7
API/DTOs/Account/ConfirmMigrationEmailDto.cs
Normal file
7
API/DTOs/Account/ConfirmMigrationEmailDto.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace API.DTOs.Account;
|
||||
|
||||
public class ConfirmMigrationEmailDto
|
||||
{
|
||||
public string Email { get; set; }
|
||||
public string Token { get; set; }
|
||||
}
|
14
API/DTOs/Account/ConfirmPasswordResetDto.cs
Normal file
14
API/DTOs/Account/ConfirmPasswordResetDto.cs
Normal 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; }
|
||||
}
|
21
API/DTOs/Account/InviteUserDto.cs
Normal file
21
API/DTOs/Account/InviteUserDto.cs
Normal 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;
|
||||
}
|
9
API/DTOs/Account/MigrateUserEmailDto.cs
Normal file
9
API/DTOs/Account/MigrateUserEmailDto.cs
Normal 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; }
|
||||
}
|
7
API/DTOs/Account/TokenRequestDto.cs
Normal file
7
API/DTOs/Account/TokenRequestDto.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace API.DTOs.Account;
|
||||
|
||||
public class TokenRequestDto
|
||||
{
|
||||
public string Token { get; init; }
|
||||
public string RefreshToken { get; init; }
|
||||
}
|
23
API/DTOs/Account/UpdateUserDto.cs
Normal file
23
API/DTOs/Account/UpdateUserDto.cs
Normal 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; }
|
||||
|
||||
}
|
@ -1,7 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using API.DTOs.Metadata;
|
||||
using API.Entities;
|
||||
|
||||
namespace API.DTOs
|
||||
{
|
||||
|
12
API/DTOs/Email/ConfirmationEmailDto.cs
Normal file
12
API/DTOs/Email/ConfirmationEmailDto.cs
Normal 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; }
|
||||
}
|
12
API/DTOs/Email/EmailMigrationDto.cs
Normal file
12
API/DTOs/Email/EmailMigrationDto.cs
Normal 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; }
|
||||
}
|
10
API/DTOs/Email/EmailTestResultDto.cs
Normal file
10
API/DTOs/Email/EmailTestResultDto.cs
Normal 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; }
|
||||
}
|
11
API/DTOs/Email/PasswordResetEmailDto.cs
Normal file
11
API/DTOs/Email/PasswordResetEmailDto.cs
Normal 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; }
|
||||
}
|
6
API/DTOs/Email/TestEmailDto.cs
Normal file
6
API/DTOs/Email/TestEmailDto.cs
Normal file
@ -0,0 +1,6 @@
|
||||
namespace API.DTOs.Email;
|
||||
|
||||
public class TestEmailDto
|
||||
{
|
||||
public string Url { get; set; }
|
||||
}
|
@ -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>
|
||||
|
@ -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
|
||||
|
32
API/DTOs/GroupedSeriesDto.cs
Normal file
32
API/DTOs/GroupedSeriesDto.cs
Normal 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; }
|
||||
}
|
@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
34
API/DTOs/RecentlyAddedItemDto.cs
Normal file
34
API/DTOs/RecentlyAddedItemDto.cs
Normal 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; }
|
||||
|
||||
}
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.DTOs
|
||||
namespace API.DTOs.Search
|
||||
{
|
||||
public class SearchResultDto
|
||||
{
|
21
API/DTOs/Search/SearchResultGroupDto.cs
Normal file
21
API/DTOs/Search/SearchResultGroupDto.cs
Normal 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; }
|
||||
|
||||
}
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
@ -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; }
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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;
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
|
||||
|
@ -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>
|
||||
|
30
API/Data/MigrateChangePasswordRoles.cs
Normal file
30
API/Data/MigrateChangePasswordRoles.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
|
@ -13,4 +13,4 @@
|
||||
Details = details;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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>();
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
@ -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) =>
|
||||
|
@ -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")
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
146
API/Services/BookmarkService.cs
Normal file
146
API/Services/BookmarkService.cs
Normal 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}");
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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)
|
||||
{
|
||||
|
141
API/Services/EmailService.cs
Normal file
141
API/Services/EmailService.cs
Normal 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;
|
||||
}
|
||||
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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
Loading…
x
Reference in New Issue
Block a user