Files
MilkyShots/Lactose/Controllers/AssetController.cs
T
REDCODE fff5ea65df feat: add bulk delete and visibility change to AlbumDetail select mode
- Fix AssetController.BulkUpdate/BulkDelete — add missing Save() call
- Add BulkUpdateAssetsAsync and BulkDeleteAssetsAsync to AssetService
- Rename 'Remove N' → 'Unlink N' (removes from album, original behavior)
- Add 'Delete N' button — soft-deletes selected assets via BulkDelete
- Add 'Visibility' button — opens ModalFrame to pick Public/Protected/Private
- Apply calls BulkUpdateAssetsAsync with chosen visibility level
2026-07-15 21:46:28 +02:00

336 lines
14 KiB
C#

using Butter.Dtos;
using Butter.Dtos.Asset;
using Butter.Types;
using Lactose.Mapper;
using Lactose.Models;
using Lactose.Repositories;
using Lactose.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
namespace Lactose.Controllers;
/// <summary>
/// Manages assets — images, videos, and other media files.
/// </summary>
/// <param name="logger">Logger instance.</param>
/// <param name="authService">Authentication service.</param>
/// <param name="assetRepository">Asset repository.</param>
/// <param name="userRepository">User repository.</param>
/// <param name="personRepository">Person repository (for maintainer scope checks).</param>
[ApiController]
[Route("api/[controller]")]
public class AssetController(
ILogger<AssetController> logger,
LactoseAuthService authService,
IAssetRepository assetRepository,
IUserRepository userRepository,
IPersonRepository personRepository
) : ControllerBase {
/// <summary>
/// Gets an asset by its ID with full details, respecting access level.
/// </summary>
/// <param name="id">The asset ID.</param>
/// <returns>The full asset details, or 404/401 based on permissions.</returns>
[HttpGet("{id}")]
public ActionResult<AssetDto> Get(Guid id) {
var uid = authService.GetUserData(User)?.Id;
EAccessLevel accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
logger.LogTrace(
$"""
Requested asset: {id}
by user: {uid}
access level: {accessLevel}
"""
);
var asset = assetRepository.FindVisible(id, uid, accessLevel);
if (asset == null) {
logger.LogWarning($"Request asset {id} not found or not visible!");
return NotFound();
}
AssetDto dto = asset.ToFullAssetsDto(accessLevel, uid);
logger.LogTrace(
$"""
Outbound DTO:
dto.Id: {dto.Id}
dto.FileName: {dto.FileName}
dto.AssetType: {dto.AssetType}
dto.Visibility: {dto.Visibility}
dto.CreatedAt: {dto.CreatedAt}
dto.UpdatedAt: {dto.UpdatedAt}
dto.DeletedAt: {dto.DeletedAt}
dto.UploadedBy: {dto.UploadedBy.Id} {dto.UploadedBy.Username}
"""
);
return Ok(dto);
}
/// <summary>
/// Searches assets with optional date range, type filter, and random ordering with pagination.
/// </summary>
/// <param name="searchOptionsDto">Search options including date range, type filter, random ordering, and pagination.</param>
/// <returns>A list of asset previews.</returns>
[HttpGet]
public ActionResult<List<AssetPreviewDto>> GetAll([FromQuery] AssetSearchOptionsDto searchOptionsDto) {
var userData = authService.GetUserData(User);
var uid = userData?.Id;
EAccessLevel accessLevel = userData?.AccessLevel ?? EAccessLevel.User;
DateTime? from = string.IsNullOrEmpty(searchOptionsDto.StartDate) ? null : DateTime.Parse(searchOptionsDto.StartDate);
DateTime? to = string.IsNullOrEmpty(searchOptionsDto.EndDate) ? null : DateTime.Parse(searchOptionsDto.EndDate);
if (from.HasValue && to.HasValue && from > to) {
logger.LogWarning("Invalid date range provided");
return BadRequest();
}
if (searchOptionsDto.Page < 0 || searchOptionsDto.PageSize < 1 || searchOptionsDto.PageSize > PagedParametersDto.MaxPageSize) {
logger.LogWarning("Invalid pagination parameters provided");
return BadRequest();
}
var assets = assetRepository.GetAssets(
searchOptionsDto.Type, from, to, searchOptionsDto.Random, searchOptionsDto.Seed,
searchOptionsDto.Page, searchOptionsDto.PageSize, out int total,
uid, accessLevel
);
logger.LogTrace(
$"""
Requested assets
by user: {uid}
access level: {accessLevel}
type: {searchOptionsDto.Type}
random: {searchOptionsDto.Random}
date range: {from} - {to}
page: {searchOptionsDto.Page}
"""
);
var dtoList = assets.ToAssetPreviewDto(accessLevel).ToList();
logger.LogTrace($"Returning {dtoList.Count} assets (total: {total})");
Response.Headers["X-Total-Count"] = total.ToString();
return Ok(dtoList);
}
/// <summary>
/// Updates an asset's metadata.
/// </summary>
/// <param name="id">The asset ID.</param>
/// <param name="dto">The asset update data.</param>
/// <returns>200 on success, 404 if not found, 401 if unauthorized.</returns>
[HttpPost("{id}")]
[Authorize(Roles = "Maintainer,Curator,Admin")]
public IStatusCodeActionResult Update([FromRoute] Guid id, [FromBody] AssetUpdateDto dto) {
var uid = authService.GetUserData(User)?.Id;
var accesslevel = authService.GetUserData(User)!.AccessLevel;
var asset = assetRepository.FindWithAlbums(id);
if (asset == null) {
logger.LogWarning($"Request asset {id} not found in the database!");
return NotFound();
}
if (asset.UploadedBy != uid && accesslevel != EAccessLevel.Admin && accesslevel != EAccessLevel.Curator
&& (accesslevel != EAccessLevel.Maintainer || !uid.HasValue || !IsMaintainedByUser(uid.Value, asset))) {
logger.LogWarning($"User {authService.GetUserData(User)?.Id} is trying to update asset {id} that they do not own");
return Unauthorized();
}
string log = "";
asset.Visibility = dto.Visibility ?? asset.Visibility;
log += $"Visibility: {asset.Visibility} -> {dto.Visibility ?? asset.Visibility}\n";
asset.DeletedAt = dto.DeletedAt ?? asset.DeletedAt;
log += $"DeletedAt: {asset.DeletedAt} -> {dto.DeletedAt ?? asset.DeletedAt}\n";
if (accesslevel == EAccessLevel.Admin) {
asset.UploadedBy = dto.UploadedBy ?? asset.UploadedBy;
log += $"UploadedBy: {asset.UploadedBy} -> {dto.UploadedBy ?? asset.UploadedBy}\n";
}
logger.LogTrace($"Updating asset {id}\n{log}");
assetRepository.Update(asset);
assetRepository.Save();
return Ok();
}
/// <summary>
/// Updates multiple assets in a bulk operation.
/// </summary>
/// <param name="bulkDto">The bulk update data with asset IDs and shared values.</param>
/// <returns>200 on success, or an error response.</returns>
[HttpPost]
[Authorize(Roles = "Maintainer,Curator,Admin")]
public IStatusCodeActionResult BulkUpdate([FromBody] BulkDto<AssetUpdateDto> bulkDto) {
var uid = authService.GetUserData(User)?.Id;
var accesslevel = authService.GetUserData(User)!.AccessLevel;
var assets = assetRepository.FindBulk(bulkDto.Ids);
var enumerable = assets as List<Asset> ?? assets.ToList();
if (enumerable.Count() != bulkDto.Ids.Count) {
logger.LogWarning(
$"Some assets were not found in the database" +
$"\nrequested assets: {bulkDto.Ids.Count}, found assets: {enumerable.Count()}"
);
var missingIds = bulkDto.Ids.Except(enumerable.Select(a => a.Id)).ToList();
logger.LogTrace($"Missing IDs: {string.Join(", ", missingIds)}");
}
//If the user is not an admin, they can only update their own assets
if (accesslevel != EAccessLevel.Admin && uid.HasValue)
enumerable = accesslevel == EAccessLevel.Maintainer
? enumerable.Where(x => x.UploadedBy == uid || IsMaintainedByUser(uid.Value, x)).ToList()
: enumerable.Where(x => x.UploadedBy == uid).ToList();
enumerable.ForEach(
x => {
x.Visibility = bulkDto.Data.Visibility ?? x.Visibility;
x.DeletedAt = bulkDto.Data.DeletedAt ?? x.DeletedAt;
if (accesslevel == EAccessLevel.Admin) {
x.UploadedBy = bulkDto.Data.UploadedBy ?? x.UploadedBy;
}
}
);
assetRepository.UpdateBulk(enumerable);
assetRepository.Save();
return Ok();
}
/// <summary>
/// Soft-deletes an asset by its ID.
/// </summary>
/// <param name="id">The asset ID.</param>
/// <returns>200 on success, 404 if not found, 401 if unauthorized.</returns>
[HttpDelete("{id}")]
[Authorize(Roles = "Maintainer,Curator,Admin")]
public IStatusCodeActionResult Delete([FromRoute] Guid id) {
var uid = authService.GetUserData(User)?.Id;
var accesslevel = authService.GetUserData(User)!.AccessLevel;
var asset = assetRepository.FindWithAlbums(id);
if (asset == null) {
logger.LogWarning($"Request asset {id} not found in the database!");
return NotFound();
}
if (asset.UploadedBy != uid && accesslevel != EAccessLevel.Admin && accesslevel != EAccessLevel.Curator
&& (accesslevel != EAccessLevel.Maintainer || !uid.HasValue || !IsMaintainedByUser(uid.Value, asset))) {
logger.LogWarning($"User {authService.GetUserData(User)?.Id} is trying to delete asset {id} that they do not own");
return Unauthorized();
}
asset.DeletedAt = DateTime.UtcNow;
assetRepository.Update(asset);
assetRepository.Save();
return Ok();
}
/// <summary>
/// Soft-deletes multiple assets in a bulk operation.
/// </summary>
/// <param name="ids">The list of asset IDs to delete.</param>
/// <returns>200 on success, or an error response.</returns>
[HttpDelete]
[Authorize(Roles = "Maintainer,Curator,Admin")]
public IStatusCodeActionResult BulkDelete([FromBody] List<Guid> ids) {
var uid = authService.GetUserData(User)?.Id;
var accesslevel = authService.GetUserData(User)!.AccessLevel;
var assets = assetRepository.FindBulk(ids);
var enumerable = assets as List<Asset> ?? assets.ToList();
if (enumerable.Count() != ids.Count) {
logger.LogWarning(
$"Some assets were not found in the database" +
$"\nrequested assets: {ids.Count}, found assets: {enumerable.Count()}"
);
var missingIds = ids.Except(enumerable.Select(a => a.Id)).ToList();
logger.LogTrace($"Missing IDs: {string.Join(", ", missingIds)}");
}
//If the user is not an admin, they can only delete their own assets
if (accesslevel != EAccessLevel.Admin && uid.HasValue)
enumerable = accesslevel == EAccessLevel.Maintainer
? enumerable.Where(x => x.UploadedBy == uid || IsMaintainedByUser(uid.Value, x)).ToList()
: enumerable.Where(x => x.UploadedBy == uid).ToList();
enumerable.ForEach(x => x.DeletedAt = DateTime.UtcNow);
assetRepository.UpdateBulk(enumerable);
assetRepository.Save();
return Ok();
}
/// <summary>
/// Creates a new asset record.
/// </summary>
/// <param name="dto">The asset creation data.</param>
/// <returns>200 with the new asset ID, or an error response.</returns>
[NonAction]
[HttpPut]
[Authorize(Roles = "Curator,Admin")]
public ActionResult<Guid> Create([FromBody] AssetCreateDto dto) {
var uid = authService.GetUserData(User)?.Id;
var accesslevel = authService.GetUserData(User)!.AccessLevel;
if (dto.UploadedBy.HasValue) {
if (userRepository.Find(dto.UploadedBy.Value) == null) {
logger.LogWarning($"User {dto.UploadedBy} does not exist in the database");
return BadRequest();
}
if (accesslevel != EAccessLevel.Admin && dto.UploadedBy != uid) {
logger.LogWarning($"User {uid} is trying to create an asset for user {dto.UploadedBy}");
return Unauthorized();
}
}
var asset = new Asset {
Id = Guid.NewGuid(),
Type = dto.AssetType,
Visibility = dto.Visibility,
UploadedBy = dto.UploadedBy,
CreatedAt = dto.CreatedAt,
MimeType = dto.MimeType,
ResolutionWidth = dto.ResolutionWidth,
ResolutionHeight = dto.ResolutionHeight,
FileSize = dto.FileSize,
Duration = dto.Duration ?? 0,
FrameRate = dto.FrameRate ?? 0,
OriginalPath = String.Empty,
OriginalFilename = String.Empty
};
assetRepository.Insert(asset);
assetRepository.Save();
return Ok(asset.Id);
}
/// <summary>
/// Checks whether a Maintainer has access to an asset through the cosplayers they maintain.
/// A Maintainer can access assets that are in any album belonging to a person they maintain.
/// </summary>
/// <param name="uid">The maintainer's user ID.</param>
/// <param name="asset">The asset to check.</param>
/// <returns><see langword="true"/> if the maintainer is linked via album→person→maintainer chain.</returns>
private bool IsMaintainedByUser(Guid uid, Asset asset) =>
asset.Albums?.Any(a => a.PersonOwnerId.HasValue && personRepository.IsMaintainerOf(uid, a.PersonOwnerId.Value)) == true;
}