using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using API.Constants; using API.DTOs; using API.DTOs.Account; using API.DTOs.Dashboard; using API.DTOs.Filtering.v2; using API.DTOs.KavitaPlus.Account; using API.DTOs.Reader; using API.DTOs.Scrobbling; using API.DTOs.SeriesDetail; using API.DTOs.SideNav; using API.Entities; using API.Extensions; using API.Extensions.QueryExtensions; using API.Extensions.QueryExtensions.Filtering; using API.Helpers; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; #nullable enable [Flags] public enum AppUserIncludes { None = 1, Progress = 2, Bookmarks = 4, ReadingLists = 8, Ratings = 16, UserPreferences = 32, WantToRead = 64, ReadingListsWithItems = 128, Devices = 256, ScrobbleHolds = 512, SmartFilters = 1024, DashboardStreams = 2048, SideNavStreams = 4096, ExternalSources = 8192, Collections = 16384, // 2^14 ChapterRatings = 1 << 15, } public interface IUserRepository { void Add(AppUserBookmark bookmark); void Add(AppUser bookmark); void Update(AppUser user); void Update(AppUserPreferences preferences); void Update(AppUserBookmark bookmark); void Update(AppUserDashboardStream stream); void Update(AppUserSideNavStream stream); void Delete(AppUser? user); void Delete(AppUserBookmark bookmark); void Delete(IEnumerable streams); void Delete(AppUserDashboardStream stream); void Delete(IEnumerable streams); void Delete(AppUserSideNavStream stream); Task> GetEmailConfirmedMemberDtosAsync(bool emailConfirmed = true); Task> GetAdminUsersAsync(); Task IsUserAdminAsync(AppUser? user); Task> GetRoles(int userId); Task GetUserRatingAsync(int seriesId, int userId); Task GetUserChapterRatingAsync(int userId, int chapterId); Task> GetUserRatingDtosForSeriesAsync(int seriesId, int userId); Task> GetUserRatingDtosForChapterAsync(int chapterId, int userId); Task GetPreferencesAsync(string username); Task> GetBookmarkDtosForSeries(int userId, int seriesId); Task> GetBookmarkDtosForVolume(int userId, int volumeId); Task> GetBookmarkDtosForChapter(int userId, int chapterId); Task> GetAllBookmarkDtos(int userId, FilterV2Dto filter); Task> GetAllBookmarksAsync(); Task GetBookmarkForPage(int page, int chapterId, int userId); Task GetBookmarkAsync(int bookmarkId); Task GetUserIdByApiKeyAsync(string apiKey); Task GetUserByUsernameAsync(string username, AppUserIncludes includeFlags = AppUserIncludes.None); Task GetUserByIdAsync(int userId, AppUserIncludes includeFlags = AppUserIncludes.None); Task GetUserIdByUsernameAsync(string username); Task> GetAllBookmarksByIds(IList bookmarkIds); Task GetUserByEmailAsync(string email, AppUserIncludes includes = AppUserIncludes.None); Task> GetAllPreferencesByThemeAsync(int themeId); Task HasAccessToLibrary(int libraryId, int userId); Task HasAccessToSeries(int userId, int seriesId); Task> GetAllUsersAsync(AppUserIncludes includeFlags = AppUserIncludes.None, bool track = true); Task GetUserByConfirmationToken(string token); Task GetDefaultAdminUser(AppUserIncludes includes = AppUserIncludes.None); Task> GetSeriesWithRatings(int userId); Task> GetSeriesWithReviews(int userId); Task HasHoldOnSeries(int userId, int seriesId); Task> GetHolds(int userId); Task GetLocale(int userId); Task> GetDashboardStreams(int userId, bool visibleOnly = false); Task> GetAllDashboardStreams(); Task GetDashboardStream(int streamId); Task> GetDashboardStreamWithFilter(int filterId); Task> GetSideNavStreams(int userId, bool visibleOnly = false); Task GetSideNavStream(int streamId); Task GetSideNavStreamWithUser(int streamId); Task> GetSideNavStreamWithFilter(int filterId); Task> GetSideNavStreamsByLibraryId(int libraryId); Task> GetSideNavStreamWithExternalSource(int externalSourceId); Task> GetDashboardStreamsByIds(IList streamIds); Task> GetUserTokenInfo(); Task GetUserByDeviceEmail(string deviceEmail); } public class UserRepository : IUserRepository { private readonly DataContext _context; private readonly UserManager _userManager; private readonly IMapper _mapper; public UserRepository(DataContext context, UserManager userManager, IMapper mapper) { _context = context; _userManager = userManager; _mapper = mapper; } public void Add(AppUserBookmark bookmark) { _context.AppUserBookmark.Add(bookmark); } public void Add(AppUser user) { _context.AppUser.Add(user); } public void Update(AppUser user) { _context.Entry(user).State = EntityState.Modified; } public void Update(AppUserPreferences preferences) { _context.Entry(preferences).State = EntityState.Modified; } public void Update(AppUserBookmark bookmark) { _context.Entry(bookmark).State = EntityState.Modified; } public void Update(AppUserDashboardStream stream) { _context.Entry(stream).State = EntityState.Modified; } public void Update(AppUserSideNavStream stream) { _context.Entry(stream).State = EntityState.Modified; } public void Delete(AppUser? user) { if (user == null) return; _context.AppUser.Remove(user); } public void Delete(AppUserBookmark bookmark) { _context.AppUserBookmark.Remove(bookmark); } public void Delete(IEnumerable streams) { _context.AppUserDashboardStream.RemoveRange(streams); } public void Delete(AppUserDashboardStream stream) { _context.AppUserDashboardStream.Remove(stream); } public void Delete(IEnumerable streams) { _context.AppUserSideNavStream.RemoveRange(streams); } public void Delete(AppUserSideNavStream stream) { _context.AppUserSideNavStream.Remove(stream); } /// /// A one stop shop to get a tracked AppUser instance with any number of JOINs generated by passing bitwise flags. /// /// /// Includes() you want. Pass multiple with flag1 | flag2 /// public async Task GetUserByUsernameAsync(string username, AppUserIncludes includeFlags = AppUserIncludes.None) { return await _context.Users .Where(x => x.UserName == username) .Includes(includeFlags) .SingleOrDefaultAsync(); } /// /// A one stop shop to get a tracked AppUser instance with any number of JOINs generated by passing bitwise flags. /// /// /// Includes() you want. Pass multiple with flag1 | flag2 /// public async Task GetUserByIdAsync(int userId, AppUserIncludes includeFlags = AppUserIncludes.None) { return await _context.Users .Where(x => x.Id == userId) .Includes(includeFlags) .FirstOrDefaultAsync(); } public async Task> GetAllBookmarksAsync() { return await _context.AppUserBookmark.ToListAsync(); } public async Task GetBookmarkForPage(int page, int chapterId, int userId) { return await _context.AppUserBookmark .Where(b => b.Page == page && b.ChapterId == chapterId && b.AppUserId == userId) .SingleOrDefaultAsync(); } public async Task GetBookmarkAsync(int bookmarkId) { return await _context.AppUserBookmark .Where(b => b.Id == bookmarkId) .SingleOrDefaultAsync(); } /// /// This fetches the Id for a user. Use whenever you just need an ID. /// /// /// public async Task GetUserIdByUsernameAsync(string username) { return await _context.Users .Where(x => x.UserName == username) .Select(u => u.Id) .SingleOrDefaultAsync(); } /// /// Returns all Bookmarks for a given set of Ids /// /// /// public async Task> GetAllBookmarksByIds(IList bookmarkIds) { return await _context.AppUserBookmark .Where(b => bookmarkIds.Contains(b.Id)) .OrderBy(b => b.Created) .ToListAsync(); } public async Task GetUserByEmailAsync(string email, AppUserIncludes includes = AppUserIncludes.None) { var lowerEmail = email.ToLower(); return await _context.AppUser .Includes(includes) .FirstOrDefaultAsync(u => u.Email != null && u.Email.ToLower().Equals(lowerEmail)); } public async Task> GetAllPreferencesByThemeAsync(int themeId) { return await _context.AppUserPreferences .Include(p => p.Theme) .Where(p => p.Theme.Id == themeId) .AsSplitQuery() .ToListAsync(); } public async Task HasAccessToLibrary(int libraryId, int userId) { return await _context.Library .Include(l => l.AppUsers) .AsSplitQuery() .AnyAsync(library => library.AppUsers.Any(user => user.Id == userId) && library.Id == libraryId); } /// /// Does the user have library and age restriction access to a given series /// /// public async Task HasAccessToSeries(int userId, int seriesId) { var userRating = await _context.AppUser.GetUserAgeRestriction(userId); return await _context.Series .Include(s => s.Library) .Where(s => s.Library.AppUsers.Any(user => user.Id == userId)) .RestrictAgainstAgeRestriction(userRating) .AsSplitQuery() .AnyAsync(s => s.Id == seriesId); } public async Task> GetAllUsersAsync(AppUserIncludes includeFlags = AppUserIncludes.None, bool track = true) { var query = _context.AppUser .Includes(includeFlags); if (track) { return await query.ToListAsync(); } return await query .AsNoTracking() .ToListAsync(); } public async Task GetUserByConfirmationToken(string token) { return await _context.AppUser .SingleOrDefaultAsync(u => u.ConfirmationToken != null && u.ConfirmationToken.Equals(token)); } /// /// Returns the first admin account created /// /// public async Task GetDefaultAdminUser(AppUserIncludes includes = AppUserIncludes.None) { return await _context.AppUser .Includes(includes) .Where(u => u.UserRoles.Any(r => r.Role.Name == PolicyConstants.AdminRole)) .OrderBy(u => u.Created) .FirstAsync(); } public async Task> GetSeriesWithRatings(int userId) { return await _context.AppUserRating .Where(u => u.AppUserId == userId && u.Rating > 0) .Include(u => u.Series) .AsSplitQuery() .ToListAsync(); } public async Task> GetSeriesWithReviews(int userId) { return await _context.AppUserRating .Where(u => u.AppUserId == userId && !string.IsNullOrEmpty(u.Review)) .Include(u => u.Series) .AsSplitQuery() .ToListAsync(); } public async Task HasHoldOnSeries(int userId, int seriesId) { return await _context.AppUser .AsSplitQuery() .AnyAsync(u => u.ScrobbleHolds.Select(s => s.SeriesId).Contains(seriesId) && u.Id == userId); } public async Task> GetHolds(int userId) { return await _context.ScrobbleHold .Where(s => s.AppUserId == userId) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); } public async Task GetLocale(int userId) { return await _context.AppUserPreferences.Where(p => p.AppUserId == userId) .Select(p => p.Locale) .SingleAsync(); } public async Task> GetDashboardStreams(int userId, bool visibleOnly = false) { return await _context.AppUserDashboardStream .Where(d => d.AppUserId == userId) .WhereIf(visibleOnly, d => d.Visible) .OrderBy(d => d.Order) .Include(d => d.SmartFilter) .Select(d => new DashboardStreamDto() { Id = d.Id, Name = d.Name, IsProvided = d.IsProvided, SmartFilterId = d.SmartFilter == null ? 0 : d.SmartFilter.Id, SmartFilterEncoded = d.SmartFilter == null ? null : d.SmartFilter.Filter, StreamType = d.StreamType, Order = d.Order, Visible = d.Visible }) .ToListAsync(); } public async Task> GetAllDashboardStreams() { return await _context.AppUserDashboardStream .OrderBy(d => d.Order) .ToListAsync(); } public async Task GetDashboardStream(int streamId) { return await _context.AppUserDashboardStream .Include(d => d.SmartFilter) .FirstOrDefaultAsync(d => d.Id == streamId); } public async Task> GetDashboardStreamWithFilter(int filterId) { return await _context.AppUserDashboardStream .Include(d => d.SmartFilter) .Where(d => d.SmartFilter != null && d.SmartFilter.Id == filterId) .AsSplitQuery() .ToListAsync(); } public async Task> GetSideNavStreams(int userId, bool visibleOnly = false) { var sideNavStreams = await _context.AppUserSideNavStream .Where(d => d.AppUserId == userId) .WhereIf(visibleOnly, d => d.Visible) .OrderBy(d => d.Order) .Include(d => d.SmartFilter) .Select(d => new SideNavStreamDto() { Id = d.Id, Name = d.Name, IsProvided = d.IsProvided, SmartFilterId = d.SmartFilter == null ? 0 : d.SmartFilter.Id, SmartFilterEncoded = d.SmartFilter == null ? null : d.SmartFilter.Filter, LibraryId = d.LibraryId ?? 0, ExternalSourceId = d.ExternalSourceId ?? 0, StreamType = d.StreamType, Order = d.Order, Visible = d.Visible }) .AsSplitQuery() .ToListAsync(); var libraryIds = sideNavStreams.Where(d => d.StreamType == SideNavStreamType.Library) .Select(d => d.LibraryId) .ToList(); var libraryDtos = await _context.Library .Where(l => libraryIds.Contains(l.Id)) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); foreach (var dto in sideNavStreams.Where(dto => dto.StreamType == SideNavStreamType.Library)) { dto.Library = libraryDtos.FirstOrDefault(l => l.Id == dto.LibraryId); } var externalSourceIds = sideNavStreams.Where(d => d.StreamType == SideNavStreamType.ExternalSource) .Select(d => d.ExternalSourceId) .ToList(); var externalSourceDtos = _context.AppUserExternalSource .Where(l => externalSourceIds.Contains(l.Id)) .ProjectTo(_mapper.ConfigurationProvider) .ToList(); foreach (var dto in sideNavStreams.Where(dto => dto.StreamType == SideNavStreamType.ExternalSource)) { dto.ExternalSource = externalSourceDtos.FirstOrDefault(l => l.Id == dto.ExternalSourceId); } return sideNavStreams; } public async Task GetSideNavStream(int streamId) { return await _context.AppUserSideNavStream .Include(d => d.SmartFilter) .FirstOrDefaultAsync(d => d.Id == streamId); } public async Task GetSideNavStreamWithUser(int streamId) { return await _context.AppUserSideNavStream .Include(d => d.SmartFilter) .Include(d => d.AppUser) .FirstOrDefaultAsync(d => d.Id == streamId); } public async Task> GetSideNavStreamWithFilter(int filterId) { return await _context.AppUserSideNavStream .Include(d => d.SmartFilter) .Where(d => d.SmartFilter != null && d.SmartFilter.Id == filterId) .ToListAsync(); } public async Task> GetSideNavStreamsByLibraryId(int libraryId) { return await _context.AppUserSideNavStream .Where(d => d.LibraryId == libraryId) .ToListAsync(); } public async Task> GetSideNavStreamWithExternalSource(int externalSourceId) { return await _context.AppUserSideNavStream .Where(d => d.ExternalSourceId == externalSourceId) .ToListAsync(); } public async Task> GetDashboardStreamsByIds(IList streamIds) { return await _context.AppUserSideNavStream .Where(d => streamIds.Contains(d.Id)) .ToListAsync(); } public async Task> GetUserTokenInfo() { var users = await _context.AppUser .Select(u => new { u.Id, u.UserName, u.AniListAccessToken, // JWT Token u.MalAccessToken // JWT Token }) .ToListAsync(); var userTokenInfos = users.Select(user => new UserTokenInfo { UserId = user.Id, Username = user.UserName, IsAniListTokenSet = !string.IsNullOrEmpty(user.AniListAccessToken), AniListValidUntilUtc = JwtHelper.GetTokenExpiry(user.AniListAccessToken), IsAniListTokenValid = JwtHelper.IsTokenValid(user.AniListAccessToken), IsMalTokenSet = !string.IsNullOrEmpty(user.MalAccessToken), }); return userTokenInfos; } /// /// Returns the first user with a device email matching /// /// /// public async Task GetUserByDeviceEmail(string deviceEmail) { return await _context.AppUser .Where(u => u.Devices.Any(d => d.EmailAddress == deviceEmail)) .FirstOrDefaultAsync(); } public async Task> GetAdminUsersAsync() { return (await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole)).OrderBy(u => u.CreatedUtc); } public async Task IsUserAdminAsync(AppUser? user) { if (user == null) return false; return await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole); } public async Task> GetRoles(int userId) { var user = await _context.Users.FirstOrDefaultAsync(u => u.Id == userId); if (user == null) return ArraySegment.Empty; if (_userManager == null) { // userManager is null on Unit Tests only return await _context.UserRoles .Where(ur => ur.UserId == userId) .Select(ur => ur.Role.Name) .ToListAsync(); } return await _userManager.GetRolesAsync(user); } public async Task GetUserRatingAsync(int seriesId, int userId) { return await _context.AppUserRating .Where(r => r.SeriesId == seriesId && r.AppUserId == userId) .FirstOrDefaultAsync(); } public async Task GetUserChapterRatingAsync(int userId, int chapterId) { return await _context.AppUserChapterRating .Where(r => r.AppUserId == userId && r.ChapterId == chapterId) .FirstOrDefaultAsync(); } public async Task> GetUserRatingDtosForSeriesAsync(int seriesId, int userId) { return await _context.AppUserRating .Include(r => r.AppUser) .Where(r => r.SeriesId == seriesId) .Where(r => r.AppUser.UserPreferences.ShareReviews || r.AppUserId == userId) .OrderBy(r => r.AppUserId == userId) .ThenBy(r => r.Rating) .AsSplitQuery() .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); } public async Task> GetUserRatingDtosForChapterAsync(int chapterId, int userId) { return await _context.AppUserChapterRating .Include(r => r.AppUser) .Where(r => r.ChapterId == chapterId) .Where(r => r.AppUser.UserPreferences.ShareReviews || r.AppUserId == userId) .OrderBy(r => r.AppUserId == userId) .ThenBy(r => r.Rating) .AsSplitQuery() .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); } public async Task GetPreferencesAsync(string username) { return await _context.AppUserPreferences .Include(p => p.AppUser) .Include(p => p.Theme) .AsSplitQuery() .SingleOrDefaultAsync(p => p.AppUser.UserName == username); } public async Task> GetBookmarkDtosForSeries(int userId, int seriesId) { return await _context.AppUserBookmark .Where(x => x.AppUserId == userId && x.SeriesId == seriesId) .OrderBy(x => x.Created) .AsNoTracking() .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); } public async Task> GetBookmarkDtosForVolume(int userId, int volumeId) { return await _context.AppUserBookmark .Where(x => x.AppUserId == userId && x.VolumeId == volumeId) .OrderBy(x => x.Created) .AsNoTracking() .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); } public async Task> GetBookmarkDtosForChapter(int userId, int chapterId) { return await _context.AppUserBookmark .Where(x => x.AppUserId == userId && x.ChapterId == chapterId) .OrderBy(x => x.Created) .AsNoTracking() .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); } /// /// Get all bookmarks for the user /// /// /// Only supports SeriesNameQuery /// public async Task> GetAllBookmarkDtos(int userId, FilterV2Dto filter) { var query = _context.AppUserBookmark .Where(x => x.AppUserId == userId) .OrderBy(x => x.Created) .AsNoTracking(); var filterSeriesQuery = query.Join(_context.Series, b => b.SeriesId, s => s.Id, (bookmark, series) => new BookmarkSeriesPair() { Bookmark = bookmark, Series = series }); var filterStatement = filter.Statements.FirstOrDefault(f => f.Field == FilterField.SeriesName); if (filterStatement == null || string.IsNullOrWhiteSpace(filterStatement.Value)) { return await ApplyLimit(filterSeriesQuery .Sort(filter.SortOptions) .AsSplitQuery(), filter.LimitTo) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); } var queryString = filterStatement.Value.ToNormalized(); switch (filterStatement.Comparison) { case FilterComparison.Equal: filterSeriesQuery = filterSeriesQuery.Where(s => s.Series.Name.Equals(queryString) || s.Series.OriginalName.Equals(queryString) || s.Series.LocalizedName.Equals(queryString) || s.Series.SortName.Equals(queryString)); break; case FilterComparison.BeginsWith: filterSeriesQuery = filterSeriesQuery.Where(s => EF.Functions.Like(s.Series.Name, $"{queryString}%") ||EF.Functions.Like(s.Series.OriginalName, $"{queryString}%") || EF.Functions.Like(s.Series.LocalizedName, $"{queryString}%") || EF.Functions.Like(s.Series.SortName, $"{queryString}%")); break; case FilterComparison.EndsWith: filterSeriesQuery = filterSeriesQuery.Where(s => EF.Functions.Like(s.Series.Name, $"%{queryString}") ||EF.Functions.Like(s.Series.OriginalName, $"%{queryString}") || EF.Functions.Like(s.Series.LocalizedName, $"%{queryString}") || EF.Functions.Like(s.Series.SortName, $"%{queryString}")); break; case FilterComparison.Matches: filterSeriesQuery = filterSeriesQuery.Where(s => EF.Functions.Like(s.Series.Name, $"%{queryString}%") ||EF.Functions.Like(s.Series.OriginalName, $"%{queryString}%") || EF.Functions.Like(s.Series.LocalizedName, $"%{queryString}%") || EF.Functions.Like(s.Series.SortName, $"%{queryString}%")); break; case FilterComparison.NotEqual: filterSeriesQuery = filterSeriesQuery.Where(s => s.Series.Name != queryString || s.Series.OriginalName != queryString || s.Series.LocalizedName != queryString || s.Series.SortName != queryString); break; case FilterComparison.MustContains: case FilterComparison.NotContains: case FilterComparison.GreaterThan: case FilterComparison.GreaterThanEqual: case FilterComparison.LessThan: case FilterComparison.LessThanEqual: case FilterComparison.Contains: case FilterComparison.IsBefore: case FilterComparison.IsAfter: case FilterComparison.IsInLast: case FilterComparison.IsNotInLast: default: break; } return await ApplyLimit(filterSeriesQuery .Sort(filter.SortOptions) .AsSplitQuery(), filter.LimitTo) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); } private static IQueryable ApplyLimit(IQueryable query, int limit) { return limit <= 0 ? query : query.Take(limit); } /// /// Fetches the UserId by API Key. This does not include any extra information /// /// /// public async Task GetUserIdByApiKeyAsync(string apiKey) { return await _context.AppUser .Where(u => u.ApiKey != null && u.ApiKey.Equals(apiKey)) .Select(u => u.Id) .FirstOrDefaultAsync(); } public async Task> GetEmailConfirmedMemberDtosAsync(bool emailConfirmed = true) { return await _context.Users .Where(u => (emailConfirmed && u.EmailConfirmed) || !emailConfirmed) .Include(x => x.Libraries) .Include(r => r.UserRoles) .ThenInclude(r => r.Role) .OrderBy(u => u.UserName) .Select(u => new MemberDto { Id = u.Id, Username = u.UserName, Email = u.Email, Created = u.Created, CreatedUtc = u.CreatedUtc, LastActive = u.LastActive, LastActiveUtc = u.LastActiveUtc, Roles = u.UserRoles.Select(r => r.Role.Name).ToList(), IsPending = !u.EmailConfirmed, AgeRestriction = new AgeRestrictionDto() { AgeRating = u.AgeRestriction, IncludeUnknowns = u.AgeRestrictionIncludeUnknowns }, Libraries = u.Libraries.Select(l => new LibraryDto { Name = l.Name, Type = l.Type, LastScanned = l.LastScanned, Folders = l.Folders.Select(x => x.Path).ToList() }).ToList() }) .AsSplitQuery() .AsNoTracking() .ToListAsync(); } }