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

@ -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