Files
MilkyShots/Lactose/Repositories/AssetRepository.cs
REDCODE 4f3e3a28cc fix: enforce data visibility per access level with test coverage
- UsersMapper.ToGetUsersDto: filter Email, BannedAt, DeletedAt, AccessLevel,
  MaintainedPersonIds by viewer access (admin/self only)
- AssetsMapper.ToFullAssetsDto: pass viewerId to restrict UploadedBy.Email;
  null-safe Uploader fallback
- AssetsMapper.ToAssetPreviewDto: gate FileName behind Curator+ (was public)
- AlbumMapper: gate FileName behind Curator+; fix AssetCount to count only
  non-deleted assets
- AssetRepository.FindVisible: include Uploader to prevent NRE (500 error)
- AssetController: restrict UploadedBy reassignment to Admin only
- UserController: pass viewer context to all ToGetUsersDto calls
- WepApiTest.http: add 18 data visibility tests (#64-#81) verifying field
  gating per role; fix 3 pre-existing stale assertions; avoid jsonPath NPE
  on null values by using response.body directly
2026-07-15 14:53:39 +02:00

256 lines
10 KiB
C#

using Butter.Types;
using Lactose.Context;
using Lactose.Models;
using Pgvector.EntityFrameworkCore;
using System.Collections;
using System.Data;
namespace Lactose.Repositories;
/// <inheritdoc />
public class AssetRepository(LactoseDbContext context) : IAssetRepository {
/// <inheritdoc />
public Asset? Find(Guid id) => context.Assets.Find(id);
/// <inheritdoc />
public Asset? FindVisible(Guid id, Guid? userId, EAccessLevel accessLevel) {
IQueryable<Asset> query = context.Assets
.AsSplitQuery()
.Include(a => a.Uploader)
.Where(a => a.Id == id);
return accessLevel switch {
>= EAccessLevel.Admin => query.FirstOrDefault(),
EAccessLevel.Curator => query.FirstOrDefault(a =>
a.DeletedAt == null || a.UploadedBy == userId),
EAccessLevel.Maintainer when userId.HasValue => query
.Include(a => a.Albums)
.FirstOrDefault(a => a.DeletedAt == null && (
a.Visibility == EVisibility.Public ||
a.Visibility == EVisibility.Protected ||
a.UploadedBy == userId ||
a.Albums!.Any(al => al.PersonOwnerId.HasValue &&
context.PersonMaintainers.Any(pm =>
pm.UserId == userId.Value && pm.PersonId == al.PersonOwnerId.Value))
)),
_ => query.FirstOrDefault(a =>
a.DeletedAt == null && (
a.Visibility == EVisibility.Public ||
(a.Visibility == EVisibility.Protected && userId.HasValue) ||
(a.Visibility == EVisibility.Private && a.UploadedBy == userId)
))
};
}
/// <inheritdoc />
public Asset? FindWithAlbums(Guid id) => context.Assets
.AsSplitQuery()
.Include(a => a.Albums)
.FirstOrDefault(a => a.Id == id);
/// <inheritdoc />
public IEnumerable<Asset> FindByDateRange(DateTime from, DateTime to) =>
context.Assets.Where(x => x.CreatedAt >= from && x.UpdatedAt <= to);
/// <inheritdoc />
public IEnumerable<Asset> FindByDateRange(DateTime from, DateTime to, int pageNumber, int pageSize) =>
context.Assets.Where(x => x.CreatedAt >= from && x.UpdatedAt <= to)
.Skip(pageNumber * pageSize)
.Take(pageSize);
/// <inheritdoc />
public void Save() => context.SaveChanges();
/// <inheritdoc />
public void Insert(Asset asset) {
if(!context.Assets.Any(x => x.OriginalPath == asset.OriginalPath))
context.Assets.Add(asset);
else
throw new DuplicateNameException("The required asset already exists in the database.");
}
/// <inheritdoc />
public void Update(Asset asset) {
asset.UpdatedAt = DateTime.UtcNow;
context.Assets.Update(asset);
}
/// <inheritdoc />
public void UpdateBulk(IEnumerable<Asset> assets) {
foreach (var asset in assets) {
asset.UpdatedAt = DateTime.UtcNow;
context.Assets.Update(asset);
}
}
/// <inheritdoc />
public IEnumerable<Asset> FindByUploader(Guid uploaderId) => context.Assets.Where(a => a.UploadedBy == uploaderId);
/// <inheritdoc />
public IEnumerable<Asset> FindBulk(IEnumerable<Guid> ids) => context.Assets
.AsSplitQuery()
.Include(a => a.Albums)
.Where(a => ids.Contains(a.Id));
/// <inheritdoc />
public Asset? FindByPath(string path) => context.Assets.FirstOrDefault(a => a.OriginalPath == path);
/// <inheritdoc />
public int CountAssetsMissingPHash() {
var empty = new BitArray(64);
return context.Assets.Count(a => a.Hash == empty && a.DeletedAt == null);
}
/// <inheritdoc />
public IEnumerable<Asset> GetAssetsMissingPHash(int limit, int offset) {
var empty = new BitArray(64);
return context.Assets.Where(a => a.Hash == empty && a.DeletedAt == null)
.OrderBy(a => a.Id)
.Skip(offset)
.Take(limit);
}
/// <inheritdoc />
public IEnumerable<List<Asset>> GetDuplicates() => context.Assets
.GroupBy(a => new { a.Hash })
.Where(g => g.Count() > 1)
.Select(g => g.ToList());
/// <inheritdoc />
public IEnumerable<Asset> GetWithinHammingDistance(ulong phash, int distance) {
if(distance < 0 || distance > 63)
throw new ArgumentOutOfRangeException(nameof(distance), "Distance must be between 0 and 64.");
return context.Assets
.OrderBy(a => a.HammingDistance(phash))
.Where(a => a.Hash.HammingDistance(phash) <= distance);
}
/// <inheritdoc />
public IEnumerable<Asset> GetWithinHammingDistance(ulong phash, float distance)
=> GetWithinHammingDistance(phash, (int)(distance * 64));
/// <inheritdoc />
public IEnumerable<Asset> GetAssetsMissingThumbnail(out int count) {
count = context.Assets.Count(a => (a.ThumbnailPath == null || a.ThumbnailPath == "") && a.DeletedAt == null);
return context.Assets.Where(a => (a.ThumbnailPath == null || a.ThumbnailPath == "") && a.DeletedAt == null);
}
/// <inheritdoc />
public int CountAssetsMissingOrWrongThumbnail(int thumbnailSize, string expectedFormat) =>
context.Assets.Count(a => (a.ThumbnailPath == null || a.ThumbnailPath == "" || a.ThumbnailSize != thumbnailSize || a.ThumbnailFormat != expectedFormat) && a.DeletedAt == null);
/// <inheritdoc />
public IEnumerable<Asset> GetAssetsMissingOrWrongThumbnail(int thumbnailSize, string expectedFormat, int limit, int offset) =>
context.Assets
.Where(a => (a.ThumbnailPath == null || a.ThumbnailPath == "" || a.ThumbnailSize != thumbnailSize || a.ThumbnailFormat != expectedFormat) && a.DeletedAt == null)
.OrderBy(a => a.Id)
.Skip(offset)
.Take(limit);
/// <inheritdoc />
public int CountAssetsMissingOrWrongPreview(int previewSize, string expectedFormat) =>
context.Assets.Count(a => (a.PreviewPath == null || a.PreviewPath == "" || a.PreviewSize != previewSize || a.PreviewFormat != expectedFormat) && a.DeletedAt == null);
/// <inheritdoc />
public IEnumerable<Asset> GetAssetsMissingOrWrongPreview(int previewSize, string expectedFormat, int limit, int offset) =>
context.Assets
.Where(a => (a.PreviewPath == null || a.PreviewPath == "" || a.PreviewSize != previewSize || a.PreviewFormat != expectedFormat) && a.DeletedAt == null)
.OrderBy(a => a.Id)
.Skip(offset)
.Take(limit);
/// <inheritdoc />
public int CountAssetsMissingMetadata() =>
context.Assets.Count(a => a.Type == EAssetType.Image && a.ResolutionWidth == 0 && a.DeletedAt == null);
/// <inheritdoc />
public IEnumerable<Asset> GetAssetsMissingMetadata(int limit, int offset) =>
context.Assets.Where(a => a.Type == EAssetType.Image && a.ResolutionWidth == 0 && a.DeletedAt == null)
.OrderBy(a => a.Id)
.Skip(offset)
.Take(limit);
/// <inheritdoc />
public IEnumerable<Asset> GetAssets(EAssetType? type, DateTime? from, DateTime? to, bool orderRandomly, Guid? seed, int pageNumber, int pageSize, out int total, Guid? userId = null, EAccessLevel accessLevel = EAccessLevel.User) {
var query = context.Assets.AsQueryable();
if (type.HasValue)
query = query.Where(a => a.Type == type.Value);
if (from.HasValue)
query = query.Where(a => a.CreatedAt >= from.Value);
if (to.HasValue)
query = query.Where(a => a.UpdatedAt <= to.Value);
// Apply visibility filter
query = accessLevel switch {
EAccessLevel.Admin => query,
EAccessLevel.Curator => query.Where(a => a.DeletedAt == null || a.UploadedBy == userId),
EAccessLevel.Maintainer => query.Where(a => a.DeletedAt == null && (
a.Visibility == EVisibility.Public ||
a.Visibility == EVisibility.Protected ||
a.UploadedBy == userId ||
a.Albums!.Any(al => al.PersonOwnerId.HasValue &&
context.PersonMaintainers.Any(pm =>
pm.UserId == userId && pm.PersonId == al.PersonOwnerId.Value))
)),
_ => query.Where(a => a.DeletedAt == null && (
a.Visibility == EVisibility.Public ||
(a.Visibility == EVisibility.Protected && userId.HasValue) ||
(a.Visibility == EVisibility.Private && a.UploadedBy == userId)
))
};
total = query.Count();
if (orderRandomly && seed.HasValue) {
var allIds = query.Select(a => a.Id).ToList();
var seedHash = seed.Value.GetHashCode();
var shuffledIds = allIds
.OrderBy(id => DeterministicHash(id, seedHash))
.Skip(pageNumber * pageSize)
.Take(pageSize)
.ToList();
var assets = context.Assets
.AsSplitQuery()
.Where(a => shuffledIds.Contains(a.Id))
.Include(a => a.Albums!).ThenInclude(a => a.PersonOwner)
.ToList();
return shuffledIds.Select(id => assets.First(a => a.Id == id)).ToList();
}
IQueryable<Asset> dataQuery = query
.AsSplitQuery()
.Include(a => a.Albums!).ThenInclude(a => a.PersonOwner);
dataQuery = orderRandomly ? dataQuery.OrderBy(a => a.Id) : dataQuery.OrderByDescending(a => a.CreatedAt);
return dataQuery
.Skip(pageNumber * pageSize)
.Take(pageSize)
.ToList();
}
private static int DeterministicHash(Guid id, int seed) {
var bytes = id.ToByteArray();
unchecked {
var hash = seed;
for (var i = 0; i < bytes.Length; i++)
hash = hash * 31 + bytes[i];
return hash & 0x7FFFFFFF;
}
}
/// <inheritdoc />
public void Dispose() => context.Dispose();
/// <inheritdoc />
public List<string> GetRandomPathsByFolder(Guid folderId, int count) =>
context.Assets
.Where(a => a.FolderId == folderId && a.DeletedAt == null)
.OrderBy(_ => Guid.NewGuid())
.Take(count)
.Select(a => a.OriginalPath)
.ToList();
}