Safe code and changes

This commit is contained in:
2026-05-14 01:00:44 +02:00
parent 7f0266d914
commit 8ad203c974
25 changed files with 568 additions and 266 deletions
@@ -62,13 +62,31 @@ namespace Musify.Application.PlayLists.Handlers
{
var publishResult = await PublishPictureUpdateEventAsync(playList, cancellationToken);
if (!publishResult.IsSuccess)
{
await TryRollbackOnPublishFailureAsync(playList, cancellationToken);
return publishResult;
}
}
logger.LogInformation("Created playlist {PlayListId} for user {UserId}", playList.Id, request.UserId);
return Result.Created(PlayListResponse.FromEntity(playList));
}
async Task TryRollbackOnPublishFailureAsync(PlayList playList, CancellationToken cancellationToken)
{
try
{
database.PlayLists.Remove(playList);
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to rollback playlist creation after publish failure for playlist {PlayListId}", playList.Id);
}
await RollbackAllPicturesAsync(playList, cancellationToken);
}
async Task<Result> UploadPictureAsync(PlayList playList, IFileData picture, CancellationToken cancellationToken)
{
var pictureName = Guid.NewGuid() + picture.FileType;
@@ -123,6 +141,9 @@ namespace Musify.Application.PlayLists.Handlers
if (string.IsNullOrEmpty(playList.OriginalPictureName))
return;
if (playList.OriginalPictureName == playListConfiguration.Routes.PresetOriginalPicture)
return;
var originalPicturePath = playListConfiguration.Routes.BuildOriginalPicturePath(playList.OriginalPictureName);
try
@@ -28,25 +28,73 @@ namespace Musify.Application.PlayLists.Handlers
return Result.Unauthorized();
}
if (!string.IsNullOrWhiteSpace(playList.OriginalPictureName))
await PublishRemoveFileEventAsync(playList.Id,
playListConfiguration.Routes.BuildOriginalPicturePath(playList.OriginalPictureName), cancellationToken);
if (playList.SmallPictureName != playListConfiguration.Routes.PresetSmallPicture)
await PublishRemoveFileEventAsync(playList.Id,
playListConfiguration.Routes.BuildSmallPicturePath(playList.SmallPictureName), cancellationToken);
if (playList.MediumPictureName != playListConfiguration.Routes.PresetMediumPicture)
await PublishRemoveFileEventAsync(playList.Id,
playListConfiguration.Routes.BuildMediumPicturePath(playList.MediumPictureName), cancellationToken);
if (playList.LargePictureName != playListConfiguration.Routes.PresetLargePicture)
await PublishRemoveFileEventAsync(playList.Id,
playListConfiguration.Routes.BuildLargePicturePath(playList.LargePictureName), cancellationToken);
var originalPictureName = playList.OriginalPictureName;
var smallPictureName = playList.SmallPictureName;
var mediumPictureName = playList.MediumPictureName;
var largePictureName = playList.LargePictureName;
database.PlayLists.Remove(playList);
await database.SaveChangesAsync(cancellationToken);
var originalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(playList.OriginalPictureName);
var smallPictureKey = playListConfiguration.Routes.BuildSmallPicturePath(playList.SmallPictureName);
var mediumPictureKey = playListConfiguration.Routes.BuildMediumPicturePath(playList.MediumPictureName);
var largePictureKey = playListConfiguration.Routes.BuildLargePicturePath(playList.LargePictureName);
try
{
database.PlayLists.Remove(playList);
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to delete playlist {PlayListId}", request.PlayListId);
return Result.Error($"Failed to delete playlist {request.PlayListId}");
}
await PublishRemoveFileEventsBestEffortAsync(
playList.Id,
originalPictureName,
smallPictureName,
mediumPictureName,
largePictureName,
originalPictureKey,
smallPictureKey,
mediumPictureKey,
largePictureKey,
playListConfiguration.Routes,
cancellationToken);
return Result.NoContent();
}
async Task PublishRemoveFileEventsBestEffortAsync(
Guid playListId,
string originalPictureName,
string smallPictureName,
string mediumPictureName,
string largePictureName,
string originalPictureKey,
string smallPictureKey,
string mediumPictureKey,
string largePictureKey,
PlayListRoutes playListRoutes,
CancellationToken cancellationToken)
{
if (originalPictureName != playListRoutes.PresetOriginalPicture)
await PublishRemoveFileEventIgnoringErrorsAsync(playListId, originalPictureKey, cancellationToken);
if (smallPictureName != playListRoutes.PresetSmallPicture)
await PublishRemoveFileEventIgnoringErrorsAsync(playListId, smallPictureKey, cancellationToken);
if (mediumPictureName != playListRoutes.PresetMediumPicture)
await PublishRemoveFileEventIgnoringErrorsAsync(playListId, mediumPictureKey, cancellationToken);
if (largePictureName != playListRoutes.PresetLargePicture)
await PublishRemoveFileEventIgnoringErrorsAsync(playListId, largePictureKey, cancellationToken);
}
async Task PublishRemoveFileEventIgnoringErrorsAsync(Guid playListId, string key, CancellationToken cancellationToken)
{
var result = await PublishRemoveFileEventAsync(playListId, key, cancellationToken);
if (!result.IsSuccess)
logger.LogWarning("Remove file event publish failed for playlist {PlayListId} with key {Key}", playListId, key);
}
async Task<Result> PublishRemoveFileEventAsync(Guid playListId, string key, CancellationToken cancellationToken)
{
try
@@ -16,14 +16,16 @@ namespace Musify.Application.PlayLists.Handlers
{
var pageNumber = request.PageNumber < 1 ? 1 : request.PageNumber;
var pageSize = request.PageSize < 1 ? 10 : request.PageSize;
var normalizedName = request.Name?.Trim().ToUpperInvariant() ?? string.Empty;
var playListsQuery = database.PlayLists
.AsNoTracking()
.Where(p => p.UserId == request.UserId);
if (!string.IsNullOrWhiteSpace(normalizedName))
if (request.Name is not null)
{
var normalizedName = request.Name.Trim().ToUpperInvariant();
playListsQuery = playListsQuery.Where(p => p.NormalizedName.Contains(normalizedName));
}
var totalCount = await playListsQuery.CountAsync(cancellationToken);
@@ -17,6 +17,9 @@ namespace Musify.Application.PlayLists.Handlers
PlayListConfiguration playListConfiguration)
: ICommandHandler<UpdatePlayListCommand, Result<PlayListResponse>>
{
record UpdatePictureOutcome(Result Result, bool PictureUpdated, string? UploadedPictureKey);
record PreviousPlayListState(string Name, string NormalizedName, string Description, string OriginalPictureName);
public async ValueTask<Result<PlayListResponse>> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken)
{
var playListEntity = await database.PlayLists.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
@@ -32,11 +35,14 @@ namespace Musify.Application.PlayLists.Handlers
return Result.Unauthorized();
}
var previousState = CapturePreviousState(playListEntity);
UpdateName(playListEntity, request.NewName);
UpdateDescription(playListEntity, request.NewDescription);
var updatePictureResult = await UpdatePicture(playListEntity, request.NewPicture, cancellationToken);
if (!updatePictureResult.IsSuccess)
return updatePictureResult;
var pictureOutcome = await UpdatePicture(playListEntity, request.NewPicture, cancellationToken);
if (!pictureOutcome.Result.IsSuccess)
return pictureOutcome.Result;
try
{
@@ -45,20 +51,24 @@ namespace Musify.Application.PlayLists.Handlers
}
catch (Exception exception)
{
await Rollback(playListEntity.OriginalPictureName, cancellationToken);
if (pictureOutcome.PictureUpdated && pictureOutcome.UploadedPictureKey is not null)
await Rollback(pictureOutcome.UploadedPictureKey, cancellationToken);
logger.LogError(exception, "Failed to update playlist {PlayListId}", playListEntity.Id);
return Result.Error($"Failed to update playlist {playListEntity.Id}");
}
try
if (pictureOutcome.PictureUpdated)
{
await PublishPlayListPictureEvent(playListEntity, cancellationToken);
await RemoveOldPictures(playListEntity, cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish playlist picture update event or remove old pictures for playlist {PlayListId}", playListEntity.Id);
return Result.Error($"Failed to update playlist {playListEntity.Id}");
var publishResult = await PublishPlayListPictureEvent(playListEntity, cancellationToken);
if (!publishResult.IsSuccess)
{
await TryRollbackAfterPublishFailureAsync(
playListEntity,
previousState,
pictureOutcome.UploadedPictureKey,
cancellationToken);
return Result.Error($"Failed to update playlist {playListEntity.Id}");
}
}
logger.LogInformation("Updated playlist {PlayListId}", playListEntity.Id);
@@ -67,26 +77,34 @@ namespace Musify.Application.PlayLists.Handlers
void UpdateName(PlayList playList, string? newName)
{
if (!string.IsNullOrWhiteSpace(newName))
{
playList.Name = newName;
playList.NormalizedName = newName.Trim().ToUpperInvariant();
}
if (newName is null)
return;
playList.Name = newName;
playList.NormalizedName = newName.Trim().ToUpperInvariant();
}
void UpdateDescription(PlayList playList, string? newDescription)
{
if (!string.IsNullOrWhiteSpace(newDescription))
playList.Description = newDescription;
if (newDescription is null)
return;
playList.Description = newDescription;
}
async Task<Result> UpdatePicture(PlayList playList, IFileData? newPicture, CancellationToken cancellationToken)
static PreviousPlayListState CapturePreviousState(PlayList playList)
=> new(playList.Name, playList.NormalizedName, playList.Description, playList.OriginalPictureName);
async Task<UpdatePictureOutcome> UpdatePicture(
PlayList playList,
IFileData? newPicture,
CancellationToken cancellationToken)
{
if (newPicture is null)
return Result.Success();
return new UpdatePictureOutcome(Result.Success(), false, null);
var pictureName = Guid.NewGuid() + newPicture.FileType;
var pictureKey = Path.Combine(playListConfiguration.Routes.OriginalPicturesPath, pictureName);
var pictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(pictureName);
try
{
await storageHandler.UploadFileAsync(
@@ -98,11 +116,11 @@ namespace Musify.Application.PlayLists.Handlers
}
catch
{
return Result.Error("Failed to upload picture to storage");
return new UpdatePictureOutcome(Result.Error("Failed to upload picture to storage"), false, null);
}
playList.OriginalPictureName = pictureName;
return Result.Success();
return new UpdatePictureOutcome(Result.Success(), true, pictureKey);
}
async Task<Result> PublishPlayListPictureEvent(PlayList playList, CancellationToken cancellationToken)
@@ -112,7 +130,7 @@ namespace Musify.Application.PlayLists.Handlers
await eventBus.PublishAsync(new UpdatePlayListPictureEvent(
playList.Id,
storageConfiguration.Bucket,
Path.Combine(playListConfiguration.Routes.OriginalPicturesPath, playList.OriginalPictureName),
playListConfiguration.Routes.BuildOriginalPicturePath(playList.OriginalPictureName),
new ImageSize(
playListConfiguration.Routes.SmallPicturesPath,
playListConfiguration.PicturesSizes.SmallPictureWidth,
@@ -135,25 +153,6 @@ namespace Musify.Application.PlayLists.Handlers
}
}
async Task RemoveOldPictures(PlayList playList, CancellationToken cancellationToken)
{
if (playList.SmallPictureName != playListConfiguration.Routes.PresetSmallPicture)
await PublishRemoveFileEvent(Path.Combine(
playListConfiguration.Routes.SmallPicturesPath,
playList.SmallPictureName),
cancellationToken);
if (playList.MediumPictureName != playListConfiguration.Routes.PresetMediumPicture)
await PublishRemoveFileEvent(Path.Combine(
playListConfiguration.Routes.MediumPicturesPath,
playList.MediumPictureName),
cancellationToken);
if (playList.LargePictureName != playListConfiguration.Routes.PresetLargePicture)
await PublishRemoveFileEvent(Path.Combine(
playListConfiguration.Routes.LargePicturesPath,
playList.LargePictureName),
cancellationToken);
}
async Task Rollback(string pictureKey, CancellationToken cancellationToken)
{
await storageHandler.RemoveFileAsync(
@@ -163,20 +162,37 @@ namespace Musify.Application.PlayLists.Handlers
logger.LogInformation("Rolled back uploaded picture {PictureKey} from storage", pictureKey);
}
async Task<Result> PublishRemoveFileEvent(string key, CancellationToken cancellationToken)
async Task TryRollbackAfterPublishFailureAsync(
PlayList playList,
PreviousPlayListState previousState,
string? uploadedPictureKey,
CancellationToken cancellationToken)
{
playList.Name = previousState.Name;
playList.NormalizedName = previousState.NormalizedName;
playList.Description = previousState.Description;
playList.OriginalPictureName = previousState.OriginalPictureName;
try
{
await eventBus.PublishAsync(new RemoveFileEvent(
storageConfiguration.Bucket,
key), cancellationToken);
return Result.NoContent();
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish remove file event for key {Key}", key);
return Result.Error($"Failed to publish remove file event for {key}");
logger.LogError(exception, "Failed to rollback playlist state after publish failure for playlist {PlayListId}", playList.Id);
}
if (uploadedPictureKey is null)
return;
try
{
await Rollback(uploadedPictureKey, cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to rollback uploaded picture after publish failure for playlist {PlayListId}", playList.Id);
}
}
}
}
}
@@ -7,7 +7,7 @@ namespace Musify.Application.PlayLists.Queries
{
public record GetPlayListsByUserIdQuery(
Guid UserId,
string Name = "",
string? Name = null,
int PageNumber = 1,
int PageSize = 10)
: IQuery<Result<PaginatedResponse<PlayListResponse>> >;
@@ -34,13 +34,35 @@ namespace Musify.Application.Tracks.Handler
var fullPictureKey = trackConfiguration.Routes.BuildOriginalPicturePath(trackEntity.OriginalPictureName);
var fullAudioKey = trackConfiguration.Routes.BuildOriginalAudioPath(trackEntity.OriginalAudioName);
await UploadFilesAsync(request, fullPictureKey, fullAudioKey, cancellationToken);
var uploadResult = await UploadFilesAsync(request, fullPictureKey, fullAudioKey, cancellationToken);
if (!uploadResult.IsSuccess)
return uploadResult;
await PublishUpdateEvent(trackEntity, cancellationToken);
await PublishTranscodeEvent(trackEntity, cancellationToken);
try
{
await database.Tracks.AddAsync(trackEntity, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to save track {Title} after uploading files", request.Title);
await RollbackFilesAsync(fullPictureKey, fullAudioKey, cancellationToken);
return Result.Error($"Failed to create track for {request.Title}");
}
await database.Tracks.AddAsync(trackEntity, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
var publishPictureResult = await PublishUpdateEvent(trackEntity, cancellationToken);
if (!publishPictureResult.IsSuccess)
{
await RollbackTrackAfterPublishFailureAsync(trackEntity, fullPictureKey, fullAudioKey, cancellationToken);
return Result.Error($"Failed to create track for {request.Title}");
}
var publishTranscodeResult = await PublishTranscodeEvent(trackEntity, cancellationToken);
if (!publishTranscodeResult.IsSuccess)
{
await RollbackTrackAfterPublishFailureAsync(trackEntity, fullPictureKey, fullAudioKey, cancellationToken);
return Result.Error($"Failed to create track for {request.Title}");
}
return Result.Created(TrackResponse.FromEntity(trackEntity));
}
@@ -91,7 +113,7 @@ namespace Musify.Application.Tracks.Handler
logger.LogInformation("Rolled back uploaded files {PictureKey}, {AudioKey}", pictureKey, audioKey);
}
async Task PublishUpdateEvent(Track track, CancellationToken cancellationToken)
async Task<Result> PublishUpdateEvent(Track track, CancellationToken cancellationToken)
{
try
{
@@ -111,14 +133,16 @@ namespace Musify.Application.Tracks.Handler
trackConfiguration.Routes.LargePicturesPath,
trackConfiguration.PicturesSizes.LargePictureWidth,
trackConfiguration.PicturesSizes.LargePictureHeight)), cancellationToken);
return Result.Success();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish track picture update event for track {TrackId}", track.Id);
return Result.Error($"Failed to publish track picture update event for track {track.Id}");
}
}
async Task PublishTranscodeEvent(Track track, CancellationToken cancellationToken)
async Task<Result> PublishTranscodeEvent(Track track, CancellationToken cancellationToken)
{
var destinationFolderAudio = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString());
@@ -130,10 +154,38 @@ namespace Musify.Application.Tracks.Handler
trackConfiguration.Routes.BuildOriginalAudioPath(track.OriginalAudioName),
storageConfiguration.Bucket,
destinationFolderAudio), cancellationToken);
return Result.Success();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish track audio update event for track {TrackId}", track.Id);
return Result.Error($"Failed to publish track audio update event for track {track.Id}");
}
}
async Task RollbackTrackAfterPublishFailureAsync(
Track track,
string pictureKey,
string audioKey,
CancellationToken cancellationToken)
{
try
{
database.Tracks.Remove(track);
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to rollback track {TrackId} persistence after publish failure", track.Id);
}
try
{
await RollbackFilesAsync(pictureKey, audioKey, cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to rollback uploaded files after publish failure for track {TrackId}", track.Id);
}
}
}
@@ -24,7 +24,10 @@ namespace Musify.Application.Tracks.Handler
return Result.NotFound();
}
if (track.Id != request.UserId)
var isOwner = await database.UserHasTracks
.AsNoTracking()
.AnyAsync(ut => ut.TrackId == request.TrackId && ut.UserId == request.UserId, cancellationToken);
if (!isOwner)
{
logger.LogWarning("User {UserId} unauthorized to delete track {TrackId}", request.UserId, request.TrackId);
return Result.Unauthorized();
@@ -37,13 +40,46 @@ namespace Musify.Application.Tracks.Handler
return Result.Conflict("Track is currently being processed and cannot be deleted");
}
var previousPicturesStatus = track.PicturesProcessingStatus;
var previousAudioStatus = track.AudioTranscodeProcessingStatus;
track.PicturesProcessingStatus = ProcessingStatus.Processing;
track.AudioTranscodeProcessingStatus = ProcessingStatus.Processing;
await database.SaveChangesAsync(cancellationToken);
await eventBus.PublishAsync(
new DeleteTrackEvent(track.Id, request.UserId),
cancellationToken);
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to mark track {TrackId} as processing before deletion", request.TrackId);
return Result.Error($"Failed to delete track {request.TrackId}");
}
try
{
await eventBus.PublishAsync(
new DeleteTrackEvent(track.Id, request.UserId),
cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish delete track event for track {TrackId}", request.TrackId);
track.PicturesProcessingStatus = previousPicturesStatus;
track.AudioTranscodeProcessingStatus = previousAudioStatus;
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception rollbackException)
{
logger.LogError(rollbackException, "Failed to rollback track {TrackId} processing flags after publish failure", request.TrackId);
}
return Result.Error($"Failed to delete track {request.TrackId}");
}
return Result.NoContent();
}
@@ -16,15 +16,17 @@ namespace Musify.Application.Tracks.Handler
{
var pageNumber = request.PageNumber < 1 ? 1 : request.PageNumber;
var pageSize = request.PageSize < 1 ? 10 : request.PageSize;
var normalizedName = request.Name?.Trim().ToUpperInvariant() ?? string.Empty;
var tracksQuery = database.UserHasTracks
.AsNoTracking()
.Include(ut => ut.Track)
.Where(p => p.UserId == request.UserId);
if (!string.IsNullOrWhiteSpace(normalizedName))
if (request.Name is not null)
{
var normalizedName = request.Name.Trim().ToUpperInvariant();
tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName));
}
var totalCount = await tracksQuery.CountAsync(cancellationToken);
@@ -17,15 +17,16 @@ namespace Musify.Application.Tracks.Handler
var pageNumber = request.PageNumber < 1 ? 1 : request.PageNumber;
var pageSize = request.PageSize < 1 ? 10 : request.PageSize;
var totalCount = await database.PlayLists.CountAsync(cancellationToken);
var pagedPlayLists = await database.UserHasTracks
var tracksQuery = database.UserHasTracks
.AsNoTracking()
.OrderBy(t => t.Id)
.Include(ut => ut.Track)
.Select(t => TrackResponse.FromEntity(t.Track))
.ToPagedListAsync(pageNumber, pageSize, totalCount, cancellationToken);
.Select(t => TrackResponse.FromEntity(t.Track));
return Result.Success(PaginatedResponse<TrackResponse>.FromPagedList(pagedPlayLists));
var totalCount = await tracksQuery.CountAsync(cancellationToken);
var pagedTracks = await tracksQuery.ToPagedListAsync(pageNumber, pageSize, totalCount, cancellationToken);
return Result.Success(PaginatedResponse<TrackResponse>.FromPagedList(pagedTracks));
}
}
}
@@ -7,7 +7,7 @@ namespace Musify.Application.Tracks.Queries
{
public record GetTracksByUserIdQuery(
Guid UserId,
string Name = "",
string? Name = null,
int PageNumber = 1,
int PageSize = 10)
: IQuery<Result<PaginatedResponse<TrackResponse> > >;
@@ -21,8 +21,17 @@ namespace Musify.Application.Users.Handlers
}
var newUser = CreateUserCommand.ToEntity(request);
await database.Users.AddAsync(newUser, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
try
{
await database.Users.AddAsync(newUser, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to create user {UserId}", request.Id);
return Result.Error($"Failed to create user {request.Id}");
}
logger.LogInformation("Created user {UserId}", newUser.Id);
return Result.Created(UserResponse.FromEntity(newUser));
@@ -6,7 +6,6 @@ using Musify.Application.Contracts.Infrastructure;
using Musify.Application.Users.Queries;
using Musify.Application.Users.Responses;
using X.PagedList.EF;
using X.PagedList.Extensions;
namespace Musify.Application.Users.Handlers
{
@@ -17,12 +16,12 @@ namespace Musify.Application.Users.Handlers
{
var pageNumber = request.PageNumber < 1 ? 1 : request.PageNumber;
var pageSize = request.PageSize < 1 ? 10 : request.PageSize;
var first = (pageNumber - 1) * pageSize;
var user = database.Users
.AsNoTracking()
.AsQueryable();
if (!string.IsNullOrEmpty(request.UsernameSearch))
if (request.UsernameSearch is not null)
{
var normalizedUsername = request.UsernameSearch.Trim().ToUpperInvariant();
user = user.Where(u => u.NormalizedName.Contains(normalizedUsername));
+1 -1
View File
@@ -5,6 +5,6 @@ using Musify.Application.Users.Responses;
namespace Musify.Application.Users.Queries
{
public record GetUsersQuery(int PageNumber, int PageSize, string UsernameSearch)
public record GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch = null)
: IQuery<Result<PaginatedResponse<UserResponse> >>;
}