Polish for Release (#2314)

This commit is contained in:
Joe Milazzo 2023-10-15 13:39:11 -05:00 committed by GitHub
parent fe4af4b648
commit 59b950c4bd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
54 changed files with 1162 additions and 1056 deletions

View file

@ -68,22 +68,22 @@
<PackageReference Include="Hangfire.Storage.SQLite" Version="0.3.4" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.53" />
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="7.0.11" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="7.0.11" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="7.0.11" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="7.0.12" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="7.0.12" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="7.0.12" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.11">
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.12">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.12" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="2.3.2" />
<PackageReference Include="MimeTypeMapOfficial" Version="1.0.17" />
<PackageReference Include="Nager.ArticleNumber" Version="1.0.7" />
<PackageReference Include="NetVips" Version="2.3.1" />
<PackageReference Include="NetVips.Native" Version="8.14.5" />
<PackageReference Include="NReco.Logging.File" Version="1.1.6" />
<PackageReference Include="NReco.Logging.File" Version="1.1.7" />
<PackageReference Include="Serilog" Version="3.0.1" />
<PackageReference Include="Serilog.AspNetCore" Version="7.0.0" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="3.2.0-dev-00752" />
@ -93,15 +93,15 @@
<PackageReference Include="Serilog.Sinks.Console" Version="4.1.0" />
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
<PackageReference Include="Serilog.Sinks.SignalR.Core" Version="0.1.2" />
<PackageReference Include="SharpCompress" Version="0.34.0" />
<PackageReference Include="SharpCompress" Version="0.34.1" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.0.2" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.10.0.77988">
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.12.0.78982">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="7.0.11" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.0.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.0.2" />
<PackageReference Include="System.IO.Abstractions" Version="19.2.69" />
<PackageReference Include="System.Drawing.Common" Version="7.0.0" />
<PackageReference Include="VersOne.Epub" Version="3.3.1" />

View file

@ -306,12 +306,13 @@ public class AccountController : BaseApiController
/// <summary>
/// Resets the API Key assigned with a user
/// </summary>
/// <remarks>This will log unauthorized requests to Security log</remarks>
/// <returns></returns>
[HttpPost("reset-api-key")]
public async Task<ActionResult<string>> ResetApiKey()
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (user == null) return Unauthorized();
if (user == null) throw new KavitaUnauthenticatedUserException();
user.ApiKey = HashUtil.ApiKey();

View file

@ -98,8 +98,11 @@ public class LibraryController : BaseApiController
admin.Libraries.Add(library);
}
var userIds = admins.Select(u => u.Id).Append(User.GetUserId()).ToList();
if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-library"));
_logger.LogInformation("Created a new library: {LibraryName}", library.Name);
// Assign all the necessary users with this library side nav
var userIds = admins.Select(u => u.Id).Append(User.GetUserId()).ToList();
var userNeedingNewLibrary = (await _unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.SideNavStreams))
.Where(u => userIds.Contains(u.Id))
.ToList();
@ -119,10 +122,8 @@ public class LibraryController : BaseApiController
_unitOfWork.UserRepository.Update(user);
}
if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-library"));
_logger.LogInformation("Created a new library: {LibraryName}", library.Name);
await _libraryWatcher.RestartWatching();
_taskScheduler.ScanLibrary(library.Id);
await _eventHub.SendMessageAsync(MessageFactory.LibraryModified,

View file

@ -216,19 +216,41 @@ public class OpdsController : BaseApiController
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/collections"),
}
});
feed.Entries.Add(new FeedEntry()
if ((_unitOfWork.AppUserSmartFilterRepository.GetAllDtosByUserId(userId)).Any())
{
Id = "allSmartFilters",
Title = await _localizationService.Translate(userId, "smart-filters"),
Content = new FeedEntryContent()
feed.Entries.Add(new FeedEntry()
{
Text = await _localizationService.Translate(userId, "browse-smart-filters")
},
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/smart-filters"),
}
});
Id = "allSmartFilters",
Title = await _localizationService.Translate(userId, "smart-filters"),
Content = new FeedEntryContent()
{
Text = await _localizationService.Translate(userId, "browse-smart-filters")
},
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/smart-filters"),
}
});
}
// if ((await _unitOfWork.AppUserExternalSourceRepository.GetExternalSources(userId)).Any())
// {
// feed.Entries.Add(new FeedEntry()
// {
// Id = "allExternalSources",
// Title = await _localizationService.Translate(userId, "external-sources"),
// Content = new FeedEntryContent()
// {
// Text = await _localizationService.Translate(userId, "browse-external-sources")
// },
// Links = new List<FeedLink>()
// {
// CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/external-sources"),
// }
// });
// }
return CreateXmlResult(SerializeXml(feed));
}
@ -306,6 +328,38 @@ public class OpdsController : BaseApiController
return CreateXmlResult(SerializeXml(feed));
}
[HttpGet("{apiKey}/external-sources")]
[Produces("application/xml")]
public async Task<IActionResult> GetExternalSources(string apiKey)
{
// NOTE: This doesn't seem possible in OPDS v2.1 due to the resulting stream using relative links and most apps resolve against source url. Even using full paths doesn't work
var userId = await GetUser(apiKey);
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest(await _localizationService.Translate(userId, "opds-disabled"));
var (baseUrl, prefix) = await GetPrefix();
var externalSources = await _unitOfWork.AppUserExternalSourceRepository.GetExternalSources(userId);
var feed = CreateFeed(await _localizationService.Translate(userId, "external-sources"), $"{prefix}{apiKey}/external-sources", apiKey, prefix);
SetFeedId(feed, "externalSources");
foreach (var externalSource in externalSources)
{
var opdsUrl = $"{externalSource.Host}api/opds/{externalSource.ApiKey}";
feed.Entries.Add(new FeedEntry()
{
Id = externalSource.Id.ToString(),
Title = externalSource.Name,
Summary = externalSource.Host,
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.Start, FeedLinkType.AtomNavigation, opdsUrl),
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{opdsUrl}/favicon")
}
});
}
return CreateXmlResult(SerializeXml(feed));
}
[HttpGet("{apiKey}/libraries")]
[Produces("application/xml")]
@ -318,12 +372,16 @@ public class OpdsController : BaseApiController
var libraries = await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId);
var feed = CreateFeed(await _localizationService.Translate(userId, "libraries"), $"{prefix}{apiKey}/libraries", apiKey, prefix);
SetFeedId(feed, "libraries");
foreach (var library in libraries)
// Ensure libraries follow SideNav order
var userSideNavStreams = await _unitOfWork.UserRepository.GetSideNavStreams(userId, false);
foreach (var sideNavStream in userSideNavStreams.Where(s => s.StreamType == SideNavStreamType.Library))
{
var library = sideNavStream.Library;
feed.Entries.Add(new FeedEntry()
{
Id = library.Id.ToString(),
Title = library.Name,
Id = library!.Id.ToString(),
Title = library.Name!,
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/libraries/{library.Id}"),

View file

@ -28,6 +28,7 @@ public class PluginController : BaseApiController
/// Authenticate with the Server given an apiKey. This will log you in by returning the user object and the JWT token.
/// </summary>
/// <remarks>This API is not fully built out and may require more information in later releases</remarks>
/// <remarks>This will log unauthorized requests to Security log</remarks>
/// <param name="apiKey">API key which will be used to authenticate and return a valid user token back</param>
/// <param name="pluginName">Name of the Plugin</param>
/// <returns></returns>
@ -37,8 +38,19 @@ public class PluginController : BaseApiController
{
// NOTE: In order to log information about plugins, we need some Plugin Description information for each request
// Should log into access table so we can tell the user
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
var userAgent = HttpContext.Request.Headers["User-Agent"];
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
if (userId <= 0) return Unauthorized();
if (userId <= 0)
{
_logger.LogInformation("A Plugin ({PluginName}) tried to authenticate with an apiKey that doesn't match. Information {Information}", pluginName, new
{
IpAddress = ipAddress,
UserAgent = userAgent,
ApiKey = apiKey
});
throw new KavitaUnauthenticatedUserException();
}
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
_logger.LogInformation("Plugin {PluginName} has authenticated with {UserName} ({UserId})'s API Key", pluginName, user!.UserName, userId);
return new UserDto
@ -54,6 +66,7 @@ public class PluginController : BaseApiController
/// <summary>
/// Returns the version of the Kavita install
/// </summary>
/// <remarks>This will log unauthorized requests to Security log</remarks>
/// <param name="apiKey">Required for authenticating to get result</param>
/// <returns></returns>
[AllowAnonymous]
@ -61,7 +74,7 @@ public class PluginController : BaseApiController
public async Task<ActionResult<string>> GetVersion([Required] string apiKey)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
if (userId <= 0) return Unauthorized();
if (userId <= 0) throw new KavitaUnauthenticatedUserException();
return Ok((await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion)).Value);
}
}

View file

@ -111,6 +111,7 @@ public class ScrobblingController : BaseApiController
pagination ??= UserParams.Default;
var events = await _unitOfWork.ScrobbleRepository.GetUserEvents(User.GetUserId(), filter, pagination);
Response.AddPaginationHeader(events.CurrentPage, events.PageSize, events.TotalCount, events.TotalPages);
return Ok(events);
}

View file

@ -183,4 +183,11 @@ public class StreamController : BaseApiController
await _streamService.UpdateSideNavStreamPosition(User.GetUserId(), dto);
return Ok();
}
[HttpPost("bulk-sidenav-stream-visibility")]
public async Task<ActionResult> BulkUpdateSideNavStream(BulkUpdateSideNavStreamVisibilityDto dto)
{
await _streamService.UpdateSideNavStreamBulk(User.GetUserId(), dto);
return Ok();
}
}

View file

@ -0,0 +1,9 @@
using System.Collections.Generic;
namespace API.DTOs.SideNav;
public class BulkUpdateSideNavStreamVisibilityDto
{
public required IList<int> Ids { get; set; }
public required bool Visibility { get; set; }
}

View file

@ -0,0 +1,35 @@
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace API.Data.ManualMigrations;
/// <summary>
/// v0.7.8.6 explicitly introduced DashboardStream and v0.7.8.9 changed the default seed titles to use locale strings.
/// This migration will target nightly releases and should be removed before v0.7.9 release.
/// </summary>
public static class MigrateDashboardStreamNamesToLocaleKeys
{
public static async Task Migrate(IUnitOfWork unitOfWork, DataContext dataContext, ILogger<Program> logger)
{
var allStreams = await unitOfWork.UserRepository.GetAllDashboardStreams();
if (!allStreams.Any(s => s.Name.Equals("On Deck"))) return;
logger.LogCritical("Running MigrateDashboardStreamNamesToLocaleKeys migration. Please be patient, this may take some time depending on the size of your library. Do not abort, this can break your Database");
foreach (var stream in allStreams.Where(s => s.IsProvided))
{
stream.Name = stream.Name switch
{
"On Deck" => "on-deck",
"Recently Updated" => "recently-updated",
"Newly Added" => "newly-added",
"More In" => "more-in-genre",
_ => stream.Name
};
unitOfWork.UserRepository.Update(stream);
}
await unitOfWork.CommitAsync();
logger.LogInformation("MigrateDashboardStreamNamesToLocaleKeys migration finished");
}
}

View file

@ -84,6 +84,7 @@ public interface IUserRepository
Task<IList<ScrobbleHoldDto>> GetHolds(int userId);
Task<string> GetLocale(int userId);
Task<IList<DashboardStreamDto>> GetDashboardStreams(int userId, bool visibleOnly = false);
Task<IList<AppUserDashboardStream>> GetAllDashboardStreams();
Task<AppUserDashboardStream?> GetDashboardStream(int streamId);
Task<IList<AppUserDashboardStream>> GetDashboardStreamWithFilter(int filterId);
Task<IList<SideNavStreamDto>> GetSideNavStreams(int userId, bool visibleOnly = false);
@ -91,6 +92,7 @@ public interface IUserRepository
Task<IList<AppUserSideNavStream>> GetSideNavStreamWithFilter(int filterId);
Task<IList<AppUserSideNavStream>> GetSideNavStreamsByLibraryId(int libraryId);
Task<IList<AppUserSideNavStream>> GetSideNavStreamWithExternalSource(int externalSourceId);
Task<IList<AppUserSideNavStream>> GetDashboardStreamsByIds(IList<int> streamIds);
}
public class UserRepository : IUserRepository
@ -356,6 +358,13 @@ public class UserRepository : IUserRepository
.ToListAsync();
}
public async Task<IList<AppUserDashboardStream>> GetAllDashboardStreams()
{
return await _context.AppUserDashboardStream
.OrderBy(d => d.Order)
.ToListAsync();
}
public async Task<AppUserDashboardStream?> GetDashboardStream(int streamId)
{
return await _context.AppUserDashboardStream
@ -453,6 +462,13 @@ public class UserRepository : IUserRepository
.ToListAsync();
}
public async Task<IList<AppUserSideNavStream>> GetDashboardStreamsByIds(IList<int> streamIds)
{
return await _context.AppUserSideNavStream
.Where(d => streamIds.Contains(d.Id))
.ToListAsync();
}
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
{

View file

@ -151,6 +151,8 @@
"collections": "All Collections",
"browse-collections": "Browse by Collections",
"smart-filters": "Smart Filters",
"external-sources": "External Sources",
"browse-external-sources": "Browse External Sources",
"browse-smart-filters": "Browse by Smart Filters",
"reading-list-restricted": "Reading list does not exist or you don't have access",
"query-required": "You must pass a query parameter",

View file

@ -0,0 +1,69 @@
using System;
using System.IO;
using System.Net;
using System.Text.Json;
using System.Threading.Tasks;
using API.Errors;
using Kavita.Common;
using Microsoft.AspNetCore.Http;
using Serilog;
using ILogger = Serilog.ILogger;
namespace API.Middleware;
public class SecurityEventMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger _logger;
public SecurityEventMiddleware(RequestDelegate next)
{
_next = next;
_logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.File(Path.Join(Directory.GetCurrentDirectory(), "config/logs/", "security.log"), rollingInterval: RollingInterval.Day)
.CreateLogger();
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (KavitaUnauthenticatedUserException ex)
{
var ipAddress = context.Connection.RemoteIpAddress?.ToString();
var requestMethod = context.Request.Method;
var requestPath = context.Request.Path;
var userAgent = context.Request.Headers["User-Agent"];
var securityEvent = new
{
IpAddress = ipAddress,
RequestMethod = requestMethod,
RequestPath = requestPath,
UserAgent = userAgent,
CreatedAt = DateTime.Now,
CreatedAtUtc = DateTime.UtcNow,
};
_logger.Information("Unauthorized User attempting to access API. {@Event}", securityEvent);
context.Response.ContentType = "application/json";
context.Response.StatusCode = (int) HttpStatusCode.Unauthorized;
const string errorMessage = "Unauthorized";
var response = new ApiException(context.Response.StatusCode, errorMessage, ex.StackTrace);
var options = new JsonSerializerOptions
{
PropertyNamingPolicy =
JsonNamingPolicy.CamelCase
};
var json = JsonSerializer.Serialize(response, options);
await context.Response.WriteAsync(json);
}
}
}

View file

@ -24,6 +24,7 @@ public interface IStreamService
Task<DashboardStreamDto> CreateDashboardStreamFromSmartFilter(int userId, int smartFilterId);
Task UpdateDashboardStream(int userId, DashboardStreamDto dto);
Task UpdateDashboardStreamPosition(int userId, UpdateStreamPositionDto dto);
Task UpdateSideNavStreamBulk(int userId, BulkUpdateSideNavStreamVisibilityDto dto);
Task<SideNavStreamDto> CreateSideNavStreamFromSmartFilter(int userId, int smartFilterId);
Task<SideNavStreamDto> CreateSideNavStreamFromExternalSource(int userId, int externalSourceId);
Task UpdateSideNavStream(int userId, SideNavStreamDto dto);
@ -31,6 +32,7 @@ public interface IStreamService
Task<ExternalSourceDto> CreateExternalSource(int userId, ExternalSourceDto dto);
Task<ExternalSourceDto> UpdateExternalSource(int userId, ExternalSourceDto dto);
Task DeleteExternalSource(int userId, int externalSourceId);
}
public class StreamService : IStreamService
@ -134,6 +136,20 @@ public class StreamService : IStreamService
user.Id);
}
public async Task UpdateSideNavStreamBulk(int userId, BulkUpdateSideNavStreamVisibilityDto dto)
{
var streams = await _unitOfWork.UserRepository.GetDashboardStreamsByIds(dto.Ids);
foreach (var stream in streams)
{
stream.Visible = dto.Visibility;
_unitOfWork.UserRepository.Update(stream);
}
await _unitOfWork.CommitAsync();
await _eventHub.SendMessageToAsync(MessageFactory.SideNavUpdate, MessageFactory.SideNavUpdateEvent(userId),
userId);
}
public async Task<SideNavStreamDto> CreateSideNavStreamFromSmartFilter(int userId, int smartFilterId)
{
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.SideNavStreams);

View file

@ -252,6 +252,7 @@ public class Startup
// v0.7.9
await MigrateUserLibrarySideNavStream.Migrate(unitOfWork, dataContext, logger);
await MigrateDashboardStreamNamesToLocaleKeys.Migrate(unitOfWork, dataContext, logger);
// Update the version in the DB after all migrations are run
var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion);
@ -269,9 +270,9 @@ public class Startup
logger.LogCritical(ex, "An error occurred during migration");
}
app.UseMiddleware<ExceptionMiddleware>();
app.UseMiddleware<SecurityEventMiddleware>();
if (env.IsDevelopment())
{