Fix query and API bugs: 403 for non-owners, page bounds, lifecycle filters, cover fallback, race-safe inserts, async album delete

This commit is contained in:
2026-09-24 05:14:05 +02:00
parent ab388e6ad6
commit 0e3576922f
90 changed files with 2083 additions and 322 deletions
@@ -3,6 +3,7 @@ using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Domain.Entities;
namespace Musify.Application.PlayLists;
@@ -18,44 +19,30 @@ public class AddTrackToPlayListCommandHandler(
public async ValueTask<ErrorOr<Success>> Handle(AddTrackToPlayListCommand request, CancellationToken cancellationToken)
{
var playList = await database.PlayLists
.AsNoTracking()
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
if (playList == null)
{
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
return Error.NotFound();
}
return AppErrors.NotFound("PlayList", request.PlayListId);
if (playList.UserId != request.UserId)
{
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
return Error.Unauthorized();
}
return AppErrors.Forbidden("PlayList", request.PlayListId);
var trackExists = await database.Tracks
.AsNoTracking()
.AnyAsync(t => t.Id == request.TrackId, cancellationToken);
if (!trackExists)
{
logger.LogInformation("Track {TrackId} not found", request.TrackId);
return Error.NotFound(description: $"Track {request.TrackId} not found");
}
return AppErrors.NotFound("Track", request.TrackId);
var alreadyAdded = await database.PlayListHasTracks
.AsNoTracking()
.AnyAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == request.TrackId, cancellationToken);
if (alreadyAdded)
{
logger.LogInformation("Track {TrackId} already in playlist {PlayListId}", request.TrackId, request.PlayListId);
return Error.Conflict(description: "Track is already in the playlist.");
}
return AlreadyInPlayList;
var playListTracksQuery = database.PlayListHasTracks
.Where(plt => plt.PlayListId == request.PlayListId);
var hasTracks = await playListTracksQuery.AnyAsync(cancellationToken);
var nextPosition = hasTracks
? await playListTracksQuery.MaxAsync(plt => plt.Position, cancellationToken) + 1
: 0;
// Playlist positions are internal indexes, so they start at 0.
var nextPosition = (await database.PlayListHasTracks
.Where(plt => plt.PlayListId == request.PlayListId)
.MaxAsync(plt => (int?)plt.Position, cancellationToken) ?? -1) + 1;
await database.PlayListHasTracks.AddAsync(new PlayListHasTrack
{
@@ -64,11 +51,15 @@ public class AddTrackToPlayListCommandHandler(
Position = nextPosition
}, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
var saved = await database.TrySaveChangesAsync(AlreadyInPlayList, cancellationToken);
if (saved.IsError)
return saved;
logger.LogInformation("Added track {TrackId} to playlist {PlayListId} at position {Position}",
request.TrackId, request.PlayListId, nextPosition);
return new Success();
return Result.Success;
}
private static Error AlreadyInPlayList => AppErrors.Conflict("PlayList.TrackAlreadyAdded", "Track is already in the playlist.");
}
@@ -53,7 +53,7 @@ public class CreatePlayListCommandHandler(
var playList = new PlayList
{
UserId = request.UserId,
Name = request.Name,
Name = request.Name.Trim(),
NormalizedName = request.Name.Trim().ToUpperInvariant(),
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
Pictures = resolvedPictures.Pictures,
@@ -97,13 +97,7 @@ public class CreatePlayListCommandHandler(
if (validation.IsError)
return validation.Errors;
return new ResolvedPlayListPictures(new PlayListPictures
{
OriginalName = validation.Value.ObjectName,
SmallName = validation.Value.ObjectName,
MediumName = validation.Value.ObjectName,
LargeName = validation.Value.ObjectName
}, validation.Value);
return new ResolvedPlayListPictures(PlayListPictures.Pending(validation.Value.ObjectName), validation.Value);
}
private async Task<ErrorOr<Success>> PublishCreatePlayListEventAsync(
@@ -3,6 +3,7 @@ using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Events;
using Musify.Domain.ValueObjects;
@@ -29,7 +30,7 @@ public class DeletePlayListCommandHandler(
if (playList.UserId != request.UserId)
{
logger.LogWarning("Playlist {PlayListId} does not belong to user {UserId}", request.PlayListId, request.UserId);
return Error.Unauthorized();
return AppErrors.Forbidden("PlayList", request.PlayListId);
}
playList.LifeCycleStatus = LifeCycleStatus.Removing;
@@ -17,7 +17,7 @@ public class GetPlayListByIdQueryHandler(IDatabase database)
{
var entry = await database.PlayLists
.AsNoTracking()
.Where(p => p.Id == request.Id)
.Where(p => p.Id == request.Id && p.LifeCycleStatus == LifeCycleStatus.Active)
.Select(p => new
{
PlayList = p,
@@ -4,6 +4,8 @@ using Microsoft.EntityFrameworkCore;
using MimeMapping;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists;
@@ -20,14 +22,15 @@ public class GetPlayListCoverQueryHandler(
{
public async ValueTask<ErrorOr<PlayListCoverLocation>> Handle(GetPlayListCoverQuery request, CancellationToken cancellationToken)
{
var pictures = await database.PlayLists.AsNoTracking()
.Where(p => p.Id == request.PlayListId)
.Select(p => p.Pictures)
var owned = await database.PlayLists.AsNoTracking()
.Where(e => e.Id == request.PlayListId && e.LifeCycleStatus == LifeCycleStatus.Active)
.Select(e => new { OwnerUserId = e.UserId, e.Pictures })
.SingleOrDefaultAsync(cancellationToken);
if (pictures == null)
return Error.NotFound();
if (owned?.Pictures == null)
return AppErrors.NotFound("PlayListCover", request.PlayListId);
var pictures = owned.Pictures;
var routes = playListConfiguration.Routes;
var (name, key) = request.Size.ToLowerInvariant() switch
{
@@ -36,8 +39,15 @@ public class GetPlayListCoverQueryHandler(
_ => (pictures.MediumName, BuildKey(routes.BuildMediumPicturePath, pictures.MediumName))
};
// The resized files only exist once processing has finished; until then serve the original.
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key))
return Error.NotFound();
{
name = pictures.OriginalName;
key = string.IsNullOrEmpty(name) ? null : routes.BuildOriginalPicturePath(owned.OwnerUserId, name);
}
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key))
return AppErrors.NotFound("PlayListCover", request.PlayListId);
var contentType = MimeUtility.GetMimeMapping(name);
return new PlayListCoverLocation(storageConfiguration.Bucket, key, contentType);
@@ -26,6 +26,7 @@ public class GetPlayListTracksQueryHandler(IDatabase database)
.AsNoTracking()
.AnyAsync(
p => p.Id == request.PlayListId
&& p.LifeCycleStatus == LifeCycleStatus.Active
&& (p.Visibility == PlaylistVisibility.Public || p.UserId == request.ViewerId),
cancellationToken);
if (!playListExists)
@@ -35,7 +36,7 @@ public class GetPlayListTracksQueryHandler(IDatabase database)
.AsNoTracking()
.Include(plt => plt.Track.Owner)
.Include(plt => plt.Track.Tags)
.Where(plt => plt.PlayListId == request.PlayListId);
.Where(plt => plt.PlayListId == request.PlayListId && plt.Track.LifeCycleStatus == LifeCycleStatus.Active);
var totalCount = await tracksQuery.CountAsync(cancellationToken);
@@ -26,6 +26,7 @@ public class GetPlayListsByUserIdQueryHandler(IDatabase database)
var playListsQuery = database.PlayLists
.AsNoTracking()
.Where(p => p.UserId == request.UserId
&& p.LifeCycleStatus == LifeCycleStatus.Active
&& (p.Visibility == PlaylistVisibility.Public || p.UserId == request.ViewerId));
if (!string.IsNullOrEmpty(request.Name))
@@ -38,7 +39,7 @@ public class GetPlayListsByUserIdQueryHandler(IDatabase database)
var pagedEntities = await playListsQuery
.OrderBy(p => p.CreatedAt)
.Where(p => p.LifeCycleStatus == LifeCycleStatus.Active)
.ThenBy(p => p.Id)
.Select(p => new
{
PlayList = p,
@@ -3,6 +3,7 @@ using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Shared;
namespace Musify.Application.PlayLists;
@@ -17,32 +18,33 @@ public class RemoveTrackFromPlayListCommandHandler(
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromPlayListCommand request, CancellationToken cancellationToken)
{
var playList = await database.PlayLists
.AsNoTracking()
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
if (playList == null)
{
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
return Error.NotFound();
}
return AppErrors.NotFound("PlayList", request.PlayListId);
if (playList.UserId != request.UserId)
{
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
return Error.Unauthorized();
}
return AppErrors.Forbidden("PlayList", request.PlayListId);
var link = await database.PlayListHasTracks
.SingleOrDefaultAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == request.TrackId, cancellationToken);
var playListTracks = await database.PlayListHasTracks
.Where(plt => plt.PlayListId == request.PlayListId)
.OrderBy(plt => plt.Position)
.ToListAsync(cancellationToken);
var link = playListTracks.SingleOrDefault(plt => plt.TrackId == request.TrackId);
if (link == null)
{
logger.LogInformation("Track {TrackId} not in playlist {PlayListId}", request.TrackId, request.PlayListId);
return Error.NotFound(description: "Track is not in the playlist.");
}
return Error.NotFound("PlayList.TrackNotFound", "Track is not in the playlist.");
database.PlayListHasTracks.Remove(link);
var position = 0;
foreach (var remaining in playListTracks.Where(plt => plt.Id != link.Id))
remaining.Position = position++;
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Removed track {TrackId} from playlist {PlayListId}", request.TrackId, request.PlayListId);
return new Success();
return Result.Success;
}
}
@@ -44,18 +44,18 @@ public class UpdatePlayListCommandHandler(
if (playListEntity.UserId != request.UserId)
{
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
return Error.Unauthorized();
return AppErrors.Forbidden("PlayList", request.PlayListId);
}
if (!string.IsNullOrWhiteSpace(request.NewName))
{
playListEntity.Name = request.NewName;
playListEntity.Name = request.NewName.Trim();
playListEntity.NormalizedName = request.NewName.Trim().ToUpperInvariant();
}
if (!string.IsNullOrWhiteSpace(request.NewDescription))
if (request.NewDescription != null)
{
playListEntity.Description = request.NewDescription;
playListEntity.Description = string.IsNullOrWhiteSpace(request.NewDescription) ? null : request.NewDescription.Trim();
}
if (request.NewVisibility.HasValue)
@@ -75,13 +75,7 @@ public class UpdatePlayListCommandHandler(
return validation.Errors;
pictureIntent = validation.Value;
playListEntity.Pictures = new PlayListPictures
{
OriginalName = pictureIntent.ObjectName,
SmallName = pictureIntent.ObjectName,
MediumName = pictureIntent.ObjectName,
LargeName = pictureIntent.ObjectName
};
playListEntity.Pictures = PlayListPictures.Pending(pictureIntent.ObjectName);
finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
}