Restricted Profiles (#1581)

* Added ReadingList age rating from all series and started on some unit tests for the new flows.

* Wrote more unit tests for Reading Lists

* Added ability to restrict user accounts to a given age rating via admin edit user modal and invite user. This commit contains all basic code, but no query modifications.

* When updating a reading list's title via UI, explicitly check if there is an existing RL with the same title.

* Refactored Reading List calculation to work properly in the flows it's invoked from.

* Cleaned up an unused method

* Promoted Collections no longer show tags where a Series exists within them that is above the user's age rating.

* Collection search now respects age restrictions

* Series Detail page now checks if the user has explicit access (as a user might bypass with direct url access)

* Hooked up age restriction for dashboard activity streams.

* Refactored some methods from Series Controller and Library Controller to a new Search Controller to keep things organized

* Updated Search to respect age restrictions

* Refactored all the Age Restriction queries to extensions

* Related Series no longer show up if they are out of the age restriction

* Fixed a bad mapping for the update age restriction api

* Fixed a UI state change after updating age restriction

* Fixed unit test

* Added a migration for reading lists

* Code cleanup
This commit is contained in:
Joe Milazzo 2022-10-10 12:59:20 -05:00 committed by GitHub
parent 0ad1638ec0
commit 442af965c6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
63 changed files with 4638 additions and 262 deletions

View File

@ -4,10 +4,13 @@ using System.IO.Abstractions.TestingHelpers;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.DTOs.ReadingLists;
using API.Entities;
using API.Entities.Enums;
using API.Helpers;
using API.Services;
using API.SignalR;
using AutoMapper;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
@ -79,9 +82,10 @@ public class ReadingListServiceTests
private async Task ResetDb()
{
_context.Series.RemoveRange(_context.Series.ToList());
await _context.SaveChangesAsync();
_context.AppUser.RemoveRange(_context.AppUser);
_context.Series.RemoveRange(_context.Series);
_context.ReadingList.RemoveRange(_context.ReadingList);
await _unitOfWork.CommitAsync();
}
private static MockFileSystem CreateFileSystem()
@ -99,11 +103,373 @@ public class ReadingListServiceTests
#endregion
#region UpdateReadingListItemPosition
#region RemoveFullyReadItems
[Fact]
public async Task UpdateReadingListItemPosition_MoveLastToFirst_TwoItemsShouldShift()
{
await ResetDb();
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007",
ReadingLists = new List<ReadingList>(),
Libraries = new List<Library>()
{
new Library()
{
Name = "Test LIb",
Type = LibraryType.Book,
Series = new List<Series>()
{
new Series()
{
Name = "Test",
Metadata = DbFactory.SeriesMetadata(new List<CollectionTag>()),
Volumes = new List<Volume>()
{
new Volume()
{
Name = "0",
Chapters = new List<Chapter>()
{
new Chapter()
{
Number = "1",
AgeRating = AgeRating.Everyone,
},
new Chapter()
{
Number = "2",
AgeRating = AgeRating.X18Plus
},
new Chapter()
{
Number = "3",
AgeRating = AgeRating.X18Plus
}
}
}
}
}
}
},
}
});
await _context.SaveChangesAsync();
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.ReadingLists);
var readingList = new ReadingList();
user.ReadingLists = new List<ReadingList>()
{
readingList
};
await _readingListService.AddChaptersToReadingList(1, new List<int>() {1, 2, 3}, readingList);
await _unitOfWork.CommitAsync();
Assert.Equal(3, readingList.Items.Count);
await _readingListService.UpdateReadingListItemPosition(new UpdateReadingListPosition()
{
FromPosition = 2, ToPosition = 0, ReadingListId = 1, ReadingListItemId = 3
});
Assert.Equal(3, readingList.Items.Count);
Assert.Equal(0, readingList.Items.Single(i => i.ChapterId == 3).Order);
Assert.Equal(1, readingList.Items.Single(i => i.ChapterId == 1).Order);
Assert.Equal(2, readingList.Items.Single(i => i.ChapterId == 2).Order);
}
// TODO: Implement all methods here
#endregion
#region DeleteReadingListItem
[Fact]
public async Task DeleteReadingListItem_DeleteFirstItem_SecondShouldBecomeFirst()
{
await ResetDb();
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007",
ReadingLists = new List<ReadingList>(),
Libraries = new List<Library>()
{
new Library()
{
Name = "Test LIb",
Type = LibraryType.Book,
Series = new List<Series>()
{
new Series()
{
Name = "Test",
Metadata = DbFactory.SeriesMetadata(new List<CollectionTag>()),
Volumes = new List<Volume>()
{
new Volume()
{
Name = "0",
Chapters = new List<Chapter>()
{
new Chapter()
{
Number = "1",
AgeRating = AgeRating.Everyone
},
new Chapter()
{
Number = "2",
AgeRating = AgeRating.X18Plus
}
}
}
}
}
}
},
}
});
await _context.SaveChangesAsync();
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.ReadingLists);
var readingList = new ReadingList();
user.ReadingLists = new List<ReadingList>()
{
readingList
};
await _readingListService.AddChaptersToReadingList(1, new List<int>() {1, 2}, readingList);
await _unitOfWork.CommitAsync();
Assert.Equal(2, readingList.Items.Count);
await _readingListService.DeleteReadingListItem(new UpdateReadingListPosition()
{
ReadingListId = 1, ReadingListItemId = 1
});
Assert.Equal(1, readingList.Items.Count);
Assert.Equal(2, readingList.Items.First().ChapterId);
}
#endregion
#region RemoveFullyReadItems
[Fact]
public async Task RemoveFullyReadItems_RemovesAllFullyReadItems()
{
await ResetDb();
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007",
ReadingLists = new List<ReadingList>(),
Libraries = new List<Library>()
{
new Library()
{
Name = "Test LIb",
Type = LibraryType.Book,
Series = new List<Series>()
{
new Series()
{
Name = "Test",
Metadata = DbFactory.SeriesMetadata(new List<CollectionTag>()),
Volumes = new List<Volume>()
{
new Volume()
{
Name = "0",
Chapters = new List<Chapter>()
{
new Chapter()
{
Number = "1",
AgeRating = AgeRating.Everyone,
Pages = 1
},
new Chapter()
{
Number = "2",
AgeRating = AgeRating.X18Plus,
Pages = 1
},
new Chapter()
{
Number = "3",
AgeRating = AgeRating.X18Plus,
Pages = 1
}
}
}
}
}
}
},
}
});
await _context.SaveChangesAsync();
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.ReadingLists | AppUserIncludes.Progress);
var readingList = new ReadingList();
user.ReadingLists = new List<ReadingList>()
{
readingList
};
await _readingListService.AddChaptersToReadingList(1, new List<int>() {1, 2, 3}, readingList);
await _unitOfWork.CommitAsync();
Assert.Equal(3, readingList.Items.Count);
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(),
Substitute.For<IEventHub>());
// Mark 2 as fully read
await readerService.MarkChaptersAsRead(user, 1,
await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(new List<int>() {2}));
await _unitOfWork.CommitAsync();
await _readingListService.RemoveFullyReadItems(1, user);
Assert.Equal(2, readingList.Items.Count);
Assert.DoesNotContain(readingList.Items, i => i.Id == 2);
}
#endregion
#region CalculateAgeRating
[Fact]
public async Task CalculateAgeRating_ShouldUpdateToUnknown_IfNoneSet()
{
await ResetDb();
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007",
ReadingLists = new List<ReadingList>(),
Libraries = new List<Library>()
{
new Library()
{
Name = "Test LIb",
Type = LibraryType.Book,
Series = new List<Series>()
{
new Series()
{
Name = "Test",
Metadata = DbFactory.SeriesMetadata(new List<CollectionTag>()),
Volumes = new List<Volume>()
{
new Volume()
{
Name = "0",
Chapters = new List<Chapter>()
{
new Chapter()
{
Number = "1",
},
new Chapter()
{
Number = "2",
}
}
}
}
}
}
},
}
});
await _context.SaveChangesAsync();
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.ReadingLists);
var readingList = new ReadingList();
user.ReadingLists = new List<ReadingList>()
{
readingList
};
await _readingListService.AddChaptersToReadingList(1, new List<int>() {1, 2}, readingList);
_unitOfWork.UserRepository.Update(user);
await _unitOfWork.CommitAsync();
await _readingListService.CalculateReadingListAgeRating(readingList);
Assert.Equal(AgeRating.Unknown, readingList.AgeRating);
}
[Fact]
public async Task CalculateAgeRating_ShouldUpdateToMax()
{
await ResetDb();
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007",
ReadingLists = new List<ReadingList>(),
Libraries = new List<Library>()
{
new Library()
{
Name = "Test LIb",
Type = LibraryType.Book,
Series = new List<Series>()
{
new Series()
{
Name = "Test",
Metadata = DbFactory.SeriesMetadata(new List<CollectionTag>()),
Volumes = new List<Volume>()
{
new Volume()
{
Name = "0",
Chapters = new List<Chapter>()
{
new Chapter()
{
Number = "1",
},
new Chapter()
{
Number = "2",
}
}
}
}
}
}
},
}
});
await _context.SaveChangesAsync();
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.ReadingLists);
var readingList = new ReadingList();
user.ReadingLists = new List<ReadingList>()
{
readingList
};
await _readingListService.AddChaptersToReadingList(1, new List<int>() {1, 2}, readingList);
_unitOfWork.UserRepository.Update(user);
await _unitOfWork.CommitAsync();
await _readingListService.CalculateReadingListAgeRating(readingList);
Assert.Equal(AgeRating.Unknown, readingList.AgeRating);
}
#endregion
}

View File

@ -27,7 +27,11 @@ public static class PolicyConstants
/// Used to give a user ability to bookmark files on the server
/// </summary>
public const string BookmarkRole = "Bookmark";
/// <summary>
/// Used to give a user ability to Change Restrictions on their account
/// </summary>
public const string ChangeRestrictionRole = "Change Restriction";
public static readonly ImmutableArray<string> ValidRoles =
ImmutableArray.Create(AdminRole, PlebRole, DownloadRole, ChangePasswordRole, BookmarkRole);
ImmutableArray.Create(AdminRole, PlebRole, DownloadRole, ChangePasswordRole, BookmarkRole, ChangeRestrictionRole);
}

View File

@ -12,7 +12,6 @@ using API.DTOs.Account;
using API.DTOs.Email;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Enums.UserPreferences;
using API.Errors;
using API.Extensions;
using API.Services;
@ -358,6 +357,34 @@ public class AccountController : BaseApiController
return Ok();
}
[HttpPost("update/age-restriction")]
public async Task<ActionResult> UpdateAgeRestriction(UpdateAgeRestrictionDto dto)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (user == null) return Unauthorized("You do not have permission");
if (dto == null) return BadRequest("Invalid payload");
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
user.AgeRestriction = isAdmin ? AgeRating.NotApplicable : dto.AgeRestriction;
_unitOfWork.UserRepository.Update(user);
if (!_unitOfWork.HasChanges()) return Ok();
try
{
await _unitOfWork.CommitAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an error updating the age restriction");
return BadRequest("There was an error updating the age restriction");
}
await _eventHub.SendMessageToAsync(MessageFactory.UserUpdate, MessageFactory.UserUpdateEvent(user.Id, user.UserName), user.Id);
return Ok();
}
/// <summary>
/// Update the user account. This can only affect Username, Email (will require confirming), Roles, and Library access.
/// </summary>
@ -428,6 +455,9 @@ public class AccountController : BaseApiController
lib.AppUsers.Add(user);
}
user.AgeRestriction = hasAdminRole ? AgeRating.NotApplicable : dto.AgeRestriction;
_unitOfWork.UserRepository.Update(user);
if (!_unitOfWork.HasChanges() || await _unitOfWork.CommitAsync())
{
await _eventHub.SendMessageToAsync(MessageFactory.UserUpdate, MessageFactory.UserUpdateEvent(user.Id, user.UserName), user.Id);
@ -540,6 +570,8 @@ public class AccountController : BaseApiController
lib.AppUsers.Add(user);
}
user.AgeRestriction = hasAdminRole ? AgeRating.NotApplicable : dto.AgeRestriction;
var token = await _userManager.GenerateEmailConfirmationTokenAsync(user);
if (string.IsNullOrEmpty(token))
{

View File

@ -41,7 +41,8 @@ public class CollectionController : BaseApiController
{
return await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync();
}
return await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync();
return await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync(user.Id);
}
/// <summary>
@ -56,9 +57,10 @@ public class CollectionController : BaseApiController
{
queryString ??= "";
queryString = queryString.Replace(@"%", string.Empty);
if (queryString.Length == 0) return await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync();
if (queryString.Length == 0) return await GetAllTags();
return await _unitOfWork.CollectionTagRepository.SearchTagDtosAsync(queryString);
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
return await _unitOfWork.CollectionTagRepository.SearchTagDtosAsync(queryString, user.Id);
}
/// <summary>

View File

@ -319,23 +319,6 @@ public class LibraryController : BaseApiController
}
[HttpGet("search")]
public async Task<ActionResult<SearchResultGroupDto>> Search(string queryString)
{
queryString = Uri.UnescapeDataString(queryString).Trim().Replace(@"%", string.Empty).Replace(":", string.Empty);
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
// 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");
if (!libraries.Any()) return BadRequest("User does not have access to any libraries");
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
var series = await _unitOfWork.SeriesRepository.SearchSeries(user.Id, isAdmin, libraries.Select(l => l.Id).ToArray(), queryString);
return Ok(series);
}
[HttpGet("type")]
public async Task<ActionResult<LibraryType>> GetLibraryType(int libraryId)

View File

@ -76,7 +76,9 @@ public class MetadataController : BaseApiController
/// Fetches all age ratings from the instance
/// </summary>
/// <param name="libraryIds">String separated libraryIds or null for all ratings</param>
/// <remarks>This API is cached for 1 hour, varying by libraryIds</remarks>
/// <returns></returns>
[ResponseCache(Duration = 60 * 5, Location = ResponseCacheLocation.Any, VaryByQueryKeys = new [] {"libraryIds"})]
[HttpGet("age-ratings")]
public async Task<ActionResult<IList<AgeRatingDto>>> GetAllAgeRatings(string? libraryIds)
{
@ -90,14 +92,16 @@ public class MetadataController : BaseApiController
{
Title = t.ToDescription(),
Value = t
}));
}).Where(r => r.Value > AgeRating.NotApplicable));
}
/// <summary>
/// Fetches all publication status' from the instance
/// </summary>
/// <param name="libraryIds">String separated libraryIds or null for all publication status</param>
/// <remarks>This API is cached for 1 hour, varying by libraryIds</remarks>
/// <returns></returns>
[ResponseCache(Duration = 60 * 5, Location = ResponseCacheLocation.Any, VaryByQueryKeys = new [] {"libraryIds"})]
[HttpGet("publication-status")]
public ActionResult<IList<AgeRatingDto>> GetAllPublicationStatus(string? libraryIds)
{

View File

@ -196,8 +196,8 @@ public class OpdsController : BaseApiController
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
IList<CollectionTagDto> tags = isAdmin ? (await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync()).ToList()
: (await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync()).ToList();
IEnumerable<CollectionTagDto> tags = isAdmin ? (await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync())
: (await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync(userId));
var feed = CreateFeed("All Collections", $"{apiKey}/collections", apiKey);
@ -239,7 +239,7 @@ public class OpdsController : BaseApiController
}
else
{
tags = await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync();
tags = await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync(userId);
}
var tag = tags.SingleOrDefault(t => t.Id == collectionId);

View File

@ -45,11 +45,11 @@ public class ReadingListController : BaseApiController
/// <summary>
/// Returns reading lists (paginated) for a given user.
/// </summary>
/// <param name="includePromoted">Defaults to true</param>
/// <param name="includePromoted">Include Promoted Reading Lists along with user's Reading Lists. Defaults to true</param>
/// <param name="userParams">Pagination parameters</param>
/// <returns></returns>
[HttpPost("lists")]
public async Task<ActionResult<IEnumerable<ReadingListDto>>> GetListsForUser([FromQuery] UserParams userParams, [FromQuery] bool includePromoted = true)
public async Task<ActionResult<IEnumerable<ReadingListDto>>> GetListsForUser([FromQuery] UserParams userParams, bool includePromoted = true)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var items = await _unitOfWork.ReadingListRepository.GetReadingListDtosForUserAsync(userId, includePromoted,
@ -217,9 +217,15 @@ public class ReadingListController : BaseApiController
return BadRequest("You do not have permissions on this reading list or the list doesn't exist");
}
dto.Title = dto.Title.Trim();
if (!string.IsNullOrEmpty(dto.Title))
{
readingList.Title = dto.Title; // Should I check if this is unique?
var hasExisting = user.ReadingLists.Any(l => l.Title.Equals(dto.Title));
if (hasExisting)
{
return BadRequest("A list of this name already exists");
}
readingList.Title = dto.Title;
readingList.NormalizedTitle = Services.Tasks.Scanner.Parser.Parser.Normalize(readingList.Title);
}
if (!string.IsNullOrEmpty(dto.Title))

View File

@ -0,0 +1,67 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.DTOs;
using API.DTOs.Search;
using API.Extensions;
using Microsoft.AspNetCore.Mvc;
namespace API.Controllers;
/// <summary>
/// Responsible for the Search interface from the UI
/// </summary>
public class SearchController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
public SearchController(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}
/// <summary>
/// Returns the series for the MangaFile id. If the user does not have access (shouldn't happen by the UI),
/// then null is returned
/// </summary>
/// <param name="mangaFileId"></param>
/// <returns></returns>
[HttpGet("series-for-mangafile")]
public async Task<ActionResult<SeriesDto>> GetSeriesForMangaFile(int mangaFileId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.SeriesRepository.GetSeriesForMangaFile(mangaFileId, userId));
}
/// <summary>
/// Returns the series for the Chapter id. If the user does not have access (shouldn't happen by the UI),
/// then null is returned
/// </summary>
/// <param name="chapterId"></param>
/// <returns></returns>
[HttpGet("series-for-chapter")]
public async Task<ActionResult<SeriesDto>> GetSeriesForChapter(int chapterId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.SeriesRepository.GetSeriesForChapter(chapterId, userId));
}
[HttpGet("search")]
public async Task<ActionResult<SearchResultGroupDto>> Search(string queryString)
{
queryString = Uri.UnescapeDataString(queryString).Trim().Replace(@"%", string.Empty).Replace(":", string.Empty);
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
// 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");
if (!libraries.Any()) return BadRequest("User does not have access to any libraries");
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
var series = await _unitOfWork.SeriesRepository.SearchSeries(user.Id, isAdmin, libraries.Select(l => l.Id).ToArray(), queryString);
return Ok(series);
}
}

View File

@ -363,10 +363,13 @@ public class SeriesController : BaseApiController
/// </summary>
/// <param name="ageRating"></param>
/// <returns></returns>
/// <remarks>This is cached for an hour</remarks>
[ResponseCache(Duration = 60 * 60, Location = ResponseCacheLocation.Any, VaryByQueryKeys = new [] {"ageRating"})]
[HttpGet("age-rating")]
public ActionResult<string> GetAgeRating(int ageRating)
{
var val = (AgeRating) ageRating;
if (val == AgeRating.NotApplicable) return "No Restriction";
return Ok(val.ToDescription());
}
@ -385,31 +388,7 @@ public class SeriesController : BaseApiController
return await _seriesService.GetSeriesDetail(seriesId, userId);
}
/// <summary>
/// Returns the series for the MangaFile id. If the user does not have access (shouldn't happen by the UI),
/// then null is returned
/// </summary>
/// <param name="mangaFileId"></param>
/// <returns></returns>
[HttpGet("series-for-mangafile")]
public async Task<ActionResult<SeriesDto>> GetSeriesForMangaFile(int mangaFileId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.SeriesRepository.GetSeriesForMangaFile(mangaFileId, userId));
}
/// <summary>
/// Returns the series for the Chapter id. If the user does not have access (shouldn't happen by the UI),
/// then null is returned
/// </summary>
/// <param name="chapterId"></param>
/// <returns></returns>
[HttpGet("series-for-chapter")]
public async Task<ActionResult<SeriesDto>> GetSeriesForChapter(int chapterId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.SeriesRepository.GetSeriesForChapter(chapterId, userId));
}
/// <summary>
/// Fetches the related series for a given series

View File

@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using API.Entities.Enums;
namespace API.DTOs.Account;
@ -16,4 +17,8 @@ public class InviteUserDto
/// A list of libraries to grant access to
/// </summary>
public IList<int> Libraries { get; init; }
/// <summary>
/// An Age Rating which will limit the account to seeing everything equal to or below said rating.
/// </summary>
public AgeRating AgeRestriction { get; set; }
}

View File

@ -0,0 +1,10 @@
using System.ComponentModel.DataAnnotations;
using API.Entities.Enums;
namespace API.DTOs.Account;
public class UpdateAgeRestrictionDto
{
[Required]
public AgeRating AgeRestriction { get; set; }
}

View File

@ -1,4 +1,7 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
using API.Entities.Enums;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion.Internal;
namespace API.DTOs.Account;
@ -13,5 +16,9 @@ public record UpdateUserDto
/// A list of libraries to grant access to
/// </summary>
public IList<int> Libraries { get; init; }
/// <summary>
/// An Age Rating which will limit the account to seeing everything equal to or below said rating.
/// </summary>
public AgeRating AgeRestriction { get; init; }
}

View File

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using API.Entities.Enums;
namespace API.DTOs;
@ -11,6 +12,11 @@ public class MemberDto
public int Id { get; init; }
public string Username { get; init; }
public string Email { get; init; }
/// <summary>
/// The maximum age rating a user has access to. -1 if not applicable
/// </summary>
public AgeRating AgeRestriction { get; init; } = AgeRating.NotApplicable;
public DateTime Created { get; init; }
public DateTime LastActive { get; init; }
public IEnumerable<LibraryDto> Libraries { get; init; }

View File

@ -1,4 +1,6 @@

using API.Entities.Enums;
namespace API.DTOs;
public class UserDto
@ -9,4 +11,8 @@ public class UserDto
public string RefreshToken { get; set; }
public string ApiKey { get; init; }
public UserPreferencesDto Preferences { get; set; }
/// <summary>
/// The highest age rating the user has access to. Not applicable for admins
/// </summary>
public AgeRating AgeRestriction { get; set; } = AgeRating.NotApplicable;
}

View File

@ -0,0 +1,36 @@
using System.Threading.Tasks;
using API.Constants;
using API.Entities;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
namespace API.Data;
/// <summary>
/// New role introduced in v0.6. Adds the role to all users.
/// </summary>
public static class MigrateChangeRestrictionRoles
{
/// <summary>
/// Will not run if any users have the <see cref="PolicyConstants.ChangeRestrictionRole"/> role already
/// </summary>
/// <param name="unitOfWork"></param>
/// <param name="userManager"></param>
/// <param name="logger"></param>
public static async Task Migrate(IUnitOfWork unitOfWork, UserManager<AppUser> userManager, ILogger<Program> logger)
{
var usersWithRole = await userManager.GetUsersInRoleAsync(PolicyConstants.ChangeRestrictionRole);
if (usersWithRole.Count != 0) return;
logger.LogCritical("Running MigrateChangeRestrictionRoles migration");
var allUsers = await unitOfWork.UserRepository.GetAllUsers();
foreach (var user in allUsers)
{
await userManager.RemoveFromRoleAsync(user, PolicyConstants.ChangeRestrictionRole);
await userManager.AddToRoleAsync(user, PolicyConstants.ChangeRestrictionRole);
}
logger.LogInformation("MigrateChangeRestrictionRoles migration complete");
}
}

View File

@ -0,0 +1,41 @@
using System;
using System.Threading.Tasks;
using API.Constants;
using API.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using SQLitePCL;
namespace API.Data;
/// <summary>
/// New role introduced in v0.6. Calculates the Age Rating on all Reading Lists
/// </summary>
public static class MigrateReadingListAgeRating
{
/// <summary>
/// Will not run if any above v0.5.6.24 or v0.6.0
/// </summary>
/// <param name="context"></param>
/// <param name="readingListService"></param>
/// <param name="logger"></param>
public static async Task Migrate(IUnitOfWork unitOfWork, DataContext context, IReadingListService readingListService, ILogger<Program> logger)
{
var settings = await unitOfWork.SettingsRepository.GetSettingsDtoAsync();
if (Version.Parse(settings.InstallVersion) > new Version(0, 5, 6, 24))
{
return;
}
logger.LogInformation("MigrateReadingListAgeRating migration starting");
var readingLists = await context.ReadingList.Include(r => r.Items).ToListAsync();
foreach (var readingList in readingLists)
{
await readingListService.CalculateReadingListAgeRating(readingList);
context.ReadingList.Update(readingList);
}
await context.SaveChangesAsync();
logger.LogInformation("MigrateReadingListAgeRating migration complete");
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
public partial class ReadingListAgeRating : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "AgeRating",
table: "ReadingList",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AgeRating",
table: "ReadingList");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,27 @@
using API.Entities.Enums;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
public partial class UserAgeRating : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "AgeRestriction",
table: "AspNetUsers",
type: "INTEGER",
nullable: false,
defaultValue: AgeRating.NotApplicable);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AgeRestriction",
table: "AspNetUsers");
}
}
}

View File

@ -53,6 +53,9 @@ namespace API.Data.Migrations
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<int>("AgeRestriction")
.HasColumnType("INTEGER");
b.Property<string>("ApiKey")
.HasColumnType("TEXT");
@ -736,6 +739,9 @@ namespace API.Data.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AgeRating")
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");

View File

@ -4,6 +4,7 @@ using System.Linq;
using System.Threading.Tasks;
using API.DTOs.CollectionTags;
using API.Entities;
using API.Extensions;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.EntityFrameworkCore;
@ -15,9 +16,9 @@ public interface ICollectionTagRepository
void Add(CollectionTag tag);
void Remove(CollectionTag tag);
Task<IEnumerable<CollectionTagDto>> GetAllTagDtosAsync();
Task<IEnumerable<CollectionTagDto>> SearchTagDtosAsync(string searchQuery);
Task<IEnumerable<CollectionTagDto>> SearchTagDtosAsync(string searchQuery, int userId);
Task<string> GetCoverImageAsync(int collectionTagId);
Task<IEnumerable<CollectionTagDto>> GetAllPromotedTagDtosAsync();
Task<IEnumerable<CollectionTagDto>> GetAllPromotedTagDtosAsync(int userId);
Task<CollectionTag> GetTagAsync(int tagId);
Task<CollectionTag> GetFullTagAsync(int tagId);
void Update(CollectionTag tag);
@ -85,6 +86,7 @@ public class CollectionTagRepository : ICollectionTagRepository
public async Task<IEnumerable<CollectionTagDto>> GetAllTagDtosAsync()
{
return await _context.CollectionTag
.OrderBy(c => c.NormalizedTitle)
.AsNoTracking()
@ -92,10 +94,12 @@ public class CollectionTagRepository : ICollectionTagRepository
.ToListAsync();
}
public async Task<IEnumerable<CollectionTagDto>> GetAllPromotedTagDtosAsync()
public async Task<IEnumerable<CollectionTagDto>> GetAllPromotedTagDtosAsync(int userId)
{
var userRating = (await _context.AppUser.SingleAsync(u => u.Id == userId)).AgeRestriction;
return await _context.CollectionTag
.Where(c => c.Promoted)
.RestrictAgainstAgeRestriction(userRating)
.OrderBy(c => c.NormalizedTitle)
.AsNoTracking()
.ProjectTo<CollectionTagDto>(_mapper.ConfigurationProvider)
@ -118,11 +122,13 @@ public class CollectionTagRepository : ICollectionTagRepository
.SingleOrDefaultAsync();
}
public async Task<IEnumerable<CollectionTagDto>> SearchTagDtosAsync(string searchQuery)
public async Task<IEnumerable<CollectionTagDto>> SearchTagDtosAsync(string searchQuery, int userId)
{
var userRating = (await _context.AppUser.SingleAsync(u => u.Id == userId)).AgeRestriction;
return await _context.CollectionTag
.Where(s => EF.Functions.Like(s.Title, $"%{searchQuery}%")
|| EF.Functions.Like(s.NormalizedTitle, $"%{searchQuery}%"))
.RestrictAgainstAgeRestriction(userRating)
.OrderBy(s => s.Title)
.AsNoTracking()
.OrderBy(c => c.NormalizedTitle)

View File

@ -23,6 +23,7 @@ public interface IReadingListRepository
Task<IEnumerable<ReadingListDto>> GetReadingListDtosForSeriesAndUserAsync(int userId, int seriesId,
bool includePromoted);
void Remove(ReadingListItem item);
void Add(ReadingList list);
void BulkRemove(IEnumerable<ReadingListItem> items);
void Update(ReadingList list);
Task<int> Count();
@ -46,6 +47,11 @@ public class ReadingListRepository : IReadingListRepository
_context.Entry(list).State = EntityState.Modified;
}
public void Add(ReadingList list)
{
_context.Add(list);
}
public async Task<int> Count()
{
return await _context.ReadingList.CountAsync();
@ -82,8 +88,10 @@ public class ReadingListRepository : IReadingListRepository
public async Task<PagedList<ReadingListDto>> GetReadingListDtosForUserAsync(int userId, bool includePromoted, UserParams userParams)
{
var userAgeRating = (await _context.AppUser.SingleAsync(u => u.Id == userId)).AgeRestriction;
var query = _context.ReadingList
.Where(l => l.AppUserId == userId || (includePromoted && l.Promoted ))
.Where(l => l.AgeRating >= userAgeRating)
.OrderBy(l => l.LastModified)
.ProjectTo<ReadingListDto>(_mapper.ConfigurationProvider)
.AsNoTracking();
@ -97,7 +105,7 @@ public class ReadingListRepository : IReadingListRepository
.Where(l => l.AppUserId == userId || (includePromoted && l.Promoted ))
.Where(l => l.Items.Any(i => i.SeriesId == seriesId))
.AsSplitQuery()
.OrderBy(l => l.LastModified)
.OrderBy(l => l.Title)
.ProjectTo<ReadingListDto>(_mapper.ConfigurationProvider)
.AsNoTracking();

View File

@ -51,6 +51,7 @@ internal class RecentlyAddedSeries
public string ChapterTitle { get; init; }
public bool IsSpecial { get; init; }
public int VolumeNumber { get; init; }
public AgeRating AgeRating { get; init; }
}
public interface ISeriesRepository
@ -118,11 +119,11 @@ public interface ISeriesRepository
Task<SeriesDto> GetSeriesForMangaFile(int mangaFileId, int userId);
Task<SeriesDto> GetSeriesForChapter(int chapterId, int userId);
Task<PagedList<SeriesDto>> GetWantToReadForUserAsync(int userId, UserParams userParams, FilterDto filter);
Task<int> GetSeriesIdByFolder(string folder);
Task<Series> GetSeriesByFolderPath(string folder, SeriesIncludes includes = SeriesIncludes.None);
Task<Series> GetFullSeriesByAnyName(string seriesName, string localizedName, int libraryId, MangaFormat format, bool withFullIncludes = true);
Task<List<Series>> RemoveSeriesNotInList(IList<ParsedSeries> seenSeries, int libraryId);
Task<IDictionary<string, IList<SeriesModified>>> GetFolderPathMap(int libraryId);
Task<AgeRating> GetMaxAgeRatingFromSeriesAsync(IEnumerable<int> seriesIds);
}
public class SeriesRepository : ISeriesRepository
@ -307,9 +308,11 @@ public class SeriesRepository : ISeriesRepository
const int maxRecords = 15;
var result = new SearchResultGroupDto();
var searchQueryNormalized = Services.Tasks.Scanner.Parser.Parser.Normalize(searchQuery);
var userRating = await GetUserAgeRestriction(userId);
var seriesIds = _context.Series
.Where(s => libraryIds.Contains(s.LibraryId))
.RestrictAgainstAgeRestriction(userRating)
.Select(s => s.Id)
.ToList();
@ -333,6 +336,7 @@ public class SeriesRepository : ISeriesRepository
|| EF.Functions.Like(s.LocalizedName, $"%{searchQuery}%")
|| EF.Functions.Like(s.NormalizedName, $"%{searchQueryNormalized}%")
|| (hasYearInQuery && s.Metadata.ReleaseYear == yearComparison))
.RestrictAgainstAgeRestriction(userRating)
.Include(s => s.Library)
.OrderBy(s => s.SortName)
.AsNoTracking()
@ -341,19 +345,20 @@ public class SeriesRepository : ISeriesRepository
.ProjectTo<SearchResultDto>(_mapper.ConfigurationProvider)
.AsEnumerable();
result.ReadingLists = await _context.ReadingList
.Where(rl => rl.AppUserId == userId || rl.Promoted)
.Where(rl => EF.Functions.Like(rl.Title, $"%{searchQuery}%"))
.RestrictAgainstAgeRestriction(userRating)
.AsSplitQuery()
.Take(maxRecords)
.ProjectTo<ReadingListDto>(_mapper.ConfigurationProvider)
.ToListAsync();
result.Collections = await _context.CollectionTag
.Where(s => EF.Functions.Like(s.Title, $"%{searchQuery}%")
|| EF.Functions.Like(s.NormalizedTitle, $"%{searchQueryNormalized}%"))
.Where(s => s.Promoted || isAdmin)
.Where(c => EF.Functions.Like(c.Title, $"%{searchQuery}%")
|| EF.Functions.Like(c.NormalizedTitle, $"%{searchQueryNormalized}%"))
.Where(c => c.Promoted || isAdmin)
.RestrictAgainstAgeRestriction(userRating)
.OrderBy(s => s.Title)
.AsNoTracking()
.AsSplitQuery()
@ -392,7 +397,7 @@ public class SeriesRepository : ISeriesRepository
.ToListAsync();
var fileIds = _context.Series
.Where(s => libraryIds.Contains(s.LibraryId))
.Where(s => seriesIds.Contains(s.Id))
.AsSplitQuery()
.SelectMany(s => s.Volumes)
.SelectMany(v => v.Chapters)
@ -735,6 +740,8 @@ public class SeriesRepository : ISeriesRepository
private async Task<IQueryable<Series>> CreateFilteredSearchQueryable(int userId, int libraryId, FilterDto filter)
{
var userLibraries = await GetUserLibraries(libraryId, userId);
var userRating = await GetUserAgeRestriction(userId);
var formats = ExtractFilters(libraryId, userId, filter, ref userLibraries,
out var allPeopleIds, out var hasPeopleFilter, out var hasGenresFilter,
out var hasCollectionTagFilter, out var hasRatingFilter, out var hasProgressFilter,
@ -759,8 +766,13 @@ public class SeriesRepository : ISeriesRepository
.Where(s => !hasSeriesNameFilter ||
EF.Functions.Like(s.Name, $"%{filter.SeriesNameQuery}%")
|| EF.Functions.Like(s.OriginalName, $"%{filter.SeriesNameQuery}%")
|| EF.Functions.Like(s.LocalizedName, $"%{filter.SeriesNameQuery}%"))
.AsNoTracking();
|| EF.Functions.Like(s.LocalizedName, $"%{filter.SeriesNameQuery}%"));
if (userRating != AgeRating.NotApplicable)
{
query = query.RestrictAgainstAgeRestriction(userRating);
}
query = query.AsNoTracking();
// If no sort options, default to using SortName
filter.SortOptions ??= new SortOptions()
@ -1033,7 +1045,10 @@ public class SeriesRepository : ISeriesRepository
{
var seriesMap = new Dictionary<string, GroupedSeriesDto>();
var index = 0;
foreach (var item in await GetRecentlyAddedChaptersQuery(userId))
var userRating = await GetUserAgeRestriction(userId);
var items = (await GetRecentlyAddedChaptersQuery(userId));
foreach (var item in items.Where(c => c.AgeRating <= userRating))
{
if (seriesMap.Keys.Count == pageSize) break;
@ -1061,11 +1076,19 @@ public class SeriesRepository : ISeriesRepository
return seriesMap.Values.AsEnumerable();
}
private async Task<AgeRating> GetUserAgeRestriction(int userId)
{
return (await _context.AppUser.SingleAsync(u => u.Id == userId)).AgeRestriction;
}
public async Task<IEnumerable<SeriesDto>> GetSeriesForRelationKind(int userId, int seriesId, RelationKind kind)
{
var libraryIds = GetLibraryIdsForUser(userId);
var userRating = await GetUserAgeRestriction(userId);
var usersSeriesIds = _context.Series
.Where(s => libraryIds.Contains(s.LibraryId))
.RestrictAgainstAgeRestriction(userRating)
.Select(s => s.Id);
var targetSeries = _context.SeriesRelation
@ -1078,6 +1101,7 @@ public class SeriesRepository : ISeriesRepository
return await _context.Series
.Where(s => targetSeries.Contains(s.Id))
.RestrictAgainstAgeRestriction(userRating)
.AsSplitQuery()
.AsNoTracking()
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
@ -1128,6 +1152,8 @@ public class SeriesRepository : ISeriesRepository
public async Task<SeriesDto> GetSeriesForMangaFile(int mangaFileId, int userId)
{
var libraryIds = GetLibraryIdsForUser(userId);
var userRating = await GetUserAgeRestriction(userId);
return await _context.MangaFile
.Where(m => m.Id == mangaFileId)
.AsSplitQuery()
@ -1135,6 +1161,7 @@ public class SeriesRepository : ISeriesRepository
.Select(c => c.Volume)
.Select(v => v.Series)
.Where(s => libraryIds.Contains(s.LibraryId))
.RestrictAgainstAgeRestriction(userRating)
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
.SingleOrDefaultAsync();
}
@ -1142,31 +1169,18 @@ public class SeriesRepository : ISeriesRepository
public async Task<SeriesDto> GetSeriesForChapter(int chapterId, int userId)
{
var libraryIds = GetLibraryIdsForUser(userId);
var userRating = await GetUserAgeRestriction(userId);
return await _context.Chapter
.Where(m => m.Id == chapterId)
.AsSplitQuery()
.Select(c => c.Volume)
.Select(v => v.Series)
.Where(s => libraryIds.Contains(s.LibraryId))
.RestrictAgainstAgeRestriction(userRating)
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
.SingleOrDefaultAsync();
}
/// <summary>
/// Given a folder path return a Series with the <see cref="Series.FolderPath"/> that matches.
/// </summary>
/// <remarks>This will apply normalization on the path.</remarks>
/// <param name="folder"></param>
/// <returns></returns>
public async Task<int> GetSeriesIdByFolder(string folder)
{
var normalized = Services.Tasks.Scanner.Parser.Parser.NormalizePath(folder);
var series = await _context.Series
.Where(s => s.FolderPath.Equals(normalized))
.SingleOrDefaultAsync();
return series?.Id ?? 0;
}
/// <summary>
/// Return a Series by Folder path. Null if not found.
/// </summary>
@ -1368,21 +1382,22 @@ public class SeriesRepository : ISeriesRepository
{
var libraryIds = GetLibraryIdsForUser(userId);
var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds);
var userRating = await GetUserAgeRestriction(userId);
return new RelatedSeriesDto()
{
SourceSeriesId = seriesId,
Adaptations = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Adaptation),
Characters = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Character),
Prequels = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Prequel),
Sequels = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Sequel),
Contains = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Contains),
SideStories = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.SideStory),
SpinOffs = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.SpinOff),
Others = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Other),
AlternativeSettings = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.AlternativeSetting),
AlternativeVersions = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.AlternativeVersion),
Doujinshis = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Doujinshi),
Adaptations = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Adaptation, userRating),
Characters = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Character, userRating),
Prequels = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Prequel, userRating),
Sequels = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Sequel, userRating),
Contains = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Contains, userRating),
SideStories = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.SideStory, userRating),
SpinOffs = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.SpinOff, userRating),
Others = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Other, userRating),
AlternativeSettings = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.AlternativeSetting, userRating),
AlternativeVersions = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.AlternativeVersion, userRating),
Doujinshis = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Doujinshi, userRating),
Parent = await _context.Series
.SelectMany(s =>
s.RelationOf.Where(r => r.TargetSeriesId == seriesId
@ -1390,6 +1405,7 @@ public class SeriesRepository : ISeriesRepository
&& r.RelationKind != RelationKind.Prequel
&& r.RelationKind != RelationKind.Sequel)
.Select(sr => sr.Series))
.RestrictAgainstAgeRestriction(userRating)
.AsSplitQuery()
.AsNoTracking()
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
@ -1404,11 +1420,12 @@ public class SeriesRepository : ISeriesRepository
.Select(s => s.Id);
}
private async Task<IEnumerable<SeriesDto>> GetRelatedSeriesQuery(int seriesId, IEnumerable<int> usersSeriesIds, RelationKind kind)
private async Task<IEnumerable<SeriesDto>> GetRelatedSeriesQuery(int seriesId, IEnumerable<int> usersSeriesIds, RelationKind kind, AgeRating userRating)
{
return await _context.Series.SelectMany(s =>
s.Relations.Where(sr => sr.RelationKind == kind && sr.SeriesId == seriesId && usersSeriesIds.Contains(sr.TargetSeriesId))
.Select(sr => sr.TargetSeries))
.RestrictAgainstAgeRestriction(userRating)
.AsSplitQuery()
.AsNoTracking()
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
@ -1417,16 +1434,15 @@ public class SeriesRepository : ISeriesRepository
private async Task<IEnumerable<RecentlyAddedSeries>> GetRecentlyAddedChaptersQuery(int userId)
{
var libraries = await _context.AppUser
var libraryIds = await _context.AppUser
.Where(u => u.Id == userId)
.SelectMany(u => u.Libraries.Select(l => new {LibraryId = l.Id, LibraryType = l.Type}))
.Select(l => l.LibraryId)
.ToListAsync();
var libraryIds = libraries.Select(l => l.LibraryId).ToList();
var withinLastWeek = DateTime.Now - TimeSpan.FromDays(12);
var ret = _context.Chapter
.Where(c => c.Created >= withinLastWeek)
.AsNoTracking()
return _context.Chapter
.Where(c => c.Created >= withinLastWeek).AsNoTracking()
.Include(c => c.Volume)
.ThenInclude(v => v.Series)
.ThenInclude(s => s.Library)
@ -1445,12 +1461,12 @@ public class SeriesRepository : ISeriesRepository
ChapterRange = c.Range,
IsSpecial = c.IsSpecial,
VolumeNumber = c.Volume.Number,
ChapterTitle = c.Title
ChapterTitle = c.Title,
AgeRating = c.Volume.Series.Metadata.AgeRating
})
.AsSplitQuery()
.Where(c => c.Created >= withinLastWeek && libraryIds.Contains(c.LibraryId))
.AsEnumerable();
return ret;
}
public async Task<PagedList<SeriesDto>> GetWantToReadForUserAsync(int userId, UserParams userParams, FilterDto filter)
@ -1503,6 +1519,21 @@ public class SeriesRepository : ISeriesRepository
return map;
}
/// <summary>
/// Returns the highest Age Rating for a list of Series
/// </summary>
/// <param name="seriesIds"></param>
/// <returns></returns>
public async Task<AgeRating> GetMaxAgeRatingFromSeriesAsync(IEnumerable<int> seriesIds)
{
return await _context.Series
.Where(s => seriesIds.Contains(s.Id))
.Include(s => s.Metadata)
.Select(s => s.Metadata.AgeRating)
.OrderBy(s => s)
.LastOrDefaultAsync();
}
private static IQueryable<Series> AddIncludesToQuery(IQueryable<Series> query, SeriesIncludes includeFlags)
{
if (includeFlags.HasFlag(SeriesIncludes.Library))

View File

@ -396,6 +396,7 @@ public class UserRepository : IUserRepository
Created = u.Created,
LastActive = u.LastActive,
Roles = u.UserRoles.Select(r => r.Role.Name).ToList(),
AgeRestriction = u.AgeRestriction,
Libraries = u.Libraries.Select(l => new LibraryDto
{
Name = l.Name,
@ -429,6 +430,7 @@ public class UserRepository : IUserRepository
Created = u.Created,
LastActive = u.LastActive,
Roles = u.UserRoles.Select(r => r.Role.Name).ToList(),
AgeRestriction = u.AgeRestriction,
Libraries = u.Libraries.Select(l => new LibraryDto
{
Name = l.Name,

View File

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using API.Entities.Enums;
using API.Entities.Interfaces;
using Microsoft.AspNetCore.Identity;
@ -40,7 +41,10 @@ public class AppUser : IdentityUser<int>, IHasConcurrencyToken
/// The confirmation token for the user (invite). This will be set to null after the user confirms.
/// </summary>
public string ConfirmationToken { get; set; }
/// <summary>
/// The highest age rating the user has access to. Not applicable for admins
/// </summary>
public AgeRating AgeRestriction { get; set; } = AgeRating.NotApplicable;
/// <inheritdoc />
[ConcurrencyCheck]

View File

@ -8,6 +8,11 @@ namespace API.Entities.Enums;
/// <remarks>Based on ComicInfo.xml v2.1 https://github.com/anansi-project/comicinfo/blob/main/drafts/v2.1/ComicInfo.xsd</remarks>
public enum AgeRating
{
/// <summary>
/// This is for Age Restriction for Restricted Profiles
/// </summary>
[Description("Not Applicable")]
NotApplicable = -1,
[Description("Unknown")]
Unknown = 0,
[Description("Rating Pending")]

View File

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using API.Entities.Enums;
using API.Entities.Interfaces;
namespace API.Entities;
@ -27,6 +28,12 @@ public class ReadingList : IEntityDate
public string CoverImage { get; set; }
public bool CoverImageLocked { get; set; }
/// <summary>
/// The highest age rating from all Series within the reading list
/// </summary>
/// <remarks>Introduced in v0.6</remarks>
public AgeRating AgeRating { get; set; } = AgeRating.Unknown;
public ICollection<ReadingListItem> Items { get; set; }
public DateTime Created { get; set; }
public DateTime LastModified { get; set; }

View File

@ -0,0 +1,23 @@
using System.Linq;
using API.Entities;
using API.Entities.Enums;
namespace API.Extensions;
public static class QueryableExtensions
{
public static IQueryable<Series> RestrictAgainstAgeRestriction(this IQueryable<Series> queryable, AgeRating rating)
{
return queryable.Where(s => rating == AgeRating.NotApplicable || s.Metadata.AgeRating <= rating);
}
public static IQueryable<CollectionTag> RestrictAgainstAgeRestriction(this IQueryable<CollectionTag> queryable, AgeRating rating)
{
return queryable.Where(c => c.SeriesMetadatas.All(sm => sm.AgeRating <= rating));
}
public static IQueryable<ReadingList> RestrictAgainstAgeRestriction(this IQueryable<ReadingList> queryable, AgeRating rating)
{
return queryable.Where(rl => rl.AgeRating <= rating);
}
}

View File

@ -118,4 +118,15 @@ public class AccountService : IAccountService
return roles.Contains(PolicyConstants.DownloadRole) || roles.Contains(PolicyConstants.AdminRole);
}
/// <summary>
/// Does the user have Change Restriction permission or admin rights
/// </summary>
/// <param name="user"></param>
/// <returns></returns>
public async Task<bool> HasChangeRestrictionRole(AppUser user)
{
var roles = await _userManager.GetRolesAsync(user);
return roles.Contains(PolicyConstants.ChangePasswordRole) || roles.Contains(PolicyConstants.AdminRole);
}
}

View File

@ -17,7 +17,7 @@ public interface IReadingListService
Task<bool> DeleteReadingListItem(UpdateReadingListPosition dto);
Task<AppUser?> UserHasReadingListAccess(int readingListId, string username);
Task<bool> DeleteReadingList(int readingListId, AppUser user);
Task CalculateReadingListAgeRating(ReadingList readingList);
Task<bool> AddChaptersToReadingList(int seriesId, IList<int> chapterIds,
ReadingList readingList);
}
@ -41,7 +41,7 @@ public class ReadingListService : IReadingListService
/// <summary>
/// Removes all entries that are fully read from the reading list
/// Removes all entries that are fully read from the reading list. This commits
/// </summary>
/// <remarks>If called from API layer, expected for <see cref="UserHasReadingListAccess"/> to be called beforehand</remarks>
/// <param name="readingListId">Reading List Id</param>
@ -62,10 +62,12 @@ public class ReadingListService : IReadingListService
itemIdsToRemove.Contains(r.Id));
_unitOfWork.ReadingListRepository.BulkRemove(listItems);
var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(readingListId);
await CalculateReadingListAgeRating(readingList);
if (!_unitOfWork.HasChanges()) return true;
await _unitOfWork.CommitAsync();
return true;
return await _unitOfWork.CommitAsync();
}
catch
{
@ -97,6 +99,11 @@ public class ReadingListService : IReadingListService
return await _unitOfWork.CommitAsync();
}
/// <summary>
/// Removes a certain reading list item from a reading list
/// </summary>
/// <param name="dto">Only ReadingListId and ReadingListItemId are used</param>
/// <returns></returns>
public async Task<bool> DeleteReadingListItem(UpdateReadingListPosition dto)
{
var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(dto.ReadingListId);
@ -109,11 +116,34 @@ public class ReadingListService : IReadingListService
index++;
}
await CalculateReadingListAgeRating(readingList);
if (!_unitOfWork.HasChanges()) return true;
return await _unitOfWork.CommitAsync();
}
/// <summary>
/// Calculates the highest Age Rating from each Reading List Item
/// </summary>
/// <param name="readingList"></param>
public async Task CalculateReadingListAgeRating(ReadingList readingList)
{
await CalculateReadingListAgeRating(readingList, readingList.Items.Select(i => i.SeriesId));
}
/// <summary>
/// Calculates the highest Age Rating from each Reading List Item
/// </summary>
/// <remarks>This method is used when the ReadingList doesn't have items yet</remarks>
/// <param name="readingList"></param>
/// <param name="seriesIds">The series ids of all the reading list items</param>
private async Task CalculateReadingListAgeRating(ReadingList readingList, IEnumerable<int> seriesIds)
{
var ageRating = await _unitOfWork.SeriesRepository.GetMaxAgeRatingFromSeriesAsync(seriesIds);
readingList.AgeRating = ageRating;
}
/// <summary>
/// Validates the user has access to the reading list to perform actions on it
/// </summary>
@ -167,16 +197,18 @@ public class ReadingListService : IReadingListService
var existingChapterExists = readingList.Items.Select(rli => rli.ChapterId).ToHashSet();
var chaptersForSeries = (await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(chapterIds))
.OrderBy(c => Tasks.Scanner.Parser.Parser.MinNumberFromRange(c.Volume.Name))
.ThenBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting);
.ThenBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting)
.ToList();
var index = lastOrder + 1;
foreach (var chapter in chaptersForSeries)
foreach (var chapter in chaptersForSeries.Where(chapter => !existingChapterExists.Contains(chapter.Id)))
{
if (existingChapterExists.Contains(chapter.Id)) continue;
readingList.Items.Add(DbFactory.ReadingListItem(index, seriesId, chapter.VolumeId, chapter.Id));
index += 1;
}
await CalculateReadingListAgeRating(readingList, new []{ seriesId });
return index > lastOrder + 1;
}
}

View File

@ -474,6 +474,14 @@ public class SeriesService : ISeriesService
if (!libraryIds.Contains(series.LibraryId))
throw new UnauthorizedAccessException("User does not have access to the library this series belongs to");
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
if (user.AgeRestriction != AgeRating.NotApplicable)
{
var seriesMetadata = await _unitOfWork.SeriesRepository.GetSeriesMetadata(seriesId);
if (seriesMetadata.AgeRating > user.AgeRestriction)
throw new UnauthorizedAccessException("User is not allowed to view this series due to age restrictions");
}
var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId);
var volumes = (await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId))
.OrderBy(v => Tasks.Scanner.Parser.Parser.MinNumberFromRange(v.Name))

View File

@ -84,7 +84,6 @@ public class ParseScannedFiles
if (scanDirectoryByDirectory)
{
// This is used in library scan, so we should check first for a ignore file and use that here as well
// TODO: We need to calculate all folders till library root and see if any kavitaignores
var potentialIgnoreFile = _directoryService.FileSystem.Path.Join(folderPath, DirectoryService.KavitaIgnoreFile);
var matcher = _directoryService.CreateMatcherFromFile(potentialIgnoreFile);
var directories = _directoryService.GetDirectories(folderPath, matcher).ToList();

View File

@ -184,16 +184,20 @@ public class Startup
var userManager = serviceProvider.GetRequiredService<UserManager<AppUser>>();
var themeService = serviceProvider.GetRequiredService<IThemeService>();
var dataContext = serviceProvider.GetRequiredService<DataContext>();
var readingListService = serviceProvider.GetRequiredService<IReadingListService>();
// Only run this if we are upgrading
await MigrateChangePasswordRoles.Migrate(unitOfWork, userManager);
await MigrateRemoveExtraThemes.Migrate(unitOfWork, themeService);
// only needed for v0.5.4 and v0.6.0
await MigrateNormalizedEverything.Migrate(unitOfWork, dataContext, logger);
// v0.6.0
await MigrateChangeRestrictionRoles.Migrate(unitOfWork, userManager, logger);
await MigrateReadingListAgeRating.Migrate(unitOfWork, dataContext, readingListService, logger);
// Update the version in the DB after all migrations are run
var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion);
installVersion.Value = BuildInfo.Version.ToString();

1
TestData Submodule

@ -0,0 +1 @@
Subproject commit 4f5750025a1c0b48cd72eaa6f1b61642c41f147f

View File

@ -1,4 +1,5 @@
import { Library } from './library';
import { AgeRating } from './metadata/age-rating';
export interface Member {
id: number;
@ -6,7 +7,10 @@ export interface Member {
email: string;
lastActive: string; // datetime
created: string; // datetime
//isAdmin: boolean;
roles: string[];
libraries: Library[];
/**
* If not applicable, will store a -1
*/
ageRestriction: AgeRating;
}

View File

@ -1,4 +1,8 @@
export enum AgeRating {
/**
* This is not a valid state for Series/Chapters, but used for Restricted Profiles
*/
NotApplicable = -1,
Unknown = 0,
AdultsOnly = 1,
EarlyChildhood = 2,

View File

@ -1,3 +1,4 @@
import { AgeRating } from './metadata/age-rating';
import { Preferences } from './preferences/preferences';
// This interface is only used for login and storing/retreiving JWT from local storage
@ -9,4 +10,5 @@ export interface User {
preferences: Preferences;
apiKey: string;
email: string;
ageRestriction: AgeRating;
}

View File

@ -10,8 +10,16 @@ import { EVENTS, MessageHubService } from './message-hub.service';
import { ThemeService } from './theme.service';
import { InviteUserResponse } from '../_models/invite-user-response';
import { UserUpdateEvent } from '../_models/events/user-update-event';
import { DeviceService } from './device.service';
import { UpdateEmailResponse } from '../_models/email/update-email-response';
import { AgeRating } from '../_models/metadata/age-rating';
export enum Role {
Admin = 'Admin',
ChangePassword = 'Change Password',
Bookmark = 'Bookmark',
Download = 'Download',
ChangeRestriction = 'Change Restriction'
}
@Injectable({
providedIn: 'root'
@ -49,19 +57,23 @@ export class AccountService implements OnDestroy {
}
hasAdminRole(user: User) {
return user && user.roles.includes('Admin');
return user && user.roles.includes(Role.Admin);
}
hasChangePasswordRole(user: User) {
return user && user.roles.includes('Change Password');
return user && user.roles.includes(Role.ChangePassword);
}
hasChangeAgeRestrictionRole(user: User) {
return user && user.roles.includes(Role.ChangeRestriction);
}
hasDownloadRole(user: User) {
return user && user.roles.includes('Download');
return user && user.roles.includes(Role.Download);
}
hasBookmarkRole(user: User) {
return user && user.roles.includes('Bookmark');
return user && user.roles.includes(Role.Bookmark);
}
getRoles() {
@ -149,7 +161,7 @@ export class AccountService implements OnDestroy {
return this.httpClient.post<string>(this.baseUrl + 'account/resend-confirmation-email?userId=' + userId, {}, {responseType: 'text' as 'json'});
}
inviteUser(model: {email: string, roles: Array<string>, libraries: Array<number>}) {
inviteUser(model: {email: string, roles: Array<string>, libraries: Array<number>, ageRestriction: AgeRating}) {
return this.httpClient.post<InviteUserResponse>(this.baseUrl + 'account/invite', model);
}
@ -186,7 +198,7 @@ export class AccountService implements OnDestroy {
return this.httpClient.post(this.baseUrl + 'account/reset-password', {username, password, oldPassword}, {responseType: 'json' as 'text'});
}
update(model: {email: string, roles: Array<string>, libraries: Array<number>, userId: number}) {
update(model: {email: string, roles: Array<string>, libraries: Array<number>, userId: number, ageRestriction: AgeRating}) {
return this.httpClient.post(this.baseUrl + 'account/update', model);
}
@ -194,6 +206,10 @@ export class AccountService implements OnDestroy {
return this.httpClient.post<UpdateEmailResponse>(this.baseUrl + 'account/update/email', {email});
}
updateAgeRestriction(ageRating: AgeRating) {
return this.httpClient.post(this.baseUrl + 'account/update/age-restriction', {ageRating});
}
/**
* This will get latest preferences for a user and cache them into user store
* @returns

View File

@ -113,12 +113,4 @@ export class LibraryService {
return this.libraryTypes[libraryId];
}));
}
search(term: string) {
if (term === '') {
return of(new SearchResultGroup());
}
return this.httpClient.get<SearchResultGroup>(this.baseUrl + 'library/search?queryString=' + encodeURIComponent(term));
}
}

View File

@ -0,0 +1,31 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { of } from 'rxjs';
import { environment } from 'src/environments/environment';
import { SearchResultGroup } from '../_models/search/search-result-group';
import { Series } from '../_models/series';
@Injectable({
providedIn: 'root'
})
export class SearchService {
baseUrl = environment.apiUrl;
constructor(private httpClient: HttpClient) { }
search(term: string) {
if (term === '') {
return of(new SearchResultGroup());
}
return this.httpClient.get<SearchResultGroup>(this.baseUrl + 'search/search?queryString=' + encodeURIComponent(term));
}
getSeriesForMangaFile(mangaFileId: number) {
return this.httpClient.get<Series | null>(this.baseUrl + 'search/series-for-mangafile?mangaFileId=' + mangaFileId);
}
getSeriesForChapter(chapterId: number) {
return this.httpClient.get<Series | null>(this.baseUrl + 'search/series-for-chapter?chapterId=' + chapterId);
}
}

View File

@ -78,14 +78,6 @@ export class SeriesService {
return this.httpClient.get<ChapterMetadata>(this.baseUrl + 'series/chapter-metadata?chapterId=' + chapterId);
}
getSeriesForMangaFile(mangaFileId: number) {
return this.httpClient.get<Series | null>(this.baseUrl + 'series/series-for-mangafile?mangaFileId=' + mangaFileId);
}
getSeriesForChapter(chapterId: number) {
return this.httpClient.get<Series | null>(this.baseUrl + 'series/series-for-chapter?chapterId=' + chapterId);
}
delete(seriesId: number) {
return this.httpClient.delete<boolean>(this.baseUrl + 'series/' + seriesId);
}

View File

@ -1,58 +1,66 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Edit {{member.username | sentenceCase}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
</button>
</div>
<div class="modal-body">
<div class="modal-container">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Edit {{member.username | sentenceCase}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
</button>
</div>
<div class="modal-body scrollable-modal">
<form [formGroup]="userForm">
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input id="username" class="form-control" formControlName="username" type="text" [class.is-invalid]="userForm.get('username')?.invalid && userForm.get('username')?.touched">
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="userForm.dirty || userForm.touched">
<div *ngIf="userForm.get('username')?.errors?.required">
This field is required
<form [formGroup]="userForm">
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input id="username" class="form-control" formControlName="username" type="text" [class.is-invalid]="userForm.get('username')?.invalid && userForm.get('username')?.touched">
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="userForm.dirty || userForm.touched">
<div *ngIf="userForm.get('username')?.errors?.required">
This field is required
</div>
</div>
</div>
</div>
<div class="col-md-6 col-sm-12">
<div class="mb-3" style="width:100%">
<label for="email" class="form-label">Email</label>
<input class="form-control" type="email" id="email" formControlName="email">
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="userForm.dirty || userForm.touched" [class.is-invalid]="userForm.get('email')?.invalid && userForm.get('email')?.touched">
<div *ngIf="userForm.get('email')?.errors?.required">
This field is required
</div>
<div *ngIf="userForm.get('email')?.errors?.email">
This must be a valid email address
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6 col-sm-12">
<div class="mb-3" style="width:100%">
<label for="email" class="form-label">Email</label>
<input class="form-control" type="email" id="email" formControlName="email">
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="userForm.dirty || userForm.touched" [class.is-invalid]="userForm.get('email')?.invalid && userForm.get('email')?.touched">
<div *ngIf="userForm.get('email')?.errors?.required">
This field is required
</div>
<div *ngIf="userForm.get('email')?.errors?.email">
This must be a valid email address
</div>
</div>
<div class="row g-0">
<div class="col-md-6">
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true" [member]="member"></app-role-selector>
</div>
<div class="col-md-6">
<app-library-selector (selected)="updateLibrarySelection($event)" [member]="member"></app-library-selector>
</div>
</div>
</div>
<div class="row g-0">
<div class="col-md-6">
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true" [member]="member"></app-role-selector>
<div class="row g-0">
<div class="col-md-12">
<app-restriction-selector (selected)="updateRestrictionSelection($event)" [isAdmin]="hasAdminRoleSelected" [member]="member"></app-restriction-selector>
</div>
</div>
</form>
<div class="col-md-6">
<app-library-selector (selected)="updateLibrarySelection($event)" [member]="member"></app-library-selector>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="close()">
Cancel
</button>
<button type="button" class="btn btn-primary" (click)="save()" [disabled]="isSaving || !userForm.valid">
<span *ngIf="isSaving" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span>{{isSaving ? 'Saving...' : 'Update'}}</span>
</button>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="close()">
Cancel
</button>
<button type="button" class="btn btn-primary" (click)="save()" [disabled]="isSaving || !userForm.valid">
<span *ngIf="isSaving" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span>{{isSaving ? 'Saving...' : 'Update'}}</span>
</button>
</div>
</div>

View File

@ -3,6 +3,7 @@ import { FormGroup, FormControl, Validators } from '@angular/forms';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { Library } from 'src/app/_models/library';
import { Member } from 'src/app/_models/member';
import { AgeRating } from 'src/app/_models/metadata/age-rating';
import { AccountService } from 'src/app/_services/account.service';
// TODO: Rename this to EditUserModal
@ -17,6 +18,7 @@ export class EditUserComponent implements OnInit {
selectedRoles: Array<string> = [];
selectedLibraries: Array<number> = [];
selectedRating: AgeRating = AgeRating.NotApplicable;
isSaving: boolean = false;
userForm: FormGroup = new FormGroup({});
@ -24,6 +26,7 @@ export class EditUserComponent implements OnInit {
public get email() { return this.userForm.get('email'); }
public get username() { return this.userForm.get('username'); }
public get password() { return this.userForm.get('password'); }
public get hasAdminRoleSelected() { return this.selectedRoles.includes('Admin'); };
constructor(public modal: NgbActiveModal, private accountService: AccountService) { }
@ -38,6 +41,10 @@ export class EditUserComponent implements OnInit {
this.selectedRoles = roles;
}
updateRestrictionSelection(rating: AgeRating) {
this.selectedRating = rating;
}
updateLibrarySelection(libraries: Array<Library>) {
this.selectedLibraries = libraries.map(l => l.id);
}
@ -51,6 +58,8 @@ export class EditUserComponent implements OnInit {
model.userId = this.member.id;
model.roles = this.selectedRoles;
model.libraries = this.selectedLibraries;
model.ageRestriction = this.selectedRating || AgeRating.NotApplicable;
console.log('rating: ', this.selectedRating);
this.accountService.update(model).subscribe(() => {
this.modal.close(true);
});

View File

@ -1,55 +1,61 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Invite User</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
<div class="modal-container">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Invite User</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div>
<div class="modal-body scrollable-modal">
<p>
Invite a user to your server. Enter their email in and we will send them an email to create an account. If you do not want to use our email service, you can <a href="https://wiki.kavitareader.com/en/guides/misc/email" rel="noopener noreferrer" target="_blank" rel="noopener noreferrer">host your own</a>
email service or use a fake email (Forgot User will not work). A link will be presented regardless and can be used to setup the email account manually.
</p>
</button>
</div>
<div class="modal-body">
<p>
Invite a user to your server. Enter their email in and we will send them an email to create an account. If you do not want to use our email service, you can <a href="https://wiki.kavitareader.com/en/guides/misc/email" rel="noopener noreferrer" target="_blank" rel="noopener noreferrer">host your own</a>
email service or use a fake email (Forgot User will not work). A link will be presented regardless and can be used to setup the email account manually.
</p>
<form [formGroup]="inviteForm" *ngIf="emailLink === ''">
<div class="row g-0">
<div class="mb-3" style="width:100%">
<label for="email" class="form-label">Email</label>
<input class="form-control" type="email" id="email" formControlName="email" required [class.is-invalid]="inviteForm.get('email')?.invalid && inviteForm.get('email')?.touched">
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="inviteForm.dirty || inviteForm.touched">
<div *ngIf="email?.errors?.required">
This field is required
<form [formGroup]="inviteForm" *ngIf="emailLink === ''">
<div class="row g-0">
<div class="mb-3" style="width:100%">
<label for="email" class="form-label">Email</label>
<input class="form-control" type="email" id="email" formControlName="email" required [class.is-invalid]="inviteForm.get('email')?.invalid && inviteForm.get('email')?.touched">
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="inviteForm.dirty || inviteForm.touched">
<div *ngIf="email?.errors?.required">
This field is required
</div>
</div>
</div>
</div>
</div>
<div class="row g-0">
<div class="col-md-6">
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true"></app-role-selector>
<div class="row g-0">
<div class="col-md-6">
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true"></app-role-selector>
</div>
<div class="col-md-6">
<app-library-selector (selected)="updateLibrarySelection($event)"></app-library-selector>
</div>
</div>
<div class="col-md-6">
<app-library-selector (selected)="updateLibrarySelection($event)"></app-library-selector>
<div class="row g-0">
<div class="col-md-12">
<app-restriction-selector (selected)="updateRestrictionSelection($event)" [isAdmin]="hasAdminRoleSelected"></app-restriction-selector>
</div>
</div>
</div>
</form>
<ng-container *ngIf="emailLink !== ''">
<h4>User invited</h4>
<p>You can use the following link below to setup the account for your user or use the copy button. You may need to log out before using the link to register a new user.
If your server is externally accessible, an email will have been sent to the user and the links can be used by them to finish setting up their account.
</p>
<a class="email-link" href="{{emailLink}}" target="_blank" rel="noopener noreferrer">Setup user's account</a>
<app-api-key title="Invite Url" tooltipText="Copy this and paste in a new tab. You may need to log out." [showRefresh]="false" [transform]="makeLink"></app-api-key>
</ng-container>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="close()">
Cancel
</button>
<button type="button" class="btn btn-primary" (click)="invite()" [disabled]="isSending || !inviteForm.valid || emailLink !== ''">
<span *ngIf="isSending" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span>{{isSending ? 'Inviting...' : 'Invite'}}</span>
</button>
</div>
</form>
<ng-container *ngIf="emailLink !== ''">
<h4>User invited</h4>
<p>You can use the following link below to setup the account for your user or use the copy button. You may need to log out before using the link to register a new user.
If your server is externally accessible, an email will have been sent to the user and the links can be used by them to finish setting up their account.
</p>
<a class="email-link" href="{{emailLink}}" target="_blank" rel="noopener noreferrer">Setup user's account</a>
<app-api-key title="Invite Url" tooltipText="Copy this and paste in a new tab. You may need to log out." [showRefresh]="false" [transform]="makeLink"></app-api-key>
</ng-container>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="close()">
Cancel
</button>
<button type="button" class="btn btn-primary" (click)="invite()" [disabled]="isSending || !inviteForm.valid || emailLink !== ''">
<span *ngIf="isSending" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span>{{isSending ? 'Inviting...' : 'Invite'}}</span>
</button>
</div>
</div>

View File

@ -2,11 +2,10 @@ import { Component, OnInit } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { ConfirmService } from 'src/app/shared/confirm.service';
import { InviteUserResponse } from 'src/app/_models/invite-user-response';
import { Library } from 'src/app/_models/library';
import { AgeRating } from 'src/app/_models/metadata/age-rating';
import { AccountService } from 'src/app/_services/account.service';
import { ServerService } from 'src/app/_services/server.service';
@Component({
selector: 'app-invite-user',
@ -22,14 +21,16 @@ export class InviteUserComponent implements OnInit {
inviteForm: FormGroup = new FormGroup({});
selectedRoles: Array<string> = [];
selectedLibraries: Array<number> = [];
selectedRating: AgeRating = AgeRating.NotApplicable;
emailLink: string = '';
makeLink: (val: string) => string = (val: string) => {return this.emailLink};
public get hasAdminRoleSelected() { return this.selectedRoles.includes('Admin'); };
public get email() { return this.inviteForm.get('email'); }
constructor(public modal: NgbActiveModal, private accountService: AccountService, private serverService: ServerService,
private confirmService: ConfirmService, private toastr: ToastrService) { }
constructor(public modal: NgbActiveModal, private accountService: AccountService, private toastr: ToastrService) { }
ngOnInit(): void {
this.inviteForm.addControl('email', new FormControl('', [Validators.required]));
@ -47,6 +48,7 @@ export class InviteUserComponent implements OnInit {
email,
libraries: this.selectedLibraries,
roles: this.selectedRoles,
ageRestriction: this.selectedRating
}).subscribe((data: InviteUserResponse) => {
this.emailLink = data.emailLink;
this.isSending = false;
@ -67,4 +69,8 @@ export class InviteUserComponent implements OnInit {
this.selectedLibraries = libraries.map(l => l.id);
}
updateRestrictionSelection(rating: AgeRating) {
this.selectedRating = rating;
}
}

View File

@ -1,6 +1,7 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { Member } from 'src/app/_models/member';
import { User } from 'src/app/_models/user';
import { AccountService } from 'src/app/_services/account.service';
import { MemberService } from 'src/app/_services/member.service';
@ -11,7 +12,10 @@ import { MemberService } from 'src/app/_services/member.service';
})
export class RoleSelectorComponent implements OnInit {
@Input() member: Member | undefined;
/**
* This must have roles
*/
@Input() member: Member | undefined | User;
/**
* Allows the selection of Admin role
*/
@ -25,7 +29,7 @@ export class RoleSelectorComponent implements OnInit {
ngOnInit(): void {
this.accountService.getRoles().subscribe(roles => {
let bannedRoles = ['Pleb'];
const bannedRoles = ['Pleb'];
if (!this.allowAdmin) {
bannedRoles.push('Admin');
}

View File

@ -1,8 +1,3 @@
.scrollable-modal {
max-height: calc(var(--vh) * 100 - 198px);
overflow: auto;
}
.lock-active {
> .input-group-text {
background-color: var(--primary-color);

View File

@ -8,6 +8,7 @@ import { Series } from 'src/app/_models/series';
import { RelationKind, RelationKinds } from 'src/app/_models/series-detail/relation-kind';
import { ImageService } from 'src/app/_services/image.service';
import { LibraryService } from 'src/app/_services/library.service';
import { SearchService } from 'src/app/_services/search.service';
import { SeriesService } from 'src/app/_services/series.service';
interface RelationControl {
@ -47,7 +48,7 @@ export class EditSeriesRelationComponent implements OnInit, OnDestroy {
private onDestroy: Subject<void> = new Subject<void>();
constructor(private seriesService: SeriesService, private utilityService: UtilityService,
public imageService: ImageService, private libraryService: LibraryService,
public imageService: ImageService, private libraryService: LibraryService, private searchService: SearchService,
private readonly cdRef: ChangeDetectorRef) {}
ngOnInit(): void {
@ -127,7 +128,7 @@ export class EditSeriesRelationComponent implements OnInit, OnDestroy {
seriesSettings.id = 'relation--' + index;
seriesSettings.unique = true;
seriesSettings.addIfNonExisting = false;
seriesSettings.fetchFn = (searchFilter: string) => this.libraryService.search(searchFilter).pipe(
seriesSettings.fetchFn = (searchFilter: string) => this.searchService.search(searchFilter).pipe(
map(group => group.series),
map(items => seriesSettings.compareFn(items, searchFilter)),
map(series => series.filter(s => s.seriesId !== this.series.id)),
@ -142,7 +143,7 @@ export class EditSeriesRelationComponent implements OnInit, OnDestroy {
}
if (series !== undefined) {
return this.libraryService.search(series.name).pipe(
return this.searchService.search(series.name).pipe(
map(group => group.series), map(results => {
seriesSettings.savedData = results.filter(s => s.seriesId === series.id);
return seriesSettings;

View File

@ -6,7 +6,7 @@ import { debounceTime, distinctUntilChanged, filter, takeUntil, tap } from 'rxjs
import { Chapter } from 'src/app/_models/chapter';
import { MangaFile } from 'src/app/_models/manga-file';
import { ScrollService } from 'src/app/_services/scroll.service';
import { SeriesService } from 'src/app/_services/series.service';
import { SearchService } from 'src/app/_services/search.service';
import { FilterQueryParam } from '../../shared/_services/filter-utilities.service';
import { CollectionTag } from '../../_models/collection-tag';
import { Library } from '../../_models/library';
@ -52,8 +52,8 @@ export class NavHeaderComponent implements OnInit, OnDestroy {
private readonly onDestroy = new Subject<void>();
constructor(public accountService: AccountService, private router: Router, public navService: NavService,
private libraryService: LibraryService, public imageService: ImageService, @Inject(DOCUMENT) private document: Document,
private scrollService: ScrollService, private seriesService: SeriesService, private readonly cdRef: ChangeDetectorRef) {
public imageService: ImageService, @Inject(DOCUMENT) private document: Document,
private scrollService: ScrollService, private searchService: SearchService, private readonly cdRef: ChangeDetectorRef) {
this.scrollElem = this.document.body;
}
@ -110,7 +110,7 @@ export class NavHeaderComponent implements OnInit, OnDestroy {
this.searchTerm = val.trim();
this.cdRef.markForCheck();
this.libraryService.search(val.trim()).pipe(takeUntil(this.onDestroy)).subscribe(results => {
this.searchService.search(val.trim()).pipe(takeUntil(this.onDestroy)).subscribe(results => {
this.searchResults = results;
this.isLoading = false;
this.cdRef.markForCheck();
@ -185,7 +185,7 @@ export class NavHeaderComponent implements OnInit, OnDestroy {
clickFileSearchResult(item: MangaFile) {
this.clearSearch();
this.seriesService.getSeriesForMangaFile(item.id).subscribe(series => {
this.searchService.getSeriesForMangaFile(item.id).subscribe(series => {
if (series !== undefined && series !== null) {
this.router.navigate(['library', series.libraryId, 'series', series.id]);
}
@ -194,7 +194,7 @@ export class NavHeaderComponent implements OnInit, OnDestroy {
clickChapterSearchResult(item: Chapter) {
this.clearSearch();
this.seriesService.getSeriesForChapter(item.id).subscribe(series => {
this.searchService.getSeriesForChapter(item.id).subscribe(series => {
if (series !== undefined && series !== null) {
this.router.navigate(['library', series.libraryId, 'series', series.id]);
}

View File

@ -11,7 +11,9 @@ export class AgeRatingPipe implements PipeTransform {
constructor(private metadataService: MetadataService) {}
transform(value: AgeRating | AgeRatingDto): Observable<string> {
transform(value: AgeRating | AgeRatingDto | undefined): Observable<string> {
if (value === undefined || value === null) return of('undefined');
if (value.hasOwnProperty('title')) {
return of((value as AgeRatingDto).title);
}

View File

@ -6,7 +6,7 @@ import { Pipe, PipeTransform } from '@angular/core';
export class DefaultValuePipe implements PipeTransform {
transform(value: any, replacementString = '—'): string {
if (value === null || value === undefined || value === '' || value === Infinity || value === NaN || value === {}) return replacementString;
if (value === null || value === undefined || value === '' || value === Infinity || value === NaN) return replacementString;
return value;
}

View File

@ -0,0 +1,32 @@
<div class="card mt-2">
<div class="card-body">
<div class="card-title">
<div class="row mb-2">
<div class="col-11"><h4 id="age-restriction">Age Restriction</h4></div>
<div class="col-1">
<button class="btn btn-primary btn-sm" (click)="toggleViewMode()" *ngIf="(hasChangeAgeRestrictionAbility | async)">{{isViewMode ? 'Edit' : 'Cancel'}}</button>
</div>
</div>
</div>
<ng-container *ngIf="isViewMode">
<span >{{user?.ageRestriction | ageRating | async}}</span>
</ng-container>
<div #collapse="ngbCollapse" [(ngbCollapse)]="isViewMode">
<ng-container *ngIf="user">
<app-restriction-selector (selected)="updateRestrictionSelection($event)" [showContext]="false" [member]="user" [reset]="reset"></app-restriction-selector>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
<button type="button" class="flex-fill btn btn-secondary me-2" aria-describedby="age-restriction" (click)="resetForm()">Reset</button>
<button type="submit" class="flex-fill btn btn-primary" aria-describedby="age-restriction" (click)="saveForm()">Save</button>
</div>
</ng-container>
</div>
</div>
</div>

View File

@ -0,0 +1,79 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, OnInit } from '@angular/core';
import { ToastrService } from 'ngx-toastr';
import { Observable, of, Subject, takeUntil, shareReplay, map, take } from 'rxjs';
import { AgeRating } from 'src/app/_models/metadata/age-rating';
import { User } from 'src/app/_models/user';
import { AccountService } from 'src/app/_services/account.service';
@Component({
selector: 'app-change-age-restriction',
templateUrl: './change-age-restriction.component.html',
styleUrls: ['./change-age-restriction.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ChangeAgeRestrictionComponent implements OnInit {
user: User | undefined = undefined;
hasChangeAgeRestrictionAbility: Observable<boolean> = of(false);
isViewMode: boolean = true;
selectedRating: AgeRating = AgeRating.NotApplicable;
originalRating!: AgeRating;
reset: EventEmitter<AgeRating> = new EventEmitter();
get AgeRating() { return AgeRating; }
private onDestroy = new Subject<void>();
constructor(private accountService: AccountService, private toastr: ToastrService, private readonly cdRef: ChangeDetectorRef) { }
ngOnInit(): void {
this.accountService.currentUser$.pipe(takeUntil(this.onDestroy), shareReplay(), take(1)).subscribe(user => {
this.user = user;
this.originalRating = this.user?.ageRestriction || AgeRating.NotApplicable;
this.cdRef.markForCheck();
});
this.hasChangeAgeRestrictionAbility = this.accountService.currentUser$.pipe(takeUntil(this.onDestroy), shareReplay(), map(user => {
return user !== undefined && (!this.accountService.hasAdminRole(user) && this.accountService.hasChangeAgeRestrictionRole(user));
}));
this.cdRef.markForCheck();
}
updateRestrictionSelection(rating: AgeRating) {
this.selectedRating = rating;
}
ngOnDestroy() {
this.onDestroy.next();
this.onDestroy.complete();
}
resetForm() {
if (!this.user) return;
console.log('resetting to ', this.originalRating)
this.reset.emit(this.originalRating);
this.cdRef.markForCheck();
}
saveForm() {
if (this.user === undefined) { return; }
this.accountService.updateAgeRestriction(this.selectedRating).subscribe(() => {
this.toastr.success('Age Restriction has been updated');
this.originalRating = this.selectedRating;
if (this.user) {
this.user.ageRestriction = this.selectedRating;
}
this.resetForm();
this.isViewMode = true;
}, err => {
});
}
toggleViewMode() {
this.isViewMode = !this.isViewMode;
this.resetForm();
}
}

View File

@ -1,7 +1,7 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { ToastrService } from 'ngx-toastr';
import { map, Observable, of, shareReplay, Subject, takeUntil } from 'rxjs';
import { map, Observable, of, shareReplay, Subject, take, takeUntil } from 'rxjs';
import { User } from 'src/app/_models/user';
import { AccountService } from 'src/app/_services/account.service';
@ -29,6 +29,12 @@ export class ChangePasswordComponent implements OnInit, OnDestroy {
constructor(private accountService: AccountService, private toastr: ToastrService, private readonly cdRef: ChangeDetectorRef) { }
ngOnInit(): void {
this.accountService.currentUser$.pipe(takeUntil(this.onDestroy), shareReplay(), take(1)).subscribe(user => {
this.user = user;
this.cdRef.markForCheck();
});
this.hasChangePasswordAbility = this.accountService.currentUser$.pipe(takeUntil(this.onDestroy), shareReplay(), map(user => {
return user !== undefined && (this.accountService.hasAdminRole(user) || this.accountService.hasChangePasswordRole(user));
}));
@ -38,8 +44,6 @@ export class ChangePasswordComponent implements OnInit, OnDestroy {
this.passwordChangeForm.addControl('confirmPassword', new FormControl('', [Validators.required]));
this.passwordChangeForm.addControl('oldPassword', new FormControl('', [Validators.required]));
this.observableHandles.push(this.passwordChangeForm.valueChanges.subscribe(() => {
const values = this.passwordChangeForm.value;
this.passwordsMatch = values.password === values.confirmPassword;

View File

@ -0,0 +1,19 @@
<ng-container *ngIf="restrictionForm">
<ng-container *ngIf="showContext">
<h4>Age Rating Restriction</h4>
<p>When selected, all series and reading lists that have at least one item that is greater than the selected restriction will be pruned from results.
<ng-container *ngIf="isAdmin">This is not applicable for admins.</ng-container>
</p>
</ng-container>
<form [formGroup]="restrictionForm">
<div class="mb-3">
<label for="age-rating" class="form-label visually-hidden">Age Rating</label>
<div class="input-group">
<select class="form-select"id="age-rating" formControlName="ageRating">
<option value="-1">No Restriction</option>
<option *ngFor="let opt of ageRatings" [value]="opt.value">{{opt.title | titlecase}}</option>
</select>
</div>
</div>
</form>
</ng-container>

View File

@ -0,0 +1,68 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
import { Member } from 'src/app/_models/member';
import { AgeRating } from 'src/app/_models/metadata/age-rating';
import { AgeRatingDto } from 'src/app/_models/metadata/age-rating-dto';
import { User } from 'src/app/_models/user';
import { MetadataService } from 'src/app/_services/metadata.service';
@Component({
selector: 'app-restriction-selector',
templateUrl: './restriction-selector.component.html',
styleUrls: ['./restriction-selector.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class RestrictionSelectorComponent implements OnInit, OnChanges {
@Input() member: Member | undefined | User;
@Input() isAdmin: boolean = false;
/**
* Show labels and description around the form
*/
@Input() showContext: boolean = true;
@Input() reset: EventEmitter<AgeRating> | undefined;
@Output() selected: EventEmitter<AgeRating> = new EventEmitter<AgeRating>();
ageRatings: Array<AgeRatingDto> = [];
restrictionForm: FormGroup | undefined;
constructor(private metadataService: MetadataService, private readonly cdRef: ChangeDetectorRef) { }
ngOnInit(): void {
this.restrictionForm = new FormGroup({
'ageRating': new FormControl(this.member?.ageRestriction || AgeRating.NotApplicable, [])
});
if (this.isAdmin) {
this.restrictionForm.get('ageRating')?.disable();
}
if (this.reset) {
this.reset.subscribe(e => {
this.restrictionForm?.get('ageRating')?.setValue(e);
this.cdRef.markForCheck();
});
}
this.restrictionForm.get('ageRating')?.valueChanges.subscribe(e => {
this.selected.emit(parseInt(e, 10));
});
this.metadataService.getAllAgeRatings().subscribe(ratings => {
this.ageRatings = ratings;
this.cdRef.markForCheck();
});
}
ngOnChanges() {
if (!this.member) return;
console.log('changes: ');
this.restrictionForm?.get('ageRating')?.setValue(this.member?.ageRestriction || AgeRating.NotApplicable);
this.cdRef.markForCheck();
}
}

View File

@ -11,6 +11,7 @@
<ng-container *ngIf="tab.fragment === FragmentID.Account">
<app-change-email></app-change-email>
<app-change-password></app-change-password>
<app-change-age-restriction></app-change-age-restriction>
</ng-container>
<ng-container *ngIf="tab.fragment === FragmentID.Prefernces">
<p>

View File

@ -15,6 +15,8 @@ import { DevicePlatformPipe } from './_pipes/device-platform.pipe';
import { EditDeviceComponent } from './edit-device/edit-device.component';
import { ChangePasswordComponent } from './change-password/change-password.component';
import { ChangeEmailComponent } from './change-email/change-email.component';
import { ChangeAgeRestrictionComponent } from './change-age-restriction/change-age-restriction.component';
import { RestrictionSelectorComponent } from './restriction-selector/restriction-selector.component';
@NgModule({
@ -28,6 +30,8 @@ import { ChangeEmailComponent } from './change-email/change-email.component';
EditDeviceComponent,
ChangePasswordComponent,
ChangeEmailComponent,
RestrictionSelectorComponent,
ChangeAgeRestrictionComponent,
],
imports: [
CommonModule,
@ -47,7 +51,8 @@ import { ChangeEmailComponent } from './change-email/change-email.component';
],
exports: [
SiteThemeProviderPipe,
ApiKeyComponent
ApiKeyComponent,
RestrictionSelectorComponent
]
})
export class UserSettingsModule { }

View File

@ -6,3 +6,8 @@
.modal-title {
word-break: break-all;
}
.scrollable-modal {
max-height: calc(var(--vh) * 100 - 198px);
overflow: auto;
}