OPDS Support (#526)

* Added some basic OPDS implementation

* Fixed an issue with feed href

* More changes

* Added library routes and moved user code to a method so we can hack in fixed code without authentication

* Images now load on the OPDS reusing our existing Image infrastructure.

* Added the ability to download and moved some download code to a dedicated service

* Download is working, pagination is implemented.

* Refactored libraries to use pagination

* Laid foundation for OpenSearch implementation

* Fixed up some serialization issues and some old code that wasn't referencing helper methods

* Ensure chapters are sorted when we send them over OPDS

* OpenSearch implemented

* Removed any support for OPDS-PS due to lack of apps supporting it.

* Don't distribute development.json nor stats directory on build.

* Implemented In Progress feed as well.

* Ability to enable OPDS for server. OPDS now accepts initial call as POST in case app uses username/password.

* UI now properly renders state for OPDS enablement. Added Collections routes.

* Fixed pagination startIndex on OPDS feeds when there is less than 1 page.

* Chunky Reader now works. It only accepts UTF-8 encodings

* More Chunky fixes

* More chunky changes, such a fussy client.

* Implemented the ability to have a custom api key assigned to a user and use that api key as your authentication token against OPDS routing.

* Implemented the ability to reset your API Key

* Fixed favicon not being sent back correctly

* Fixed an issue where images wouldn't send on OPDS feed.

* Implemented Page streaming and fixed a pagination bug

* Hooked in the ability to save progress in Kavita when Page Streaming
This commit is contained in:
Joseph Milazzo 2021-08-27 10:19:25 -07:00 committed by GitHub
parent 2a63e5e9e2
commit 6069d93c38
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
50 changed files with 2409 additions and 116 deletions

View File

@ -73,6 +73,7 @@
<ItemGroup>
<ProjectReference Include="..\Kavita.Common\Kavita.Common.csproj" />
<Content Condition=" '$(Configuration)' == 'Release' " Include="appsettings.json" />
</ItemGroup>
@ -115,6 +116,8 @@
<Content Remove="backups\**" />
<Content Remove="logs\**" />
<Content Remove="temp\**" />
<Content Remove="stats\**" />
<Content Condition=" '$(Configuration)' == 'Release' " Remove="appsettings.Development.json" />
</ItemGroup>
<ItemGroup>

View File

@ -11,6 +11,7 @@ using API.Extensions;
using API.Interfaces;
using API.Interfaces.Services;
using AutoMapper;
using Kavita.Common;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@ -106,6 +107,7 @@ namespace API.Controllers
var user = _mapper.Map<AppUser>(registerDto);
user.UserPreferences ??= new AppUserPreferences();
user.ApiKey = HashUtil.ApiKey();
var result = await _userManager.CreateAsync(user, registerDto.Password);
@ -136,6 +138,7 @@ namespace API.Controllers
{
Username = user.UserName,
Token = await _tokenService.CreateToken(user),
ApiKey = user.ApiKey,
Preferences = _mapper.Map<UserPreferencesDto>(user.UserPreferences)
};
}
@ -180,6 +183,7 @@ namespace API.Controllers
{
Username = user.UserName,
Token = await _tokenService.CreateToken(user),
ApiKey = user.ApiKey,
Preferences = _mapper.Map<UserPreferencesDto>(user.UserPreferences)
};
}
@ -237,5 +241,26 @@ namespace API.Controllers
return BadRequest("Something went wrong, unable to update user's roles");
}
/// <summary>
/// Resets the API Key assigned with a user
/// </summary>
/// <returns></returns>
[HttpPost("reset-api-key")]
public async Task<ActionResult<string>> ResetApiKey()
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
user.ApiKey = HashUtil.ApiKey();
if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync())
{
return Ok(user.ApiKey);
}
await _unitOfWork.RollbackAsync();
return BadRequest("Something went wrong, unable to reset key");
}
}
}

View File

@ -25,15 +25,17 @@ namespace API.Controllers
private readonly IArchiveService _archiveService;
private readonly IDirectoryService _directoryService;
private readonly ICacheService _cacheService;
private readonly IDownloadService _downloadService;
private readonly NumericComparer _numericComparer;
private const string DefaultContentType = "application/octet-stream"; // "application/zip"
private const string DefaultContentType = "application/octet-stream";
public DownloadController(IUnitOfWork unitOfWork, IArchiveService archiveService, IDirectoryService directoryService, ICacheService cacheService)
public DownloadController(IUnitOfWork unitOfWork, IArchiveService archiveService, IDirectoryService directoryService, ICacheService cacheService, IDownloadService downloadService)
{
_unitOfWork = unitOfWork;
_archiveService = archiveService;
_directoryService = directoryService;
_cacheService = cacheService;
_downloadService = downloadService;
_numericComparer = new NumericComparer();
}
@ -82,25 +84,8 @@ namespace API.Controllers
private async Task<ActionResult> GetFirstFileDownload(IEnumerable<MangaFile> files)
{
var firstFile = files.Select(c => c.FilePath).First();
var fileProvider = new FileExtensionContentTypeProvider();
// Figures out what the content type should be based on the file name.
if (!fileProvider.TryGetContentType(firstFile, out var contentType))
{
contentType = Path.GetExtension(firstFile).ToLowerInvariant() switch
{
".cbz" => "application/zip",
".cbr" => "application/vnd.rar",
".cb7" => "application/x-compressed",
".epub" => "application/epub+zip",
".7z" => "application/x-7z-compressed",
".7zip" => "application/x-7z-compressed",
".pdf" => "application/pdf",
_ => contentType
};
}
return File(await _directoryService.ReadFileAsync(firstFile), contentType, Path.GetFileName(firstFile));
var (bytes, contentType, fileDownloadName) = await _downloadService.GetFirstFileDownload(files);
return File(bytes, contentType, fileDownloadName);
}
[HttpGet("chapter")]

View File

@ -0,0 +1,697 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.Xml.Serialization;
using API.Comparators;
using API.Constants;
using API.DTOs;
using API.DTOs.Filtering;
using API.DTOs.OPDS;
using API.Entities;
using API.Extensions;
using API.Helpers;
using API.Interfaces;
using API.Interfaces.Services;
using API.Services;
using Kavita.Common;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace API.Controllers
{
public class OpdsController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly IDownloadService _downloadService;
private readonly IDirectoryService _directoryService;
private readonly UserManager<AppUser> _userManager;
private readonly ICacheService _cacheService;
private readonly IReaderService _readerService;
private readonly XmlSerializer _xmlSerializer;
private readonly XmlSerializer _xmlOpenSearchSerializer;
private const string Prefix = "/api/opds/";
private readonly FilterDto _filterDto = new FilterDto()
{
MangaFormat = null
};
private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer();
public OpdsController(IUnitOfWork unitOfWork, IDownloadService downloadService,
IDirectoryService directoryService, UserManager<AppUser> userManager,
ICacheService cacheService, IReaderService readerService)
{
_unitOfWork = unitOfWork;
_downloadService = downloadService;
_directoryService = directoryService;
_userManager = userManager;
_cacheService = cacheService;
_readerService = readerService;
_xmlSerializer = new XmlSerializer(typeof(Feed));
_xmlOpenSearchSerializer = new XmlSerializer(typeof(OpenSearchDescription));
}
[HttpPost("{apiKey}")]
[HttpGet("{apiKey}")]
[Produces("application/xml")]
public async Task<IActionResult> Get(string apiKey)
{
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest("OPDS is not enabled on this server");
var feed = CreateFeed("Kavita", string.Empty, apiKey);
feed.Id = "root";
feed.Entries.Add(new FeedEntry()
{
Id = "inProgress",
Title = "In Progress",
Content = new FeedEntryContent()
{
Text = "Browse by In Progress"
},
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/in-progress"),
}
});
feed.Entries.Add(new FeedEntry()
{
Id = "recentlyAdded",
Title = "Recently Added",
Content = new FeedEntryContent()
{
Text = "Browse by Recently Added"
},
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/recently-added"),
}
});
feed.Entries.Add(new FeedEntry()
{
Id = "allLibraries",
Title = "All Libraries",
Content = new FeedEntryContent()
{
Text = "Browse by Libraries"
},
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/libraries"),
}
});
feed.Entries.Add(new FeedEntry()
{
Id = "allCollections",
Title = "All Collections",
Content = new FeedEntryContent()
{
Text = "Browse by Collections"
},
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/collections"),
}
});
return CreateXmlResult(SerializeXml(feed));
}
[HttpGet("{apiKey}/libraries")]
[Produces("application/xml")]
public async Task<IActionResult> GetLibraries(string apiKey)
{
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest("OPDS is not enabled on this server");
var user = await GetUser(apiKey);
var libraries = await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id);
var feed = CreateFeed("All Libraries", $"{apiKey}/libraries", apiKey);
foreach (var library in libraries)
{
feed.Entries.Add(new FeedEntry()
{
Id = library.Id.ToString(),
Title = library.Name,
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/libraries/{library.Id}"),
}
});
}
return CreateXmlResult(SerializeXml(feed));
}
[HttpGet("{apiKey}/collections")]
[Produces("application/xml")]
public async Task<IActionResult> GetCollections(string apiKey)
{
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest("OPDS is not enabled on this server");
var user = await GetUser(apiKey);
var isAdmin = await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole);
IEnumerable <CollectionTagDto> tags;
if (isAdmin)
{
tags = await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync();
}
else
{
tags = await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync();
}
var feed = CreateFeed("All Collections", $"{apiKey}/collections", apiKey);
foreach (var tag in tags)
{
feed.Entries.Add(new FeedEntry()
{
Id = tag.Id.ToString(),
Title = tag.Title,
Summary = tag.Summary,
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/collections/{tag.Id}"),
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/collection-cover?collectionId={tag.Id}"),
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"/api/image/collection-cover?collectionId={tag.Id}")
}
});
}
return CreateXmlResult(SerializeXml(feed));
}
[HttpGet("{apiKey}/collections/{collectionId}")]
[Produces("application/xml")]
public async Task<IActionResult> GetCollection(int collectionId, string apiKey, [FromQuery] int pageNumber = 0)
{
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest("OPDS is not enabled on this server");
var user = await GetUser(apiKey);
var isAdmin = await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole);
IEnumerable <CollectionTagDto> tags;
if (isAdmin)
{
tags = await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync();
}
else
{
tags = await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync();
}
var tag = tags.SingleOrDefault(t => t.Id == collectionId);
if (tag == null)
{
return BadRequest("Collection does not exist or you don't have access");
}
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForCollectionAsync(collectionId, user.Id, new UserParams()
{
PageNumber = pageNumber,
PageSize = 20
});
var feed = CreateFeed(tag.Title + " Collection", $"{apiKey}/collections/{collectionId}", apiKey);
AddPagination(feed, series, $"{Prefix}{apiKey}/collections/{collectionId}");
foreach (var seriesDto in series)
{
feed.Entries.Add(CreateSeries(seriesDto, apiKey));
}
return CreateXmlResult(SerializeXml(feed));
}
[HttpGet("{apiKey}/libraries/{libraryId}")]
[Produces("application/xml")]
public async Task<IActionResult> GetSeriesForLibrary(int libraryId, string apiKey, [FromQuery] int pageNumber = 0)
{
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest("OPDS is not enabled on this server");
var user = await GetUser(apiKey);
var library =
(await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).SingleOrDefault(l =>
l.Id == libraryId);
if (library == null)
{
return BadRequest("User does not have access to this library");
}
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, user.Id, new UserParams()
{
PageNumber = pageNumber,
PageSize = 20
}, _filterDto);
var feed = CreateFeed(library.Name, $"{apiKey}/libraries/{libraryId}", apiKey);
AddPagination(feed, series, $"{Prefix}{apiKey}/libraries/{libraryId}");
foreach (var seriesDto in series)
{
feed.Entries.Add(CreateSeries(seriesDto, apiKey));
}
return CreateXmlResult(SerializeXml(feed));
}
[HttpGet("{apiKey}/recently-added")]
[Produces("application/xml")]
public async Task<IActionResult> GetRecentlyAdded(string apiKey, [FromQuery] int pageNumber = 1)
{
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest("OPDS is not enabled on this server");
var user = await GetUser(apiKey);
var recentlyAdded = await _unitOfWork.SeriesRepository.GetRecentlyAdded(0, user.Id, new UserParams()
{
PageNumber = pageNumber,
PageSize = 20
}, _filterDto);
var feed = CreateFeed("Recently Added", $"{apiKey}/recently-added", apiKey);
AddPagination(feed, recentlyAdded, $"{Prefix}{apiKey}/recently-added");
foreach (var seriesDto in recentlyAdded)
{
feed.Entries.Add(CreateSeries(seriesDto, apiKey));
}
return CreateXmlResult(SerializeXml(feed));
}
[HttpGet("{apiKey}/in-progress")]
[Produces("application/xml")]
public async Task<IActionResult> GetInProgress(string apiKey, [FromQuery] int pageNumber = 1)
{
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest("OPDS is not enabled on this server");
var user = await GetUser(apiKey);
var userParams = new UserParams()
{
PageNumber = pageNumber,
PageSize = 20
};
var results = await _unitOfWork.SeriesRepository.GetInProgress(user.Id, 0, userParams, _filterDto);
var listResults = results.DistinctBy(s => s.Name).Skip((userParams.PageNumber - 1) * userParams.PageSize)
.Take(userParams.PageSize).ToList();
var pagedList = new PagedList<SeriesDto>(listResults, listResults.Count, userParams.PageNumber, userParams.PageSize);
Response.AddPaginationHeader(pagedList.CurrentPage, pagedList.PageSize, pagedList.TotalCount, pagedList.TotalPages);
var feed = CreateFeed("In Progress", $"{apiKey}/in-progress", apiKey);
AddPagination(feed, pagedList, $"{Prefix}{apiKey}/in-progress");
foreach (var seriesDto in pagedList)
{
feed.Entries.Add(CreateSeries(seriesDto, apiKey));
}
return CreateXmlResult(SerializeXml(feed));
}
[HttpGet("{apiKey}/series")]
[Produces("application/xml")]
public async Task<IActionResult> SearchSeries(string apiKey, [FromQuery] string query)
{
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest("OPDS is not enabled on this server");
var user = await GetUser(apiKey);
if (string.IsNullOrEmpty(query))
{
return BadRequest("You must pass a query parameter");
}
query = query.Replace(@"%", "");
// Get libraries user has access to
var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).ToList();
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 feed = CreateFeed(query, $"{apiKey}/series?query=" + query, apiKey);
foreach (var seriesDto in series)
{
feed.Entries.Add(CreateSeries(seriesDto, apiKey));
}
return CreateXmlResult(SerializeXml(feed));
}
[HttpGet("{apiKey}/search")]
[Produces("application/xml")]
public async Task<IActionResult> GetSearchDescriptor(string apiKey)
{
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest("OPDS is not enabled on this server");
var feed = new OpenSearchDescription()
{
ShortName = "Search",
Description = "Search for Series",
Url = new SearchLink()
{
Type = FeedLinkType.AtomAcquisition,
Template = $"{Prefix}{apiKey}/series?query=" + "{searchTerms}"
}
};
await using var sm = new StringWriter();
_xmlOpenSearchSerializer.Serialize(sm, feed);
return CreateXmlResult(sm.ToString().Replace("utf-16", "utf-8"));
}
[HttpGet("{apiKey}/series/{seriesId}")]
[Produces("application/xml")]
public async Task<IActionResult> GetSeries(string apiKey, int seriesId)
{
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest("OPDS is not enabled on this server");
var user = await GetUser(apiKey);
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, user.Id);
var volumes = await _unitOfWork.SeriesRepository.GetVolumesDtoAsync(seriesId, user.Id);
var feed = CreateFeed(series.Name + " - Volumes", $"{apiKey}/series/{series.Id}", apiKey);
feed.Links.Add(CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/series-cover?seriesId={seriesId}"));
foreach (var volumeDto in volumes)
{
feed.Entries.Add(CreateVolume(volumeDto, seriesId, apiKey));
}
return CreateXmlResult(SerializeXml(feed));
}
[HttpGet("{apiKey}/series/{seriesId}/volume/{volumeId}")]
[Produces("application/xml")]
public async Task<IActionResult> GetVolume(string apiKey, int seriesId, int volumeId)
{
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest("OPDS is not enabled on this server");
var user = await GetUser(apiKey);
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, user.Id);
var volume = await _unitOfWork.SeriesRepository.GetVolumeAsync(volumeId);
var chapters =
(await _unitOfWork.VolumeRepository.GetChaptersAsync(volumeId)).OrderBy(x => double.Parse(x.Number),
_chapterSortComparer);
var feed = CreateFeed(series.Name + " - Volume " + volume.Name + " - Chapters ", $"{apiKey}/series/{seriesId}/volume/{volumeId}", apiKey);
foreach (var chapter in chapters)
{
feed.Entries.Add(new FeedEntry()
{
Id = chapter.Id.ToString(),
Title = "Chapter " + chapter.Number,
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapter.Id}"),
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/chapter-cover?chapterId={chapter.Id}")
}
});
}
return CreateXmlResult(SerializeXml(feed));
}
[HttpGet("{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}")]
[Produces("application/xml")]
public async Task<IActionResult> GetChapter(string apiKey, int seriesId, int volumeId, int chapterId)
{
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest("OPDS is not enabled on this server");
var user = await GetUser(apiKey);
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, user.Id);
var volume = await _unitOfWork.SeriesRepository.GetVolumeAsync(volumeId);
var chapter = await _unitOfWork.VolumeRepository.GetChapterDtoAsync(chapterId);
var files = await _unitOfWork.VolumeRepository.GetFilesForChapterAsync(chapterId);
var feed = CreateFeed(series.Name + " - Volume " + volume.Name + " - Chapters ", $"{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}", apiKey);
foreach (var mangaFile in files)
{
feed.Entries.Add(CreateChapter(seriesId, volumeId, chapterId, mangaFile, series, volume, chapter, apiKey));
}
return CreateXmlResult(SerializeXml(feed));
}
/// <summary>
/// Downloads a file
/// </summary>
/// <param name="seriesId"></param>
/// <param name="volumeId"></param>
/// <param name="chapterId"></param>
/// <param name="filename">Not used. Only for Chunky to allow download links</param>
/// <returns></returns>
[HttpGet("{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}/download/{filename}")]
public async Task<ActionResult> DownloadFile(string apiKey, int seriesId, int volumeId, int chapterId, string filename)
{
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest("OPDS is not enabled on this server");
var files = await _unitOfWork.VolumeRepository.GetFilesForChapterAsync(chapterId);
var (bytes, contentType, fileDownloadName) = await _downloadService.GetFirstFileDownload(files);
return File(bytes, contentType, fileDownloadName);
}
private static ContentResult CreateXmlResult(string xml)
{
return new ContentResult
{
ContentType = "application/xml",
Content = xml,
StatusCode = 200
};
}
private static void AddPagination(Feed feed, PagedList<SeriesDto> list, string href)
{
var url = href;
if (href.Contains("?"))
{
url += "&amp;";
}
else
{
url += "?";
}
var pageNumber = Math.Max(list.CurrentPage, 1);
if (pageNumber > 1)
{
feed.Links.Add(CreateLink(FeedLinkRelation.Prev, FeedLinkType.AtomNavigation, url + "pageNumber=" + (pageNumber - 1)));
}
if (pageNumber + 1 < list.TotalPages)
{
feed.Links.Add(CreateLink(FeedLinkRelation.Next, FeedLinkType.AtomNavigation, url + "pageNumber=" + (pageNumber + 1)));
}
// Update self to point to current page
var selfLink = feed.Links.SingleOrDefault(l => l.Rel == FeedLinkRelation.Self);
if (selfLink != null)
{
selfLink.Href = url + "pageNumber=" + pageNumber;
}
feed.Total = list.TotalPages * list.PageSize;
feed.ItemsPerPage = list.PageSize;
feed.StartIndex = (Math.Max(list.CurrentPage - 1, 0) * list.PageSize) + 1;
}
private static FeedEntry CreateSeries(SeriesDto seriesDto, string apiKey)
{
return new FeedEntry()
{
Id = seriesDto.Id.ToString(),
Title = $"{seriesDto.Name} ({seriesDto.Format})",
Summary = seriesDto.Summary,
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/series/{seriesDto.Id}"),
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/series-cover?seriesId={seriesDto.Id}"),
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"/api/image/series-cover?seriesId={seriesDto.Id}")
}
};
}
private static FeedEntry CreateSeries(SearchResultDto searchResultDto, string apiKey)
{
return new FeedEntry()
{
Id = searchResultDto.SeriesId.ToString(),
Title = $"{searchResultDto.Name} ({searchResultDto.Format})",
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/series/{searchResultDto.SeriesId}"),
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/series-cover?seriesId={searchResultDto.SeriesId}"),
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"/api/image/series-cover?seriesId={searchResultDto.SeriesId}")
}
};
}
private static FeedEntry CreateVolume(VolumeDto volumeDto, int seriesId, string apiKey)
{
return new FeedEntry()
{
Id = volumeDto.Id.ToString(),
Title = volumeDto.IsSpecial ? "Specials" : "Volume " + volumeDto.Name,
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/series/{seriesId}/volume/{volumeDto.Id}"),
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/volume-cover?volumeId={volumeDto.Id}"),
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"/api/image/volume-cover?volumeId={volumeDto.Id}")
}
};
}
private FeedEntry CreateChapter(int seriesId, int volumeId, int chapterId, MangaFile mangaFile, SeriesDto series, Volume volume, ChapterDto chapter, string apiKey)
{
var fileSize =
DirectoryService.GetHumanReadableBytes(DirectoryService.GetTotalSize(new List<string>()
{mangaFile.FilePath}));
var fileType = _downloadService.GetContentTypeFromFile(mangaFile.FilePath);
var filename = Uri.EscapeUriString(Path.GetFileName(mangaFile.FilePath) ?? string.Empty);
return new FeedEntry()
{
Id = mangaFile.Id.ToString(),
Title = $"{series.Name} - Volume {volume.Name} - Chapter {chapter.Number}",
Extent = fileSize,
Summary = $"{fileType.Split("/")[1]} - {fileSize}",
Format = mangaFile.Format.ToString(),
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/chapter-cover?chapterId={chapterId}"),
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"/api/image/chapter-cover?chapterId={chapterId}"),
// Chunky requires a file at the end. Our API ignores this
CreateLink(FeedLinkRelation.Acquisition, fileType, $"{Prefix}{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}/download/{filename}"),
CreatePageStreamLink(seriesId, volumeId, chapterId, mangaFile, apiKey)
},
Content = new FeedEntryContent()
{
Text = fileType,
Type = "text"
}
};
}
[HttpGet("{apiKey}/image")]
public async Task<ActionResult> GetPageStreamedImage(string apiKey, [FromQuery] int seriesId, [FromQuery] int volumeId,[FromQuery] int chapterId, [FromQuery] int pageNumber)
{
if (pageNumber < 0) return BadRequest("Page cannot be less than 0");
var chapter = await _cacheService.Ensure(chapterId);
if (chapter == null) return BadRequest("There was an issue finding image file for reading");
try
{
var (path, _) = await _cacheService.GetCachedPagePath(chapter, pageNumber);
if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No such image for page {pageNumber}");
var content = await _directoryService.ReadFileAsync(path);
var format = Path.GetExtension(path).Replace(".", "");
// Calculates SHA1 Hash for byte[]
Response.AddCacheHeader(content);
// Save progress for the user
await _readerService.SaveReadingProgress(new ProgressDto()
{
ChapterId = chapterId,
PageNum = pageNumber,
SeriesId = seriesId,
VolumeId = volumeId
}, await GetUser(apiKey));
return File(content, "image/" + format);
}
catch (Exception)
{
_cacheService.CleanupChapters(new []{ chapterId });
throw;
}
}
[HttpGet("{apiKey}/favicon")]
public async Task<ActionResult> GetFavicon(string apiKey)
{
var files = _directoryService.GetFilesWithExtension(Path.Join(Directory.GetCurrentDirectory(), ".."), @"\.ico");
if (files.Length == 0) return BadRequest("Cannot find icon");
var path = files[0];
var content = await _directoryService.ReadFileAsync(path);
var format = Path.GetExtension(path).Replace(".", "");
// Calculates SHA1 Hash for byte[]
Response.AddCacheHeader(content);
return File(content, "image/" + format);
}
/// <summary>
/// Gets the user from the API key
/// </summary>
/// <returns></returns>
private async Task<AppUser> GetUser(string apiKey)
{
var user = await _unitOfWork.UserRepository.GetUserByApiKeyAsync(apiKey);
if (user == null)
{
throw new KavitaException("User does not exist");
}
return user;
}
private static FeedLink CreatePageStreamLink(int seriesId, int volumeId, int chapterId, MangaFile mangaFile, string apiKey)
{
var link = CreateLink(FeedLinkRelation.Stream, "image/jpeg", $"{Prefix}{apiKey}/image?seriesId={seriesId}&volumeId={volumeId}&chapterId={chapterId}&pageNumber=" + "{pageNumber}");
link.TotalPages = mangaFile.Pages;
return link;
}
private static FeedLink CreateLink(string rel, string type, string href)
{
return new FeedLink()
{
Rel = rel,
Href = href,
Type = type
};
}
private static Feed CreateFeed(string title, string href, string apiKey)
{
var link = CreateLink(FeedLinkRelation.Self, string.IsNullOrEmpty(href) ?
FeedLinkType.AtomNavigation :
FeedLinkType.AtomAcquisition, Prefix + href);
return new Feed()
{
Title = title,
Icon = Prefix + $"{apiKey}/favicon",
Links = new List<FeedLink>()
{
link,
CreateLink(FeedLinkRelation.Start, FeedLinkType.AtomNavigation, Prefix + apiKey),
CreateLink(FeedLinkRelation.Search, FeedLinkType.AtomSearch, Prefix + $"{apiKey}/search")
},
};
}
private string SerializeXml(Feed feed)
{
if (feed == null) return string.Empty;
using var sm = new StringWriter();
_xmlSerializer.Serialize(sm, feed);
return sm.ToString().Replace("utf-16", "utf-8"); // Chunky cannot accept UTF-16 feeds
}
}
}

View File

@ -24,17 +24,20 @@ namespace API.Controllers
private readonly ICacheService _cacheService;
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<ReaderController> _logger;
private readonly IReaderService _readerService;
private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer();
private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst();
private readonly NaturalSortComparer _naturalSortComparer = new NaturalSortComparer();
/// <inheritdoc />
public ReaderController(IDirectoryService directoryService, ICacheService cacheService, IUnitOfWork unitOfWork, ILogger<ReaderController> logger)
public ReaderController(IDirectoryService directoryService, ICacheService cacheService,
IUnitOfWork unitOfWork, ILogger<ReaderController> logger, IReaderService readerService)
{
_directoryService = directoryService;
_cacheService = cacheService;
_unitOfWork = unitOfWork;
_logger = logger;
_readerService = readerService;
}
/// <summary>
@ -285,57 +288,7 @@ namespace API.Controllers
public async Task<ActionResult> BookmarkProgress(ProgressDto progressDto)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
// Don't let user save past total pages.
var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(progressDto.ChapterId);
if (progressDto.PageNum > chapter.Pages)
{
progressDto.PageNum = chapter.Pages;
}
if (progressDto.PageNum < 0)
{
progressDto.PageNum = 0;
}
try
{
user.Progresses ??= new List<AppUserProgress>();
var userProgress =
user.Progresses.FirstOrDefault(x => x.ChapterId == progressDto.ChapterId && x.AppUserId == user.Id);
if (userProgress == null)
{
user.Progresses.Add(new AppUserProgress
{
PagesRead = progressDto.PageNum,
VolumeId = progressDto.VolumeId,
SeriesId = progressDto.SeriesId,
ChapterId = progressDto.ChapterId,
BookScrollId = progressDto.BookScrollId,
LastModified = DateTime.Now
});
}
else
{
userProgress.PagesRead = progressDto.PageNum;
userProgress.SeriesId = progressDto.SeriesId;
userProgress.VolumeId = progressDto.VolumeId;
userProgress.BookScrollId = progressDto.BookScrollId;
userProgress.LastModified = DateTime.Now;
}
_unitOfWork.UserRepository.Update(user);
if (await _unitOfWork.CommitAsync())
{
return Ok();
}
}
catch (Exception)
{
await _unitOfWork.RollbackAsync();
}
if (await _readerService.SaveReadingProgress(progressDto, user)) return Ok(true);
return BadRequest("Could not save progress");
}

View File

@ -16,7 +16,6 @@ using Microsoft.Extensions.Logging;
namespace API.Controllers
{
[Authorize(Policy = "RequireAdminRole")]
public class SettingsController : BaseApiController
{
private readonly ILogger<SettingsController> _logger;
@ -30,6 +29,7 @@ namespace API.Controllers
_taskScheduler = taskScheduler;
}
[Authorize(Policy = "RequireAdminRole")]
[HttpGet]
public async Task<ActionResult<ServerSettingDto>> GetSettings()
{
@ -39,6 +39,7 @@ namespace API.Controllers
return Ok(settingsDto);
}
[Authorize(Policy = "RequireAdminRole")]
[HttpPost]
public async Task<ActionResult<ServerSettingDto>> UpdateSettings(ServerSettingDto updateSettingsDto)
{
@ -86,6 +87,12 @@ namespace API.Controllers
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.EnableOpds && updateSettingsDto.EnableOpds + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.EnableOpds + string.Empty;
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.AllowStatCollection && updateSettingsDto.AllowStatCollection + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.AllowStatCollection + string.Empty;
@ -114,22 +121,32 @@ namespace API.Controllers
return Ok(updateSettingsDto);
}
[Authorize(Policy = "RequireAdminRole")]
[HttpGet("task-frequencies")]
public ActionResult<IEnumerable<string>> GetTaskFrequencies()
{
return Ok(CronConverter.Options);
}
[Authorize(Policy = "RequireAdminRole")]
[HttpGet("library-types")]
public ActionResult<IEnumerable<string>> GetLibraryTypes()
{
return Ok(Enum.GetValues<LibraryType>().Select(t => t.ToDescription()));
}
[Authorize(Policy = "RequireAdminRole")]
[HttpGet("log-levels")]
public ActionResult<IEnumerable<string>> GetLogLevels()
{
return Ok(new [] {"Trace", "Debug", "Information", "Warning", "Critical"});
}
[HttpGet("opds-enabled")]
public async Task<ActionResult<bool>> GetOpdsEnabled()
{
var settingsDto = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
return Ok(settingsDto.EnableOpds);
}
}
}

View File

@ -4,6 +4,9 @@ namespace API.DTOs.Filtering
{
public class FilterDto
{
/// <summary>
/// Pass null if you want all formats
/// </summary>
public MangaFormat? MangaFormat { get; init; } = null;
}

12
API/DTOs/OPDS/Author.cs Normal file
View File

@ -0,0 +1,12 @@
using System.Xml.Serialization;
namespace API.DTOs.OPDS
{
public class Author
{
[XmlElement("name")]
public string Name { get; set; }
[XmlElement("uri")]
public string Uri { get; set; }
}
}

62
API/DTOs/OPDS/Feed.cs Normal file
View File

@ -0,0 +1,62 @@
using System;
using System.Collections.Generic;
using System.Xml.Serialization;
namespace API.DTOs.OPDS
{
/// <summary>
///
/// </summary>
[XmlRoot("feed", Namespace = "http://www.w3.org/2005/Atom")]
public class Feed
{
[XmlElement("updated")]
public string Updated { get; init; } = DateTime.UtcNow.ToString("s");
[XmlElement("id")]
public string Id { get; set; }
[XmlElement("title")]
public string Title { get; set; }
[XmlElement("icon")]
public string Icon { get; set; } = "/favicon.ico";
[XmlElement("author")]
public Author Author { get; set; } = new Author()
{
Name = "Kavita",
Uri = "https://kavitareader.com"
};
[XmlElement("totalResults", Namespace = "http://a9.com/-/spec/opensearch/1.1/")]
public int? Total { get; set; } = null;
[XmlElement("itemsPerPage", Namespace = "http://a9.com/-/spec/opensearch/1.1/")]
public int? ItemsPerPage { get; set; } = null;
[XmlElement("startIndex", Namespace = "http://a9.com/-/spec/opensearch/1.1/")]
public int? StartIndex { get; set; } = null;
[XmlElement("link")]
public List<FeedLink> Links { get; set; } = new List<FeedLink>() ;
[XmlElement("entry")]
public List<FeedEntry> Entries { get; set; } = new List<FeedEntry>();
public bool ShouldSerializeTotal()
{
return Total.HasValue;
}
public bool ShouldSerializeItemsPerPage()
{
return ItemsPerPage.HasValue;
}
public bool ShouldSerializeStartIndex()
{
return StartIndex.HasValue;
}
}
}

View File

@ -0,0 +1,51 @@
using System;
using System.Collections.Generic;
using System.Xml.Serialization;
namespace API.DTOs.OPDS
{
public class FeedEntry
{
[XmlElement("updated")]
public string Updated { get; init; } = DateTime.UtcNow.ToString("s");
[XmlElement("id")]
public string Id { get; set; }
[XmlElement("title")]
public string Title { get; set; }
[XmlElement("summary")]
public string Summary { get; set; }
/// <summary>
/// Represents Size of the Entry
/// Tag: , ElementName = "dcterms:extent"
/// <example>2 MB</example>
/// </summary>
[XmlElement("extent", Namespace = "http://purl.org/dc/terms/")]
public string Extent { get; set; }
/// <summary>
/// Format of the file
/// https://dublincore.org/specifications/dublin-core/dcmi-terms/
/// </summary>
[XmlElement("format", Namespace = "http://purl.org/dc/terms/format")]
public string Format { get; set; }
[XmlElement("language", Namespace = "http://purl.org/dc/terms/")]
public string Language { get; set; }
[XmlElement("content")]
public FeedEntryContent Content { get; set; }
[XmlElement("link")]
public List<FeedLink> Links = new List<FeedLink>();
// [XmlElement("author")]
// public List<FeedAuthor> Authors = new List<FeedAuthor>();
// [XmlElement("category")]
// public List<FeedCategory> Categories = new List<FeedCategory>();
}
}

View File

@ -0,0 +1,12 @@
using System.Xml.Serialization;
namespace API.DTOs.OPDS
{
public class FeedEntryContent
{
[XmlAttribute("type")]
public string Type = "text";
[XmlText]
public string Text;
}
}

33
API/DTOs/OPDS/FeedLink.cs Normal file
View File

@ -0,0 +1,33 @@
using System.Xml.Serialization;
namespace API.DTOs.OPDS
{
public class FeedLink
{
/// <summary>
/// Relation on the Link
/// </summary>
[XmlAttribute("rel")]
public string Rel { get; set; }
/// <summary>
/// Should be any of the types here <see cref="FeedLinkType"/>
/// </summary>
[XmlAttribute("type")]
public string Type { get; set; }
[XmlAttribute("href")]
public string Href { get; set; }
[XmlAttribute("title")]
public string Title { get; set; }
[XmlAttribute("count", Namespace = "http://vaemendis.net/opds-pse/ns")]
public int TotalPages { get; set; } = 0;
public bool ShouldSerializeTotalPages()
{
return TotalPages > 0;
}
}
}

View File

@ -0,0 +1,24 @@
namespace API.DTOs.OPDS
{
public static class FeedLinkRelation
{
public const string Debug = "debug";
public const string Search = "search";
public const string Self = "self";
public const string Start = "start";
public const string Next = "next";
public const string Prev = "prev";
public const string Alternate = "alternate";
public const string SubSection = "subsection";
public const string Related = "related";
public const string Image = "http://opds-spec.org/image";
public const string Thumbnail = "http://opds-spec.org/image/thumbnail";
/// <summary>
/// This will allow for a download to occur
/// </summary>
public const string Acquisition = "http://opds-spec.org/acquisition/open-access";
#pragma warning disable S1075
public const string Stream = "http://vaemendis.net/opds-pse/stream";
#pragma warning restore S1075
}
}

View File

@ -0,0 +1,11 @@
namespace API.DTOs.OPDS
{
public static class FeedLinkType
{
public const string Atom = "application/atom+xml";
public const string AtomSearch = "application/opensearchdescription+xml";
public const string AtomNavigation = "application/atom+xml;profile=opds-catalog;kind=navigation";
public const string AtomAcquisition = "application/atom+xml;profile=opds-catalog;kind=acquisition";
public const string Image = "image/jpeg";
}
}

View File

@ -0,0 +1,42 @@
using System.Xml.Serialization;
namespace API.DTOs.OPDS
{
[XmlRoot("OpenSearchDescription", Namespace = "http://a9.com/-/spec/opensearch/1.1/")]
public class OpenSearchDescription
{
/// <summary>
/// Contains a brief human-readable title that identifies this search engine.
/// </summary>
public string ShortName { get; set; }
/// <summary>
/// Contains an extended human-readable title that identifies this search engine.
/// </summary>
public string LongName { get; set; }
/// <summary>
/// Contains a human-readable text description of the search engine.
/// </summary>
public string Description { get; set; }
/// <summary>
/// https://github.com/dewitt/opensearch/blob/master/opensearch-1-1-draft-6.md#the-url-element
/// </summary>
public SearchLink Url { get; set; }
/// <summary>
/// Contains a set of words that are used as keywords to identify and categorize this search content.
/// Tags must be a single word and are delimited by the space character (' ').
/// </summary>
public string Tags { get; set; }
/// <summary>
/// Contains a URL that identifies the location of an image that can be used in association with this search content.
/// <example><Image height="64" width="64" type="image/png">http://example.com/websearch.png</Image></example>
/// </summary>
public string Image { get; set; }
public string InputEncoding { get; set; } = "UTF-8";
public string OutputEncoding { get; set; } = "UTF-8";
/// <summary>
/// Contains the human-readable name or identifier of the creator or maintainer of the description document.
/// </summary>
public string Developer { get; set; } = "kavitareader.com";
}
}

View File

@ -0,0 +1,16 @@
using System.Xml.Serialization;
namespace API.DTOs.OPDS
{
public class SearchLink
{
[XmlAttribute("type")]
public string Type { get; set; }
[XmlAttribute("rel")]
public string Rel { get; set; } = "results";
[XmlAttribute("template")]
public string Template { get; set; }
}
}

View File

@ -4,9 +4,22 @@
{
public string CacheDirectory { get; set; }
public string TaskScan { get; set; }
/// <summary>
/// Logging level for server. Managed in appsettings.json.
/// </summary>
public string LoggingLevel { get; set; }
public string TaskBackup { get; set; }
/// <summary>
/// Port the server listens on. Managed in appsettings.json.
/// </summary>
public int Port { get; set; }
/// <summary>
/// Allows anonymous information to be collected and sent to KavitaStats
/// </summary>
public bool AllowStatCollection { get; set; }
/// <summary>
/// Enables OPDS connections to be made to the server.
/// </summary>
public bool EnableOpds { get; set; }
}
}
}

View File

@ -5,6 +5,7 @@ namespace API.DTOs
{
public string Username { get; init; }
public string Token { get; init; }
public string ApiKey { get; init; }
public UserPreferencesDto Preferences { get; set; }
}
}
}

View File

@ -0,0 +1,928 @@
// <auto-generated />
using System;
using API.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace API.Data.Migrations
{
[DbContext(typeof(DataContext))]
[Migration("20210826203258_userApiKey")]
partial class userApiKey
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "5.0.8");
modelBuilder.Entity("API.Entities.AppRole", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles");
});
modelBuilder.Entity("API.Entities.AppUser", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ApiKey")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastActive")
.HasColumnType("TEXT");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers");
});
modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<int>("Page")
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int>("VolumeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.ToTable("AppUserBookmark");
});
modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<bool>("AutoCloseMenu")
.HasColumnType("INTEGER");
b.Property<bool>("BookReaderDarkMode")
.HasColumnType("INTEGER");
b.Property<string>("BookReaderFontFamily")
.HasColumnType("TEXT");
b.Property<int>("BookReaderFontSize")
.HasColumnType("INTEGER");
b.Property<int>("BookReaderLineSpacing")
.HasColumnType("INTEGER");
b.Property<int>("BookReaderMargin")
.HasColumnType("INTEGER");
b.Property<int>("BookReaderReadingDirection")
.HasColumnType("INTEGER");
b.Property<bool>("BookReaderTapToPaginate")
.HasColumnType("INTEGER");
b.Property<int>("PageSplitOption")
.HasColumnType("INTEGER");
b.Property<int>("ReaderMode")
.HasColumnType("INTEGER");
b.Property<int>("ReadingDirection")
.HasColumnType("INTEGER");
b.Property<int>("ScalingOption")
.HasColumnType("INTEGER");
b.Property<bool>("SiteDarkMode")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId")
.IsUnique();
b.ToTable("AppUserPreferences");
});
modelBuilder.Entity("API.Entities.AppUserProgress", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<string>("BookScrollId")
.HasColumnType("TEXT");
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<int>("PagesRead")
.HasColumnType("INTEGER");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int>("VolumeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.ToTable("AppUserProgresses");
});
modelBuilder.Entity("API.Entities.AppUserRating", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<int>("Rating")
.HasColumnType("INTEGER");
b.Property<string>("Review")
.HasColumnType("TEXT");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.ToTable("AppUserRating");
});
modelBuilder.Entity("API.Entities.AppUserRole", b =>
{
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.Property<int>("RoleId")
.HasColumnType("INTEGER");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles");
});
modelBuilder.Entity("API.Entities.Chapter", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<bool>("CoverImageLocked")
.HasColumnType("INTEGER");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<bool>("IsSpecial")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<string>("Number")
.HasColumnType("TEXT");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.Property<string>("Range")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.Property<int>("VolumeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("VolumeId");
b.ToTable("Chapter");
});
modelBuilder.Entity("API.Entities.CollectionTag", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<bool>("CoverImageLocked")
.HasColumnType("INTEGER");
b.Property<string>("NormalizedTitle")
.HasColumnType("TEXT");
b.Property<bool>("Promoted")
.HasColumnType("INTEGER");
b.Property<uint>("RowVersion")
.HasColumnType("INTEGER");
b.Property<string>("Summary")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Id", "Promoted")
.IsUnique();
b.ToTable("CollectionTag");
});
modelBuilder.Entity("API.Entities.FolderPath", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("LastScanned")
.HasColumnType("TEXT");
b.Property<int>("LibraryId")
.HasColumnType("INTEGER");
b.Property<string>("Path")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("LibraryId");
b.ToTable("FolderPath");
});
modelBuilder.Entity("API.Entities.Library", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CoverImage")
.HasColumnType("TEXT");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Library");
});
modelBuilder.Entity("API.Entities.MangaFile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<string>("FilePath")
.HasColumnType("TEXT");
b.Property<int>("Format")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ChapterId");
b.ToTable("MangaFile");
});
modelBuilder.Entity("API.Entities.Series", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<bool>("CoverImageLocked")
.HasColumnType("INTEGER");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<int>("Format")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<int>("LibraryId")
.HasColumnType("INTEGER");
b.Property<string>("LocalizedName")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasColumnType("TEXT");
b.Property<string>("OriginalName")
.HasColumnType("TEXT");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.Property<string>("SortName")
.HasColumnType("TEXT");
b.Property<string>("Summary")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("LibraryId");
b.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId", "Format")
.IsUnique();
b.ToTable("Series");
});
modelBuilder.Entity("API.Entities.SeriesMetadata", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("SeriesId")
.IsUnique();
b.HasIndex("Id", "SeriesId")
.IsUnique();
b.ToTable("SeriesMetadata");
});
modelBuilder.Entity("API.Entities.ServerSetting", b =>
{
b.Property<int>("Key")
.HasColumnType("INTEGER");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("ServerSetting");
});
modelBuilder.Entity("API.Entities.Volume", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Number")
.HasColumnType("INTEGER");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("SeriesId");
b.ToTable("Volume");
});
modelBuilder.Entity("AppUserLibrary", b =>
{
b.Property<int>("AppUsersId")
.HasColumnType("INTEGER");
b.Property<int>("LibrariesId")
.HasColumnType("INTEGER");
b.HasKey("AppUsersId", "LibrariesId");
b.HasIndex("LibrariesId");
b.ToTable("AppUserLibrary");
});
modelBuilder.Entity("CollectionTagSeriesMetadata", b =>
{
b.Property<int>("CollectionTagsId")
.HasColumnType("INTEGER");
b.Property<int>("SeriesMetadatasId")
.HasColumnType("INTEGER");
b.HasKey("CollectionTagsId", "SeriesMetadatasId");
b.HasIndex("SeriesMetadatasId");
b.ToTable("CollectionTagSeriesMetadata");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<int>("RoleId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<int>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<int>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<int>", b =>
{
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens");
});
modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithMany("Bookmarks")
.HasForeignKey("AppUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AppUser");
});
modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithOne("UserPreferences")
.HasForeignKey("API.Entities.AppUserPreferences", "AppUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AppUser");
});
modelBuilder.Entity("API.Entities.AppUserProgress", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithMany("Progresses")
.HasForeignKey("AppUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AppUser");
});
modelBuilder.Entity("API.Entities.AppUserRating", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithMany("Ratings")
.HasForeignKey("AppUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AppUser");
});
modelBuilder.Entity("API.Entities.AppUserRole", b =>
{
b.HasOne("API.Entities.AppRole", "Role")
.WithMany("UserRoles")
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.AppUser", "User")
.WithMany("UserRoles")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Role");
b.Navigation("User");
});
modelBuilder.Entity("API.Entities.Chapter", b =>
{
b.HasOne("API.Entities.Volume", "Volume")
.WithMany("Chapters")
.HasForeignKey("VolumeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Volume");
});
modelBuilder.Entity("API.Entities.FolderPath", b =>
{
b.HasOne("API.Entities.Library", "Library")
.WithMany("Folders")
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Library");
});
modelBuilder.Entity("API.Entities.MangaFile", b =>
{
b.HasOne("API.Entities.Chapter", "Chapter")
.WithMany("Files")
.HasForeignKey("ChapterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Chapter");
});
modelBuilder.Entity("API.Entities.Series", b =>
{
b.HasOne("API.Entities.Library", "Library")
.WithMany("Series")
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Library");
});
modelBuilder.Entity("API.Entities.SeriesMetadata", b =>
{
b.HasOne("API.Entities.Series", "Series")
.WithOne("Metadata")
.HasForeignKey("API.Entities.SeriesMetadata", "SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Series");
});
modelBuilder.Entity("API.Entities.Volume", b =>
{
b.HasOne("API.Entities.Series", "Series")
.WithMany("Volumes")
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Series");
});
modelBuilder.Entity("AppUserLibrary", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("AppUsersId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.Library", null)
.WithMany()
.HasForeignKey("LibrariesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("CollectionTagSeriesMetadata", b =>
{
b.HasOne("API.Entities.CollectionTag", null)
.WithMany()
.HasForeignKey("CollectionTagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.SeriesMetadata", null)
.WithMany()
.HasForeignKey("SeriesMetadatasId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b =>
{
b.HasOne("API.Entities.AppRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<int>", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<int>", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<int>", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("API.Entities.AppRole", b =>
{
b.Navigation("UserRoles");
});
modelBuilder.Entity("API.Entities.AppUser", b =>
{
b.Navigation("Bookmarks");
b.Navigation("Progresses");
b.Navigation("Ratings");
b.Navigation("UserPreferences");
b.Navigation("UserRoles");
});
modelBuilder.Entity("API.Entities.Chapter", b =>
{
b.Navigation("Files");
});
modelBuilder.Entity("API.Entities.Library", b =>
{
b.Navigation("Folders");
b.Navigation("Series");
});
modelBuilder.Entity("API.Entities.Series", b =>
{
b.Navigation("Metadata");
b.Navigation("Volumes");
});
modelBuilder.Entity("API.Entities.Volume", b =>
{
b.Navigation("Chapters");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,23 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace API.Data.Migrations
{
public partial class userApiKey : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ApiKey",
table: "AspNetUsers",
type: "TEXT",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ApiKey",
table: "AspNetUsers");
}
}
}

View File

@ -52,6 +52,9 @@ namespace API.Data.Migrations
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ApiKey")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
@ -345,7 +348,6 @@ namespace API.Data.Migrations
.HasColumnType("INTEGER");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<string>("Summary")

View File

@ -48,6 +48,7 @@ namespace API.Data
new () {Key = ServerSettingKey.BackupDirectory, Value = Path.GetFullPath(Path.Join(Directory.GetCurrentDirectory(), "backups/"))},
new () {Key = ServerSettingKey.Port, Value = "5000"}, // Not used from DB, but DB is sync with appSettings.json
new () {Key = ServerSettingKey.AllowStatCollection, Value = "true"},
new () {Key = ServerSettingKey.EnableOpds, Value = "false"},
};
foreach (var defaultSetting in defaultSettings)
@ -71,19 +72,18 @@ namespace API.Data
}
public static async Task SeedSeriesMetadata(DataContext context)
public static async Task SeedUserApiKeys(DataContext context)
{
await context.Database.EnsureCreatedAsync();
context.Database.EnsureCreated();
var series = await context.Series
.Include(s => s.Metadata).ToListAsync();
foreach (var s in series)
var users = await context.AppUser.ToListAsync();
foreach (var user in users)
{
s.Metadata ??= new SeriesMetadata();
if (string.IsNullOrEmpty(user.ApiKey))
{
user.ApiKey = HashUtil.ApiKey();
}
}
await context.SaveChangesAsync();
}
}

View File

@ -53,6 +53,19 @@ namespace API.Data
.SingleOrDefaultAsync(x => x.UserName == username);
}
/// <summary>
/// Gets an AppUser by id. Returns back Progress information.
/// </summary>
/// <param name="username"></param>
/// <returns></returns>
public async Task<AppUser> GetUserByIdAsync(int id)
{
return await _context.Users
.Include(u => u.Progresses)
.Include(u => u.Bookmarks)
.SingleOrDefaultAsync(x => x.Id == id);
}
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
{
return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole);
@ -116,6 +129,12 @@ namespace API.Data
.ToListAsync();
}
public async Task<AppUser> GetUserByApiKeyAsync(string apiKey)
{
return await _context.AppUser
.SingleOrDefaultAsync(u => u.ApiKey.Equals(apiKey));
}
public async Task<IEnumerable<MemberDto>> GetMembersAsync()
{

View File

@ -17,6 +17,11 @@ namespace API.Entities
public ICollection<AppUserRating> Ratings { get; set; }
public AppUserPreferences UserPreferences { get; set; }
public ICollection<AppUserBookmark> Bookmarks { get; set; }
/// <summary>
/// An API Key to interact with external services, like OPDS
/// </summary>
public string ApiKey { get; set; }
/// <inheritdoc />
[ConcurrencyCheck]

View File

@ -18,6 +18,8 @@ namespace API.Entities.Enums
BackupDirectory = 5,
[Description("AllowStatCollection")]
AllowStatCollection = 6,
[Description("EnableOpds")]
EnableOpds = 7,
}
}
}

View File

@ -34,6 +34,8 @@ namespace API.Extensions
services.AddScoped<IBookService, BookService>();
services.AddScoped<IImageService, ImageService>();
services.AddScoped<IVersionUpdaterService, VersionUpdaterService>();
services.AddScoped<IDownloadService, DownloadService>();
services.AddScoped<IReaderService, ReaderService>();
services.AddScoped<IPresenceTracker, PresenceTracker>();

View File

@ -33,10 +33,13 @@ namespace API.Helpers.Converters
case ServerSettingKey.AllowStatCollection:
destination.AllowStatCollection = bool.Parse(row.Value);
break;
case ServerSettingKey.EnableOpds:
destination.EnableOpds = bool.Parse(row.Value);
break;
}
}
return destination;
}
}
}
}

View File

@ -11,6 +11,7 @@ namespace API.Interfaces
void Update(AppUserPreferences preferences);
public void Delete(AppUser user);
Task<AppUser> GetUserByUsernameAsync(string username);
Task<AppUser> GetUserByIdAsync(int id);
Task<IEnumerable<MemberDto>> GetMembersAsync();
Task<IEnumerable<AppUser>> GetAdminUsersAsync();
Task<AppUserRating> GetUserRating(int seriesId, int userId);
@ -20,5 +21,6 @@ namespace API.Interfaces
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForVolume(int userId, int volumeId);
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForChapter(int userId, int chapterId);
Task<IEnumerable<BookmarkDto>> GetAllBookmarkDtos(int userId);
Task<AppUser> GetUserByApiKeyAsync(string apiKey);
}
}

View File

@ -0,0 +1,11 @@
using System.Threading.Tasks;
using API.DTOs;
using API.Entities;
namespace API.Interfaces.Services
{
public interface IReaderService
{
Task<bool> SaveReadingProgress(ProgressDto progressDto, AppUser user);
}
}

View File

@ -0,0 +1,82 @@

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.DTOs;
using API.Entities;
namespace API.Interfaces.Services
{
public class ReaderService : IReaderService
{
private readonly IUnitOfWork _unitOfWork;
public ReaderService(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}
/// <summary>
/// Saves progress to DB
/// </summary>
/// <param name="progressDto"></param>
/// <param name="user"></param>
/// <returns></returns>
public async Task<bool> SaveReadingProgress(ProgressDto progressDto, AppUser user)
{
// Don't let user save past total pages.
var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(progressDto.ChapterId);
if (progressDto.PageNum > chapter.Pages)
{
progressDto.PageNum = chapter.Pages;
}
if (progressDto.PageNum < 0)
{
progressDto.PageNum = 0;
}
try
{
user.Progresses ??= new List<AppUserProgress>();
var userProgress =
user.Progresses.FirstOrDefault(x => x.ChapterId == progressDto.ChapterId && x.AppUserId == user.Id);
if (userProgress == null)
{
user.Progresses.Add(new AppUserProgress
{
PagesRead = progressDto.PageNum,
VolumeId = progressDto.VolumeId,
SeriesId = progressDto.SeriesId,
ChapterId = progressDto.ChapterId,
BookScrollId = progressDto.BookScrollId,
LastModified = DateTime.Now
});
}
else
{
userProgress.PagesRead = progressDto.PageNum;
userProgress.SeriesId = progressDto.SeriesId;
userProgress.VolumeId = progressDto.VolumeId;
userProgress.BookScrollId = progressDto.BookScrollId;
userProgress.LastModified = DateTime.Now;
}
_unitOfWork.UserRepository.Update(user);
if (await _unitOfWork.CommitAsync())
{
return true;
}
}
catch (Exception)
{
await _unitOfWork.RollbackAsync();
}
return false;
}
}
}

View File

@ -1,22 +0,0 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace API.Middleware
{
public class BookRedirectMiddleware
{
private readonly ILogger<BookRedirectMiddleware> _logger;
public BookRedirectMiddleware(ILogger<BookRedirectMiddleware> logger)
{
_logger = logger;
}
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
_logger.LogDebug("BookRedirect Path: {Path}", context.Request.Path.ToString());
await next.Invoke(context);
}
}
}

View File

@ -53,6 +53,7 @@ namespace API
await context.Database.MigrateAsync();
await Seed.SeedRoles(roleManager);
await Seed.SeedSettings(context);
await Seed.SeedUserApiKeys(context);
}
catch (Exception ex)
{

View File

@ -19,6 +19,7 @@ namespace API.Services.Clients
{
_client = client;
_logger = logger;
_client.Timeout = TimeSpan.FromSeconds(30);
}
public async Task SendDataToStatsServer(UsageStatisticsDto data)

View File

@ -432,5 +432,60 @@ namespace API.Services
}
}
}
/// <summary>
/// Returns the human-readable file size for an arbitrary, 64-bit file size
/// <remarks>The default format is "0.## XB", e.g. "4.2 KB" or "1.43 GB"</remarks>
/// </summary>
/// https://www.somacon.com/p576.php
/// <param name="bytes"></param>
/// <returns></returns>
public static string GetHumanReadableBytes(long bytes)
{
// Get absolute value
var absoluteBytes = (bytes < 0 ? -bytes : bytes);
// Determine the suffix and readable value
string suffix;
double readable;
switch (absoluteBytes)
{
// Exabyte
case >= 0x1000000000000000:
suffix = "EB";
readable = (bytes >> 50);
break;
// Petabyte
case >= 0x4000000000000:
suffix = "PB";
readable = (bytes >> 40);
break;
// Terabyte
case >= 0x10000000000:
suffix = "TB";
readable = (bytes >> 30);
break;
// Gigabyte
case >= 0x40000000:
suffix = "GB";
readable = (bytes >> 20);
break;
// Megabyte
case >= 0x100000:
suffix = "MB";
readable = (bytes >> 10);
break;
// Kilobyte
case >= 0x400:
suffix = "KB";
readable = bytes;
break;
default:
return bytes.ToString("0 B"); // Byte
}
// Divide by 1024 to get fractional value
readable = (readable / 1024);
// Return formatted number with suffix
return readable.ToString("0.## ") + suffix;
}
}
}

View File

@ -0,0 +1,59 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using API.Entities;
using API.Interfaces.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.StaticFiles;
namespace API.Services
{
public interface IDownloadService
{
Task<(byte[], string, string)> GetFirstFileDownload(IEnumerable<MangaFile> files);
string GetContentTypeFromFile(string filepath);
}
public class DownloadService : IDownloadService
{
private readonly IDirectoryService _directoryService;
private readonly FileExtensionContentTypeProvider _fileTypeProvider = new FileExtensionContentTypeProvider();
public DownloadService(IDirectoryService directoryService)
{
_directoryService = directoryService;
}
/// <summary>
/// Downloads the first file in the file enumerable for download
/// </summary>
/// <param name="files"></param>
/// <returns></returns>
public async Task<(byte[], string, string)> GetFirstFileDownload(IEnumerable<MangaFile> files)
{
var firstFile = files.Select(c => c.FilePath).First();
return (await _directoryService.ReadFileAsync(firstFile), GetContentTypeFromFile(firstFile), Path.GetFileName(firstFile));
}
public string GetContentTypeFromFile(string filepath)
{
// Figures out what the content type should be based on the file name.
if (!_fileTypeProvider.TryGetContentType(filepath, out var contentType))
{
contentType = Path.GetExtension(filepath).ToLowerInvariant() switch
{
".cbz" => "application/zip",
".cbr" => "application/vnd.rar",
".cb7" => "application/x-compressed",
".epub" => "application/epub+zip",
".7z" => "application/x-7z-compressed",
".7zip" => "application/x-7z-compressed",
".pdf" => "application/pdf",
_ => contentType
};
}
return contentType;
}
}
}

View File

@ -1,4 +1,5 @@
using System;
using System.Security.Cryptography;
using System.Text;
namespace Kavita.Common
@ -37,5 +38,20 @@ namespace Kavita.Common
var seed = $"{Environment.ProcessorCount}_{Environment.OSVersion.Platform}_{Configuration.JwtToken}_{Environment.UserName}";
return CalculateCrc(seed);
}
/// <summary>
/// Generates a unique API key to this server instance
/// </summary>
/// <returns></returns>
public static string ApiKey()
{
var id = Guid.NewGuid();
if (id.Equals(Guid.Empty))
{
id = Guid.NewGuid();
}
return id.ToString();
}
}
}

View File

@ -1,4 +1,5 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=1BC0273F_002DFEBE_002D4DA1_002DBC04_002D3A3167E4C86C_002Fd_003AData_002Fd_003AMigrations/@EntryIndexedValue">ExplicitlyExcluded</s:String>
<s:Boolean x:Key="/Default/CodeInspection/Highlighting/RunLongAnalysisInSwa/@EntryValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/Highlighting/RunValueAnalysisInNullableWarningsEnabledContext2/@EntryValue">True</s:Boolean></wpf:ResourceDictionary>
<s:Boolean x:Key="/Default/CodeInspection/Highlighting/RunValueAnalysisInNullableWarningsEnabledContext2/@EntryValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Opds/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

View File

@ -6,4 +6,5 @@ export interface User {
token: string;
roles: string[];
preferences: Preferences;
apiKey: string;
}

View File

@ -130,4 +130,21 @@ export class AccountService implements OnDestroy {
return undefined;
}
resetApiKey() {
return this.httpClient.post<string>(this.baseUrl + 'account/reset-api-key', {}, {responseType: 'text' as 'json'}).pipe(map(key => {
const user = this.getUserFromLocalStorage();
if (user) {
user.apiKey = key;
localStorage.setItem(this.userKey, JSON.stringify(user));
this.currentUserSource.next(user);
this.currentUser = user;
}
return key;
}));
}
}

View File

@ -5,4 +5,5 @@ export interface ServerSettings {
loggingLevel: string;
port: number;
allowStatCollection: boolean;
enableOpds: boolean;
}

View File

@ -33,7 +33,7 @@ import { ChangelogComponent } from './changelog/changelog.component';
FilterPipe,
EditRbsModalComponent,
ManageSystemComponent,
ChangelogComponent
ChangelogComponent,
],
imports: [
CommonModule,

View File

@ -28,11 +28,20 @@
<label for="stat-collection" aria-describedby="collection-info">Allow Anonymous Usage Collection</label>
<p class="accent" id="collection-info">Send anonymous usage and error information to Kavita's servers. This includes information on your browser, error reporting as well as OS and runtime version. We will use this information to prioritize features, bug fixes, and preformance tuning. Requires restart to take effect.</p>
<div class="form-check">
<input id="stat-collection" type="checkbox" aria-label="Admin" class="form-check-input" formControlName="allowStatCollection">
<input id="stat-collection" type="checkbox" aria-label="Stat Collection" class="form-check-input" formControlName="allowStatCollection">
<label for="stat-collection" class="form-check-label">Send Data</label>
</div>
</div>
<div class="form-group">
<label for="opds" aria-describedby="opds-info">OPDS</label>
<p class="accent" id="opds-info">OPDS support will allow all users to use OPDS to read and download content from the server. If OPDS is enabled, a user will not need download permissions to download media while using it.</p>
<div class="form-check">
<input id="opds" type="checkbox" aria-label="OPDS Support" class="form-check-input" formControlName="enableOpds">
<label for="opds" class="form-check-label">Enable OPDS</label>
</div>
</div>
<h4>Reoccuring Tasks</h4>
<div class="form-group">
<label for="settings-tasks-scan">Library Scan</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="taskScanTooltip" role="button" tabindex="0"></i>

View File

@ -34,6 +34,7 @@ export class ManageSettingsComponent implements OnInit {
this.settingsForm.addControl('port', new FormControl(this.serverSettings.port, [Validators.required]));
this.settingsForm.addControl('loggingLevel', new FormControl(this.serverSettings.loggingLevel, [Validators.required]));
this.settingsForm.addControl('allowStatCollection', new FormControl(this.serverSettings.allowStatCollection, [Validators.required]));
this.settingsForm.addControl('enableOpds', new FormControl(this.serverSettings.enableOpds, [Validators.required]));
});
}
@ -44,6 +45,7 @@ export class ManageSettingsComponent implements OnInit {
this.settingsForm.get('port')?.setValue(this.serverSettings.port);
this.settingsForm.get('loggingLevel')?.setValue(this.serverSettings.loggingLevel);
this.settingsForm.get('allowStatCollection')?.setValue(this.serverSettings.allowStatCollection);
this.settingsForm.get('enableOpds')?.setValue(this.serverSettings.enableOpds);
}
saveSettings() {

View File

@ -31,4 +31,8 @@ export class SettingsService {
getLibraryTypes() {
return this.http.get<string[]>(this.baseUrl + 'settings/library-types');
}
getOpdsEnabled() {
return this.http.get<boolean>(this.baseUrl + 'settings/opds-enabled', {responseType: 'text' as 'json'});
}
}

View File

@ -0,0 +1,11 @@
<div class="form-group">
<label for="api-key--{{title}}">{{title}}</label><span *ngIf="tooltipText.length > 0">&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="tooltip" role="button" tabindex="0"></i></span>
<ng-template #tooltip>{{tooltipText}}</ng-template>
<div class="input-group">
<input #apiKey type="text" readonly class="form-control" id="api-key--{{title}}" aria-describedby="button-addon4" [value]="key" (click)="selectAll()">
<div class="input-group-append" id="button-addon4">
<button class="btn btn-outline-secondary" type="button" (click)="copy()"><span class="sr-only">Copy</span><i class="fa fa-copy" aria-hidden="true"></i></button>
<button class="btn btn-danger" type="button" (click)="refresh()" *ngIf="showRefresh"><span class="sr-only">Regenerate</span><i class="fa fa-sync-alt" aria-hidden="true"></i></button>
</div>
</div>
</div>

View File

@ -0,0 +1,66 @@
import { Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { ToastrService } from 'ngx-toastr';
import { Subject } from 'rxjs';
import { take, takeUntil } from 'rxjs/operators';
import { ConfirmService } from 'src/app/shared/confirm.service';
import { AccountService } from 'src/app/_services/account.service';
@Component({
selector: 'app-api-key',
templateUrl: './api-key.component.html',
styleUrls: ['./api-key.component.scss']
})
export class ApiKeyComponent implements OnInit, OnDestroy {
@Input() title: string = 'API Key';
@Input() showRefresh: boolean = true;
@Input() transform: (val: string) => string = (val: string) => val;
@Input() tooltipText: string = '';
@ViewChild('apiKey') inputElem!: ElementRef;
key: string = '';
private readonly onDestroy = new Subject<void>();
constructor(private confirmService: ConfirmService, private accountService: AccountService, private toastr: ToastrService) { }
ngOnInit(): void {
this.accountService.currentUser$.pipe(takeUntil(this.onDestroy)).subscribe(user => {
let key = '';
if (user) {
key = user.apiKey;
} else {
key = 'ERROR - KEY NOT SET';
}
if (this.transform != undefined) {
this.key = this.transform(key);
}
});
}
ngOnDestroy() {
this.onDestroy.next();
this.onDestroy.complete();
}
async copy() {
await navigator.clipboard.writeText(this.key);
}
async refresh() {
if (!await this.confirmService.confirm('This will invalidate any OPDS configurations you have setup. Are you sure you want to continue?')) {
return;
}
this.accountService.resetApiKey().subscribe(newKey => {
this.key = newKey;
this.toastr.success('API Key reset');
});
}
selectAll() {
if (this.inputElem) {
this.inputElem.nativeElement.setSelectionRange(0, this.key.length);
}
}
}

View File

@ -190,6 +190,17 @@
</form>
</ng-template>
</ngb-panel>
<ngb-panel id="api-panel" title="OPDS">
<ng-template ngbPanelContent>
<p class="alert alert-danger" role="alert" *ngIf="!opdsEnabled">
OPDS is not enabled on this server!
</p>
<ng-container *ngIf="opdsEnabled">
<app-api-key tooltipText="The API key is like a password. Keep it secret, Keep it safe."></app-api-key>
<app-api-key title="OPDS URL" [showRefresh]="false" [transform]="makeUrl"></app-api-key>
</ng-container>
</ng-template>
</ngb-panel>
</ngb-accordion>
</ng-container>
<ng-container *ngIf="tab.fragment === 'bookmarks'">

View File

@ -10,6 +10,9 @@ import { User } from 'src/app/_models/user';
import { AccountService } from 'src/app/_services/account.service';
import { NavService } from 'src/app/_services/nav.service';
import { ActivatedRoute } from '@angular/router';
import { SettingsService } from 'src/app/admin/settings.service';
import { keyframes } from '@angular/animations';
import { environment } from 'src/environments/environment';
@Component({
selector: 'app-user-preferences',
@ -49,15 +52,16 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
};
fontFamilies: Array<string> = [];
//tabs = ['Preferences', 'Bookmarks'];
tabs: Array<{title: string, fragment: string}> = [
{title: 'Preferences', fragment: ''},
{title: 'Bookmarks', fragment: 'bookmarks'},
];
active = this.tabs[0];
opdsEnabled: boolean = false;
makeUrl: (val: string) => string = (val: string) => {return this.transformKeyToOpdsUrl(val)};
constructor(private accountService: AccountService, private toastr: ToastrService, private bookService: BookService,
private navService: NavService, private titleService: Title, private route: ActivatedRoute) {
private navService: NavService, private titleService: Title, private route: ActivatedRoute, private settingsService: SettingsService) {
this.fontFamilies = this.bookService.getFontFamilies();
this.route.fragment.subscribe(frag => {
@ -68,6 +72,10 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
this.active = this.tabs[0]; // Default to first tab
}
});
this.settingsService.getOpdsEnabled().subscribe(res => {
this.opdsEnabled = res;
})
}
ngOnInit(): void {
@ -177,4 +185,15 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
this.resetPasswordErrors = err;
}));
}
transformKeyToOpdsUrl(key: string) {
let apiUrl = environment.apiUrl;
if (environment.production) {
apiUrl = `${location.protocol}//${location.origin}`;
if (location.port != '80') {
apiUrl += ':' + location.port;
}
}
return `${apiUrl}opds/${key}`;
}
}

View File

@ -6,13 +6,15 @@ import { NgbAccordionModule, NgbNavModule, NgbTooltipModule } from '@ng-bootstra
import { ReactiveFormsModule } from '@angular/forms';
import { NgxSliderModule } from '@angular-slider/ngx-slider';
import { UserSettingsRoutingModule } from './user-settings-routing.module';
import { ApiKeyComponent } from './api-key/api-key.component';
@NgModule({
declarations: [
SeriesBookmarksComponent,
UserPreferencesComponent
UserPreferencesComponent,
ApiKeyComponent
],
imports: [
CommonModule,