- 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
336 lines
14 KiB
C#
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;
|
|
}
|