diff --git a/API.Tests/Comparers/NaturalSortComparerTest.cs b/API.Tests/Comparers/NaturalSortComparerTest.cs index 099da0546..39bad2003 100644 --- a/API.Tests/Comparers/NaturalSortComparerTest.cs +++ b/API.Tests/Comparers/NaturalSortComparerTest.cs @@ -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); diff --git a/API.Tests/Services/ArchiveServiceTests.cs b/API.Tests/Services/ArchiveServiceTests.cs index d907ab75a..50d2d0673 100644 --- a/API.Tests/Services/ArchiveServiceTests.cs +++ b/API.Tests/Services/ArchiveServiceTests.cs @@ -16,11 +16,12 @@ namespace API.Tests.Services private readonly ITestOutputHelper _testOutputHelper; private readonly ArchiveService _archiveService; private readonly ILogger _logger = Substitute.For>(); + private readonly ILogger _directoryServiceLogger = Substitute.For>(); 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(_logger); + var archiveService = Substitute.For(_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(_logger); + var archiveService = Substitute.For(_logger, new DirectoryService(_directoryServiceLogger)); var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/CoverImages"); var expectedBytes = File.ReadAllBytes(Path.Join(testDirectory, expectedOutputFile)); diff --git a/API/Constants/PolicyConstants.cs b/API/Constants/PolicyConstants.cs index 6b6d93ae0..c76d71926 100644 --- a/API/Constants/PolicyConstants.cs +++ b/API/Constants/PolicyConstants.cs @@ -4,5 +4,9 @@ { public const string AdminRole = "Admin"; public const string PlebRole = "Pleb"; + /// + /// Used to give a user ability to download files from the server + /// + public const string DownloadRole = "Download"; } } \ No newline at end of file diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index 8c3c05c85..4959fc5f4 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -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(user.UserPreferences) }; } + + [HttpGet("roles")] + public ActionResult> 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 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"); + + } } } \ No newline at end of file diff --git a/API/Controllers/DownloadController.cs b/API/Controllers/DownloadController.cs new file mode 100644 index 000000000..67d23ac8e --- /dev/null +++ b/API/Controllers/DownloadController.cs @@ -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> 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> 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> GetSeriesSize(int seriesId) + { + var files = await _unitOfWork.SeriesRepository.GetFilesForSeries(seriesId); + return Ok(DirectoryService.GetTotalSize(files.Select(c => c.FilePath))); + } + + [HttpGet("volume")] + public async Task 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 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 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); + } + } + } +} \ No newline at end of file diff --git a/API/Controllers/ServerController.cs b/API/Controllers/ServerController.cs index 475323e07..7bedceb3f 100644 --- a/API/Controllers/ServerController.cs +++ b/API/Controllers/ServerController.cs @@ -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 _logger; private readonly IConfiguration _config; - private readonly IDirectoryService _directoryService; private readonly IBackupService _backupService; + private readonly IArchiveService _archiveService; public ServerController(IHostApplicationLifetime applicationLifetime, ILogger 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 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)); } + + } } \ No newline at end of file diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs index b0bc941af..b30d7fdd3 100644 --- a/API/Controllers/SettingsController.cs +++ b/API/Controllers/SettingsController.cs @@ -16,7 +16,7 @@ using Microsoft.Extensions.Logging; namespace API.Controllers { - [Authorize] + [Authorize(Policy = "RequireAdminRole")] public class SettingsController : BaseApiController { private readonly ILogger _logger; @@ -31,7 +31,7 @@ namespace API.Controllers _taskScheduler = taskScheduler; _configuration = configuration; } - + [HttpGet("")] public async Task> GetSettings() { @@ -40,8 +40,7 @@ namespace API.Controllers settingsDto.LoggingLevel = Configuration.GetLogLevel(Program.GetAppSettingFilename()); return Ok(settingsDto); } - - [Authorize(Policy = "RequireAdminRole")] + [HttpPost("")] public async Task> UpdateSettings(ServerSettingDto updateSettingsDto) { @@ -103,22 +102,19 @@ namespace API.Controllers _taskScheduler.ScheduleTasks(); 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.GetNames(typeof(LibraryType))); } - [Authorize(Policy = "RequireAdminRole")] [HttpGet("log-levels")] public ActionResult> GetLogLevels() { diff --git a/API/DTOs/UpdateRBSDto.cs b/API/DTOs/UpdateRBSDto.cs new file mode 100644 index 000000000..8bf37d314 --- /dev/null +++ b/API/DTOs/UpdateRBSDto.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; + +namespace API.DTOs +{ + public class UpdateRbsDto + { + public string Username { get; init; } + public IList Roles { get; init; } + } +} \ No newline at end of file diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index 511fb8c1c..01befd20c 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -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 roleManager) { - var roles = new List - { - 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) { diff --git a/API/Data/SeriesRepository.cs b/API/Data/SeriesRepository.cs index c6575126b..0f725444b 100644 --- a/API/Data/SeriesRepository.cs +++ b/API/Data/SeriesRepository.cs @@ -411,5 +411,16 @@ namespace API.Data return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); } + + public async Task> 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(); + } } } \ No newline at end of file diff --git a/API/Data/VolumeRepository.cs b/API/Data/VolumeRepository.cs index 6b9e541ea..78a078e03 100644 --- a/API/Data/VolumeRepository.cs +++ b/API/Data/VolumeRepository.cs @@ -65,6 +65,8 @@ namespace API.Data .SingleOrDefaultAsync(); } + + public async Task GetChapterDtoAsync(int chapterId) { @@ -84,5 +86,15 @@ namespace API.Data .AsNoTracking() .ToListAsync(); } + + public async Task> GetFilesForVolume(int volumeId) + { + return await _context.Chapter + .Where(c => volumeId == c.VolumeId) + .Include(c => c.Files) + .SelectMany(c => c.Files) + .AsNoTracking() + .ToListAsync(); + } } } \ No newline at end of file diff --git a/API/Extensions/IdentityServiceExtensions.cs b/API/Extensions/IdentityServiceExtensions.cs index 2d2a235f5..5310cf2ef 100644 --- a/API/Extensions/IdentityServiceExtensions.cs +++ b/API/Extensions/IdentityServiceExtensions.cs @@ -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; diff --git a/API/Interfaces/ISeriesRepository.cs b/API/Interfaces/ISeriesRepository.cs index 0b89d16b6..166ab05c3 100644 --- a/API/Interfaces/ISeriesRepository.cs +++ b/API/Interfaces/ISeriesRepository.cs @@ -61,5 +61,6 @@ namespace API.Interfaces Task> GetRecentlyAdded(int libraryId, int userId, UserParams userParams); Task GetSeriesMetadata(int seriesId); Task> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams); + Task> GetFilesForSeries(int seriesId); } } \ No newline at end of file diff --git a/API/Interfaces/IVolumeRepository.cs b/API/Interfaces/IVolumeRepository.cs index faf18abb8..b5ac06087 100644 --- a/API/Interfaces/IVolumeRepository.cs +++ b/API/Interfaces/IVolumeRepository.cs @@ -13,5 +13,6 @@ namespace API.Interfaces Task> GetFilesForChapter(int chapterId); Task> GetChaptersAsync(int volumeId); Task GetChapterCoverImageAsync(int chapterId); + Task> GetFilesForVolume(int volumeId); } } \ No newline at end of file diff --git a/API/Interfaces/Services/IArchiveService.cs b/API/Interfaces/Services/IArchiveService.cs index aa5df49e2..f77784878 100644 --- a/API/Interfaces/Services/IArchiveService.cs +++ b/API/Interfaces/Services/IArchiveService.cs @@ -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> CreateZipForDownload(IEnumerable files, string tempFolder); } } \ No newline at end of file diff --git a/API/Services/ArchiveService.cs b/API/Services/ArchiveService.cs index 1f99334b7..a90d429ed 100644 --- a/API/Services/ArchiveService.cs +++ b/API/Services/ArchiveService.cs @@ -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 _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 logger) + public ArchiveService(ILogger 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> CreateZipForDownload(IEnumerable 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(".")) diff --git a/API/Services/DirectoryService.cs b/API/Services/DirectoryService.cs index 40271ccd0..ac5f5ec5e 100644 --- a/API/Services/DirectoryService.cs +++ b/API/Services/DirectoryService.cs @@ -102,6 +102,16 @@ namespace API.Services return !Directory.Exists(path) ? Array.Empty() : Directory.GetFiles(path); } + /// + /// Returns the total number of bytes for a given set of full file paths + /// + /// + /// Total bytes + public static long GetTotalSize(IEnumerable paths) + { + return paths.Sum(path => new FileInfo(path).Length); + } + /// /// 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. ///