Implemented download log files (not in service). Refactored backupservice to handle log file splitting. Improved a few interfaces and added some unit tests around them.

This commit is contained in:
Joseph Milazzo 2021-02-24 11:59:16 -06:00
parent 30352403cf
commit bbb4240e20
22 changed files with 292 additions and 46 deletions

1
.gitignore vendored
View File

@ -451,3 +451,4 @@ appsettings.json
cache/ cache/
/API/wwwroot/ /API/wwwroot/
/API/cache/ /API/cache/
/API/temp/

View File

@ -0,0 +1,46 @@
using API.Interfaces;
using API.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using NSubstitute;
namespace API.Tests.Services
{
public class BackupServiceTests
{
private readonly DirectoryService _directoryService;
private readonly BackupService _backupService;
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
private readonly ILogger<DirectoryService> _directoryLogger = Substitute.For<ILogger<DirectoryService>>();
private readonly ILogger<BackupService> _logger = Substitute.For<ILogger<BackupService>>();
private readonly IConfiguration _config;
// public BackupServiceTests()
// {
// var inMemorySettings = new Dictionary<string, string> {
// {"Logging:File:MaxRollingFiles", "0"},
// {"Logging:File:Path", "file.log"},
// };
//
// _config = new ConfigurationBuilder()
// .AddInMemoryCollection(inMemorySettings)
// .Build();
//
// //_config.GetMaxRollingFiles().Returns(0);
// //_config.GetLoggingFileName().Returns("file.log");
// //var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/BackupService/");
// //Directory.GetCurrentDirectory().Returns(testDirectory);
//
// _directoryService = new DirectoryService(_directoryLogger);
// _backupService = new BackupService(_unitOfWork, _logger, _directoryService, _config);
// }
//
// [Fact]
// public void Test()
// {
// _backupService.BackupDatabase();
// }
}
}

View File

@ -48,7 +48,7 @@ namespace API.Tests.Services
// var cacheService = Substitute.ForPartsOf<CacheService>(); // var cacheService = Substitute.ForPartsOf<CacheService>();
// cacheService.Configure().CacheDirectoryIsAccessible().Returns(true); // cacheService.Configure().CacheDirectoryIsAccessible().Returns(true);
// cacheService.Configure().GetVolumeCachePath(1, volume.Files.ElementAt(0)).Returns("cache/1/"); // cacheService.Configure().GetVolumeCachePath(1, volume.Files.ElementAt(0)).Returns("cache/1/");
// _directoryService.Configure().GetFiles("cache/1/").Returns(new string[] {"pexels-photo-6551949.jpg"}); // _directoryService.Configure().GetFilesWithExtension("cache/1/").Returns(new string[] {"pexels-photo-6551949.jpg"});
// Assert.Equal(expected, _cacheService.GetCachedPagePath(volume, pageNum)); // Assert.Equal(expected, _cacheService.GetCachedPagePath(volume, pageNum));
Assert.True(true); Assert.True(true);
} }

View File

@ -1,4 +1,6 @@
using API.Services; using System.IO;
using System.Linq;
using API.Services;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using NSubstitute; using NSubstitute;
using Xunit; using Xunit;
@ -17,14 +19,59 @@ namespace API.Tests.Services
} }
[Fact] [Fact]
public void GetFiles_Test() public void GetFiles_WithCustomRegex_ShouldPass_Test()
{ {
//_directoryService.GetFiles() var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/DirectoryService/regex");
var files = _directoryService.GetFiles(testDirectory, @"file\d*.txt");
Assert.Equal(2, files.Count());
} }
[Fact] [Fact]
public void ListDirectory_Test() public void GetFiles_TopLevel_ShouldBeEmpty_Test()
{ {
var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/DirectoryService");
var files = _directoryService.GetFiles(testDirectory);
Assert.Empty(files);
}
[Fact]
public void GetFilesWithExtensions_ShouldBeEmpty_Test()
{
var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/DirectoryService/extensions");
var files = _directoryService.GetFiles(testDirectory, "*.txt");
Assert.Empty(files);
}
[Fact]
public void GetFilesWithExtensions_Test()
{
var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/DirectoryService/extension");
var files = _directoryService.GetFiles(testDirectory, ".cbz|.rar");
Assert.Equal(3, files.Count());
}
[Fact]
public void GetFilesWithExtensions_BadDirectory_ShouldBeEmpty_Test()
{
var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/DirectoryService/doesntexist");
var files = _directoryService.GetFiles(testDirectory, ".cbz|.rar");
Assert.Empty(files);
}
[Fact]
public void ListDirectory_SubDirectory_Test()
{
var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/DirectoryService/");
var dirs = _directoryService.ListDirectory(testDirectory);
Assert.Contains(dirs, s => s.Contains("regex"));
}
[Fact]
public void ListDirectory_NoSubDirectory_Test()
{
var dirs = _directoryService.ListDirectory("");
Assert.DoesNotContain(dirs, s => s.Contains("regex"));
} }
} }

View File

@ -142,7 +142,7 @@ namespace API.Controllers
public async Task<ActionResult> Bookmark(BookmarkDto bookmarkDto) public async Task<ActionResult> Bookmark(BookmarkDto bookmarkDto)
{ {
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
_logger.LogInformation("Saving {UserName} progress for Chapter {ChapterId} to page {PageNum}", user.UserName, bookmarkDto.ChapterId, bookmarkDto.PageNum); _logger.LogDebug("Saving {UserName} progress for Chapter {ChapterId} to page {PageNum}", user.UserName, bookmarkDto.ChapterId, bookmarkDto.PageNum);
// Don't let user bookmark past total pages. // Don't let user bookmark past total pages.
var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(bookmarkDto.ChapterId); var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(bookmarkDto.ChapterId);

View File

@ -1,6 +1,11 @@
using API.Extensions; using System;
using System.IO;
using System.Threading.Tasks;
using API.Extensions;
using API.Interfaces.Services;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -11,11 +16,16 @@ namespace API.Controllers
{ {
private readonly IHostApplicationLifetime _applicationLifetime; private readonly IHostApplicationLifetime _applicationLifetime;
private readonly ILogger<ServerController> _logger; private readonly ILogger<ServerController> _logger;
private readonly IConfiguration _config;
private readonly IDirectoryService _directoryService;
public ServerController(IHostApplicationLifetime applicationLifetime, ILogger<ServerController> logger) public ServerController(IHostApplicationLifetime applicationLifetime, ILogger<ServerController> logger, IConfiguration config,
IDirectoryService directoryService)
{ {
_applicationLifetime = applicationLifetime; _applicationLifetime = applicationLifetime;
_logger = logger; _logger = logger;
_config = config;
_directoryService = directoryService;
} }
[HttpPost("restart")] [HttpPost("restart")]
@ -26,5 +36,44 @@ namespace API.Controllers
_applicationLifetime.StopApplication(); _applicationLifetime.StopApplication();
return Ok(); return Ok();
} }
[HttpGet("logs")]
public async Task<ActionResult> GetLogs()
{
// TODO: Zip up the log files
var maxRollingFiles = int.Parse(_config.GetSection("Logging").GetSection("File").GetSection("MaxRollingFiles").Value);
var loggingSection = _config.GetSection("Logging").GetSection("File").GetSection("Path").Value;
var multipleFileRegex = maxRollingFiles > 0 ? @"\d*" : string.Empty;
FileInfo fi = new FileInfo(loggingSection);
var files = _directoryService.GetFilesWithExtension(Directory.GetCurrentDirectory(), $@"{fi.Name}{multipleFileRegex}\.log");
Console.WriteLine(files);
var logFile = Path.Join(Directory.GetCurrentDirectory(), loggingSection);
_logger.LogInformation("Fetching download of logs: {LogFile}", logFile);
// First, copy the file to temp
var originalFile = new FileInfo(logFile);
var tempDirectory = Path.Join(Directory.GetCurrentDirectory(), "temp");
_directoryService.ExistOrCreate(tempDirectory);
var tempLocation = Path.Join(tempDirectory, originalFile.Name);
originalFile.CopyTo(tempLocation); // TODO: Make this unique based on date
// Read into memory
await using var memory = new MemoryStream();
// We need to copy it else it will throw an exception
await using (var stream = new FileStream(tempLocation, FileMode.Open, FileAccess.Read))
{
await stream.CopyToAsync(memory);
}
memory.Position = 0;
// Delete temp
(new FileInfo(tempLocation)).Delete();
return File(memory, "text/plain", Path.GetFileName(logFile));
}
} }
} }

View File

@ -16,8 +16,8 @@ namespace API.Data
{ {
ChangeTracker.Tracked += OnEntityTracked; ChangeTracker.Tracked += OnEntityTracked;
ChangeTracker.StateChanged += OnEntityStateChanged; ChangeTracker.StateChanged += OnEntityStateChanged;
} }
public DbSet<Library> Library { get; set; } public DbSet<Library> Library { get; set; }
public DbSet<Series> Series { get; set; } public DbSet<Series> Series { get; set; }
@ -34,6 +34,7 @@ namespace API.Data
{ {
base.OnModelCreating(builder); base.OnModelCreating(builder);
builder.Entity<AppUser>() builder.Entity<AppUser>()
.HasMany(ur => ur.UserRoles) .HasMany(ur => ur.UserRoles)
.WithOne(u => u.User) .WithOne(u => u.User)

View File

@ -32,7 +32,10 @@ namespace API.Extensions
services.AddDbContext<DataContext>(options => services.AddDbContext<DataContext>(options =>
{ {
options.UseSqlite(config.GetConnectionString("DefaultConnection")); options.UseSqlite(config.GetConnectionString("DefaultConnection"), builder =>
{
//builder.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery);
});
}); });
services.AddLogging(loggingBuilder => services.AddLogging(loggingBuilder =>

View File

@ -0,0 +1,16 @@
using Microsoft.Extensions.Configuration;
namespace API.Extensions
{
public static class ConfigurationExtensions
{
public static int GetMaxRollingFiles(this IConfiguration config)
{
return int.Parse(config.GetSection("Logging").GetSection("File").GetSection("MaxRollingFiles").Value);
}
public static string GetLoggingFileName(this IConfiguration config)
{
return config.GetSection("Logging").GetSection("File").GetSection("Path").Value;
}
}
}

View File

@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.DTOs; using API.DTOs;
@ -20,7 +21,7 @@ namespace API.Interfaces.Services
/// <param name="path"></param> /// <param name="path"></param>
/// <param name="searchPatternExpression"></param> /// <param name="searchPatternExpression"></param>
/// <returns></returns> /// <returns></returns>
string[] GetFiles(string path, string searchPatternExpression = ""); string[] GetFilesWithExtension(string path, string searchPatternExpression = "");
/// <summary> /// <summary>
/// Returns true if the path exists and is a directory. If path does not exist, this will create it. Returns false in all fail cases. /// Returns true if the path exists and is a directory. If path does not exist, this will create it. Returns false in all fail cases.
/// </summary> /// </summary>
@ -28,6 +29,21 @@ namespace API.Interfaces.Services
/// <returns></returns> /// <returns></returns>
bool ExistOrCreate(string directoryPath); bool ExistOrCreate(string directoryPath);
/// <summary>
/// Deletes all files within the directory, then the directory itself.
/// </summary>
/// <param name="directoryPath"></param>
void ClearAndDeleteDirectory(string directoryPath); void ClearAndDeleteDirectory(string directoryPath);
/// <summary>
/// Deletes all files within the directory.
/// </summary>
/// <param name="directoryPath"></param>
/// <returns></returns>
void ClearDirectory(string directoryPath);
bool CopyFilesToDirectory(IEnumerable<string> filePaths, string directoryPath);
IEnumerable<string> GetFiles(string path, string searchPatternExpression = "",
SearchOption searchOption = SearchOption.TopDirectoryOnly);
} }
} }

View File

@ -3,11 +3,8 @@ using System.Diagnostics;
using System.IO; using System.IO;
using System.IO.Compression; using System.IO.Compression;
using System.Linq; using System.Linq;
using System.Xml;
using System.Xml.Linq;
using System.Xml.Serialization; using System.Xml.Serialization;
using API.Extensions; using API.Extensions;
using API.Interfaces;
using API.Interfaces.Services; using API.Interfaces.Services;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using NetVips; using NetVips;
@ -20,7 +17,7 @@ namespace API.Services
public class ArchiveService : IArchiveService public class ArchiveService : IArchiveService
{ {
private readonly ILogger<ArchiveService> _logger; private readonly ILogger<ArchiveService> _logger;
private const int ThumbnailWidth = 320; private const int ThumbnailWidth = 320; // 153w x 230h TODO: Look into optimizing the images to be smaller
public ArchiveService(ILogger<ArchiveService> logger) public ArchiveService(ILogger<ArchiveService> logger)
{ {
@ -94,7 +91,7 @@ namespace API.Services
{ {
using var stream = entry.Open(); using var stream = entry.Open();
using var ms = new MemoryStream(); using var ms = new MemoryStream();
stream.CopyTo(ms); stream.CopyTo(ms); // TODO: Check if we can use CopyToAsync here
var data = ms.ToArray(); var data = ms.ToArray();
return data; return data;

View File

@ -5,8 +5,10 @@ using System.IO.Compression;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Entities.Enums; using API.Entities.Enums;
using API.Extensions;
using API.Interfaces; using API.Interfaces;
using API.Interfaces.Services; using API.Interfaces.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace API.Services namespace API.Services
@ -18,22 +20,36 @@ namespace API.Services
private readonly IDirectoryService _directoryService; private readonly IDirectoryService _directoryService;
private readonly string _tempDirectory = Path.Join(Directory.GetCurrentDirectory(), "temp"); private readonly string _tempDirectory = Path.Join(Directory.GetCurrentDirectory(), "temp");
private readonly IList<string> _backupFiles = new List<string>() private readonly IList<string> _backupFiles;
public BackupService(IUnitOfWork unitOfWork, ILogger<BackupService> logger, IDirectoryService directoryService, IConfiguration config)
{
_unitOfWork = unitOfWork;
_logger = logger;
_directoryService = directoryService;
var maxRollingFiles = config.GetMaxRollingFiles();
var loggingSection = config.GetLoggingFileName();
var multipleFileRegex = maxRollingFiles > 0 ? @"\d*" : string.Empty;
var fi = new FileInfo(loggingSection);
var files = maxRollingFiles > 0
? _directoryService.GetFiles(Directory.GetCurrentDirectory(), $@"{fi.Name}{multipleFileRegex}\.log")
: new string[] {"kavita.log"};
_backupFiles = new List<string>()
{ {
"appsettings.json", "appsettings.json",
"Hangfire.db", "Hangfire.db",
"Hangfire-log.db", "Hangfire-log.db",
"kavita.db", "kavita.db",
"kavita.db-shm", "kavita.db-shm", // This wont always be there
"kavita.db-wal", "kavita.db-wal", // This wont always be there
"kavita.log",
}; };
foreach (var file in files.Select(f => (new FileInfo(f)).Name).ToList())
public BackupService(IUnitOfWork unitOfWork, ILogger<BackupService> logger, IDirectoryService directoryService)
{ {
_unitOfWork = unitOfWork; _backupFiles.Add(file);
_logger = logger; }
_directoryService = directoryService;
} }
public void BackupDatabase() public void BackupDatabase()
@ -59,14 +75,10 @@ namespace API.Services
var tempDirectory = Path.Join(_tempDirectory, dateString); var tempDirectory = Path.Join(_tempDirectory, dateString);
_directoryService.ExistOrCreate(tempDirectory); _directoryService.ExistOrCreate(tempDirectory);
_directoryService.ClearDirectory(tempDirectory);
_directoryService.CopyFilesToDirectory(
foreach (var file in _backupFiles) _backupFiles.Select(file => Path.Join(Directory.GetCurrentDirectory(), file)).ToList(), tempDirectory);
{
var originalFile = new FileInfo(Path.Join(Directory.GetCurrentDirectory(), file));
originalFile.CopyTo(Path.Join(tempDirectory, originalFile.Name));
}
try try
{ {
ZipFile.CreateFromDirectory(tempDirectory, zipPath); ZipFile.CreateFromDirectory(tempDirectory, zipPath);
@ -79,5 +91,6 @@ namespace API.Services
_directoryService.ClearAndDeleteDirectory(tempDirectory); _directoryService.ClearAndDeleteDirectory(tempDirectory);
_logger.LogInformation("Database backup completed"); _logger.LogInformation("Database backup completed");
} }
} }
} }

View File

@ -111,7 +111,7 @@ namespace API.Services
if (page <= (mangaFile.NumberOfPages + pagesSoFar)) if (page <= (mangaFile.NumberOfPages + pagesSoFar))
{ {
var path = GetCachePath(chapter.Id); var path = GetCachePath(chapter.Id);
var files = _directoryService.GetFiles(path, Parser.Parser.ImageFileExtensions); var files = _directoryService.GetFilesWithExtension(path, Parser.Parser.ImageFileExtensions);
Array.Sort(files, _numericComparer); Array.Sort(files, _numericComparer);
// Since array is 0 based, we need to keep that in account (only affects last image) // Since array is 0 based, we need to keep that in account (only affects last image)

View File

@ -40,7 +40,22 @@ namespace API.Services
reSearchPattern.IsMatch(Path.GetExtension(file))); reSearchPattern.IsMatch(Path.GetExtension(file)));
} }
public string[] GetFiles(string path, string searchPatternExpression = "") public IEnumerable<string> GetFiles(string path, string searchPatternExpression = "",
SearchOption searchOption = SearchOption.TopDirectoryOnly)
{
if (searchPatternExpression != string.Empty)
{
if (!Directory.Exists(path)) return ImmutableList<string>.Empty;
var reSearchPattern = new Regex(searchPatternExpression, RegexOptions.IgnoreCase);
return Directory.EnumerateFiles(path, "*", searchOption)
.Where(file =>
reSearchPattern.IsMatch(file));
}
return !Directory.Exists(path) ? Array.Empty<string>() : Directory.GetFiles(path);
}
public string[] GetFilesWithExtension(string path, string searchPatternExpression = "")
{ {
if (searchPatternExpression != string.Empty) if (searchPatternExpression != string.Empty)
{ {
@ -70,6 +85,15 @@ namespace API.Services
{ {
DirectoryInfo di = new DirectoryInfo(directoryPath); DirectoryInfo di = new DirectoryInfo(directoryPath);
ClearDirectory(directoryPath);
di.Delete(true);
}
public void ClearDirectory(string directoryPath)
{
DirectoryInfo di = new DirectoryInfo(directoryPath);
foreach (var file in di.EnumerateFiles()) foreach (var file in di.EnumerateFiles())
{ {
file.Delete(); file.Delete();
@ -78,8 +102,35 @@ namespace API.Services
{ {
dir.Delete(true); dir.Delete(true);
} }
}
di.Delete(true); public bool CopyFilesToDirectory(IEnumerable<string> filePaths, string directoryPath)
{
string currentFile = null;
try
{
foreach (var file in filePaths)
{
currentFile = file;
var fileInfo = new FileInfo(file);
if (fileInfo.Exists)
{
fileInfo.CopyTo(Path.Join(directoryPath, fileInfo.Name));
}
else
{
_logger.LogWarning("Tried to copy {File} but it doesn't exist", file);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Unable to copy {File} to {DirectoryPath}", currentFile, directoryPath);
return false;
}
return true;
} }
public IEnumerable<string> ListDirectory(string rootPath) public IEnumerable<string> ListDirectory(string rootPath)

View File

@ -22,6 +22,7 @@ namespace API.Services
public TokenService(IConfiguration config, UserManager<AppUser> userManager) public TokenService(IConfiguration config, UserManager<AppUser> userManager)
{ {
_userManager = userManager; _userManager = userManager;
_key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["TokenKey"])); _key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["TokenKey"]));
} }

View File

@ -24,7 +24,6 @@ namespace API
// This method gets called by the runtime. Use this method to add services to the container. // This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services) public void ConfigureServices(IServiceCollection services)
{ {
services.AddApplicationServices(_config); services.AddApplicationServices(_config);
services.AddControllers(); services.AddControllers();
services.Configure<ForwardedHeadersOptions>(options => services.Configure<ForwardedHeadersOptions>(options =>
@ -72,7 +71,7 @@ namespace API
app.UseStaticFiles(new StaticFileOptions app.UseStaticFiles(new StaticFileOptions
{ {
ContentTypeProvider = new FileExtensionContentTypeProvider() // this is not set by default ContentTypeProvider = new FileExtensionContentTypeProvider()
}); });

View File

@ -1,6 +1,7 @@
# Kavita # Kavita
Kavita, meaning Stories, is a lightweight manga server. The goal is to replace Ubooqity with an Kavita, meaning Stories, is a lightweight manga server. The goal is to replace Ubooquity with an
open source variant that is flexible and packs more punch, without sacrificing ease to use. open source variant that is flexible and packs more punch, without sacrificing ease to use.
Think: ***Plex but for Manga.***
## Goals: ## Goals:
* Serve up Manga (cbr, cbz, zip/rar, raw images) and Books (epub, mobi, azw, djvu, pdf) * Serve up Manga (cbr, cbz, zip/rar, raw images) and Books (epub, mobi, azw, djvu, pdf)
@ -9,10 +10,15 @@ open source variant that is flexible and packs more punch, without sacrificing e
* Provide hooks into metadata providers to fetch Manga data * Provide hooks into metadata providers to fetch Manga data
* Metadata should allow for collections, want to read integration from 3rd party services, genres. * Metadata should allow for collections, want to read integration from 3rd party services, genres.
* Ability to manage users, access, and ratings * Ability to manage users, access, and ratings
* Expose an OPDS API/Stream for external readers to use
* Allow downloading files directly from WebApp
## How to Deploy ## How to Deploy
* Build kavita-webui via ng build --prod. The dest should be placed in the API/wwwroot directory * Build kavita-webui via ng build --prod. The dest should be placed in the API/wwwroot directory
* Run publish command * Run publish command
## How to install
1. Unzip the archive for your target OS
2. Place in a directory that is writable. If on windows, do not place in Program Files
3. Open appsettings.json and modify TokenKey to a random string ideally generated from [https://passwordsgenerator.net/](https://passwordsgenerator.net/)
4. Run API.exe