This commit is contained in:
2026-05-14 21:42:31 +02:00
parent 91ec85760e
commit 80f44422a0
11 changed files with 158 additions and 100 deletions
@@ -0,0 +1,23 @@
using Ardalis.Result;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Domain.Entities;
namespace Musify.Application.Abstractions.Application;
public interface IUploadIntentService
{
Task<Result> CheckQuotaAsync(
UploadIntentConfiguration config,
ILogger logger,
Guid userId,
long requiredBytes,
int requiredIntentCount,
CancellationToken cancellationToken);
Task<Result<UploadIntent>> ValidateAndLoadAsync(
UploadIntentConfiguration config,
Guid intentId,
Guid userId,
CancellationToken cancellationToken);
}
@@ -0,0 +1,25 @@
using Ardalis.Result;
namespace Musify.Application.Extensions;
public static class ResultExtensions
{
public static Result<TOut> As<TIn, TOut>(this Result<TIn> result)
{
return result.Status switch
{
ResultStatus.Ok or
ResultStatus.Created or
ResultStatus.NoContent => throw new InvalidOperationException($"Cannot convert a successful result ( {result.Status} ) to another type. Use Map or Bind instead."),
ResultStatus.NotFound => Result<TOut>.NotFound(result.Errors.ToArray()),
ResultStatus.Forbidden => Result<TOut>.Forbidden(result.Errors.ToArray()),
ResultStatus.Unauthorized => Result<TOut>.Unauthorized(result.Errors.ToArray()),
ResultStatus.Invalid => Result<TOut>.Invalid(result.ValidationErrors.ToArray()),
ResultStatus.Conflict => Result<TOut>.Conflict(result.Errors.ToArray()),
ResultStatus.CriticalError => Result<TOut>.CriticalError(result.Errors.ToArray()),
ResultStatus.Unavailable => Result<TOut>.Unavailable(result.Errors.ToArray()),
ResultStatus.Error => Result<TOut>.Error(string.Join(";", result.Errors)),
_ => Result<TOut>.Error(string.Join(";", result.Errors))
};
}
}
@@ -8,6 +8,8 @@ using Musify.Application.Events;
using Musify.Application.PlayLists.Commands; using Musify.Application.PlayLists.Commands;
using Musify.Application.PlayLists.Responses; using Musify.Application.PlayLists.Responses;
using Musify.Application.UploadIntents; using Musify.Application.UploadIntents;
using Musify.Application.Extensions;
using Musify.Application.Abstractions.Application;
using Musify.Domain.Entities; using Musify.Domain.Entities;
namespace Musify.Application.PlayLists.Handlers namespace Musify.Application.PlayLists.Handlers
@@ -15,6 +17,7 @@ namespace Musify.Application.PlayLists.Handlers
public class CreatePlayListCommandHandler( public class CreatePlayListCommandHandler(
IEventBus eventBus, IEventBus eventBus,
IDatabase database, IDatabase database,
IUploadIntentService uploadIntentService,
IStorageService storageService, IStorageService storageService,
ILogger<CreatePlayListCommandHandler> logger, ILogger<CreatePlayListCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration, ApplicationStorageConfiguration storageConfiguration,
@@ -38,13 +41,13 @@ namespace Musify.Application.PlayLists.Handlers
if (request.PictureIntentId.HasValue) if (request.PictureIntentId.HasValue)
{ {
var validation = await UploadIntentHelpers.ValidateAndLoadAsync( var validation = await uploadIntentService.ValidateAndLoadAsync(
database, storageService, uploadIntentConfiguration, uploadIntentConfiguration,
request.PictureIntentId.Value, request.UserId, cancellationToken); request.PictureIntentId.Value, request.UserId, cancellationToken);
if (!validation.IsSuccess) if (!validation.IsSuccess)
return validation.Error!.Value; return validation.As<UploadIntent, PlayListApplicationResponse>();
pictureIntent = validation.Intent!; pictureIntent = validation.Value;
originalPictureName = pictureIntent.ObjectName; originalPictureName = pictureIntent.ObjectName;
var finalKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, originalPictureName); var finalKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, originalPictureName);
@@ -131,4 +134,4 @@ namespace Musify.Application.PlayLists.Handlers
} }
} }
} }
} }
@@ -3,6 +3,7 @@ using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Application.Abstractions.Infrastructure; using Musify.Application.Abstractions.Infrastructure;
using Musify.Application.Abstractions.Application;
using Musify.Application.Configuration; using Musify.Application.Configuration;
using Musify.Application.PlayLists.Commands; using Musify.Application.PlayLists.Commands;
using Musify.Application.PlayLists.Responses; using Musify.Application.PlayLists.Responses;
@@ -13,6 +14,7 @@ namespace Musify.Application.PlayLists.Handlers
{ {
public class RequestPlayListPictureUploadCommandHandler( public class RequestPlayListPictureUploadCommandHandler(
IDatabase database, IDatabase database,
IUploadIntentService uploadIntentService,
IStorageService storageService, IStorageService storageService,
ILogger<RequestPlayListPictureUploadCommandHandler> logger, ILogger<RequestPlayListPictureUploadCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration, ApplicationStorageConfiguration storageConfiguration,
@@ -57,8 +59,8 @@ namespace Musify.Application.PlayLists.Handlers
await using var transaction = await database.BeginTransactionAsync( await using var transaction = await database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, cancellationToken); System.Data.IsolationLevel.Serializable, cancellationToken);
var quotaCheck = await UploadIntentHelpers.CheckQuotaAsync( var quotaCheck = await uploadIntentService.CheckQuotaAsync(
database, uploadIntentConfiguration, logger, uploadIntentConfiguration, logger,
request.UserId, effectiveSizeBytes, 1, cancellationToken); request.UserId, effectiveSizeBytes, 1, cancellationToken);
if (!quotaCheck.IsSuccess) if (!quotaCheck.IsSuccess)
return quotaCheck; return quotaCheck;
@@ -8,6 +8,8 @@ using Musify.Application.Events;
using Musify.Application.PlayLists.Commands; using Musify.Application.PlayLists.Commands;
using Musify.Application.PlayLists.Responses; using Musify.Application.PlayLists.Responses;
using Musify.Application.UploadIntents; using Musify.Application.UploadIntents;
using Musify.Application.Extensions;
using Musify.Application.Abstractions.Application;
using Musify.Domain.Entities; using Musify.Domain.Entities;
namespace Musify.Application.PlayLists.Handlers namespace Musify.Application.PlayLists.Handlers
@@ -15,6 +17,7 @@ namespace Musify.Application.PlayLists.Handlers
public class UpdatePlayListCommandHandler( public class UpdatePlayListCommandHandler(
IEventBus eventBus, IEventBus eventBus,
IDatabase database, IDatabase database,
IUploadIntentService uploadIntentService,
IStorageService storageService, IStorageService storageService,
ILogger<UpdatePlayListCommandHandler> logger, ILogger<UpdatePlayListCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration, ApplicationStorageConfiguration storageConfiguration,
@@ -50,13 +53,13 @@ namespace Musify.Application.PlayLists.Handlers
if (request.NewPictureIntentId.HasValue) if (request.NewPictureIntentId.HasValue)
{ {
var validation = await UploadIntentHelpers.ValidateAndLoadAsync( var validation = await uploadIntentService.ValidateAndLoadAsync(
database, storageService, uploadIntentConfiguration, uploadIntentConfiguration,
request.NewPictureIntentId.Value, request.UserId, cancellationToken); request.NewPictureIntentId.Value, request.UserId, cancellationToken);
if (!validation.IsSuccess) if (!validation.IsSuccess)
return validation.Error!.Value; return validation.As<UploadIntent, PlayListApplicationResponse>();
var intent = validation.Intent!; var intent = validation.Value;
var finalKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, intent.ObjectName); var finalKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, intent.ObjectName);
try try
{ {
@@ -121,4 +124,4 @@ namespace Musify.Application.PlayLists.Handlers
} }
} }
} }
} }
@@ -0,0 +1,71 @@
using Ardalis.Result;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Abstractions.Infrastructure;
using Musify.Application.Abstractions.Application;
using Musify.Application.Configuration;
using Musify.Domain.Entities;
namespace Musify.Application.Services;
public sealed class UploadIntentService(
IDatabase database,
IStorageService storageService) : IUploadIntentService
{
public async Task<Result> CheckQuotaAsync(
UploadIntentConfiguration config,
ILogger logger,
Guid userId,
long requiredBytes,
int requiredIntentCount,
CancellationToken cancellationToken)
{
var activeIntents = await database.UploadIntents
.AsNoTracking()
.Where(i => i.UserId == userId && i.Status == UploadIntentStatus.Issued)
.ToListAsync(cancellationToken);
if (activeIntents.Count + requiredIntentCount > config.MaxActiveUploadIntentsPerUser)
{
logger.LogWarning("User {UserId} exceeded max active upload intents ({Max})", userId, config.MaxActiveUploadIntentsPerUser);
return Result.Invalid(new ValidationError("Upload intent limit exceeded. Wait for existing uploads to complete or expire."));
}
var activeBytes = activeIntents.Sum(i => i.ExpectedSizeBytes ?? config.DefaultExpectedPictureSizeBytes);
if (activeBytes + requiredBytes > config.MaxActiveUploadBytesPerUser)
{
logger.LogWarning("User {UserId} exceeded max active upload bytes ({Max})", userId, config.MaxActiveUploadBytesPerUser);
return Result.Invalid(new ValidationError("Upload byte quota exceeded. Wait for existing uploads to complete or expire."));
}
return Result.Success();
}
public async Task<Result<UploadIntent>> ValidateAndLoadAsync(
UploadIntentConfiguration config,
Guid intentId,
Guid userId,
CancellationToken cancellationToken)
{
var intent = await database.UploadIntents
.FirstOrDefaultAsync(i => i.Id == intentId, cancellationToken);
if (intent is null || intent.UserId != userId)
return Result.NotFound("Upload intent not found or not accessible.");
if (intent.Status == UploadIntentStatus.Consumed)
return Result.Conflict("Upload intent has already been consumed.");
if (intent.Status == UploadIntentStatus.Expired || intent.ExpiresAt < DateTime.UtcNow)
return Result.Invalid(new ValidationError("Upload intent has expired."));
var metadata = await storageService.HeadObjectAsync(intent.Bucket, intent.Key, cancellationToken);
if (metadata is null)
return Result.NotFound("Uploaded object not found in storage. Upload the file first.");
if (metadata.ContentLength > config.MaxUploadBytes)
return Result.Invalid(new ValidationError($"Uploaded file exceeds the maximum allowed size of {config.MaxUploadBytes} bytes."));
return Result.Success(intent);
}
}
@@ -8,6 +8,8 @@ using Musify.Application.Events;
using Musify.Application.Tracks.Commands; using Musify.Application.Tracks.Commands;
using Musify.Application.Tracks.Responses; using Musify.Application.Tracks.Responses;
using Musify.Application.UploadIntents; using Musify.Application.UploadIntents;
using Musify.Application.Extensions;
using Musify.Application.Abstractions.Application;
using Musify.Domain.Entities; using Musify.Domain.Entities;
namespace Musify.Application.Tracks.Handler namespace Musify.Application.Tracks.Handler
@@ -15,6 +17,7 @@ namespace Musify.Application.Tracks.Handler
public class CreateTrackCommandHandler( public class CreateTrackCommandHandler(
IDatabase database, IDatabase database,
IEventBus eventBus, IEventBus eventBus,
IUploadIntentService uploadIntentService,
IStorageService storageService, IStorageService storageService,
ILogger<CreateTrackCommandHandler> logger, ILogger<CreateTrackCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration, ApplicationStorageConfiguration storageConfiguration,
@@ -32,20 +35,20 @@ namespace Musify.Application.Tracks.Handler
return Result.NotFound($"User {request.UserId} not found"); return Result.NotFound($"User {request.UserId} not found");
} }
var pictureValidation = await UploadIntentHelpers.ValidateAndLoadAsync( var pictureValidation = await uploadIntentService.ValidateAndLoadAsync(
database, storageService, uploadIntentConfiguration, uploadIntentConfiguration,
request.PictureIntentId, request.UserId, cancellationToken); request.PictureIntentId, request.UserId, cancellationToken);
if (!pictureValidation.IsSuccess) if (!pictureValidation.IsSuccess)
return pictureValidation.Error!.Value; return pictureValidation.As<UploadIntent, TrackApplicationResponse>();
var audioValidation = await UploadIntentHelpers.ValidateAndLoadAsync( var audioValidation = await uploadIntentService.ValidateAndLoadAsync(
database, storageService, uploadIntentConfiguration, uploadIntentConfiguration,
request.AudioIntentId, request.UserId, cancellationToken); request.AudioIntentId, request.UserId, cancellationToken);
if (!audioValidation.IsSuccess) if (!audioValidation.IsSuccess)
return audioValidation.Error!.Value; return audioValidation.As<UploadIntent, TrackApplicationResponse>();
var pictureIntent = pictureValidation.Intent!; var pictureIntent = pictureValidation.Value;
var audioIntent = audioValidation.Intent!; var audioIntent = audioValidation.Value;
var finalPictureKey = trackConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName); var finalPictureKey = trackConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
var finalAudioKey = trackConfiguration.Routes.BuildOriginalAudioPath(request.UserId, audioIntent.ObjectName); var finalAudioKey = trackConfiguration.Routes.BuildOriginalAudioPath(request.UserId, audioIntent.ObjectName);
@@ -2,6 +2,7 @@ using Ardalis.Result;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Application.Abstractions.Application;
using Musify.Application.Abstractions.Infrastructure; using Musify.Application.Abstractions.Infrastructure;
using Musify.Application.Configuration; using Musify.Application.Configuration;
using Musify.Application.Tracks.Commands; using Musify.Application.Tracks.Commands;
@@ -13,6 +14,7 @@ namespace Musify.Application.Tracks.Handler
{ {
public class RequestTrackUploadUrlsCommandHandler( public class RequestTrackUploadUrlsCommandHandler(
IDatabase database, IDatabase database,
IUploadIntentService uploadIntentService,
IStorageService storageService, IStorageService storageService,
ILogger<RequestTrackUploadUrlsCommandHandler> logger, ILogger<RequestTrackUploadUrlsCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration, ApplicationStorageConfiguration storageConfiguration,
@@ -78,8 +80,8 @@ namespace Musify.Application.Tracks.Handler
await using var transaction = await database.BeginTransactionAsync( await using var transaction = await database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, cancellationToken); System.Data.IsolationLevel.Serializable, cancellationToken);
var quotaCheck = await UploadIntentHelpers.CheckQuotaAsync( var quotaCheck = await uploadIntentService.CheckQuotaAsync(
database, uploadIntentConfiguration, logger, uploadIntentConfiguration, logger,
request.UserId, effectivePictureSize + effectiveAudioSize, 2, cancellationToken); request.UserId, effectivePictureSize + effectiveAudioSize, 2, cancellationToken);
if (!quotaCheck.IsSuccess) if (!quotaCheck.IsSuccess)
return quotaCheck; return quotaCheck;
@@ -1,13 +0,0 @@
using Ardalis.Result;
using Musify.Domain.Entities;
namespace Musify.Application.UploadIntents
{
internal sealed record IntentValidationResult(Result? Error, UploadIntent? Intent)
{
public bool IsSuccess => Error is null;
public static IntentValidationResult Ok(UploadIntent intent) => new(null, intent);
public static IntentValidationResult Fail(Result error) => new(error, null);
}
}
@@ -1,74 +1,9 @@
using Ardalis.Result; using Ardalis.Result;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Abstractions.Infrastructure;
using Musify.Application.Configuration;
using Musify.Domain.Entities;
namespace Musify.Application.UploadIntents namespace Musify.Application.UploadIntents
{ {
internal static class UploadIntentHelpers internal static class UploadIntentHelpers
{ {
internal static async Task<IntentValidationResult> ValidateAndLoadAsync(
IDatabase database,
IStorageService storageService,
UploadIntentConfiguration config,
Guid intentId,
Guid userId,
CancellationToken cancellationToken)
{
var intent = await database.UploadIntents
.FirstOrDefaultAsync(i => i.Id == intentId, cancellationToken);
if (intent is null || intent.UserId != userId)
return IntentValidationResult.Fail(Result.NotFound("Upload intent not found or not accessible."));
if (intent.Status == UploadIntentStatus.Consumed)
return IntentValidationResult.Fail(Result.Conflict("Upload intent has already been consumed."));
if (intent.Status == UploadIntentStatus.Expired || intent.ExpiresAt < DateTime.UtcNow)
return IntentValidationResult.Fail(Result.Invalid(new ValidationError("Upload intent has expired.")));
var metadata = await storageService.HeadObjectAsync(intent.Bucket, intent.Key, cancellationToken);
if (metadata is null)
return IntentValidationResult.Fail(Result.NotFound("Uploaded object not found in storage. Upload the file first."));
if (metadata.ContentLength > config.MaxUploadBytes)
return IntentValidationResult.Fail(Result.Invalid(new ValidationError($"Uploaded file exceeds the maximum allowed size of {config.MaxUploadBytes} bytes.")));
return IntentValidationResult.Ok(intent);
}
internal static async Task<Result> CheckQuotaAsync(
IDatabase database,
UploadIntentConfiguration config,
ILogger logger,
Guid userId,
long requiredBytes,
int requiredIntentCount,
CancellationToken cancellationToken)
{
var activeIntents = await database.UploadIntents
.AsNoTracking()
.Where(i => i.UserId == userId && i.Status == UploadIntentStatus.Issued)
.ToListAsync(cancellationToken);
if (activeIntents.Count + requiredIntentCount > config.MaxActiveUploadIntentsPerUser)
{
logger.LogWarning("User {UserId} exceeded max active upload intents ({Max})", userId, config.MaxActiveUploadIntentsPerUser);
return Result.Invalid(new ValidationError("Upload intent limit exceeded. Wait for existing uploads to complete or expire."));
}
var activeBytes = activeIntents.Sum(i => i.ExpectedSizeBytes ?? config.DefaultExpectedPictureSizeBytes);
if (activeBytes + requiredBytes > config.MaxActiveUploadBytesPerUser)
{
logger.LogWarning("User {UserId} exceeded max active upload bytes ({Max})", userId, config.MaxActiveUploadBytesPerUser);
return Result.Invalid(new ValidationError("Upload byte quota exceeded. Wait for existing uploads to complete or expire."));
}
return Result.Success();
}
internal static Result<string> ValidateExtension(string fileType, string[] allowedExtensions) internal static Result<string> ValidateExtension(string fileType, string[] allowedExtensions)
{ {
var extension = fileType.Trim(); var extension = fileType.Trim();
@@ -3,9 +3,11 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Musify.Application.Abstractions.Infrastructure; using Musify.Application.Abstractions.Infrastructure;
using Musify.Application.Abstractions.Application;
using Musify.Application.Configuration; using Musify.Application.Configuration;
using Musify.Infrastructure.Configuration; using Musify.Infrastructure.Configuration;
using Musify.Infrastructure.Jobs; using Musify.Infrastructure.Jobs;
using Musify.Application.Services;
namespace Musify.Infrastructure.Services namespace Musify.Infrastructure.Services
{ {
@@ -54,6 +56,8 @@ namespace Musify.Infrastructure.Services
serviceDescriptors.AddSingleton(serviceProvider => serviceDescriptors.AddSingleton(serviceProvider =>
serviceProvider.GetRequiredService<IOptions<UploadIntentConfiguration>>().Value); serviceProvider.GetRequiredService<IOptions<UploadIntentConfiguration>>().Value);
serviceDescriptors.AddScoped<IUploadIntentService, UploadIntentService>();
return serviceDescriptors; return serviceDescriptors;
} }