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:
+35
@@ -0,0 +1,35 @@
|
||||
using MassTransit;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Infrastructure.MassTransit.Arguments;
|
||||
|
||||
namespace Musify.Infrastructure.MassTransit.Activities.Albums;
|
||||
|
||||
internal class DeleteAlbumFromDbActivity(
|
||||
IDatabase database,
|
||||
ILogger<DeleteAlbumFromDbActivity> logger)
|
||||
: IExecuteActivity<DeleteAlbumFromDbArguments>
|
||||
{
|
||||
public const string ExecuteEndpointName = "delete-album-from-db";
|
||||
|
||||
public async Task<ExecutionResult> Execute(ExecuteContext<DeleteAlbumFromDbArguments> executeContext)
|
||||
{
|
||||
var album = await database.Albums
|
||||
.SingleOrDefaultAsync(p => p.Id == executeContext.Arguments.AlbumId, executeContext.CancellationToken);
|
||||
|
||||
if (album == null)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Album {AlbumId} not found while deleting from DB, skipping",
|
||||
executeContext.Arguments.AlbumId);
|
||||
return executeContext.Completed();
|
||||
}
|
||||
|
||||
database.Albums.Remove(album);
|
||||
await database.SaveChangesAsync(executeContext.CancellationToken);
|
||||
|
||||
logger.LogInformation("Deleted album {AlbumId} from DB", album.Id);
|
||||
return executeContext.Completed();
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
using MassTransit;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Musify.Infrastructure.MassTransit.Arguments;
|
||||
|
||||
namespace Musify.Infrastructure.MassTransit.Activities.Albums;
|
||||
|
||||
internal class MarkAlbumAsRemovingActivity(
|
||||
IDatabase database,
|
||||
ILogger<MarkAlbumAsRemovingActivity> logger)
|
||||
: IExecuteActivity<MarkAlbumAsRemovingArguments>
|
||||
{
|
||||
public const string ExecuteEndpointName = "mark-album-as-removing";
|
||||
|
||||
public async Task<ExecutionResult> Execute(ExecuteContext<MarkAlbumAsRemovingArguments> executeContext)
|
||||
{
|
||||
var album = await database.Albums
|
||||
.SingleOrDefaultAsync(p => p.Id == executeContext.Arguments.AlbumId, executeContext.CancellationToken);
|
||||
|
||||
if (album == null)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Album {AlbumId} not found while marking as removing, skipping",
|
||||
executeContext.Arguments.AlbumId);
|
||||
return executeContext.Completed();
|
||||
}
|
||||
|
||||
album.LifeCycleStatus = LifeCycleStatus.Removing;
|
||||
await database.SaveChangesAsync(executeContext.CancellationToken);
|
||||
|
||||
logger.LogInformation("Marked album {AlbumId} as removing", album.Id);
|
||||
return executeContext.Completed();
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -23,7 +23,8 @@ internal class DownloadFileFromBucketActivity(
|
||||
using var fileStream = await storageHandler.GetFileAsync(
|
||||
executeContext.Arguments.Bucket,
|
||||
executeContext.Arguments.Key,
|
||||
executeContext.CancellationToken);
|
||||
executeContext.CancellationToken)
|
||||
?? throw new FileNotFoundException($"Object {executeContext.Arguments.Bucket}/{executeContext.Arguments.Key} was not found.");
|
||||
|
||||
var destinationDirectory = Path.GetDirectoryName(destinationPath);
|
||||
if (!string.IsNullOrWhiteSpace(destinationDirectory))
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace Musify.Infrastructure.MassTransit.Arguments;
|
||||
|
||||
internal record DeleteAlbumFromDbArguments(
|
||||
Guid AlbumId);
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
namespace Musify.Infrastructure.MassTransit.Arguments;
|
||||
|
||||
internal record MarkAlbumAsRemovingArguments(
|
||||
Guid AlbumId);
|
||||
@@ -0,0 +1,24 @@
|
||||
using MassTransit;
|
||||
using Musify.Application.Events;
|
||||
using Musify.Infrastructure.MassTransit.RoutingSlip.Builders;
|
||||
|
||||
namespace Musify.Infrastructure.MassTransit.Consumers;
|
||||
|
||||
public class DeleteAlbumConsumer(
|
||||
IBus bus,
|
||||
DeleteAlbumRoutingSlipBuilder routingSlipBuilder)
|
||||
: IConsumer<DeleteAlbumEvent>
|
||||
{
|
||||
public async Task Consume(ConsumeContext<DeleteAlbumEvent> consumeContext)
|
||||
{
|
||||
var routingSlip = (await routingSlipBuilder
|
||||
.BuildAsync(
|
||||
consumeContext.Message.AlbumId,
|
||||
consumeContext.Message.UserId,
|
||||
consumeContext.CorrelationId,
|
||||
consumeContext.CancellationToken))
|
||||
.Build();
|
||||
|
||||
await bus.Execute(routingSlip);
|
||||
}
|
||||
}
|
||||
+4
@@ -22,6 +22,7 @@ public static partial class MassTransitDependencyInjection
|
||||
services.AddScoped<PictureWorkflowRoutingSlipBuilder>();
|
||||
services.AddScoped<DeleteTrackRoutingSlipBuilder>();
|
||||
services.AddScoped<DeletePlayListRoutingSlipBuilder>();
|
||||
services.AddScoped<DeleteAlbumRoutingSlipBuilder>();
|
||||
services.AddScoped<CreateTrackRoutingSlipBuilder>();
|
||||
services.AddScoped<PlayListPictureSourceRoutingSlipBuilder>();
|
||||
services.AddScoped<AlbumPictureSourceRoutingSlipBuilder>();
|
||||
@@ -35,6 +36,7 @@ public static partial class MassTransitDependencyInjection
|
||||
options.AddConsumer<UpdateTrackAudioConsumer>();
|
||||
options.AddConsumer<DeleteTrackConsumer>();
|
||||
options.AddConsumer<DeletePlayListConsumer>();
|
||||
options.AddConsumer<DeleteAlbumConsumer>();
|
||||
options.AddConsumer<CreateTrackConsumer>();
|
||||
options.AddConsumer<CreatePlayListConsumer>();
|
||||
options.AddConsumer<UpdatePlayListPictureSourceConsumer>();
|
||||
@@ -58,6 +60,8 @@ public static partial class MassTransitDependencyInjection
|
||||
options.AddExecuteActivity<MarkTrackAsFailedActivity, MarkTrackAsFailedArguments>();
|
||||
options.AddExecuteActivity<MarkPlayListAsFailedActivity, MarkPlayListAsFailedArguments>();
|
||||
options.AddExecuteActivity<MarkAlbumAsFailedActivity, MarkAlbumAsFailedArguments>();
|
||||
options.AddExecuteActivity<MarkAlbumAsRemovingActivity, MarkAlbumAsRemovingArguments>();
|
||||
options.AddExecuteActivity<DeleteAlbumFromDbActivity, DeleteAlbumFromDbArguments>();
|
||||
options.AddExecuteActivity<PublishAlbumPictureProcessingEventActivity, PublishAlbumPictureProcessingEventArguments>();
|
||||
|
||||
options.AddActivity<ResizePictureActivity, ResizePictureLocalArguments, ResizePictureLog>();
|
||||
|
||||
@@ -3,6 +3,6 @@ namespace Musify.Infrastructure.MassTransit.Logs;
|
||||
public record UpdateAlbumPictureLog(
|
||||
Guid AlbumId,
|
||||
string PreviousOriginalPictureKey,
|
||||
string PreviousSmallPictureKey,
|
||||
string PreviousMediumPictureKey,
|
||||
string PreviousLargePictureKey);
|
||||
string? PreviousSmallPictureKey,
|
||||
string? PreviousMediumPictureKey,
|
||||
string? PreviousLargePictureKey);
|
||||
|
||||
@@ -3,6 +3,6 @@ namespace Musify.Infrastructure.MassTransit.Logs;
|
||||
public record UpdatePlayListPictureLog(
|
||||
Guid PlayListId,
|
||||
string PreviousOriginalPictureKey,
|
||||
string PreviousSmallPictureKey,
|
||||
string PreviousMediumPictureKey,
|
||||
string PreviousLargePictureKey);
|
||||
string? PreviousSmallPictureKey,
|
||||
string? PreviousMediumPictureKey,
|
||||
string? PreviousLargePictureKey);
|
||||
|
||||
@@ -9,6 +9,8 @@ internal static class ActivityNames
|
||||
internal const string MarkPlayListAsFailed = "MarkPlayListAsFailed";
|
||||
internal const string DeletePlayListFromDb = "DeletePlayListFromDb";
|
||||
internal const string MarkAlbumAsFailed = "MarkAlbumAsFailed";
|
||||
internal const string MarkAlbumAsRemoving = "MarkAlbumAsRemoving";
|
||||
internal const string DeleteAlbumFromDb = "DeleteAlbumFromDb";
|
||||
|
||||
internal const string GenerateAudioWorkflowPaths = "GenerateAudioWorkflowPaths";
|
||||
internal const string GeneratePictureWorkflowPaths = "GeneratePictureWorkflowPaths";
|
||||
@@ -55,4 +57,7 @@ internal static class ActivityNames
|
||||
internal const string RemovePlayListLargePicture = "RemovePlayListLargePicture";
|
||||
|
||||
internal const string RemoveAlbumOriginalPicture = "RemoveAlbumOriginalPicture";
|
||||
internal const string RemoveAlbumSmallPicture = "RemoveAlbumSmallPicture";
|
||||
internal const string RemoveAlbumMediumPicture = "RemoveAlbumMediumPicture";
|
||||
internal const string RemoveAlbumLargePicture = "RemoveAlbumLargePicture";
|
||||
}
|
||||
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
using MassTransit;
|
||||
using MassTransit.Courier.Contracts;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Configuration;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Infrastructure.MassTransit.Activities.Files;
|
||||
using Musify.Infrastructure.MassTransit.Activities.Albums;
|
||||
using Musify.Infrastructure.MassTransit.Arguments;
|
||||
using Musify.Infrastructure.MassTransit.Consumers;
|
||||
|
||||
namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders;
|
||||
|
||||
public class DeleteAlbumRoutingSlipBuilder(
|
||||
IDatabase database,
|
||||
AlbumConfiguration albumConfiguration,
|
||||
ApplicationStorageConfiguration storageConfiguration)
|
||||
{
|
||||
public async Task<RoutingSlipBuilder> BuildAsync(
|
||||
Guid albumId,
|
||||
long userId,
|
||||
Guid? correlationId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid());
|
||||
routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty);
|
||||
|
||||
routingSlipBuilder.AddSubscription(
|
||||
EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName),
|
||||
RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted);
|
||||
|
||||
var album = await database.Albums
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(p => p.Id == albumId, cancellationToken);
|
||||
|
||||
routingSlipBuilder.AddActivity(
|
||||
ActivityNames.MarkAlbumAsRemoving,
|
||||
EndpointHelper.BuildExecuteActivityUri(MarkAlbumAsRemovingActivity.ExecuteEndpointName),
|
||||
new MarkAlbumAsRemovingArguments(albumId));
|
||||
|
||||
if (album?.Pictures != null)
|
||||
{
|
||||
var bucket = storageConfiguration.Bucket;
|
||||
var routes = albumConfiguration.Routes;
|
||||
|
||||
if (album.Pictures.OriginalName is { } originalName)
|
||||
{
|
||||
routingSlipBuilder.AddActivity(
|
||||
ActivityNames.RemoveAlbumOriginalPicture,
|
||||
EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName),
|
||||
new RemoveFileFromBucketArguments(
|
||||
bucket,
|
||||
routes.BuildOriginalPicturePath(userId, originalName)));
|
||||
}
|
||||
|
||||
if (album.Pictures.SmallName is { } smallName)
|
||||
{
|
||||
routingSlipBuilder.AddActivity(
|
||||
ActivityNames.RemoveAlbumSmallPicture,
|
||||
EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName),
|
||||
new RemoveFileFromBucketArguments(
|
||||
bucket,
|
||||
routes.BuildSmallPicturePath(smallName)));
|
||||
}
|
||||
|
||||
if (album.Pictures.MediumName is { } mediumName)
|
||||
{
|
||||
routingSlipBuilder.AddActivity(
|
||||
ActivityNames.RemoveAlbumMediumPicture,
|
||||
EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName),
|
||||
new RemoveFileFromBucketArguments(
|
||||
bucket,
|
||||
routes.BuildMediumPicturePath(mediumName)));
|
||||
}
|
||||
|
||||
if (album.Pictures.LargeName is { } largeName)
|
||||
{
|
||||
routingSlipBuilder.AddActivity(
|
||||
ActivityNames.RemoveAlbumLargePicture,
|
||||
EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName),
|
||||
new RemoveFileFromBucketArguments(
|
||||
bucket,
|
||||
routes.BuildLargePicturePath(largeName)));
|
||||
}
|
||||
}
|
||||
|
||||
routingSlipBuilder.AddActivity(
|
||||
ActivityNames.DeleteAlbumFromDb,
|
||||
EndpointHelper.BuildExecuteActivityUri(DeleteAlbumFromDbActivity.ExecuteEndpointName),
|
||||
new DeleteAlbumFromDbArguments(albumId));
|
||||
|
||||
return routingSlipBuilder;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using ErrorOr;
|
||||
using MassTransit;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
@@ -6,6 +7,7 @@ using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Musify.Infrastructure.Configuration;
|
||||
using Musify.Infrastructure.MassTransit.Sagas;
|
||||
using Npgsql;
|
||||
using AppIDatabase = Musify.Application.Contracts.IDatabase;
|
||||
|
||||
namespace Musify.Infrastructure.Persistence;
|
||||
@@ -82,9 +84,9 @@ public class Database(DatabaseConfiguration configuration)
|
||||
.OwnsOne(album => album.Pictures, pictures =>
|
||||
{
|
||||
pictures.Property(p => p.OriginalName).HasColumnName("OriginalPictureName").HasMaxLength(64);
|
||||
pictures.Property(p => p.SmallName).HasColumnName("SmallPictureName").HasMaxLength(64);
|
||||
pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64);
|
||||
pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64);
|
||||
pictures.Property(p => p.SmallName).HasColumnName("SmallPictureName").HasMaxLength(64).IsRequired(false);
|
||||
pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64).IsRequired(false);
|
||||
pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64).IsRequired(false);
|
||||
});
|
||||
modelBuilder.Entity<Album>().Navigation(album => album.Pictures).IsRequired(false);
|
||||
|
||||
@@ -110,6 +112,10 @@ public class Database(DatabaseConfiguration configuration)
|
||||
.HasIndex(albumTrack => new { albumTrack.AlbumId, albumTrack.TrackId })
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<PlayListHasTrack>()
|
||||
.HasIndex(playListTrack => new { playListTrack.PlayListId, playListTrack.TrackId })
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<Mix>()
|
||||
.HasIndex(mix => new { mix.UserId, mix.Position });
|
||||
|
||||
@@ -205,6 +211,19 @@ public class Database(DatabaseConfiguration configuration)
|
||||
return new DatabaseTransaction(transaction);
|
||||
}
|
||||
|
||||
public async Task<ErrorOr<Success>> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await SaveChangesAsync(cancellationToken);
|
||||
return Result.Success;
|
||||
}
|
||||
catch (DbUpdateException exception) when (exception.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation })
|
||||
{
|
||||
return onUniqueViolation;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DatabaseTransaction(IDbContextTransaction inner) : IDatabaseTransaction
|
||||
{
|
||||
public Task CommitAsync(CancellationToken cancellationToken) =>
|
||||
|
||||
+1174
File diff suppressed because it is too large
Load Diff
+46
@@ -0,0 +1,46 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Musify.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class PlayListTrackUnique : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Remove duplicate rows (keeping the lowest position) before enforcing uniqueness.
|
||||
migrationBuilder.Sql("""
|
||||
DELETE FROM "PlayListHasTrack" a
|
||||
USING "PlayListHasTrack" b
|
||||
WHERE a."PlayListId" = b."PlayListId"
|
||||
AND a."TrackId" = b."TrackId"
|
||||
AND (a."Position", a."Id") > (b."Position", b."Id");
|
||||
""");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_PlayListHasTrack_PlayListId",
|
||||
table: "PlayListHasTrack");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PlayListHasTrack_PlayListId_TrackId",
|
||||
table: "PlayListHasTrack",
|
||||
columns: new[] { "PlayListId", "TrackId" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_PlayListHasTrack_PlayListId_TrackId",
|
||||
table: "PlayListHasTrack");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PlayListHasTrack_PlayListId",
|
||||
table: "PlayListHasTrack",
|
||||
column: "PlayListId");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -417,10 +417,11 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayListId");
|
||||
|
||||
b.HasIndex("TrackId");
|
||||
|
||||
b.HasIndex("PlayListId", "TrackId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlayListHasTrack");
|
||||
});
|
||||
|
||||
@@ -790,13 +791,11 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b1.Property<string>("LargeName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasColumnName("LargePictureName");
|
||||
|
||||
b1.Property<string>("MediumName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasColumnName("MediumPictureName");
|
||||
@@ -808,7 +807,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
.HasColumnName("OriginalPictureName");
|
||||
|
||||
b1.Property<string>("SmallName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasColumnName("SmallPictureName");
|
||||
|
||||
@@ -15,7 +15,7 @@ public class StorageService(IDatabase database, IAmazonS3 amazonS3, ILogger<Stor
|
||||
InfrastructureStorageConfiguration storageClientConfiguration)
|
||||
: IStorageService
|
||||
{
|
||||
public async Task<Stream> GetFileAsync(string bucket, string key, CancellationToken cancellationToken)
|
||||
public async Task<Stream?> GetFileAsync(string bucket, string key, CancellationToken cancellationToken)
|
||||
{
|
||||
var request = new GetObjectRequest
|
||||
{
|
||||
@@ -29,6 +29,11 @@ public class StorageService(IDatabase database, IAmazonS3 amazonS3, ILogger<Stor
|
||||
logger.LogDebug("Retrieved file from S3 {Bucket}/{Key}", bucket, key);
|
||||
return response.ResponseStream;
|
||||
}
|
||||
catch (AmazonS3Exception exception) when (exception.ErrorCode == "NoSuchKey")
|
||||
{
|
||||
logger.LogDebug("File not found in S3 {Bucket}/{Key}", bucket, key);
|
||||
return null;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Failed to get file from S3 {Bucket}/{Key}", bucket, key);
|
||||
|
||||
Reference in New Issue
Block a user