diff --git a/API/API.csproj b/API/API.csproj
index 2a32e615d..7d2707e28 100644
--- a/API/API.csproj
+++ b/API/API.csproj
@@ -73,6 +73,7 @@
+
@@ -115,6 +116,8 @@
+
+
diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs
index 18635ea03..8e108cd28 100644
--- a/API/Controllers/AccountController.cs
+++ b/API/Controllers/AccountController.cs
@@ -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(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(user.UserPreferences)
};
}
@@ -180,6 +183,7 @@ namespace API.Controllers
{
Username = user.UserName,
Token = await _tokenService.CreateToken(user),
+ ApiKey = user.ApiKey,
Preferences = _mapper.Map(user.UserPreferences)
};
}
@@ -237,5 +241,26 @@ namespace API.Controllers
return BadRequest("Something went wrong, unable to update user's roles");
}
+
+ ///
+ /// Resets the API Key assigned with a user
+ ///
+ ///
+ [HttpPost("reset-api-key")]
+ public async Task> 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");
+
+ }
}
}
diff --git a/API/Controllers/DownloadController.cs b/API/Controllers/DownloadController.cs
index 21e2a77cb..416ffbfc5 100644
--- a/API/Controllers/DownloadController.cs
+++ b/API/Controllers/DownloadController.cs
@@ -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 GetFirstFileDownload(IEnumerable 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")]
diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs
new file mode 100644
index 000000000..438dfac77
--- /dev/null
+++ b/API/Controllers/OPDSController.cs
@@ -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 _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 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 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()
+ {
+ 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()
+ {
+ 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()
+ {
+ 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()
+ {
+ CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/collections"),
+ }
+ });
+ return CreateXmlResult(SerializeXml(feed));
+ }
+
+
+ [HttpGet("{apiKey}/libraries")]
+ [Produces("application/xml")]
+ public async Task 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()
+ {
+ CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/libraries/{library.Id}"),
+ }
+ });
+ }
+
+ return CreateXmlResult(SerializeXml(feed));
+ }
+
+ [HttpGet("{apiKey}/collections")]
+ [Produces("application/xml")]
+ public async Task 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 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()
+ {
+ 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 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 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 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 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 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(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 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 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 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 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()
+ {
+ 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 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));
+ }
+
+ ///
+ /// Downloads a file
+ ///
+ ///
+ ///
+ ///
+ /// Not used. Only for Chunky to allow download links
+ ///
+ [HttpGet("{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}/download/{filename}")]
+ public async Task 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 list, string href)
+ {
+ var url = href;
+ if (href.Contains("?"))
+ {
+ url += "&";
+ }
+ 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()
+ {
+ 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()
+ {
+ 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()
+ {
+ 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()
+ {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()
+ {
+ 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 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 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);
+ }
+
+ ///
+ /// Gets the user from the API key
+ ///
+ ///
+ private async Task 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()
+ {
+ 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
+ }
+ }
+}
diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs
index 6e37ae38a..9b112e5da 100644
--- a/API/Controllers/ReaderController.cs
+++ b/API/Controllers/ReaderController.cs
@@ -24,17 +24,20 @@ namespace API.Controllers
private readonly ICacheService _cacheService;
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger _logger;
+ private readonly IReaderService _readerService;
private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer();
private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst();
private readonly NaturalSortComparer _naturalSortComparer = new NaturalSortComparer();
///
- public ReaderController(IDirectoryService directoryService, ICacheService cacheService, IUnitOfWork unitOfWork, ILogger logger)
+ public ReaderController(IDirectoryService directoryService, ICacheService cacheService,
+ IUnitOfWork unitOfWork, ILogger logger, IReaderService readerService)
{
_directoryService = directoryService;
_cacheService = cacheService;
_unitOfWork = unitOfWork;
_logger = logger;
+ _readerService = readerService;
}
///
@@ -285,57 +288,7 @@ namespace API.Controllers
public async Task 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();
- 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");
}
diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs
index df275bb67..4d81eb22d 100644
--- a/API/Controllers/SettingsController.cs
+++ b/API/Controllers/SettingsController.cs
@@ -16,7 +16,6 @@ using Microsoft.Extensions.Logging;
namespace API.Controllers
{
- [Authorize(Policy = "RequireAdminRole")]
public class SettingsController : BaseApiController
{
private readonly ILogger _logger;
@@ -30,6 +29,7 @@ namespace API.Controllers
_taskScheduler = taskScheduler;
}
+ [Authorize(Policy = "RequireAdminRole")]
[HttpGet]
public async Task> GetSettings()
{
@@ -39,6 +39,7 @@ namespace API.Controllers
return Ok(settingsDto);
}
+ [Authorize(Policy = "RequireAdminRole")]
[HttpPost]
public async Task> 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> GetTaskFrequencies()
{
return Ok(CronConverter.Options);
}
+ [Authorize(Policy = "RequireAdminRole")]
[HttpGet("library-types")]
public ActionResult> GetLibraryTypes()
{
return Ok(Enum.GetValues().Select(t => t.ToDescription()));
}
+ [Authorize(Policy = "RequireAdminRole")]
[HttpGet("log-levels")]
public ActionResult> GetLogLevels()
{
return Ok(new [] {"Trace", "Debug", "Information", "Warning", "Critical"});
}
+
+ [HttpGet("opds-enabled")]
+ public async Task> GetOpdsEnabled()
+ {
+ var settingsDto = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
+ return Ok(settingsDto.EnableOpds);
+ }
}
}
diff --git a/API/DTOs/Filtering/FilterDto.cs b/API/DTOs/Filtering/FilterDto.cs
index 38a172016..c5956f17d 100644
--- a/API/DTOs/Filtering/FilterDto.cs
+++ b/API/DTOs/Filtering/FilterDto.cs
@@ -4,6 +4,9 @@ namespace API.DTOs.Filtering
{
public class FilterDto
{
+ ///
+ /// Pass null if you want all formats
+ ///
public MangaFormat? MangaFormat { get; init; } = null;
}
diff --git a/API/DTOs/OPDS/Author.cs b/API/DTOs/OPDS/Author.cs
new file mode 100644
index 000000000..1758a037e
--- /dev/null
+++ b/API/DTOs/OPDS/Author.cs
@@ -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; }
+ }
+}
diff --git a/API/DTOs/OPDS/Feed.cs b/API/DTOs/OPDS/Feed.cs
new file mode 100644
index 000000000..95f08448e
--- /dev/null
+++ b/API/DTOs/OPDS/Feed.cs
@@ -0,0 +1,62 @@
+using System;
+using System.Collections.Generic;
+using System.Xml.Serialization;
+
+namespace API.DTOs.OPDS
+{
+ ///
+ ///
+ ///
+ [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 Links { get; set; } = new List() ;
+
+ [XmlElement("entry")]
+ public List Entries { get; set; } = new List();
+
+ public bool ShouldSerializeTotal()
+ {
+ return Total.HasValue;
+ }
+
+ public bool ShouldSerializeItemsPerPage()
+ {
+ return ItemsPerPage.HasValue;
+ }
+
+ public bool ShouldSerializeStartIndex()
+ {
+ return StartIndex.HasValue;
+ }
+ }
+}
diff --git a/API/DTOs/OPDS/FeedEntry.cs b/API/DTOs/OPDS/FeedEntry.cs
new file mode 100644
index 000000000..9d2621dfd
--- /dev/null
+++ b/API/DTOs/OPDS/FeedEntry.cs
@@ -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; }
+
+ ///
+ /// Represents Size of the Entry
+ /// Tag: , ElementName = "dcterms:extent"
+ /// 2 MB
+ ///
+ [XmlElement("extent", Namespace = "http://purl.org/dc/terms/")]
+ public string Extent { get; set; }
+
+ ///
+ /// Format of the file
+ /// https://dublincore.org/specifications/dublin-core/dcmi-terms/
+ ///
+ [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 Links = new List();
+
+ // [XmlElement("author")]
+ // public List Authors = new List();
+
+ // [XmlElement("category")]
+ // public List Categories = new List();
+ }
+}
diff --git a/API/DTOs/OPDS/FeedEntryContent.cs b/API/DTOs/OPDS/FeedEntryContent.cs
new file mode 100644
index 000000000..d965cc3f4
--- /dev/null
+++ b/API/DTOs/OPDS/FeedEntryContent.cs
@@ -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;
+ }
+}
diff --git a/API/DTOs/OPDS/FeedLink.cs b/API/DTOs/OPDS/FeedLink.cs
new file mode 100644
index 000000000..1dc3d5b1e
--- /dev/null
+++ b/API/DTOs/OPDS/FeedLink.cs
@@ -0,0 +1,33 @@
+using System.Xml.Serialization;
+
+namespace API.DTOs.OPDS
+{
+ public class FeedLink
+ {
+ ///
+ /// Relation on the Link
+ ///
+ [XmlAttribute("rel")]
+ public string Rel { get; set; }
+
+ ///
+ /// Should be any of the types here
+ ///
+ [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;
+ }
+ }
+}
diff --git a/API/DTOs/OPDS/FeedLinkRelation.cs b/API/DTOs/OPDS/FeedLinkRelation.cs
new file mode 100644
index 000000000..9702dd943
--- /dev/null
+++ b/API/DTOs/OPDS/FeedLinkRelation.cs
@@ -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";
+ ///
+ /// This will allow for a download to occur
+ ///
+ 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
+ }
+}
diff --git a/API/DTOs/OPDS/FeedLinkType.cs b/API/DTOs/OPDS/FeedLinkType.cs
new file mode 100644
index 000000000..2119a6f80
--- /dev/null
+++ b/API/DTOs/OPDS/FeedLinkType.cs
@@ -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";
+ }
+}
diff --git a/API/DTOs/OPDS/OpenSearchDescription.cs b/API/DTOs/OPDS/OpenSearchDescription.cs
new file mode 100644
index 000000000..94eba555c
--- /dev/null
+++ b/API/DTOs/OPDS/OpenSearchDescription.cs
@@ -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
+ {
+ ///
+ /// Contains a brief human-readable title that identifies this search engine.
+ ///
+ public string ShortName { get; set; }
+ ///
+ /// Contains an extended human-readable title that identifies this search engine.
+ ///
+ public string LongName { get; set; }
+ ///
+ /// Contains a human-readable text description of the search engine.
+ ///
+ public string Description { get; set; }
+ ///
+ /// https://github.com/dewitt/opensearch/blob/master/opensearch-1-1-draft-6.md#the-url-element
+ ///
+ public SearchLink Url { get; set; }
+ ///
+ /// 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 (' ').
+ ///
+ public string Tags { get; set; }
+ ///
+ /// Contains a URL that identifies the location of an image that can be used in association with this search content.
+ /// http://example.com/websearch.png
+ ///
+ public string Image { get; set; }
+ public string InputEncoding { get; set; } = "UTF-8";
+ public string OutputEncoding { get; set; } = "UTF-8";
+ ///
+ /// Contains the human-readable name or identifier of the creator or maintainer of the description document.
+ ///
+ public string Developer { get; set; } = "kavitareader.com";
+
+ }
+}
diff --git a/API/DTOs/OPDS/SearchLink.cs b/API/DTOs/OPDS/SearchLink.cs
new file mode 100644
index 000000000..db5a20f23
--- /dev/null
+++ b/API/DTOs/OPDS/SearchLink.cs
@@ -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; }
+ }
+}
diff --git a/API/DTOs/ServerSettingDTO.cs b/API/DTOs/ServerSettingDTO.cs
index 9a52f9c09..271f7d7a6 100644
--- a/API/DTOs/ServerSettingDTO.cs
+++ b/API/DTOs/ServerSettingDTO.cs
@@ -4,9 +4,22 @@
{
public string CacheDirectory { get; set; }
public string TaskScan { get; set; }
+ ///
+ /// Logging level for server. Managed in appsettings.json.
+ ///
public string LoggingLevel { get; set; }
public string TaskBackup { get; set; }
+ ///
+ /// Port the server listens on. Managed in appsettings.json.
+ ///
public int Port { get; set; }
+ ///
+ /// Allows anonymous information to be collected and sent to KavitaStats
+ ///
public bool AllowStatCollection { get; set; }
+ ///
+ /// Enables OPDS connections to be made to the server.
+ ///
+ public bool EnableOpds { get; set; }
}
-}
\ No newline at end of file
+}
diff --git a/API/DTOs/UserDto.cs b/API/DTOs/UserDto.cs
index 052fa226e..d9c232578 100644
--- a/API/DTOs/UserDto.cs
+++ b/API/DTOs/UserDto.cs
@@ -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; }
}
-}
\ No newline at end of file
+}
diff --git a/API/Data/Migrations/20210826203258_userApiKey.Designer.cs b/API/Data/Migrations/20210826203258_userApiKey.Designer.cs
new file mode 100644
index 000000000..ece3e3dec
--- /dev/null
+++ b/API/Data/Migrations/20210826203258_userApiKey.Designer.cs
@@ -0,0 +1,928 @@
+//
+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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AccessFailedCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("ApiKey")
+ .HasColumnType("TEXT");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("TEXT");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("Email")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("EmailConfirmed")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastActive")
+ .HasColumnType("TEXT");
+
+ b.Property("LockoutEnabled")
+ .HasColumnType("INTEGER");
+
+ b.Property("LockoutEnd")
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedEmail")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedUserName")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("PasswordHash")
+ .HasColumnType("TEXT");
+
+ b.Property("PhoneNumber")
+ .HasColumnType("TEXT");
+
+ b.Property("PhoneNumberConfirmed")
+ .HasColumnType("INTEGER");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property("SecurityStamp")
+ .HasColumnType("TEXT");
+
+ b.Property("TwoFactorEnabled")
+ .HasColumnType("INTEGER");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ChapterId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Page")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.Property("VolumeId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.ToTable("AppUserBookmark");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("AutoCloseMenu")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderDarkMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderFontFamily")
+ .HasColumnType("TEXT");
+
+ b.Property("BookReaderFontSize")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderLineSpacing")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderMargin")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderReadingDirection")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderTapToPaginate")
+ .HasColumnType("INTEGER");
+
+ b.Property("PageSplitOption")
+ .HasColumnType("INTEGER");
+
+ b.Property("ReaderMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("ReadingDirection")
+ .HasColumnType("INTEGER");
+
+ b.Property("ScalingOption")
+ .HasColumnType("INTEGER");
+
+ b.Property("SiteDarkMode")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId")
+ .IsUnique();
+
+ b.ToTable("AppUserPreferences");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserProgress", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookScrollId")
+ .HasColumnType("TEXT");
+
+ b.Property("ChapterId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("PagesRead")
+ .HasColumnType("INTEGER");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.Property("VolumeId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.ToTable("AppUserProgresses");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserRating", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Rating")
+ .HasColumnType("INTEGER");
+
+ b.Property("Review")
+ .HasColumnType("TEXT");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.ToTable("AppUserRating");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserRole", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("RoleId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("UserId", "RoleId");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetUserRoles");
+ });
+
+ modelBuilder.Entity("API.Entities.Chapter", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("CoverImage")
+ .HasColumnType("BLOB");
+
+ b.Property("CoverImageLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("IsSpecial")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("Number")
+ .HasColumnType("TEXT");
+
+ b.Property("Pages")
+ .HasColumnType("INTEGER");
+
+ b.Property("Range")
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .HasColumnType("TEXT");
+
+ b.Property("VolumeId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("VolumeId");
+
+ b.ToTable("Chapter");
+ });
+
+ modelBuilder.Entity("API.Entities.CollectionTag", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("CoverImage")
+ .HasColumnType("BLOB");
+
+ b.Property("CoverImageLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("NormalizedTitle")
+ .HasColumnType("TEXT");
+
+ b.Property("Promoted")
+ .HasColumnType("INTEGER");
+
+ b.Property("RowVersion")
+ .HasColumnType("INTEGER");
+
+ b.Property("Summary")
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Id", "Promoted")
+ .IsUnique();
+
+ b.ToTable("CollectionTag");
+ });
+
+ modelBuilder.Entity("API.Entities.FolderPath", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("LastScanned")
+ .HasColumnType("TEXT");
+
+ b.Property("LibraryId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Path")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("LibraryId");
+
+ b.ToTable("FolderPath");
+ });
+
+ modelBuilder.Entity("API.Entities.Library", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("CoverImage")
+ .HasColumnType("TEXT");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.Property("Type")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.ToTable("Library");
+ });
+
+ modelBuilder.Entity("API.Entities.MangaFile", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("ChapterId")
+ .HasColumnType("INTEGER");
+
+ b.Property("FilePath")
+ .HasColumnType("TEXT");
+
+ b.Property("Format")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("Pages")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ChapterId");
+
+ b.ToTable("MangaFile");
+ });
+
+ modelBuilder.Entity("API.Entities.Series", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("CoverImage")
+ .HasColumnType("BLOB");
+
+ b.Property("CoverImageLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("Format")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("LibraryId")
+ .HasColumnType("INTEGER");
+
+ b.Property("LocalizedName")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedName")
+ .HasColumnType("TEXT");
+
+ b.Property("OriginalName")
+ .HasColumnType("TEXT");
+
+ b.Property("Pages")
+ .HasColumnType("INTEGER");
+
+ b.Property("SortName")
+ .HasColumnType("TEXT");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property("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("Key")
+ .HasColumnType("INTEGER");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property("Value")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Key");
+
+ b.ToTable("ServerSetting");
+ });
+
+ modelBuilder.Entity("API.Entities.Volume", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("CoverImage")
+ .HasColumnType("BLOB");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.Property("Number")
+ .HasColumnType("INTEGER");
+
+ b.Property("Pages")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("SeriesId");
+
+ b.ToTable("Volume");
+ });
+
+ modelBuilder.Entity("AppUserLibrary", b =>
+ {
+ b.Property("AppUsersId")
+ .HasColumnType("INTEGER");
+
+ b.Property("LibrariesId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("AppUsersId", "LibrariesId");
+
+ b.HasIndex("LibrariesId");
+
+ b.ToTable("AppUserLibrary");
+ });
+
+ modelBuilder.Entity("CollectionTagSeriesMetadata", b =>
+ {
+ b.Property("CollectionTagsId")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesMetadatasId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("CollectionTagsId", "SeriesMetadatasId");
+
+ b.HasIndex("SeriesMetadatasId");
+
+ b.ToTable("CollectionTagSeriesMetadata");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("ClaimType")
+ .HasColumnType("TEXT");
+
+ b.Property("ClaimValue")
+ .HasColumnType("TEXT");
+
+ b.Property("RoleId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetRoleClaims");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("ClaimType")
+ .HasColumnType("TEXT");
+
+ b.Property("ClaimValue")
+ .HasColumnType("TEXT");
+
+ b.Property("UserId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserClaims");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.Property("LoginProvider")
+ .HasColumnType("TEXT");
+
+ b.Property("ProviderKey")
+ .HasColumnType("TEXT");
+
+ b.Property("ProviderDisplayName")
+ .HasColumnType("TEXT");
+
+ b.Property("UserId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("LoginProvider", "ProviderKey");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserLogins");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("LoginProvider")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.Property("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", b =>
+ {
+ b.HasOne("API.Entities.AppRole", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.HasOne("API.Entities.AppUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.HasOne("API.Entities.AppUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", 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
+ }
+ }
+}
diff --git a/API/Data/Migrations/20210826203258_userApiKey.cs b/API/Data/Migrations/20210826203258_userApiKey.cs
new file mode 100644
index 000000000..5f95a253d
--- /dev/null
+++ b/API/Data/Migrations/20210826203258_userApiKey.cs
@@ -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(
+ name: "ApiKey",
+ table: "AspNetUsers",
+ type: "TEXT",
+ nullable: true);
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "ApiKey",
+ table: "AspNetUsers");
+ }
+ }
+}
diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs
index 2a54ba454..9f763c03b 100644
--- a/API/Data/Migrations/DataContextModelSnapshot.cs
+++ b/API/Data/Migrations/DataContextModelSnapshot.cs
@@ -52,6 +52,9 @@ namespace API.Data.Migrations
b.Property("AccessFailedCount")
.HasColumnType("INTEGER");
+ b.Property("ApiKey")
+ .HasColumnType("TEXT");
+
b.Property("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
@@ -345,7 +348,6 @@ namespace API.Data.Migrations
.HasColumnType("INTEGER");
b.Property("RowVersion")
- .IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property("Summary")
diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs
index 9ccd34212..f264168ae 100644
--- a/API/Data/Seed.cs
+++ b/API/Data/Seed.cs
@@ -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();
}
}
diff --git a/API/Data/UserRepository.cs b/API/Data/UserRepository.cs
index f94972b1b..2e0b9e7f3 100644
--- a/API/Data/UserRepository.cs
+++ b/API/Data/UserRepository.cs
@@ -53,6 +53,19 @@ namespace API.Data
.SingleOrDefaultAsync(x => x.UserName == username);
}
+ ///
+ /// Gets an AppUser by id. Returns back Progress information.
+ ///
+ ///
+ ///
+ public async Task GetUserByIdAsync(int id)
+ {
+ return await _context.Users
+ .Include(u => u.Progresses)
+ .Include(u => u.Bookmarks)
+ .SingleOrDefaultAsync(x => x.Id == id);
+ }
+
public async Task> GetAdminUsersAsync()
{
return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole);
@@ -116,6 +129,12 @@ namespace API.Data
.ToListAsync();
}
+ public async Task GetUserByApiKeyAsync(string apiKey)
+ {
+ return await _context.AppUser
+ .SingleOrDefaultAsync(u => u.ApiKey.Equals(apiKey));
+ }
+
public async Task> GetMembersAsync()
{
diff --git a/API/Entities/AppUser.cs b/API/Entities/AppUser.cs
index eec9d90bf..c4a24e405 100644
--- a/API/Entities/AppUser.cs
+++ b/API/Entities/AppUser.cs
@@ -17,6 +17,11 @@ namespace API.Entities
public ICollection Ratings { get; set; }
public AppUserPreferences UserPreferences { get; set; }
public ICollection Bookmarks { get; set; }
+ ///
+ /// An API Key to interact with external services, like OPDS
+ ///
+ public string ApiKey { get; set; }
+
///
[ConcurrencyCheck]
diff --git a/API/Entities/Enums/ServerSettingKey.cs b/API/Entities/Enums/ServerSettingKey.cs
index 28378d4d1..fab4a7cba 100644
--- a/API/Entities/Enums/ServerSettingKey.cs
+++ b/API/Entities/Enums/ServerSettingKey.cs
@@ -18,6 +18,8 @@ namespace API.Entities.Enums
BackupDirectory = 5,
[Description("AllowStatCollection")]
AllowStatCollection = 6,
-
+ [Description("EnableOpds")]
+ EnableOpds = 7,
+
}
-}
\ No newline at end of file
+}
diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs
index ad784045b..c08555c69 100644
--- a/API/Extensions/ApplicationServiceExtensions.cs
+++ b/API/Extensions/ApplicationServiceExtensions.cs
@@ -34,6 +34,8 @@ namespace API.Extensions
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
services.AddScoped();
diff --git a/API/Helpers/Converters/ServerSettingConverter.cs b/API/Helpers/Converters/ServerSettingConverter.cs
index 261c1bff1..dbd13ab9e 100644
--- a/API/Helpers/Converters/ServerSettingConverter.cs
+++ b/API/Helpers/Converters/ServerSettingConverter.cs
@@ -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;
}
}
-}
\ No newline at end of file
+}
diff --git a/API/Interfaces/IUserRepository.cs b/API/Interfaces/IUserRepository.cs
index 4a5d8c2e3..c58eafdfc 100644
--- a/API/Interfaces/IUserRepository.cs
+++ b/API/Interfaces/IUserRepository.cs
@@ -11,6 +11,7 @@ namespace API.Interfaces
void Update(AppUserPreferences preferences);
public void Delete(AppUser user);
Task GetUserByUsernameAsync(string username);
+ Task GetUserByIdAsync(int id);
Task> GetMembersAsync();
Task> GetAdminUsersAsync();
Task GetUserRating(int seriesId, int userId);
@@ -20,5 +21,6 @@ namespace API.Interfaces
Task> GetBookmarkDtosForVolume(int userId, int volumeId);
Task> GetBookmarkDtosForChapter(int userId, int chapterId);
Task> GetAllBookmarkDtos(int userId);
+ Task GetUserByApiKeyAsync(string apiKey);
}
}
diff --git a/API/Interfaces/Services/IReaderService.cs b/API/Interfaces/Services/IReaderService.cs
new file mode 100644
index 000000000..5bb9baeb1
--- /dev/null
+++ b/API/Interfaces/Services/IReaderService.cs
@@ -0,0 +1,11 @@
+using System.Threading.Tasks;
+using API.DTOs;
+using API.Entities;
+
+namespace API.Interfaces.Services
+{
+ public interface IReaderService
+ {
+ Task SaveReadingProgress(ProgressDto progressDto, AppUser user);
+ }
+}
diff --git a/API/Interfaces/Services/ReaderService.cs b/API/Interfaces/Services/ReaderService.cs
new file mode 100644
index 000000000..f71a10a7a
--- /dev/null
+++ b/API/Interfaces/Services/ReaderService.cs
@@ -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;
+ }
+
+ ///
+ /// Saves progress to DB
+ ///
+ ///
+ ///
+ ///
+ public async Task 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();
+ 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;
+ }
+ }
+}
diff --git a/API/Middleware/BookRedirectMiddleware.cs b/API/Middleware/BookRedirectMiddleware.cs
deleted file mode 100644
index f2e805466..000000000
--- a/API/Middleware/BookRedirectMiddleware.cs
+++ /dev/null
@@ -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 _logger;
-
- public BookRedirectMiddleware(ILogger logger)
- {
- _logger = logger;
- }
-
- public async Task InvokeAsync(HttpContext context, RequestDelegate next)
- {
- _logger.LogDebug("BookRedirect Path: {Path}", context.Request.Path.ToString());
- await next.Invoke(context);
- }
- }
-}
\ No newline at end of file
diff --git a/API/Program.cs b/API/Program.cs
index a3b0e6773..6d91f6d98 100644
--- a/API/Program.cs
+++ b/API/Program.cs
@@ -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)
{
diff --git a/API/Services/Clients/StatsApiClient.cs b/API/Services/Clients/StatsApiClient.cs
index d9b09a42f..52d1c9fcf 100644
--- a/API/Services/Clients/StatsApiClient.cs
+++ b/API/Services/Clients/StatsApiClient.cs
@@ -19,6 +19,7 @@ namespace API.Services.Clients
{
_client = client;
_logger = logger;
+ _client.Timeout = TimeSpan.FromSeconds(30);
}
public async Task SendDataToStatsServer(UsageStatisticsDto data)
diff --git a/API/Services/DirectoryService.cs b/API/Services/DirectoryService.cs
index b21cdd475..6d545ea21 100644
--- a/API/Services/DirectoryService.cs
+++ b/API/Services/DirectoryService.cs
@@ -432,5 +432,60 @@ namespace API.Services
}
}
}
+
+ ///
+ /// Returns the human-readable file size for an arbitrary, 64-bit file size
+ /// The default format is "0.## XB", e.g. "4.2 KB" or "1.43 GB"
+ ///
+ /// https://www.somacon.com/p576.php
+ ///
+ ///
+ 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;
+ }
}
}
diff --git a/API/Services/DownloadService.cs b/API/Services/DownloadService.cs
new file mode 100644
index 000000000..6013d5c84
--- /dev/null
+++ b/API/Services/DownloadService.cs
@@ -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 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;
+ }
+
+ ///
+ /// Downloads the first file in the file enumerable for download
+ ///
+ ///
+ ///
+ public async Task<(byte[], string, string)> GetFirstFileDownload(IEnumerable 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;
+ }
+ }
+}
diff --git a/Kavita.Common/HashUtil.cs b/Kavita.Common/HashUtil.cs
index 0ae9efab8..f0098f8eb 100644
--- a/Kavita.Common/HashUtil.cs
+++ b/Kavita.Common/HashUtil.cs
@@ -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);
}
+
+ ///
+ /// Generates a unique API key to this server instance
+ ///
+ ///
+ public static string ApiKey()
+ {
+ var id = Guid.NewGuid();
+ if (id.Equals(Guid.Empty))
+ {
+ id = Guid.NewGuid();
+ }
+
+ return id.ToString();
+ }
}
}
diff --git a/Kavita.sln.DotSettings b/Kavita.sln.DotSettings
index 471c3f677..322251617 100644
--- a/Kavita.sln.DotSettings
+++ b/Kavita.sln.DotSettings
@@ -1,4 +1,5 @@
ExplicitlyExcludedTrue
- True
\ No newline at end of file
+ True
+ True
\ No newline at end of file
diff --git a/UI/Web/src/app/_models/user.ts b/UI/Web/src/app/_models/user.ts
index 56caf5c33..74b7913a0 100644
--- a/UI/Web/src/app/_models/user.ts
+++ b/UI/Web/src/app/_models/user.ts
@@ -6,4 +6,5 @@ export interface User {
token: string;
roles: string[];
preferences: Preferences;
+ apiKey: string;
}
\ No newline at end of file
diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts
index ab83d8b88..bc6c762b4 100644
--- a/UI/Web/src/app/_services/account.service.ts
+++ b/UI/Web/src/app/_services/account.service.ts
@@ -130,4 +130,21 @@ export class AccountService implements OnDestroy {
return undefined;
}
+ resetApiKey() {
+ return this.httpClient.post(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;
+ }));
+
+
+ }
+
}
diff --git a/UI/Web/src/app/admin/_models/server-settings.ts b/UI/Web/src/app/admin/_models/server-settings.ts
index 6d4150899..58f43a2a0 100644
--- a/UI/Web/src/app/admin/_models/server-settings.ts
+++ b/UI/Web/src/app/admin/_models/server-settings.ts
@@ -5,4 +5,5 @@ export interface ServerSettings {
loggingLevel: string;
port: number;
allowStatCollection: boolean;
+ enableOpds: boolean;
}
diff --git a/UI/Web/src/app/admin/admin.module.ts b/UI/Web/src/app/admin/admin.module.ts
index fb7d5d297..73b3717e3 100644
--- a/UI/Web/src/app/admin/admin.module.ts
+++ b/UI/Web/src/app/admin/admin.module.ts
@@ -33,7 +33,7 @@ import { ChangelogComponent } from './changelog/changelog.component';
FilterPipe,
EditRbsModalComponent,
ManageSystemComponent,
- ChangelogComponent
+ ChangelogComponent,
],
imports: [
CommonModule,
diff --git a/UI/Web/src/app/admin/manage-settings/manage-settings.component.html b/UI/Web/src/app/admin/manage-settings/manage-settings.component.html
index 62cd28a89..796e9fccf 100644
--- a/UI/Web/src/app/admin/manage-settings/manage-settings.component.html
+++ b/UI/Web/src/app/admin/manage-settings/manage-settings.component.html
@@ -28,11 +28,20 @@
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.