Swagger, Tachiyomi, and some new settings (#1331)

* Fixed up swagger generation

* Updated Tachiyomi's latest-chapter to hopefully solve some sync issues.

* Fixed #1279 with table of contents due to new EPubReader

* When errors occur, show the event widget icon in red

* Lots of documentation added and tweaked some wording around backups and swagger

* For promidius

* Return proper ChapterDTO

* Hacks for Promidius

* Cleanup code

* No loose leaf, send max chapter

* One more encode change

* Implemented code per promiduius' requirements

* Fixed a bug in the epub parsing where even if you had a series index and series group, but didn't have the series in the title, Kavita wouldn't group them properly.

* Removed some extra comment

* Implemented the ability to change a library's type after it's been setup. This displays a warning explaining the dangers of it.

* Removed some whitespace

* Blur descriptions based on read status for list item view to avoid spoilers

* Tweaked placement of a tooltip due to new series detail styles

* Hooked up a user preference for bluring unread summaries. Fixed a bug in refresh token where we would cause re-authentication when it shouldn't be needed.
This commit is contained in:
Joseph Milazzo 2022-06-25 17:52:21 -05:00 committed by GitHub
parent f2c08092b8
commit 2ab0aedd22
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 1851 additions and 72 deletions

View file

@ -5,15 +5,17 @@
<TargetFramework>net6.0</TargetFramework>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<DebugSymbols>false</DebugSymbols>
<ApplicationIcon>../favicon.ico</ApplicationIcon>
<DocumentationFile>bin\$(Configuration)\$(AssemblyName).xml</DocumentationFile>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
<DocumentationFile>bin\Debug\API.xml</DocumentationFile>
<DocumentationFile>bin\$(Configuration)\$(AssemblyName).xml</DocumentationFile>
<NoWarn>1701;1702;1591</NoWarn>
</PropertyGroup>
@ -42,37 +44,37 @@
<PackageReference Include="ExCSS" Version="4.1.0" />
<PackageReference Include="Flurl" Version="3.0.6" />
<PackageReference Include="Flurl.Http" Version="3.2.4" />
<PackageReference Include="Hangfire" Version="1.7.29" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.7.29" />
<PackageReference Include="Hangfire" Version="1.7.30" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.7.30" />
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
<PackageReference Include="Hangfire.MemoryStorage.Core" Version="1.4.0" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.43" />
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.5" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="6.0.5" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="6.0.5" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.6" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="6.0.6" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="6.0.6" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.5">
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.6">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.6" />
<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.2" />
<PackageReference Include="NReco.Logging.File" Version="1.1.5" />
<PackageReference Include="SharpCompress" Version="0.31.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="2.1.2" />
<PackageReference Include="SharpCompress" Version="0.32.1" />
<PackageReference Include="SixLabors.ImageSharp" Version="2.1.3" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="8.40.0.48530">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.3.1" />
<PackageReference Include="System.Drawing.Common" Version="6.0.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.19.0" />
<PackageReference Include="System.IO.Abstractions" Version="17.0.15" />
<PackageReference Include="VersOne.Epub" Version="3.1.1" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.20.0" />
<PackageReference Include="System.IO.Abstractions" Version="17.0.18" />
<PackageReference Include="VersOne.Epub" Version="3.1.2" />
</ItemGroup>
<ItemGroup>
@ -134,6 +136,9 @@
</Content>
<Content Remove="covers\**" />
<Content Remove="config\covers\**" />
<Content Update="bin\$(Configuration)\$(AssemblyName).xml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>

View file

@ -207,6 +207,11 @@ namespace API.Controllers
return dto;
}
/// <summary>
/// Refreshes the user's JWT token
/// </summary>
/// <param name="tokenRequestDto"></param>
/// <returns></returns>
[HttpPost("refresh-token")]
public async Task<ActionResult<TokenRequestDto>> RefreshToken([FromBody] TokenRequestDto tokenRequestDto)
{

View file

@ -14,6 +14,10 @@ namespace API.Controllers
_userManager = userManager;
}
/// <summary>
/// Checks if an admin exists on the system. This is essentially a check to validate if the system has been setup.
/// </summary>
/// <returns></returns>
[HttpGet("exists")]
public async Task<ActionResult<bool>> AdminExists()
{
@ -21,4 +25,4 @@ namespace API.Controllers
return users.Count > 0;
}
}
}
}

View file

@ -33,6 +33,12 @@ namespace API.Controllers
_cacheService = cacheService;
}
/// <summary>
/// Retrieves information for the PDF and Epub reader
/// </summary>
/// <remarks>This only applies to Epub or PDF files</remarks>
/// <param name="chapterId"></param>
/// <returns></returns>
[HttpGet("{chapterId}/book-info")]
public async Task<ActionResult<BookInfoDto>> GetBookInfo(int chapterId)
{
@ -64,8 +70,6 @@ namespace API.Controllers
break;
case MangaFormat.Unknown:
break;
default:
throw new ArgumentOutOfRangeException();
}
return Ok(new BookInfoDto()
@ -83,6 +87,12 @@ namespace API.Controllers
});
}
/// <summary>
/// This is an entry point to fetch resources from within an epub chapter/book.
/// </summary>
/// <param name="chapterId"></param>
/// <param name="file"></param>
/// <returns></returns>
[HttpGet("{chapterId}/book-resources")]
public async Task<ActionResult> GetBookPageResources(int chapterId, [FromQuery] string file)
{
@ -102,7 +112,7 @@ namespace API.Controllers
/// <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
/// this is used to rewrite anchors in the book text so that we always load properly in our reader.
/// </summary>
/// <remarks>This is essentially building the table of contents</remarks>
/// <param name="chapterId"></param>
@ -229,6 +239,13 @@ namespace API.Controllers
}
}
/// <summary>
/// This returns a single page within the epub book. All html will be rewritten to be scoped within our reader,
/// all css is scoped, etc.
/// </summary>
/// <param name="chapterId"></param>
/// <param name="page"></param>
/// <returns></returns>
[HttpGet("{chapterId}/book-page")]
public async Task<ActionResult<string>> GetBookPage(int chapterId, [FromQuery] int page)
{

View file

@ -18,6 +18,9 @@ using Microsoft.Extensions.Logging;
namespace API.Controllers
{
/// <summary>
/// All APIs related to downloading entities from the system. Requires Download Role or Admin Role.
/// </summary>
[Authorize(Policy="RequireDownloadRole")]
public class DownloadController : BaseApiController
{
@ -42,6 +45,11 @@ namespace API.Controllers
_bookmarkService = bookmarkService;
}
/// <summary>
/// For a given volume, return the size in bytes
/// </summary>
/// <param name="volumeId"></param>
/// <returns></returns>
[HttpGet("volume-size")]
public async Task<ActionResult<long>> GetVolumeSize(int volumeId)
{
@ -49,6 +57,11 @@ namespace API.Controllers
return Ok(_directoryService.GetTotalSize(files.Select(c => c.FilePath)));
}
/// <summary>
/// For a given chapter, return the size in bytes
/// </summary>
/// <param name="chapterId"></param>
/// <returns></returns>
[HttpGet("chapter-size")]
public async Task<ActionResult<long>> GetChapterSize(int chapterId)
{
@ -56,6 +69,11 @@ namespace API.Controllers
return Ok(_directoryService.GetTotalSize(files.Select(c => c.FilePath)));
}
/// <summary>
/// For a series, return the size in bytes
/// </summary>
/// <param name="seriesId"></param>
/// <returns></returns>
[HttpGet("series-size")]
public async Task<ActionResult<long>> GetSeriesSize(int seriesId)
{
@ -64,7 +82,11 @@ namespace API.Controllers
}
/// <summary>
/// Downloads all chapters within a volume.
/// </summary>
/// <param name="volumeId"></param>
/// <returns></returns>
[Authorize(Policy="RequireDownloadRole")]
[HttpGet("volume")]
public async Task<ActionResult> DownloadVolume(int volumeId)

View file

@ -10,7 +10,7 @@ using Microsoft.AspNetCore.Mvc;
namespace API.Controllers
{
/// <summary>
/// Responsible for servicing up images stored in the DB
/// Responsible for servicing up images stored in Kavita for entities
/// </summary>
public class ImageController : BaseApiController
{

View file

@ -239,6 +239,12 @@ namespace API.Controllers
}
}
/// <summary>
/// Updates an existing Library with new name, folders, and/or type.
/// </summary>
/// <remarks>Any folder or type change will invoke a scan.</remarks>
/// <param name="libraryForUserDto"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("update")]
public async Task<ActionResult> UpdateLibrary(UpdateLibraryDto libraryForUserDto)
@ -250,10 +256,13 @@ namespace API.Controllers
library.Name = libraryForUserDto.Name;
library.Folders = libraryForUserDto.Folders.Select(s => new FolderPath() {Path = s}).ToList();
var typeUpdate = library.Type != libraryForUserDto.Type;
library.Type = libraryForUserDto.Type;
_unitOfWork.LibraryRepository.Update(library);
if (!await _unitOfWork.CommitAsync()) return BadRequest("There was a critical issue updating the library.");
if (originalFolders.Count != libraryForUserDto.Folders.Count())
if (originalFolders.Count != libraryForUserDto.Folders.Count() || typeUpdate)
{
_taskScheduler.ScanLibrary(library.Id);
}

View file

@ -191,6 +191,11 @@ namespace API.Controllers
}
/// <summary>
/// Marks a Series as read. All volumes and chapters will be marked as read during this process.
/// </summary>
/// <param name="markReadDto"></param>
/// <returns></returns>
[HttpPost("mark-read")]
public async Task<ActionResult> MarkRead(MarkReadDto markReadDto)
{
@ -204,7 +209,7 @@ namespace API.Controllers
/// <summary>
/// Marks a Series as Unread (progress)
/// Marks a Series as Unread. All volumes and chapters will be marked as unread during this process.
/// </summary>
/// <param name="markReadDto"></param>
/// <returns></returns>

View file

@ -1,26 +1,41 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading.Tasks;
using API.Comparators;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.Entities;
using API.Extensions;
using API.Services;
using AutoMapper;
using Microsoft.AspNetCore.Mvc;
namespace API.Controllers;
/// <summary>
/// All APIs are for Tachiyomi extension and app. They have hacks for our implementation and should not be used for any
/// other purposes.
/// </summary>
public class TachiyomiController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly IReaderService _readerService;
private readonly IMapper _mapper;
public TachiyomiController(IUnitOfWork unitOfWork, IReaderService readerService)
public TachiyomiController(IUnitOfWork unitOfWork, IReaderService readerService, IMapper mapper)
{
_unitOfWork = unitOfWork;
_readerService = readerService;
_mapper = mapper;
}
/// <summary>
/// Given the series Id, this should return the latest chapter that has been fully read.
/// </summary>
/// <param name="seriesId"></param>
/// <returns>ChapterDTO of latest chapter. Only Chapter number is used by consuming app. All other fields may be missing.</returns>
[HttpGet("latest-chapter")]
public async Task<ActionResult<ChapterDto>> GetLatestChapter(int seriesId)
{
@ -31,10 +46,45 @@ public class TachiyomiController : BaseApiController
var prevChapterId =
await _readerService.GetPrevChapterIdAsync(seriesId, currentChapter.VolumeId, currentChapter.Id, userId);
if (prevChapterId == -1) return null;
// If prevChapterId is -1, this means either nothing is read or everything is read.
if (prevChapterId == -1)
{
var userWithProgress = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.Progress);
var userHasProgress =
userWithProgress.Progresses.Any(x => x.SeriesId == seriesId);
// If the user doesn't have progress, then return null, which the extension will catch as 204 (no content) and report nothing as read
if (!userHasProgress) return null;
// Else return the max chapter to Tachiyomi so it can consider everything read
var volumes = (await _unitOfWork.VolumeRepository.GetVolumes(seriesId)).ToImmutableList();
var looseLeafChapterVolume = volumes.FirstOrDefault(v => v.Number == 0);
if (looseLeafChapterVolume == null)
{
var volumeChapter = _mapper.Map<ChapterDto>(volumes.Last().Chapters.OrderBy(c => float.Parse(c.Number), new ChapterSortComparerZeroFirst()).Last());
return Ok(new ChapterDto()
{
Number = $"{int.Parse(volumeChapter.Number) / 100f}"
});
}
var lastChapter = looseLeafChapterVolume.Chapters.OrderBy(c => float.Parse(c.Number), new ChapterSortComparer()).Last();
return Ok(_mapper.Map<ChapterDto>(lastChapter));
}
// There is progress, we now need to figure out the highest volume or chapter and return that.
var prevChapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(prevChapterId);
var volumeWithProgress = await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(prevChapter.VolumeId, userId);
if (volumeWithProgress.Number != 0)
{
// The progress is on a volume, encode it as a fake chapterDTO
return Ok(new ChapterDto()
{
Number = $"{volumeWithProgress.Number / 100f}"
});
}
// Progress is just on a chapter, return as is
return Ok(prevChapter);
}
@ -51,8 +101,9 @@ public class TachiyomiController : BaseApiController
switch (chapterNumber)
{
// Tachiyomi sends chapter 0.0f when there's no chapters read.
// Due to the encoding for volumes this marks all chapters in volume 0 (loose chapters) as read so we ignore it
// When Tachiyomi sync's progress, if there is no current progress in Tachiyomi, 0.0f is sent.
// Due to the encoding for volumes, this marks all chapters in volume 0 (loose chapters) as read.
// Hence we catch and return early, so we ignore the request.
case 0.0f:
return true;
case < 1.0f:

View file

@ -95,6 +95,7 @@ namespace API.Controllers
existingPreferences.BookReaderLayoutMode = preferencesDto.BookReaderLayoutMode;
existingPreferences.BookReaderImmersiveMode = preferencesDto.BookReaderImmersiveMode;
existingPreferences.GlobalPageLayoutMode = preferencesDto.GlobalPageLayoutMode;
existingPreferences.BlurUnreadSummaries = preferencesDto.BlurUnreadSummaries;
existingPreferences.Theme = await _unitOfWork.SiteThemeRepository.GetThemeById(preferencesDto.Theme.Id);
// TODO: Remove this code - this overrides layout mode to be single until the mode is released

View file

@ -1,4 +1,5 @@
using System.Collections.Generic;
using API.Entities.Enums;
namespace API.DTOs
{
@ -6,6 +7,7 @@ namespace API.DTOs
{
public int Id { get; init; }
public string Name { get; init; }
public LibraryType Type { get; set; }
public IEnumerable<string> Folders { get; init; }
}
}
}

View file

@ -88,5 +88,10 @@ namespace API.DTOs
/// </summary>
/// <remarks>Defaults to Cards</remarks>
public PageLayoutMode GlobalPageLayoutMode { get; set; } = PageLayoutMode.Cards;
/// <summary>
/// UI Site Global Setting: If unread summaries should be blurred until expanded or unless user has read it already
/// </summary>
/// <remarks>Defaults to false</remarks>
public bool BlurUnreadSummaries { get; set; } = false;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
public partial class BlurUnreadSummaries : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "BlurUnreadSummaries",
table: "AppUserPreferences",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "BlurUnreadSummaries",
table: "AppUserPreferences");
}
}
}

View file

@ -15,7 +15,7 @@ namespace API.Data.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "6.0.5");
modelBuilder.HasAnnotation("ProductVersion", "6.0.6");
modelBuilder.Entity("API.Entities.AppRole", b =>
{
@ -170,6 +170,9 @@ namespace API.Data.Migrations
.HasColumnType("TEXT")
.HasDefaultValue("#000000");
b.Property<bool>("BlurUnreadSummaries")
.HasColumnType("INTEGER");
b.Property<string>("BookReaderFontFamily")
.HasColumnType("TEXT");

View file

@ -93,6 +93,11 @@ namespace API.Entities
/// </summary>
/// <remarks>Defaults to Cards</remarks>
public PageLayoutMode GlobalPageLayoutMode { get; set; } = PageLayoutMode.Cards;
/// <summary>
/// UI Site Global Setting: If unread summaries should be blurred until expanded or unless user has read it already
/// </summary>
/// <remarks>Defaults to false</remarks>
public bool BlurUnreadSummaries { get; set; } = false;
public AppUser AppUser { get; set; }
public int AppUserId { get; set; }

View file

@ -585,8 +585,7 @@ namespace API.Services
}
}
if (!string.IsNullOrEmpty(series) && !string.IsNullOrEmpty(seriesIndex) &&
(!string.IsNullOrEmpty(specialName) || groupPosition.Equals("series") || groupPosition.Equals("set")))
if (!string.IsNullOrEmpty(series) && !string.IsNullOrEmpty(seriesIndex))
{
if (string.IsNullOrEmpty(specialName))
{
@ -606,7 +605,7 @@ namespace API.Services
};
// Don't set titleSort if the book belongs to a group
if (!string.IsNullOrEmpty(titleSort) && string.IsNullOrEmpty(seriesIndex))
if (!string.IsNullOrEmpty(titleSort) && string.IsNullOrEmpty(seriesIndex) && (groupPosition.Equals("series") || groupPosition.Equals("set")))
{
info.SeriesSort = titleSort;
}

View file

@ -74,18 +74,15 @@ public class TokenService : ITokenService
var tokenContent = tokenHandler.ReadJwtToken(request.Token);
var username = tokenContent.Claims.FirstOrDefault(q => q.Type == JwtRegisteredClaimNames.NameId)?.Value;
var user = await _userManager.FindByNameAsync(username);
if (user == null) return null; // This forces a logout
var isValid = await _userManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, "RefreshToken", request.RefreshToken);
if (isValid)
{
return new TokenRequestDto()
{
Token = await CreateToken(user),
RefreshToken = await CreateRefreshToken(user)
};
}
await _userManager.UpdateSecurityStampAsync(user);
return null;
return new TokenRequestDto()
{
Token = await CreateToken(user),
RefreshToken = await CreateRefreshToken(user)
};
}
}

View file

@ -60,17 +60,16 @@ namespace API
services.AddIdentityServices(_config);
services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "Kavita API", Version = "v1" });
c.SwaggerDoc("Kavita API", new OpenApiInfo()
c.SwaggerDoc("v1", new OpenApiInfo()
{
Description = "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage.",
Title = "Kavita API",
Version = "v1",
});
var filePath = Path.Combine(AppContext.BaseDirectory, "API.xml");
c.IncludeXmlComments(filePath);
c.IncludeXmlComments(filePath, true);
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme {
In = ParameterLocation.Header,
Description = "Please insert JWT with Bearer into field",
@ -96,6 +95,19 @@ namespace API
Description = "Local Server",
Url = "http://localhost:5000/",
});
c.AddServer(new OpenApiServer()
{
Url = "https://demo.kavitareader.com/",
Description = "Kavita Demo"
});
c.AddServer(new OpenApiServer()
{
Url = "http://" + GetLocalIpAddress() + ":5000/",
Description = "Local IP"
});
});
services.AddResponseCompression(options =>
{
@ -229,9 +241,6 @@ namespace API
ContentTypeProvider = new FileExtensionContentTypeProvider()
});
app.Use(async (context, next) =>
{
context.Response.GetTypedHeaders().CacheControl =