Download Support (#298)

* Implemented the ability to download files (series, volume, chapter)

* Added RBS checks to ensure user is either an admin or has download role

* Added the ability to change a users feature RBS. Changed the Role seed to use reflection
This commit is contained in:
Joseph Milazzo 2021-06-10 07:47:35 -05:00 committed by GitHub
parent 4ae9f078b0
commit 16a77fa8d6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 262 additions and 45 deletions

View File

@ -38,6 +38,10 @@ namespace API.Tests.Comparers
new[] {"Batman - Black white vol 1 #04.cbr", "Batman - Black white vol 1 #03.cbr", "Batman - Black white vol 1 #01.cbr", "Batman - Black white vol 1 #02.cbr"},
new[] {"Batman - Black white vol 1 #01.cbr", "Batman - Black white vol 1 #02.cbr", "Batman - Black white vol 1 #03.cbr", "Batman - Black white vol 1 #04.cbr"}
)]
[InlineData(
new[] {"3and4.cbz", "The World God Only Knows - Oneshot.cbz", "5.cbz", "1and2.cbz"},
new[] {"1and2.cbz", "3and4.cbz", "5.cbz", "The World God Only Knows - Oneshot.cbz"}
)]
public void TestNaturalSortComparer(string[] input, string[] expected)
{
Array.Sort(input, _nc);

View File

@ -16,11 +16,12 @@ namespace API.Tests.Services
private readonly ITestOutputHelper _testOutputHelper;
private readonly ArchiveService _archiveService;
private readonly ILogger<ArchiveService> _logger = Substitute.For<ILogger<ArchiveService>>();
private readonly ILogger<DirectoryService> _directoryServiceLogger = Substitute.For<ILogger<DirectoryService>>();
public ArchiveServiceTests(ITestOutputHelper testOutputHelper)
{
_testOutputHelper = testOutputHelper;
_archiveService = new ArchiveService(_logger);
_archiveService = new ArchiveService(_logger, new DirectoryService(_directoryServiceLogger));
}
[Theory]
@ -154,7 +155,7 @@ namespace API.Tests.Services
[InlineData("sorting.zip", "sorting.expected.jpg")]
public void GetCoverImage_Default_Test(string inputFile, string expectedOutputFile)
{
var archiveService = Substitute.For<ArchiveService>(_logger);
var archiveService = Substitute.For<ArchiveService>(_logger, new DirectoryService(_directoryServiceLogger));
var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/CoverImages");
var expectedBytes = File.ReadAllBytes(Path.Join(testDirectory, expectedOutputFile));
archiveService.Configure().CanOpen(Path.Join(testDirectory, inputFile)).Returns(ArchiveLibrary.Default);
@ -174,7 +175,7 @@ namespace API.Tests.Services
[InlineData("sorting.zip", "sorting.expected.jpg")]
public void GetCoverImage_SharpCompress_Test(string inputFile, string expectedOutputFile)
{
var archiveService = Substitute.For<ArchiveService>(_logger);
var archiveService = Substitute.For<ArchiveService>(_logger, new DirectoryService(_directoryServiceLogger));
var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/CoverImages");
var expectedBytes = File.ReadAllBytes(Path.Join(testDirectory, expectedOutputFile));

View File

@ -4,5 +4,9 @@
{
public const string AdminRole = "Admin";
public const string PlebRole = "Pleb";
/// <summary>
/// Used to give a user ability to download files from the server
/// </summary>
public const string DownloadRole = "Download";
}
}

View File

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using API.Constants;
using API.DTOs;
@ -150,5 +151,45 @@ namespace API.Controllers
Preferences = _mapper.Map<UserPreferencesDto>(user.UserPreferences)
};
}
[HttpGet("roles")]
public ActionResult<IList<string>> GetRoles()
{
return typeof(PolicyConstants)
.GetFields(BindingFlags.Public | BindingFlags.Static)
.Where(f => f.FieldType == typeof(string))
.ToDictionary(f => f.Name,
f => (string) f.GetValue(null)).Values.ToList();
}
[HttpPost("update-rbs")]
public async Task<ActionResult> UpdateRoles(UpdateRbsDto updateRbsDto)
{
var user = await _userManager.Users
.Include(u => u.UserPreferences)
//.Include(u => u.UserRoles)
.SingleOrDefaultAsync(x => x.NormalizedUserName == updateRbsDto.Username.ToUpper());
if (updateRbsDto.Roles.Contains(PolicyConstants.AdminRole) ||
updateRbsDto.Roles.Contains(PolicyConstants.PlebRole))
{
return BadRequest("Invalid Roles");
}
var existingRoles = (await _userManager.GetRolesAsync(user))
.Where(s => s != PolicyConstants.AdminRole && s != PolicyConstants.PlebRole)
.ToList();
// Find what needs to be added and what needs to be removed
var rolesToRemove = existingRoles.Except(updateRbsDto.Roles);
var result = await _userManager.AddToRolesAsync(user, updateRbsDto.Roles);
if (!result.Succeeded) return BadRequest("Something went wrong, unable to update user's roles");
if ((await _userManager.RemoveFromRolesAsync(user, rolesToRemove)).Succeeded)
{
return Ok();
}
return BadRequest("Something went wrong, unable to update user's roles");
}
}
}

View File

@ -0,0 +1,96 @@
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using API.Extensions;
using API.Interfaces;
using API.Interfaces.Services;
using API.Services;
using Kavita.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace API.Controllers
{
[Authorize(Policy = "RequireDownloadRole")]
public class DownloadController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly IArchiveService _archiveService;
public DownloadController(IUnitOfWork unitOfWork, IArchiveService archiveService)
{
_unitOfWork = unitOfWork;
_archiveService = archiveService;
}
[HttpGet("volume-size")]
public async Task<ActionResult<long>> GetVolumeSize(int volumeId)
{
var files = await _unitOfWork.VolumeRepository.GetFilesForVolume(volumeId);
return Ok(DirectoryService.GetTotalSize(files.Select(c => c.FilePath)));
}
[HttpGet("chapter-size")]
public async Task<ActionResult<long>> GetChapterSize(int chapterId)
{
var files = await _unitOfWork.VolumeRepository.GetFilesForChapter(chapterId);
return Ok(DirectoryService.GetTotalSize(files.Select(c => c.FilePath)));
}
[HttpGet("series-size")]
public async Task<ActionResult<long>> GetSeriesSize(int seriesId)
{
var files = await _unitOfWork.SeriesRepository.GetFilesForSeries(seriesId);
return Ok(DirectoryService.GetTotalSize(files.Select(c => c.FilePath)));
}
[HttpGet("volume")]
public async Task<ActionResult> DownloadVolume(int volumeId)
{
var files = await _unitOfWork.VolumeRepository.GetFilesForVolume(volumeId);
try
{
var (fileBytes, zipPath) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath),
$"download_{User.GetUsername()}_v{volumeId}");
return File(fileBytes, "application/zip", Path.GetFileName(zipPath));
}
catch (KavitaException ex)
{
return BadRequest(ex.Message);
}
}
[HttpGet("chapter")]
public async Task<ActionResult> DownloadChapter(int chapterId)
{
var files = await _unitOfWork.VolumeRepository.GetFilesForChapter(chapterId);
try
{
var (fileBytes, zipPath) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath),
$"download_{User.GetUsername()}_c{chapterId}");
return File(fileBytes, "application/zip", Path.GetFileName(zipPath));
}
catch (KavitaException ex)
{
return BadRequest(ex.Message);
}
}
[HttpGet("series")]
public async Task<ActionResult> DownloadSeries(int seriesId)
{
var files = await _unitOfWork.SeriesRepository.GetFilesForSeries(seriesId);
try
{
var (fileBytes, zipPath) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath),
$"download_{User.GetUsername()}_s{seriesId}");
return File(fileBytes, "application/zip", Path.GetFileName(zipPath));
}
catch (KavitaException ex)
{
return BadRequest(ex.Message);
}
}
}
}

View File

@ -5,6 +5,7 @@ using System.Threading.Tasks;
using API.Extensions;
using API.Interfaces.Services;
using API.Services;
using Kavita.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
@ -19,19 +20,19 @@ namespace API.Controllers
private readonly IHostApplicationLifetime _applicationLifetime;
private readonly ILogger<ServerController> _logger;
private readonly IConfiguration _config;
private readonly IDirectoryService _directoryService;
private readonly IBackupService _backupService;
private readonly IArchiveService _archiveService;
public ServerController(IHostApplicationLifetime applicationLifetime, ILogger<ServerController> logger, IConfiguration config,
IDirectoryService directoryService, IBackupService backupService)
IBackupService backupService, IArchiveService archiveService)
{
_applicationLifetime = applicationLifetime;
_logger = logger;
_config = config;
_directoryService = directoryService;
_backupService = backupService;
_archiveService = archiveService;
}
[HttpPost("restart")]
public ActionResult RestartServer()
{
@ -45,33 +46,17 @@ namespace API.Controllers
public async Task<ActionResult> GetLogs()
{
var files = _backupService.LogFiles(_config.GetMaxRollingFiles(), _config.GetLoggingFileName());
var tempDirectory = Path.Join(Directory.GetCurrentDirectory(), "temp");
var dateString = DateTime.Now.ToShortDateString().Replace("/", "_");
var tempLocation = Path.Join(tempDirectory, "logs_" + dateString);
DirectoryService.ExistOrCreate(tempLocation);
if (!_directoryService.CopyFilesToDirectory(files, tempLocation))
{
return BadRequest("Unable to copy files to temp directory for log download.");
}
var zipPath = Path.Join(tempDirectory, $"kavita_logs_{dateString}.zip");
try
{
ZipFile.CreateFromDirectory(tempLocation, zipPath);
var (fileBytes, zipPath) = await _archiveService.CreateZipForDownload(files, "logs");
return File(fileBytes, "application/zip", Path.GetFileName(zipPath));
}
catch (AggregateException ex)
catch (KavitaException ex)
{
_logger.LogError(ex, "There was an issue when archiving library backup");
return BadRequest("There was an issue when archiving library backup");
return BadRequest(ex.Message);
}
var fileBytes = await _directoryService.ReadFileAsync(zipPath);
DirectoryService.ClearAndDeleteDirectory(tempLocation);
(new FileInfo(zipPath)).Delete();
return File(fileBytes, "application/zip", Path.GetFileName(zipPath));
}
}
}

View File

@ -16,7 +16,7 @@ using Microsoft.Extensions.Logging;
namespace API.Controllers
{
[Authorize]
[Authorize(Policy = "RequireAdminRole")]
public class SettingsController : BaseApiController
{
private readonly ILogger<SettingsController> _logger;
@ -31,7 +31,7 @@ namespace API.Controllers
_taskScheduler = taskScheduler;
_configuration = configuration;
}
[HttpGet("")]
public async Task<ActionResult<ServerSettingDto>> GetSettings()
{
@ -40,8 +40,7 @@ namespace API.Controllers
settingsDto.LoggingLevel = Configuration.GetLogLevel(Program.GetAppSettingFilename());
return Ok(settingsDto);
}
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("")]
public async Task<ActionResult<ServerSettingDto>> UpdateSettings(ServerSettingDto updateSettingsDto)
{
@ -103,22 +102,19 @@ namespace API.Controllers
_taskScheduler.ScheduleTasks();
return Ok(updateSettingsDto);
}
[Authorize(Policy = "RequireAdminRole")]
[HttpGet("task-frequencies")]
public ActionResult<IEnumerable<string>> GetTaskFrequencies()
{
return Ok(CronConverter.Options);
}
[Authorize(Policy = "RequireAdminRole")]
[HttpGet("library-types")]
public ActionResult<IEnumerable<string>> GetLibraryTypes()
{
return Ok(Enum.GetNames(typeof(LibraryType)));
}
[Authorize(Policy = "RequireAdminRole")]
[HttpGet("log-levels")]
public ActionResult<IEnumerable<string>> GetLogLevels()
{

10
API/DTOs/UpdateRBSDto.cs Normal file
View File

@ -0,0 +1,10 @@
using System.Collections.Generic;
namespace API.DTOs
{
public class UpdateRbsDto
{
public string Username { get; init; }
public IList<string> Roles { get; init; }
}
}

View File

@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using API.Constants;
using API.Entities;
@ -15,11 +16,13 @@ namespace API.Data
{
public static async Task SeedRoles(RoleManager<AppRole> roleManager)
{
var roles = new List<AppRole>
{
new() {Name = PolicyConstants.AdminRole},
new() {Name = PolicyConstants.PlebRole}
};
var roles = typeof(PolicyConstants)
.GetFields(BindingFlags.Public | BindingFlags.Static)
.Where(f => f.FieldType == typeof(string))
.ToDictionary(f => f.Name,
f => (string) f.GetValue(null)).Values
.Select(policyName => new AppRole() {Name = policyName})
.ToList();
foreach (var role in roles)
{

View File

@ -411,5 +411,16 @@ namespace API.Data
return await PagedList<SeriesDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
}
public async Task<IList<MangaFile>> GetFilesForSeries(int seriesId)
{
return await _context.Volume
.Where(v => v.SeriesId == seriesId)
.Include(v => v.Chapters)
.ThenInclude(c => c.Files)
.SelectMany(v => v.Chapters.SelectMany(c => c.Files))
.AsNoTracking()
.ToListAsync();
}
}
}

View File

@ -65,6 +65,8 @@ namespace API.Data
.SingleOrDefaultAsync();
}
public async Task<ChapterDto> GetChapterDtoAsync(int chapterId)
{
@ -84,5 +86,15 @@ namespace API.Data
.AsNoTracking()
.ToListAsync();
}
public async Task<IList<MangaFile>> GetFilesForVolume(int volumeId)
{
return await _context.Chapter
.Where(c => volumeId == c.VolumeId)
.Include(c => c.Files)
.SelectMany(c => c.Files)
.AsNoTracking()
.ToListAsync();
}
}
}

View File

@ -39,6 +39,7 @@ namespace API.Extensions
services.AddAuthorization(opt =>
{
opt.AddPolicy("RequireAdminRole", policy => policy.RequireRole(PolicyConstants.AdminRole));
opt.AddPolicy("RequireDownloadRole", policy => policy.RequireRole(PolicyConstants.DownloadRole, PolicyConstants.AdminRole));
});
return services;

View File

@ -61,5 +61,6 @@ namespace API.Interfaces
Task<PagedList<SeriesDto>> GetRecentlyAdded(int libraryId, int userId, UserParams userParams);
Task<SeriesMetadataDto> GetSeriesMetadata(int seriesId);
Task<PagedList<SeriesDto>> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams);
Task<IList<MangaFile>> GetFilesForSeries(int seriesId);
}
}

View File

@ -13,5 +13,6 @@ namespace API.Interfaces
Task<IList<MangaFile>> GetFilesForChapter(int chapterId);
Task<IList<Chapter>> GetChaptersAsync(int volumeId);
Task<byte[]> GetChapterCoverImageAsync(int chapterId);
Task<IList<MangaFile>> GetFilesForVolume(int volumeId);
}
}

View File

@ -1,5 +1,9 @@
using System.IO.Compression;
using System;
using System.Collections.Generic;
using System.IO.Compression;
using System.Threading.Tasks;
using API.Archive;
using API.Entities;
namespace API.Interfaces.Services
{
@ -12,5 +16,6 @@ namespace API.Interfaces.Services
string GetSummaryInfo(string archivePath);
ArchiveLibrary CanOpen(string archivePath);
bool ArchiveNeedsFlattening(ZipArchive archive);
Task<Tuple<byte[], string>> CreateZipForDownload(IEnumerable<string> files, string tempFolder);
}
}

View File

@ -4,12 +4,14 @@ using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Threading.Tasks;
using System.Xml.Serialization;
using API.Archive;
using API.Comparators;
using API.Extensions;
using API.Interfaces.Services;
using API.Services.Tasks;
using Kavita.Common;
using Microsoft.Extensions.Logging;
using Microsoft.IO;
using SharpCompress.Archives;
@ -25,13 +27,15 @@ namespace API.Services
public class ArchiveService : IArchiveService
{
private readonly ILogger<ArchiveService> _logger;
private readonly IDirectoryService _directoryService;
private const int ThumbnailWidth = 320; // 153w x 230h
private static readonly RecyclableMemoryStreamManager StreamManager = new();
private readonly NaturalSortComparer _comparer;
public ArchiveService(ILogger<ArchiveService> logger)
public ArchiveService(ILogger<ArchiveService> logger, IDirectoryService directoryService)
{
_logger = logger;
_directoryService = directoryService;
_comparer = new NaturalSortComparer();
}
@ -216,7 +220,39 @@ namespace API.Services
!Path.HasExtension(archive.Entries.ElementAt(0).FullName) ||
archive.Entries.Any(e => e.FullName.Contains(Path.AltDirectorySeparatorChar) && !Parser.Parser.HasBlacklistedFolderInPath(e.FullName));
}
public async Task<Tuple<byte[], string>> CreateZipForDownload(IEnumerable<string> files, string tempFolder)
{
var tempDirectory = Path.Join(Directory.GetCurrentDirectory(), "temp");
var dateString = DateTime.Now.ToShortDateString().Replace("/", "_");
var tempLocation = Path.Join(tempDirectory, $"{tempFolder}_{dateString}");
DirectoryService.ExistOrCreate(tempLocation);
if (!_directoryService.CopyFilesToDirectory(files, tempLocation))
{
throw new KavitaException("Unable to copy files to temp directory archive download.");
}
var zipPath = Path.Join(tempDirectory, $"kavita_{tempFolder}_{dateString}.zip");
try
{
ZipFile.CreateFromDirectory(tempLocation, zipPath);
}
catch (AggregateException ex)
{
_logger.LogError(ex, "There was an issue creating temp archive");
throw new KavitaException("There was an issue creating temp archive");
}
var fileBytes = await _directoryService.ReadFileAsync(zipPath);
DirectoryService.ClearAndDeleteDirectory(tempLocation);
(new FileInfo(zipPath)).Delete();
return Tuple.Create(fileBytes, zipPath);
}
private byte[] CreateThumbnail(string entryName, Stream stream, string formatExtension = ".jpg")
{
if (!formatExtension.StartsWith("."))

View File

@ -102,6 +102,16 @@ namespace API.Services
return !Directory.Exists(path) ? Array.Empty<string>() : Directory.GetFiles(path);
}
/// <summary>
/// Returns the total number of bytes for a given set of full file paths
/// </summary>
/// <param name="paths"></param>
/// <returns>Total bytes</returns>
public static long GetTotalSize(IEnumerable<string> paths)
{
return paths.Sum(path => new FileInfo(path).Length);
}
/// <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.
/// </summary>