Generated response
This commit is contained in:
@@ -17,6 +17,10 @@ namespace Musify.Application.Abstractions.Infrastructure
|
||||
|
||||
DbSet<Upload> Uploads { get; }
|
||||
|
||||
DbSet<UploadIntent> UploadIntents { get; }
|
||||
|
||||
Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken);
|
||||
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Musify.Application.Abstractions.Infrastructure
|
||||
{
|
||||
public interface IDatabaseTransaction : IAsyncDisposable
|
||||
{
|
||||
Task CommitAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,9 @@
|
||||
{
|
||||
Task<Stream> GetFileAsync(string bucket, string key, CancellationToken cancellationToken);
|
||||
|
||||
// Returns null if the object does not exist
|
||||
Task<ObjectMetadata?> HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken);
|
||||
|
||||
Task<string> GetUrlAsync(string bucket, string key, TimeSpan ExpirationTime, CancellationToken cancellationToken);
|
||||
|
||||
Task<string> GetUploadUrlAsync(string bucket, string key, string contentType, TimeSpan expirationTime, CancellationToken cancellationToken);
|
||||
@@ -20,5 +23,8 @@
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task RemoveFolderAsync(string bucket, string folderKey, CancellationToken cancellationToken);
|
||||
|
||||
// Returns (key, lastModifiedUtc) for all objects under the given prefix
|
||||
IAsyncEnumerable<(string Key, DateTime LastModifiedUtc)> ListObjectsAsync(string bucket, string prefix, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace Musify.Application.Abstractions.Infrastructure
|
||||
{
|
||||
public record ObjectMetadata(string ContentType, long ContentLength);
|
||||
}
|
||||
@@ -69,6 +69,10 @@ namespace Musify.Application.Configuration
|
||||
|
||||
public string BuildLargePicturePath(string pictureName) => CombineKey(ParentFolders, LargePicturesFolder, pictureName);
|
||||
|
||||
// Temp path used in Phase 2 for pre-signed upload keys (moved to final on consumption)
|
||||
public string BuildTempPicturePath(string tempRootPrefix, Guid userId, string objectName) =>
|
||||
CombineKey(tempRootPrefix, userId.ToString(), ParentFolders, objectName);
|
||||
|
||||
static string CombineKey(params string[] segments)
|
||||
{
|
||||
return string.Join('/', segments
|
||||
|
||||
@@ -85,6 +85,13 @@ namespace Musify.Application.Configuration
|
||||
|
||||
public string BuildProcessedAudioPath(string audioName) => CombineKey(ProcessedAudiosPath, audioName);
|
||||
|
||||
// Temp paths used in Phase 2 for pre-signed upload keys (moved to final on consumption)
|
||||
public string BuildTempPicturePath(string tempRootPrefix, Guid userId, string objectName) =>
|
||||
CombineKey(tempRootPrefix, userId.ToString(), ParentFolder, objectName);
|
||||
|
||||
public string BuildTempAudioPath(string tempRootPrefix, Guid userId, string objectName) =>
|
||||
CombineKey(tempRootPrefix, userId.ToString(), ParentFolder, objectName);
|
||||
|
||||
static string CombineKey(params string[] segments)
|
||||
{
|
||||
return string.Join('/', segments
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Musify.Application.Configuration
|
||||
{
|
||||
public class UploadIntentConfiguration
|
||||
{
|
||||
public const string SectionName = "UploadIntent";
|
||||
|
||||
// Pre-signed URL TTL in seconds
|
||||
[Range(10, 3600)]
|
||||
public int UploadUrlExpiresInSeconds { get; set; } = 120;
|
||||
|
||||
// Max concurrent Issued intents per user
|
||||
[Range(1, 100)]
|
||||
public int MaxActiveUploadIntentsPerUser { get; set; } = 5;
|
||||
|
||||
// Max total bytes across all Issued intents for a user (default 200MB)
|
||||
[Range(1, long.MaxValue)]
|
||||
public long MaxActiveUploadBytesPerUser { get; set; } = 200L * 1024 * 1024;
|
||||
|
||||
// Max size validated via HEAD before consuming an intent (default 100MB)
|
||||
[Range(1, long.MaxValue)]
|
||||
public long MaxUploadBytes { get; set; } = 100L * 1024 * 1024;
|
||||
|
||||
// Conservative fallback size used when ExpectedSizeBytes is not provided
|
||||
[Range(1, long.MaxValue)]
|
||||
public long DefaultExpectedPictureSizeBytes { get; set; } = 10L * 1024 * 1024; // 10MB
|
||||
|
||||
[Range(1, long.MaxValue)]
|
||||
public long DefaultExpectedAudioSizeBytes { get; set; } = 100L * 1024 * 1024; // 100MB
|
||||
|
||||
// How often the expiration job runs
|
||||
[Range(30, 86400)]
|
||||
public int ExpirationJobIntervalSeconds { get; set; } = 300;
|
||||
|
||||
// How many days to retain Expired intents before deleting the DB row
|
||||
[Range(0, 365)]
|
||||
public int ExpiredIntentsRetentionDays { get; set; } = 7;
|
||||
|
||||
// Root S3 prefix for temporary uploads (Phase 2)
|
||||
[Required, MinLength(1)]
|
||||
public string TempRootPrefix { get; set; } = "temp";
|
||||
|
||||
// How many days to retain orphaned temp objects in S3 before the cleanup job deletes them
|
||||
[Range(1, 30)]
|
||||
public int TempUploadsRetentionDays { get; set; } = 3;
|
||||
|
||||
// How often the temp-upload cleanup job runs
|
||||
[Range(60, 86400)]
|
||||
public int TempCleanupJobIntervalSeconds { get; set; } = 3600;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using Ardalis.Result;
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
using Musify.Application.PlayLists.Responses;
|
||||
|
||||
@@ -8,6 +8,6 @@ namespace Musify.Application.PlayLists.Commands
|
||||
Guid UserId,
|
||||
string Name,
|
||||
string Description,
|
||||
string? OriginalPictureName)
|
||||
Guid? PictureIntentId)
|
||||
: ICommand<Result<PlayListApplicationResponse>>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace Musify.Application.PlayLists.Commands
|
||||
public record RequestPlayListPictureUploadCommand(
|
||||
Guid UserId,
|
||||
string FileType,
|
||||
string ContentType)
|
||||
string ContentType,
|
||||
long? ExpectedSizeBytes = null)
|
||||
: ICommand<Result<PlayListPictureUploadResponse>>;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Ardalis.Result;
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
using Musify.Application.PlayLists.Responses;
|
||||
|
||||
@@ -9,6 +9,6 @@ namespace Musify.Application.PlayLists.Commands
|
||||
Guid PlayListId,
|
||||
string? NewName,
|
||||
string? NewDescription,
|
||||
string? NewOriginalPictureName)
|
||||
Guid? NewPictureIntentId)
|
||||
: ICommand<Result<PlayListApplicationResponse>>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ using Musify.Application.Abstractions.Infrastructure;
|
||||
using Musify.Application.Events;
|
||||
using Musify.Application.PlayLists.Commands;
|
||||
using Musify.Application.PlayLists.Responses;
|
||||
using Musify.Application.UploadIntents;
|
||||
using Musify.Domain.Entities;
|
||||
|
||||
namespace Musify.Application.PlayLists.Handlers
|
||||
@@ -14,9 +15,11 @@ namespace Musify.Application.PlayLists.Handlers
|
||||
public class CreatePlayListCommandHandler(
|
||||
IEventBus eventBus,
|
||||
IDatabase database,
|
||||
IStorageService storageService,
|
||||
ILogger<CreatePlayListCommandHandler> logger,
|
||||
ApplicationStorageConfiguration storageConfiguration,
|
||||
PlayListConfiguration playListConfiguration)
|
||||
PlayListConfiguration playListConfiguration,
|
||||
UploadIntentConfiguration uploadIntentConfiguration)
|
||||
: ICommandHandler<CreatePlayListCommand, Result<PlayListApplicationResponse>>
|
||||
{
|
||||
public async ValueTask<Result<PlayListApplicationResponse>> Handle(CreatePlayListCommand request, CancellationToken cancellationToken)
|
||||
@@ -30,7 +33,35 @@ namespace Musify.Application.PlayLists.Handlers
|
||||
return Result.NotFound($"User {request.UserId} not found");
|
||||
}
|
||||
|
||||
var originalPictureName = request.OriginalPictureName ?? playListConfiguration.Routes.PresetOriginalPicture;
|
||||
string originalPictureName;
|
||||
UploadIntent? pictureIntent = null;
|
||||
|
||||
if (request.PictureIntentId.HasValue)
|
||||
{
|
||||
var validation = await UploadIntentHelpers.ValidateAndLoadAsync(
|
||||
database, storageService, uploadIntentConfiguration,
|
||||
request.PictureIntentId.Value, request.UserId, cancellationToken);
|
||||
if (!validation.IsSuccess)
|
||||
return validation.Error!.Value;
|
||||
|
||||
pictureIntent = validation.Intent!;
|
||||
originalPictureName = pictureIntent.ObjectName;
|
||||
|
||||
var finalKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, originalPictureName);
|
||||
try
|
||||
{
|
||||
await storageService.CopyFileAsync(pictureIntent.Bucket, pictureIntent.Key, pictureIntent.Bucket, finalKey, cancellationToken);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Failed to copy playlist picture from temp {TempKey} to final {FinalKey}", pictureIntent.Key, finalKey);
|
||||
return Result.Error("Failed to move uploaded file to its final location.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
originalPictureName = playListConfiguration.Routes.PresetOriginalPicture;
|
||||
}
|
||||
|
||||
var playList = new PlayList
|
||||
{
|
||||
@@ -46,8 +77,10 @@ namespace Musify.Application.PlayLists.Handlers
|
||||
|
||||
await database.PlayLists.AddAsync(playList, cancellationToken);
|
||||
|
||||
if (request.OriginalPictureName is not null)
|
||||
if (pictureIntent is not null)
|
||||
{
|
||||
pictureIntent.Status = UploadIntentStatus.Consumed;
|
||||
|
||||
var publishResult = await PublishPictureUpdateEventAsync(playList, cancellationToken);
|
||||
if (!publishResult.IsSuccess)
|
||||
return publishResult;
|
||||
@@ -98,4 +131,4 @@ namespace Musify.Application.PlayLists.Handlers
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ using Musify.Application.Abstractions.Infrastructure;
|
||||
using Musify.Application.Configuration;
|
||||
using Musify.Application.PlayLists.Commands;
|
||||
using Musify.Application.PlayLists.Responses;
|
||||
using Musify.Application.UploadIntents;
|
||||
using Musify.Domain.Entities;
|
||||
|
||||
namespace Musify.Application.PlayLists.Handlers
|
||||
{
|
||||
@@ -14,11 +16,11 @@ namespace Musify.Application.PlayLists.Handlers
|
||||
IStorageService storageService,
|
||||
ILogger<RequestPlayListPictureUploadCommandHandler> logger,
|
||||
ApplicationStorageConfiguration storageConfiguration,
|
||||
PlayListConfiguration playListConfiguration)
|
||||
PlayListConfiguration playListConfiguration,
|
||||
UploadIntentConfiguration uploadIntentConfiguration)
|
||||
: ICommandHandler<RequestPlayListPictureUploadCommand, Result<PlayListPictureUploadResponse>>
|
||||
{
|
||||
static readonly string[] AllowedExtensions = [".webp", ".png", ".jpg", ".jpeg"];
|
||||
const int ExpiresInSeconds = 600;
|
||||
|
||||
public async ValueTask<Result<PlayListPictureUploadResponse>> Handle(RequestPlayListPictureUploadCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -31,31 +33,61 @@ namespace Musify.Application.PlayLists.Handlers
|
||||
return Result.NotFound($"User {request.UserId} not found");
|
||||
}
|
||||
|
||||
var extensionResult = ValidateExtension(request.FileType);
|
||||
var extensionResult = UploadIntentHelpers.ValidateExtension(request.FileType, AllowedExtensions);
|
||||
if (!extensionResult.IsSuccess)
|
||||
return Result.Invalid(extensionResult.Errors.Select(error => new ValidationError(error)).ToArray());
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ContentType))
|
||||
return Result.Invalid(new ValidationError("ContentType is required"));
|
||||
var effectiveSizeBytes = request.ExpectedSizeBytes
|
||||
?? uploadIntentConfiguration.DefaultExpectedPictureSizeBytes;
|
||||
|
||||
var pictureName = Guid.NewGuid() + extensionResult.Value;
|
||||
var key = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureName);
|
||||
var objectName = Guid.NewGuid() + extensionResult.Value;
|
||||
|
||||
var tempKey = playListConfiguration.Routes.BuildTempPicturePath(
|
||||
uploadIntentConfiguration.TempRootPrefix, request.UserId, objectName);
|
||||
|
||||
try
|
||||
{
|
||||
var uploadUrl = await storageService.GetUploadUrlAsync(
|
||||
storageConfiguration.Bucket,
|
||||
key,
|
||||
tempKey,
|
||||
request.ContentType,
|
||||
TimeSpan.FromSeconds(ExpiresInSeconds),
|
||||
TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds),
|
||||
cancellationToken);
|
||||
|
||||
await using var transaction = await database.BeginTransactionAsync(
|
||||
System.Data.IsolationLevel.Serializable, cancellationToken);
|
||||
|
||||
var quotaCheck = await UploadIntentHelpers.CheckQuotaAsync(
|
||||
database, uploadIntentConfiguration, logger,
|
||||
request.UserId, effectiveSizeBytes, 1, cancellationToken);
|
||||
if (!quotaCheck.IsSuccess)
|
||||
return quotaCheck;
|
||||
|
||||
var intent = new UploadIntent
|
||||
{
|
||||
UserId = request.UserId,
|
||||
Bucket = storageConfiguration.Bucket,
|
||||
Key = tempKey,
|
||||
ObjectName = objectName,
|
||||
ContentType = request.ContentType,
|
||||
ExpectedSizeBytes = effectiveSizeBytes,
|
||||
Purpose = UploadIntentPurpose.PlayListPicture,
|
||||
ExpiresAt = DateTime.UtcNow.AddSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds),
|
||||
};
|
||||
|
||||
await database.UploadIntents.AddAsync(intent, cancellationToken);
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
logger.LogInformation("Issued upload intent {IntentId} for user {UserId} (PlayListPicture, temp key: {Key})", intent.Id, request.UserId, tempKey);
|
||||
|
||||
return Result.Success(new PlayListPictureUploadResponse(
|
||||
intent.Id,
|
||||
storageConfiguration.Bucket,
|
||||
key,
|
||||
pictureName,
|
||||
tempKey,
|
||||
objectName,
|
||||
request.ContentType,
|
||||
ExpiresInSeconds,
|
||||
uploadIntentConfiguration.UploadUrlExpiresInSeconds,
|
||||
uploadUrl));
|
||||
}
|
||||
catch (Exception exception)
|
||||
@@ -64,25 +96,5 @@ namespace Musify.Application.PlayLists.Handlers
|
||||
return Result.Error("Failed to generate upload URL");
|
||||
}
|
||||
}
|
||||
|
||||
static Result<string> ValidateExtension(string fileType)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fileType))
|
||||
return Result.Error("FileType is required");
|
||||
|
||||
var extension = fileType.Trim();
|
||||
if (!extension.StartsWith('.'))
|
||||
extension = "." + extension;
|
||||
|
||||
extension = extension.ToLowerInvariant();
|
||||
|
||||
if (extension.Contains('/') || extension.Contains('\\'))
|
||||
return Result.Error("Invalid FileType");
|
||||
|
||||
if (!AllowedExtensions.Contains(extension))
|
||||
return Result.Error($"Unsupported FileType '{extension}'");
|
||||
|
||||
return Result.Success(extension);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,15 +7,19 @@ using Musify.Application.Abstractions.Infrastructure;
|
||||
using Musify.Application.Events;
|
||||
using Musify.Application.PlayLists.Commands;
|
||||
using Musify.Application.PlayLists.Responses;
|
||||
using Musify.Application.UploadIntents;
|
||||
using Musify.Domain.Entities;
|
||||
|
||||
namespace Musify.Application.PlayLists.Handlers
|
||||
{
|
||||
public class UpdatePlayListCommandHandler(
|
||||
IEventBus eventBus,
|
||||
IDatabase database,
|
||||
IStorageService storageService,
|
||||
ILogger<UpdatePlayListCommandHandler> logger,
|
||||
ApplicationStorageConfiguration storageConfiguration,
|
||||
PlayListConfiguration playListConfiguration)
|
||||
PlayListConfiguration playListConfiguration,
|
||||
UploadIntentConfiguration uploadIntentConfiguration)
|
||||
: ICommandHandler<UpdatePlayListCommand, Result<PlayListApplicationResponse>>
|
||||
{
|
||||
public async ValueTask<Result<PlayListApplicationResponse>> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken)
|
||||
@@ -44,9 +48,28 @@ namespace Musify.Application.PlayLists.Handlers
|
||||
playListEntity.Description = request.NewDescription;
|
||||
}
|
||||
|
||||
if (request.NewOriginalPictureName is not null)
|
||||
if (request.NewPictureIntentId.HasValue)
|
||||
{
|
||||
playListEntity.OriginalPictureName = request.NewOriginalPictureName;
|
||||
var validation = await UploadIntentHelpers.ValidateAndLoadAsync(
|
||||
database, storageService, uploadIntentConfiguration,
|
||||
request.NewPictureIntentId.Value, request.UserId, cancellationToken);
|
||||
if (!validation.IsSuccess)
|
||||
return validation.Error!.Value;
|
||||
|
||||
var intent = validation.Intent!;
|
||||
var finalKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, intent.ObjectName);
|
||||
try
|
||||
{
|
||||
await storageService.CopyFileAsync(intent.Bucket, intent.Key, intent.Bucket, finalKey, cancellationToken);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Failed to copy playlist picture from temp {TempKey} to final {FinalKey}", intent.Key, finalKey);
|
||||
return Result.Error("Failed to move uploaded file to its final location.");
|
||||
}
|
||||
|
||||
playListEntity.OriginalPictureName = intent.ObjectName;
|
||||
intent.Status = UploadIntentStatus.Consumed;
|
||||
|
||||
var publishResult = await PublishPlayListPictureEvent(playListEntity.UserId, playListEntity.Id, playListEntity.OriginalPictureName, cancellationToken);
|
||||
if (!publishResult.IsSuccess)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
namespace Musify.Application.PlayLists.Responses
|
||||
{
|
||||
public record PlayListPictureUploadResponse(
|
||||
Guid IntentId,
|
||||
string Bucket,
|
||||
string Key,
|
||||
string PictureName,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Ardalis.Result;
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace Musify.Application.Tracks.Commands
|
||||
public record CreateTrackCommand(
|
||||
Guid UserId,
|
||||
string Title,
|
||||
string OriginalPictureName,
|
||||
string OriginalAudioName)
|
||||
Guid PictureIntentId,
|
||||
Guid AudioIntentId)
|
||||
: ICommand<Result<TrackApplicationResponse>>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ namespace Musify.Application.Tracks.Commands
|
||||
string PictureFileType,
|
||||
string PictureContentType,
|
||||
string AudioFileType,
|
||||
string AudioContentType)
|
||||
string AudioContentType,
|
||||
long? ExpectedPictureSizeBytes = null,
|
||||
long? ExpectedAudioSizeBytes = null)
|
||||
: ICommand<Result<TrackUploadUrlsResponse>>;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ using Musify.Application.Abstractions.Infrastructure;
|
||||
using Musify.Application.Events;
|
||||
using Musify.Application.Tracks.Commands;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
using Musify.Application.UploadIntents;
|
||||
using Musify.Domain.Entities;
|
||||
|
||||
namespace Musify.Application.Tracks.Handler
|
||||
@@ -14,9 +15,11 @@ namespace Musify.Application.Tracks.Handler
|
||||
public class CreateTrackCommandHandler(
|
||||
IDatabase database,
|
||||
IEventBus eventBus,
|
||||
IStorageService storageService,
|
||||
ILogger<CreateTrackCommandHandler> logger,
|
||||
ApplicationStorageConfiguration storageConfiguration,
|
||||
TrackConfiguration trackConfiguration)
|
||||
TrackConfiguration trackConfiguration,
|
||||
UploadIntentConfiguration uploadIntentConfiguration)
|
||||
: ICommandHandler<CreateTrackCommand, Result<TrackApplicationResponse>>
|
||||
{
|
||||
public async ValueTask<Result<TrackApplicationResponse>> Handle(CreateTrackCommand request, CancellationToken cancellationToken)
|
||||
@@ -29,12 +32,41 @@ namespace Musify.Application.Tracks.Handler
|
||||
return Result.NotFound($"User {request.UserId} not found");
|
||||
}
|
||||
|
||||
var pictureValidation = await UploadIntentHelpers.ValidateAndLoadAsync(
|
||||
database, storageService, uploadIntentConfiguration,
|
||||
request.PictureIntentId, request.UserId, cancellationToken);
|
||||
if (!pictureValidation.IsSuccess)
|
||||
return pictureValidation.Error!.Value;
|
||||
|
||||
var audioValidation = await UploadIntentHelpers.ValidateAndLoadAsync(
|
||||
database, storageService, uploadIntentConfiguration,
|
||||
request.AudioIntentId, request.UserId, cancellationToken);
|
||||
if (!audioValidation.IsSuccess)
|
||||
return audioValidation.Error!.Value;
|
||||
|
||||
var pictureIntent = pictureValidation.Intent!;
|
||||
var audioIntent = audioValidation.Intent!;
|
||||
|
||||
var finalPictureKey = trackConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
|
||||
var finalAudioKey = trackConfiguration.Routes.BuildOriginalAudioPath(request.UserId, audioIntent.ObjectName);
|
||||
|
||||
try
|
||||
{
|
||||
await storageService.CopyFileAsync(pictureIntent.Bucket, pictureIntent.Key, pictureIntent.Bucket, finalPictureKey, cancellationToken);
|
||||
await storageService.CopyFileAsync(audioIntent.Bucket, audioIntent.Key, audioIntent.Bucket, finalAudioKey, cancellationToken);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Failed to copy track files from temp to final location for user {UserId}", request.UserId);
|
||||
return Result.Error("Failed to move uploaded files to their final locations.");
|
||||
}
|
||||
|
||||
var trackEntity = new Track
|
||||
{
|
||||
Title = request.Title,
|
||||
NormalizedTitle = request.Title.ToUpperInvariant(),
|
||||
OriginalPictureName = request.OriginalPictureName,
|
||||
OriginalAudioName = request.OriginalAudioName,
|
||||
OriginalPictureName = pictureIntent.ObjectName,
|
||||
OriginalAudioName = audioIntent.ObjectName,
|
||||
SmallPictureName = trackConfiguration.Routes.PresetSmallPicture,
|
||||
MediumPictureName = trackConfiguration.Routes.PresetMediumPicture,
|
||||
LargePictureName = trackConfiguration.Routes.PresetLargePicture,
|
||||
@@ -44,6 +76,9 @@ namespace Musify.Application.Tracks.Handler
|
||||
|
||||
await database.Tracks.AddAsync(trackEntity, cancellationToken);
|
||||
|
||||
pictureIntent.Status = UploadIntentStatus.Consumed;
|
||||
audioIntent.Status = UploadIntentStatus.Consumed;
|
||||
|
||||
var publishPictureResult = await PublishUpdateEvent(request.UserId, trackEntity, cancellationToken);
|
||||
if (!publishPictureResult.IsSuccess)
|
||||
return Result.Error($"Failed to create track for {request.Title}");
|
||||
@@ -114,6 +149,5 @@ namespace Musify.Application.Tracks.Handler
|
||||
return Result.Error($"Failed to publish track audio update event for track {track.Id}");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ using Musify.Application.Abstractions.Infrastructure;
|
||||
using Musify.Application.Configuration;
|
||||
using Musify.Application.Tracks.Commands;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
using Musify.Application.UploadIntents;
|
||||
using Musify.Domain.Entities;
|
||||
|
||||
namespace Musify.Application.Tracks.Handler
|
||||
{
|
||||
@@ -14,12 +16,12 @@ namespace Musify.Application.Tracks.Handler
|
||||
IStorageService storageService,
|
||||
ILogger<RequestTrackUploadUrlsCommandHandler> logger,
|
||||
ApplicationStorageConfiguration storageConfiguration,
|
||||
TrackConfiguration trackConfiguration)
|
||||
TrackConfiguration trackConfiguration,
|
||||
UploadIntentConfiguration uploadIntentConfiguration)
|
||||
: ICommandHandler<RequestTrackUploadUrlsCommand, Result<TrackUploadUrlsResponse>>
|
||||
{
|
||||
static readonly string[] AllowedPictureExtensions = [".webp", ".png", ".jpg", ".jpeg"];
|
||||
static readonly string[] AllowedAudioExtensions = [".mp3", ".wav", ".m4a", ".flac", ".ogg", ".aac", ".opus"];
|
||||
const int ExpiresInSeconds = 600;
|
||||
|
||||
public async ValueTask<Result<TrackUploadUrlsResponse>> Handle(RequestTrackUploadUrlsCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -32,53 +34,101 @@ namespace Musify.Application.Tracks.Handler
|
||||
return Result.NotFound($"User {request.UserId} not found");
|
||||
}
|
||||
|
||||
var pictureExtensionResult = ValidateExtension(request.PictureFileType, AllowedPictureExtensions);
|
||||
var pictureExtensionResult = UploadIntentHelpers.ValidateExtension(request.PictureFileType, AllowedPictureExtensions);
|
||||
if (!pictureExtensionResult.IsSuccess)
|
||||
return Result.Invalid(pictureExtensionResult.Errors.Select(error => new ValidationError(error)).ToArray());
|
||||
|
||||
var audioExtensionResult = ValidateExtension(request.AudioFileType, AllowedAudioExtensions);
|
||||
var audioExtensionResult = UploadIntentHelpers.ValidateExtension(request.AudioFileType, AllowedAudioExtensions);
|
||||
if (!audioExtensionResult.IsSuccess)
|
||||
return Result.Invalid(audioExtensionResult.Errors.Select(error => new ValidationError(error)).ToArray());
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.PictureContentType))
|
||||
return Result.Invalid(new ValidationError("PictureContentType is required"));
|
||||
var effectivePictureSize = request.ExpectedPictureSizeBytes
|
||||
?? uploadIntentConfiguration.DefaultExpectedPictureSizeBytes;
|
||||
var effectiveAudioSize = request.ExpectedAudioSizeBytes
|
||||
?? uploadIntentConfiguration.DefaultExpectedAudioSizeBytes;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.AudioContentType))
|
||||
return Result.Invalid(new ValidationError("AudioContentType is required"));
|
||||
var pictureObjectName = Guid.NewGuid() + pictureExtensionResult.Value;
|
||||
var audioObjectName = Guid.NewGuid() + audioExtensionResult.Value;
|
||||
|
||||
var pictureName = Guid.NewGuid() + pictureExtensionResult.Value;
|
||||
var audioName = Guid.NewGuid() + audioExtensionResult.Value;
|
||||
|
||||
var pictureKey = trackConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureName);
|
||||
var audioKey = trackConfiguration.Routes.BuildOriginalAudioPath(request.UserId, audioName);
|
||||
var tempPictureKey = trackConfiguration.Routes.BuildTempPicturePath(
|
||||
uploadIntentConfiguration.TempRootPrefix, request.UserId, pictureObjectName);
|
||||
var tempAudioKey = trackConfiguration.Routes.BuildTempAudioPath(
|
||||
uploadIntentConfiguration.TempRootPrefix, request.UserId, audioObjectName);
|
||||
|
||||
try
|
||||
{
|
||||
var expiresIn = TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds);
|
||||
|
||||
var pictureUploadUrl = await storageService.GetUploadUrlAsync(
|
||||
storageConfiguration.Bucket,
|
||||
pictureKey,
|
||||
tempPictureKey,
|
||||
request.PictureContentType,
|
||||
TimeSpan.FromSeconds(ExpiresInSeconds),
|
||||
expiresIn,
|
||||
cancellationToken);
|
||||
|
||||
var audioUploadUrl = await storageService.GetUploadUrlAsync(
|
||||
storageConfiguration.Bucket,
|
||||
audioKey,
|
||||
tempAudioKey,
|
||||
request.AudioContentType,
|
||||
TimeSpan.FromSeconds(ExpiresInSeconds),
|
||||
expiresIn,
|
||||
cancellationToken);
|
||||
|
||||
var expiresAt = DateTime.UtcNow.AddSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds);
|
||||
|
||||
await using var transaction = await database.BeginTransactionAsync(
|
||||
System.Data.IsolationLevel.Serializable, cancellationToken);
|
||||
|
||||
var quotaCheck = await UploadIntentHelpers.CheckQuotaAsync(
|
||||
database, uploadIntentConfiguration, logger,
|
||||
request.UserId, effectivePictureSize + effectiveAudioSize, 2, cancellationToken);
|
||||
if (!quotaCheck.IsSuccess)
|
||||
return quotaCheck;
|
||||
|
||||
var pictureIntent = new UploadIntent
|
||||
{
|
||||
UserId = request.UserId,
|
||||
Bucket = storageConfiguration.Bucket,
|
||||
Key = tempPictureKey,
|
||||
ObjectName = pictureObjectName,
|
||||
ContentType = request.PictureContentType,
|
||||
ExpectedSizeBytes = effectivePictureSize,
|
||||
Purpose = UploadIntentPurpose.TrackPicture,
|
||||
ExpiresAt = expiresAt,
|
||||
};
|
||||
|
||||
var audioIntent = new UploadIntent
|
||||
{
|
||||
UserId = request.UserId,
|
||||
Bucket = storageConfiguration.Bucket,
|
||||
Key = tempAudioKey,
|
||||
ObjectName = audioObjectName,
|
||||
ContentType = request.AudioContentType,
|
||||
ExpectedSizeBytes = effectiveAudioSize,
|
||||
Purpose = UploadIntentPurpose.TrackAudio,
|
||||
ExpiresAt = expiresAt,
|
||||
};
|
||||
|
||||
await database.UploadIntents.AddAsync(pictureIntent, cancellationToken);
|
||||
await database.UploadIntents.AddAsync(audioIntent, cancellationToken);
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
logger.LogInformation("Issued upload intents {PictureIntentId}/{AudioIntentId} for user {UserId} (TrackPicture/TrackAudio)",
|
||||
pictureIntent.Id, audioIntent.Id, request.UserId);
|
||||
|
||||
return Result.Success(new TrackUploadUrlsResponse(
|
||||
pictureIntent.Id,
|
||||
audioIntent.Id,
|
||||
storageConfiguration.Bucket,
|
||||
pictureKey,
|
||||
pictureName,
|
||||
tempPictureKey,
|
||||
pictureObjectName,
|
||||
request.PictureContentType,
|
||||
pictureUploadUrl,
|
||||
audioKey,
|
||||
audioName,
|
||||
tempAudioKey,
|
||||
audioObjectName,
|
||||
request.AudioContentType,
|
||||
audioUploadUrl,
|
||||
ExpiresInSeconds));
|
||||
uploadIntentConfiguration.UploadUrlExpiresInSeconds));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
@@ -86,25 +136,5 @@ namespace Musify.Application.Tracks.Handler
|
||||
return Result.Error("Failed to generate upload URLs");
|
||||
}
|
||||
}
|
||||
|
||||
static Result<string> ValidateExtension(string fileType, string[] allowedExtensions)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fileType))
|
||||
return Result.Error("FileType is required");
|
||||
|
||||
var extension = fileType.Trim();
|
||||
if (!extension.StartsWith('.'))
|
||||
extension = "." + extension;
|
||||
|
||||
extension = extension.ToLowerInvariant();
|
||||
|
||||
if (extension.Contains('/') || extension.Contains('\\'))
|
||||
return Result.Error("Invalid FileType");
|
||||
|
||||
if (!allowedExtensions.Contains(extension))
|
||||
return Result.Error($"Unsupported FileType '{extension}'");
|
||||
|
||||
return Result.Success(extension);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
namespace Musify.Application.Tracks.Responses
|
||||
{
|
||||
public record TrackUploadUrlsResponse(
|
||||
Guid PictureIntentId,
|
||||
Guid AudioIntentId,
|
||||
string Bucket,
|
||||
string PictureKey,
|
||||
string PictureName,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
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
|
||||
{
|
||||
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)
|
||||
{
|
||||
var extension = fileType.Trim();
|
||||
if (!extension.StartsWith('.'))
|
||||
extension = "." + extension;
|
||||
|
||||
extension = extension.ToLowerInvariant();
|
||||
|
||||
if (extension.Contains('/') || extension.Contains('\\'))
|
||||
return Result.Error("Invalid FileType");
|
||||
|
||||
if (!allowedExtensions.Contains(extension))
|
||||
return Result.Error($"Unsupported FileType '{extension}'");
|
||||
|
||||
return Result.Success(extension);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user