diff --git a/Application/Abstractions/Infrastructure/IDatabase.cs b/Application/Abstractions/Infrastructure/IDatabase.cs index ea81d4e..9d87904 100644 --- a/Application/Abstractions/Infrastructure/IDatabase.cs +++ b/Application/Abstractions/Infrastructure/IDatabase.cs @@ -17,6 +17,10 @@ namespace Musify.Application.Abstractions.Infrastructure DbSet Uploads { get; } + DbSet UploadIntents { get; } + + Task BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken); + Task SaveChangesAsync(CancellationToken cancellationToken); } } \ No newline at end of file diff --git a/Application/Abstractions/Infrastructure/IDatabaseTransaction.cs b/Application/Abstractions/Infrastructure/IDatabaseTransaction.cs new file mode 100644 index 0000000..9e5cddc --- /dev/null +++ b/Application/Abstractions/Infrastructure/IDatabaseTransaction.cs @@ -0,0 +1,7 @@ +namespace Musify.Application.Abstractions.Infrastructure +{ + public interface IDatabaseTransaction : IAsyncDisposable + { + Task CommitAsync(CancellationToken cancellationToken = default); + } +} diff --git a/Application/Abstractions/Infrastructure/IStorageService.cs b/Application/Abstractions/Infrastructure/IStorageService.cs index 26af204..cb1c49d 100644 --- a/Application/Abstractions/Infrastructure/IStorageService.cs +++ b/Application/Abstractions/Infrastructure/IStorageService.cs @@ -4,6 +4,9 @@ { Task GetFileAsync(string bucket, string key, CancellationToken cancellationToken); + // Returns null if the object does not exist + Task HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken); + Task GetUrlAsync(string bucket, string key, TimeSpan ExpirationTime, CancellationToken cancellationToken); Task 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); } } \ No newline at end of file diff --git a/Application/Abstractions/Infrastructure/ObjectMetadata.cs b/Application/Abstractions/Infrastructure/ObjectMetadata.cs new file mode 100644 index 0000000..f92904e --- /dev/null +++ b/Application/Abstractions/Infrastructure/ObjectMetadata.cs @@ -0,0 +1,4 @@ +namespace Musify.Application.Abstractions.Infrastructure +{ + public record ObjectMetadata(string ContentType, long ContentLength); +} diff --git a/Application/Configuration/PlayListConfiguration.cs b/Application/Configuration/PlayListConfiguration.cs index 5bd7a37..9b03e7c 100644 --- a/Application/Configuration/PlayListConfiguration.cs +++ b/Application/Configuration/PlayListConfiguration.cs @@ -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 diff --git a/Application/Configuration/TrackConfiguration.cs b/Application/Configuration/TrackConfiguration.cs index f7b5e76..10e2eec 100644 --- a/Application/Configuration/TrackConfiguration.cs +++ b/Application/Configuration/TrackConfiguration.cs @@ -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 diff --git a/Application/Configuration/UploadIntentConfiguration.cs b/Application/Configuration/UploadIntentConfiguration.cs new file mode 100644 index 0000000..d25741f --- /dev/null +++ b/Application/Configuration/UploadIntentConfiguration.cs @@ -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; + } +} diff --git a/Application/PlayLists/Commands/CreatePlayListCommand.cs b/Application/PlayLists/Commands/CreatePlayListCommand.cs index 29ee783..fa7af4e 100644 --- a/Application/PlayLists/Commands/CreatePlayListCommand.cs +++ b/Application/PlayLists/Commands/CreatePlayListCommand.cs @@ -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>; -} \ No newline at end of file +} diff --git a/Application/PlayLists/Commands/RequestPlayListPictureUploadCommand.cs b/Application/PlayLists/Commands/RequestPlayListPictureUploadCommand.cs index 33e01f6..fc14a80 100644 --- a/Application/PlayLists/Commands/RequestPlayListPictureUploadCommand.cs +++ b/Application/PlayLists/Commands/RequestPlayListPictureUploadCommand.cs @@ -7,6 +7,7 @@ namespace Musify.Application.PlayLists.Commands public record RequestPlayListPictureUploadCommand( Guid UserId, string FileType, - string ContentType) + string ContentType, + long? ExpectedSizeBytes = null) : ICommand>; } diff --git a/Application/PlayLists/Commands/UpdatePlayListCommand.cs b/Application/PlayLists/Commands/UpdatePlayListCommand.cs index 5671782..f6795c2 100644 --- a/Application/PlayLists/Commands/UpdatePlayListCommand.cs +++ b/Application/PlayLists/Commands/UpdatePlayListCommand.cs @@ -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>; -} \ No newline at end of file +} diff --git a/Application/PlayLists/Handlers/CreatePlayListCommandHandler.cs b/Application/PlayLists/Handlers/CreatePlayListCommandHandler.cs index 5fb98a0..7ac68aa 100644 --- a/Application/PlayLists/Handlers/CreatePlayListCommandHandler.cs +++ b/Application/PlayLists/Handlers/CreatePlayListCommandHandler.cs @@ -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 logger, ApplicationStorageConfiguration storageConfiguration, - PlayListConfiguration playListConfiguration) + PlayListConfiguration playListConfiguration, + UploadIntentConfiguration uploadIntentConfiguration) : ICommandHandler> { public async ValueTask> 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 } } } -} \ No newline at end of file +} diff --git a/Application/PlayLists/Handlers/RequestPlayListPictureUploadCommandHandler.cs b/Application/PlayLists/Handlers/RequestPlayListPictureUploadCommandHandler.cs index 3f2c589..a4ac2a1 100644 --- a/Application/PlayLists/Handlers/RequestPlayListPictureUploadCommandHandler.cs +++ b/Application/PlayLists/Handlers/RequestPlayListPictureUploadCommandHandler.cs @@ -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 logger, ApplicationStorageConfiguration storageConfiguration, - PlayListConfiguration playListConfiguration) + PlayListConfiguration playListConfiguration, + UploadIntentConfiguration uploadIntentConfiguration) : ICommandHandler> { static readonly string[] AllowedExtensions = [".webp", ".png", ".jpg", ".jpeg"]; - const int ExpiresInSeconds = 600; public async ValueTask> 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 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); - } } } diff --git a/Application/PlayLists/Handlers/UpdatePlayListCommandHandler.cs b/Application/PlayLists/Handlers/UpdatePlayListCommandHandler.cs index 05a4501..fbbe76b 100644 --- a/Application/PlayLists/Handlers/UpdatePlayListCommandHandler.cs +++ b/Application/PlayLists/Handlers/UpdatePlayListCommandHandler.cs @@ -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 logger, ApplicationStorageConfiguration storageConfiguration, - PlayListConfiguration playListConfiguration) + PlayListConfiguration playListConfiguration, + UploadIntentConfiguration uploadIntentConfiguration) : ICommandHandler> { public async ValueTask> 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) diff --git a/Application/PlayLists/Responses/PlayListPictureUploadResponse.cs b/Application/PlayLists/Responses/PlayListPictureUploadResponse.cs index 1a8ba92..7928680 100644 --- a/Application/PlayLists/Responses/PlayListPictureUploadResponse.cs +++ b/Application/PlayLists/Responses/PlayListPictureUploadResponse.cs @@ -1,6 +1,7 @@ namespace Musify.Application.PlayLists.Responses { public record PlayListPictureUploadResponse( + Guid IntentId, string Bucket, string Key, string PictureName, diff --git a/Application/Tracks/Commands/CreateTrackCommand.cs b/Application/Tracks/Commands/CreateTrackCommand.cs index 652015a..bda3529 100644 --- a/Application/Tracks/Commands/CreateTrackCommand.cs +++ b/Application/Tracks/Commands/CreateTrackCommand.cs @@ -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>; -} \ No newline at end of file +} diff --git a/Application/Tracks/Commands/RequestTrackUploadUrlsCommand.cs b/Application/Tracks/Commands/RequestTrackUploadUrlsCommand.cs index 022a0e1..46b7151 100644 --- a/Application/Tracks/Commands/RequestTrackUploadUrlsCommand.cs +++ b/Application/Tracks/Commands/RequestTrackUploadUrlsCommand.cs @@ -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>; } diff --git a/Application/Tracks/Handler/CreateTrackCommandHandler.cs b/Application/Tracks/Handler/CreateTrackCommandHandler.cs index c014064..1cfe978 100644 --- a/Application/Tracks/Handler/CreateTrackCommandHandler.cs +++ b/Application/Tracks/Handler/CreateTrackCommandHandler.cs @@ -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 logger, ApplicationStorageConfiguration storageConfiguration, - TrackConfiguration trackConfiguration) + TrackConfiguration trackConfiguration, + UploadIntentConfiguration uploadIntentConfiguration) : ICommandHandler> { public async ValueTask> 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}"); } } - } -} \ No newline at end of file +} diff --git a/Application/Tracks/Handler/RequestTrackUploadUrlsCommandHandler.cs b/Application/Tracks/Handler/RequestTrackUploadUrlsCommandHandler.cs index d4c4d68..048cb66 100644 --- a/Application/Tracks/Handler/RequestTrackUploadUrlsCommandHandler.cs +++ b/Application/Tracks/Handler/RequestTrackUploadUrlsCommandHandler.cs @@ -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 logger, ApplicationStorageConfiguration storageConfiguration, - TrackConfiguration trackConfiguration) + TrackConfiguration trackConfiguration, + UploadIntentConfiguration uploadIntentConfiguration) : ICommandHandler> { 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> 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 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); - } } } diff --git a/Application/Tracks/Responses/TrackUploadUrlsResponse.cs b/Application/Tracks/Responses/TrackUploadUrlsResponse.cs index cc5eb04..8e1f5fb 100644 --- a/Application/Tracks/Responses/TrackUploadUrlsResponse.cs +++ b/Application/Tracks/Responses/TrackUploadUrlsResponse.cs @@ -1,6 +1,8 @@ namespace Musify.Application.Tracks.Responses { public record TrackUploadUrlsResponse( + Guid PictureIntentId, + Guid AudioIntentId, string Bucket, string PictureKey, string PictureName, diff --git a/Application/UploadIntents/IntentValidationResult.cs b/Application/UploadIntents/IntentValidationResult.cs new file mode 100644 index 0000000..5271fc0 --- /dev/null +++ b/Application/UploadIntents/IntentValidationResult.cs @@ -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); + } +} diff --git a/Application/UploadIntents/UploadIntentHelpers.cs b/Application/UploadIntents/UploadIntentHelpers.cs new file mode 100644 index 0000000..fdba870 --- /dev/null +++ b/Application/UploadIntents/UploadIntentHelpers.cs @@ -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 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 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 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); + } + } +} diff --git a/Domain/Entities/UploadIntent.cs b/Domain/Entities/UploadIntent.cs new file mode 100644 index 0000000..e15d5c7 --- /dev/null +++ b/Domain/Entities/UploadIntent.cs @@ -0,0 +1,46 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Musify.Domain.Entities +{ + [Table("UploadIntents")] + public class UploadIntent + { + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); + + [Required] + public required Guid UserId { get; set; } + + [Required, MaxLength(128)] + public required string Bucket { get; set; } + + // Full S3 key (including prefix and object name) + [Required, MaxLength(512)] + public required string Key { get; set; } + + // Just the filename portion stored in Track/PlayList entities (e.g. "{guid}.webp") + [Required, MaxLength(128)] + public required string ObjectName { get; set; } + + [Required, MaxLength(128)] + public required string ContentType { get; set; } + + public long? ExpectedSizeBytes { get; set; } + + [Required] + public UploadIntentPurpose Purpose { get; set; } + + [Required] + public UploadIntentStatus Status { get; set; } = UploadIntentStatus.Issued; + + [Required] + public DateTime ExpiresAt { get; set; } + + [Required] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + [ForeignKey(nameof(UserId))] + public User User { get; set; } = null!; + } +} diff --git a/Domain/Entities/UploadIntentPurpose.cs b/Domain/Entities/UploadIntentPurpose.cs new file mode 100644 index 0000000..f0a7f08 --- /dev/null +++ b/Domain/Entities/UploadIntentPurpose.cs @@ -0,0 +1,9 @@ +namespace Musify.Domain.Entities +{ + public enum UploadIntentPurpose + { + PlayListPicture, + TrackPicture, + TrackAudio, + } +} diff --git a/Domain/Entities/UploadIntentStatus.cs b/Domain/Entities/UploadIntentStatus.cs new file mode 100644 index 0000000..be3956b --- /dev/null +++ b/Domain/Entities/UploadIntentStatus.cs @@ -0,0 +1,9 @@ +namespace Musify.Domain.Entities +{ + public enum UploadIntentStatus + { + Issued, + Consumed, + Expired, + } +} diff --git a/Infrastructure/Jobs/TempUploadsCleanupJob.cs b/Infrastructure/Jobs/TempUploadsCleanupJob.cs new file mode 100644 index 0000000..ac83776 --- /dev/null +++ b/Infrastructure/Jobs/TempUploadsCleanupJob.cs @@ -0,0 +1,70 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Musify.Application.Abstractions.Infrastructure; +using Musify.Application.Configuration; + +namespace Musify.Infrastructure.Jobs +{ + public class TempUploadsCleanupJob( + IServiceScopeFactory scopeFactory, + ILogger logger, + ApplicationStorageConfiguration storageConfiguration, + UploadIntentConfiguration uploadIntentConfiguration) + : BackgroundService + { + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + logger.LogInformation("TempUploadsCleanupJob started (interval: {Interval}s, prefix: {Prefix})", + uploadIntentConfiguration.TempCleanupJobIntervalSeconds, + uploadIntentConfiguration.TempRootPrefix); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await RunAsync(stoppingToken); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + logger.LogError(exception, "Error in TempUploadsCleanupJob"); + } + + await Task.Delay(TimeSpan.FromSeconds(uploadIntentConfiguration.TempCleanupJobIntervalSeconds), stoppingToken); + } + } + + async Task RunAsync(CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var storageService = scope.ServiceProvider.GetRequiredService(); + + var cutoff = DateTime.UtcNow.AddDays(-uploadIntentConfiguration.TempUploadsRetentionDays); + var deleted = 0; + var errors = 0; + + await foreach (var (key, lastModified) in storageService.ListObjectsAsync( + storageConfiguration.Bucket, + uploadIntentConfiguration.TempRootPrefix, + cancellationToken)) + { + if (lastModified >= cutoff) + continue; + + try + { + await storageService.RemoveFileAsync(storageConfiguration.Bucket, key, cancellationToken); + deleted++; + } + catch (Exception exception) + { + errors++; + logger.LogWarning(exception, "Failed to delete orphaned temp object {Key}", key); + } + } + + if (deleted > 0 || errors > 0) + logger.LogInformation("TempUploadsCleanupJob: deleted {Deleted} orphaned objects, {Errors} errors", deleted, errors); + } + } +} diff --git a/Infrastructure/Jobs/UploadIntentExpirationJob.cs b/Infrastructure/Jobs/UploadIntentExpirationJob.cs new file mode 100644 index 0000000..3b46a44 --- /dev/null +++ b/Infrastructure/Jobs/UploadIntentExpirationJob.cs @@ -0,0 +1,75 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Musify.Application.Abstractions.Infrastructure; +using Musify.Application.Configuration; +using Musify.Domain.Entities; + +namespace Musify.Infrastructure.Jobs +{ + public class UploadIntentExpirationJob( + IServiceScopeFactory scopeFactory, + ILogger logger, + UploadIntentConfiguration uploadIntentConfiguration) + : BackgroundService + { + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + logger.LogInformation("UploadIntentExpirationJob started (interval: {Interval}s)", uploadIntentConfiguration.ExpirationJobIntervalSeconds); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await RunAsync(stoppingToken); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + logger.LogError(exception, "Error in UploadIntentExpirationJob"); + } + + await Task.Delay(TimeSpan.FromSeconds(uploadIntentConfiguration.ExpirationJobIntervalSeconds), stoppingToken); + } + } + + async Task RunAsync(CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var database = scope.ServiceProvider.GetRequiredService(); + + var now = DateTime.UtcNow; + + // Mark expired intents + var expiredIntents = await database.UploadIntents + .Where(i => i.Status == UploadIntentStatus.Issued && i.ExpiresAt < now) + .ToListAsync(cancellationToken); + + if (expiredIntents.Count > 0) + { + foreach (var intent in expiredIntents) + intent.Status = UploadIntentStatus.Expired; + + await database.SaveChangesAsync(cancellationToken); + logger.LogInformation("Marked {Count} upload intents as Expired", expiredIntents.Count); + } + + // Delete old Expired intents past retention window + if (uploadIntentConfiguration.ExpiredIntentsRetentionDays >= 0) + { + var retentionCutoff = now.AddDays(-uploadIntentConfiguration.ExpiredIntentsRetentionDays); + + var oldExpiredIntents = await database.UploadIntents + .Where(i => i.Status == UploadIntentStatus.Expired && i.ExpiresAt < retentionCutoff) + .ToListAsync(cancellationToken); + + if (oldExpiredIntents.Count > 0) + { + database.UploadIntents.RemoveRange(oldExpiredIntents); + await database.SaveChangesAsync(cancellationToken); + logger.LogInformation("Deleted {Count} old Expired upload intents past retention window", oldExpiredIntents.Count); + } + } + } + } +} diff --git a/Infrastructure/Persistence/Database.cs b/Infrastructure/Persistence/Database.cs index 778fc2d..c4df933 100644 --- a/Infrastructure/Persistence/Database.cs +++ b/Infrastructure/Persistence/Database.cs @@ -1,13 +1,15 @@ -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; using MassTransit; using Musify.Application.Abstractions.Infrastructure; using Musify.Domain.Entities; using Musify.Infrastructure.Configuration; +using AppIDatabase = Musify.Application.Abstractions.Infrastructure.IDatabase; namespace Musify.Infrastructure.Persistence { public class Database(DatabaseConfiguration configuration) - : DbContext, IDatabase + : DbContext, AppIDatabase { protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { @@ -34,5 +36,21 @@ namespace Musify.Infrastructure.Persistence public DbSet PlayListHasTracks => Set(); public DbSet Uploads => Set(); + + public DbSet UploadIntents => Set(); + + public async Task BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken) + { + var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken); + return new DatabaseTransaction(transaction); + } + + private sealed class DatabaseTransaction(IDbContextTransaction inner) : IDatabaseTransaction + { + public Task CommitAsync(CancellationToken cancellationToken = default) => + inner.CommitAsync(cancellationToken); + + public ValueTask DisposeAsync() => inner.DisposeAsync(); + } } -} \ No newline at end of file +} diff --git a/Infrastructure/Persistence/Migrations/20260514172047_AddUploadIntentTable.Designer.cs b/Infrastructure/Persistence/Migrations/20260514172047_AddUploadIntentTable.Designer.cs new file mode 100644 index 0000000..a27e8d3 --- /dev/null +++ b/Infrastructure/Persistence/Migrations/20260514172047_AddUploadIntentTable.Designer.cs @@ -0,0 +1,580 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Musify.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(Database))] + [Migration("20260514172047_AddUploadIntentTable")] + partial class AddUploadIntentTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LargePictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("MediumPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OriginalPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SmallPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("PlayLists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayListId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PlayListId"); + + b.HasIndex("TrackId"); + + b.ToTable("PlayListHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AudioFolderName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AudioTranscodeProcessingStatus") + .HasColumnType("integer"); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LargePictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastRetryAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("MediumPictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OriginalAudioName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("OriginalPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PicturesProcessingStatus") + .HasColumnType("integer"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("SmallPictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Tracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Upload", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("State") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Upload"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpectedSizeBytes") + .HasColumnType("bigint"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ObjectName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Purpose") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UploadIntents"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FirstName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("SecondName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId"); + + b.ToTable("UserHasTrack"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("PlayLists") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.PlayList", "PlayList") + .WithMany("PlayListTracks") + .HasForeignKey("PlayListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("PlayListTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlayList"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("UserTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("UserTracks") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Navigation("PlayListTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Navigation("PlayListTracks"); + + b.Navigation("UserTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Navigation("PlayLists"); + + b.Navigation("UserTracks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Infrastructure/Persistence/Migrations/20260514172047_AddUploadIntentTable.cs b/Infrastructure/Persistence/Migrations/20260514172047_AddUploadIntentTable.cs new file mode 100644 index 0000000..6d39875 --- /dev/null +++ b/Infrastructure/Persistence/Migrations/20260514172047_AddUploadIntentTable.cs @@ -0,0 +1,54 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddUploadIntentTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "UploadIntents", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + Bucket = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + Key = table.Column(type: "character varying(512)", maxLength: 512, nullable: false), + ObjectName = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + ContentType = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + ExpectedSizeBytes = table.Column(type: "bigint", nullable: true), + Purpose = table.Column(type: "integer", nullable: false), + Status = table.Column(type: "integer", nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UploadIntents", x => x.Id); + table.ForeignKey( + name: "FK_UploadIntents_User_UserId", + column: x => x.UserId, + principalTable: "User", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_UploadIntents_UserId", + table: "UploadIntents", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UploadIntents"); + } + } +} diff --git a/Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs b/Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs index 30ec587..292cfc5 100644 --- a/Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs +++ b/Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs @@ -374,6 +374,57 @@ namespace Musify.Infrastructure.Persistence.Migrations b.ToTable("Upload"); }); + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpectedSizeBytes") + .HasColumnType("bigint"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ObjectName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Purpose") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UploadIntents"); + }); + modelBuilder.Entity("Musify.Domain.Entities.User", b => { b.Property("Id") @@ -472,6 +523,17 @@ namespace Musify.Infrastructure.Persistence.Migrations b.Navigation("Track"); }); + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => { b.HasOne("Musify.Domain.Entities.Track", "Track") diff --git a/Infrastructure/Services/ServicesDependencyInjection.cs b/Infrastructure/Services/ServicesDependencyInjection.cs index 56e48e7..4e92451 100644 --- a/Infrastructure/Services/ServicesDependencyInjection.cs +++ b/Infrastructure/Services/ServicesDependencyInjection.cs @@ -3,7 +3,9 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Musify.Application.Abstractions.Infrastructure; +using Musify.Application.Configuration; using Musify.Infrastructure.Configuration; +using Musify.Infrastructure.Jobs; namespace Musify.Infrastructure.Services { @@ -41,6 +43,31 @@ namespace Musify.Infrastructure.Services return serviceDescriptors; } + // Called by both the web API host and the worker + public static IServiceCollection AddUploadIntentConfiguration(this IServiceCollection serviceDescriptors, IConfiguration configuration) + { + serviceDescriptors + .AddOptionsWithValidateOnStart() + .Bind(configuration.GetRequiredSection(UploadIntentConfiguration.SectionName)) + .ValidateDataAnnotations(); + + serviceDescriptors.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService>().Value); + + return serviceDescriptors; + } + + // Called only by the worker host (registers background jobs) + public static IServiceCollection AddUploadIntentJobs(this IServiceCollection serviceDescriptors, IConfiguration configuration) + { + serviceDescriptors.AddUploadIntentConfiguration(configuration); + + serviceDescriptors.AddHostedService(); + serviceDescriptors.AddHostedService(); + + return serviceDescriptors; + } + public static IServiceCollection AddAudioTranscoder(this IServiceCollection serviceDescriptors, IConfiguration configuration) { serviceDescriptors diff --git a/Infrastructure/Services/StorageService.cs b/Infrastructure/Services/StorageService.cs index 806a271..3c2efaf 100644 --- a/Infrastructure/Services/StorageService.cs +++ b/Infrastructure/Services/StorageService.cs @@ -6,6 +6,7 @@ using MimeMapping; using Musify.Application.Abstractions.Infrastructure; using Musify.Domain.Entities; using Musify.Infrastructure.Configuration; +using ObjectMetadata = Musify.Application.Abstractions.Infrastructure.ObjectMetadata; namespace Musify.Infrastructure.Services { @@ -69,6 +70,24 @@ namespace Musify.Infrastructure.Services return await amazonS3.GetPreSignedURLAsync(request); } + public async Task HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken) + { + try + { + var response = await amazonS3.GetObjectMetadataAsync(bucket, key, cancellationToken); + return new ObjectMetadata(response.Headers.ContentType, response.ContentLength); + } + catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return null; + } + catch (Exception exception) + { + logger.LogError(exception, "HEAD failed for {Bucket}/{Key}", bucket, key); + throw; + } + } + public async Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken) { var request = new DeleteObjectRequest @@ -204,6 +223,27 @@ namespace Musify.Infrastructure.Services } } + public async IAsyncEnumerable<(string Key, DateTime LastModifiedUtc)> ListObjectsAsync( + string bucket, string prefix, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + var listRequest = new ListObjectsV2Request + { + BucketName = bucket, + Prefix = prefix.EndsWith('/') ? prefix : $"{prefix}/" + }; + + ListObjectsV2Response listResponse; + do + { + listResponse = await amazonS3.ListObjectsV2Async(listRequest, cancellationToken); + foreach (var obj in listResponse.S3Objects) + yield return (obj.Key, (obj.LastModified ?? DateTime.UtcNow).ToUniversalTime()); + + listRequest.ContinuationToken = listResponse.NextContinuationToken; + } + while (listResponse.IsTruncated.HasValue && listResponse.IsTruncated.Value); + } + public async Task RemoveFolderAsync(string bucket, string folderKey, CancellationToken cancellationToken) { var prefix = folderKey.EndsWith('/') ? folderKey : $"{folderKey}/"; diff --git a/docs/changes-summary.md b/docs/changes-summary.md index 0dbe664..c25c997 100644 --- a/docs/changes-summary.md +++ b/docs/changes-summary.md @@ -159,7 +159,7 @@ Archivo: --- -## 10) Estado final +## 10) Estado final (iteración anterior) - `dotnet build MusifyBackend.slnx -c Release` ✅ @@ -192,3 +192,142 @@ Archivos clave: - `Infrastructure/MassTransit/Activities/Files/RemoveFileFromBucketActivity.cs` - `Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs` - `Infrastructure/Persistence/Migrations/20260514161518_AddLifeCycleStatus.cs` + +--- + +## 12) Hardening de pre-signed uploads (Fases 1 y 2) + +**Objetivo**: evitar abuso de coste/almacenamiento cuando usuarios hacen spam de endpoints presigned sin crear la entidad final (objetos huérfanos en S3). + +Referencia: `docs/presigned-upload-hardening.md` + +--- + +### Fase 1 — Upload Intents en DB + +#### Nuevas entidades (Domain) +- `Domain/Entities/UploadIntent.cs` — tabla `UploadIntents` con campos: `Id`, `UserId`, `Bucket`, `Key`, `ObjectName`, `ContentType`, `ExpectedSizeBytes`, `Purpose`, `Status`, `ExpiresAt`, `CreatedAt`. +- `Domain/Entities/UploadIntentStatus.cs` — enum: `Issued`, `Consumed`, `Expired`. +- `Domain/Entities/UploadIntentPurpose.cs` — enum: `PlayListPicture`, `TrackPicture`, `TrackAudio`. + +#### Nueva configuración (Application) +- `Application/Configuration/UploadIntentConfiguration.cs` — sección `UploadIntent`: + - `UploadUrlExpiresInSeconds` (default: 120) + - `MaxActiveUploadIntentsPerUser` (default: 5) + - `MaxActiveUploadBytesPerUser` (default: 200 MB) + - `MaxUploadBytes` (default: 100 MB, para validación HEAD) + - `DefaultExpectedPictureSizeBytes` / `DefaultExpectedAudioSizeBytes` (fallback cuando el caller no envía tamaño) + - `ExpirationJobIntervalSeconds` / `ExpiredIntentsRetentionDays` + - `TempRootPrefix` / `TempUploadsRetentionDays` / `TempCleanupJobIntervalSeconds` (Fase 2) + +#### Interfaces actualizadas +- `Application/Abstractions/Infrastructure/IDatabase.cs` — añadido `DbSet UploadIntents`. +- `Application/Abstractions/Infrastructure/IStorageService.cs` — añadidos: + - `HeadObjectAsync(bucket, key)` → `ObjectMetadata?` (para validación antes de consumir). + - `ListObjectsAsync(bucket, prefix)` → `IAsyncEnumerable<(string Key, DateTime LastModifiedUtc)>` (para el cleanup job). +- `Application/Abstractions/Infrastructure/ObjectMetadata.cs` — record `(ContentType, ContentLength)`. + +#### Infrastructure actualizada +- `Infrastructure/Persistence/Database.cs` — añadido `DbSet`. +- `Infrastructure/Services/StorageService.cs` — implementaciones de `HeadObjectAsync` y `ListObjectsAsync`. + +#### Commands actualizados (cambio de contrato) +- `RequestPlayListPictureUploadCommand` — nuevo campo opcional `ExpectedSizeBytes?`. +- `RequestTrackUploadUrlsCommand` — nuevos campos opcionales `ExpectedPictureSizeBytes?`, `ExpectedAudioSizeBytes?`. +- `CreatePlayListCommand` — `OriginalPictureName?` → `PictureIntentId?` (Guid). +- `CreateTrackCommand` — `OriginalPictureName` + `OriginalAudioName` → `PictureIntentId` + `AudioIntentId` (Guid). +- `UpdatePlayListCommand` — `NewOriginalPictureName?` → `NewPictureIntentId?` (Guid). + +#### Responses actualizadas +- `PlayListPictureUploadResponse` — añadido `IntentId` (Guid). +- `TrackUploadUrlsResponse` — añadidos `PictureIntentId` + `AudioIntentId` (Guid). + +#### Handlers actualizados + +**Request upload** (`RequestPlayListPictureUploadCommandHandler`, `RequestTrackUploadUrlsCommandHandler`): +- Comprueban cuota de usuario (conteo de intents activos + suma de bytes) antes de generar la URL. +- Crean un registro `UploadIntent` (Status = `Issued`) en la misma llamada. +- TTL reducido a `UploadUrlExpiresInSeconds` (configurable, default 120 s vs. los anteriores 600 s hardcodeados). + +**Create/Update** (`CreatePlayListCommandHandler`, `CreateTrackCommandHandler`, `UpdatePlayListCommandHandler`): +- Reciben `IntentId` en lugar del nombre de fichero. +- Validan el intent: existe, pertenece al usuario, Status = `Issued`, no caducado. +- Realizan `HEAD` del objeto en S3: debe existir y tamaño ≤ `MaxUploadBytes`. +- Marcan el intent como `Consumed` y guardan todo en el mismo `SaveChangesAsync`. + +#### Job de expiración +- `Infrastructure/Jobs/UploadIntentExpirationJob.cs` — `BackgroundService` que: + - Marca como `Expired` los intents `Issued` con `ExpiresAt < now`. + - Elimina intents `Expired` cuya fecha de expiración supera la ventana de retención configurable. + +#### Migración +- `Infrastructure/Persistence/Migrations/*_AddUploadIntentTable.cs` + +--- + +### Fase 2 — Prefijo temporal + cleanup de S3 + +#### Rutas temporales +- `TrackRoutes.BuildTempPicturePath` / `BuildTempAudioPath` — construyen `{TempRootPrefix}/{userId}/{ParentFolder}/{objectName}`. +- `PlayListRoutes.BuildTempPicturePath` — ídem para playlists. + +#### Handlers de request upload +- La presigned URL ahora apunta a `temp/{userId}/...` (prefijo temporal) en lugar de la ruta final. +- El intent almacena la temp key. + +#### Handlers de create/update (consume) +- Al consumir un intent, se llama `CopyFileAsync(tempKey → finalKey)` antes de publicar el evento de procesado. +- El objeto temporal queda en S3 y es eliminado posteriormente por el cleanup job. + +#### Job de cleanup de temporales +- `Infrastructure/Jobs/TempUploadsCleanupJob.cs` — `BackgroundService` que: + - Lista objetos bajo `TempRootPrefix/` usando `ListObjectsAsync`. + - Borra los que superan `TempUploadsRetentionDays` (default 3 días). + - Tolerante a errores por objeto (continúa si un borrado falla). + +#### DI +- `Infrastructure/Services/ServicesDependencyInjection.AddUploadIntentJobs()` — registra `UploadIntentConfiguration` + ambos background jobs. + +--- + +## 13) Refactor: IntentValidationResult (eliminar tuplas) + +**Objetivo**: evitar retornar tuplas `(Result?, UploadIntent?)` desde métodos privados — patrón no idiomático en C#. + +### Cambios +- Creado `Application/UploadIntents/IntentValidationResult.cs` — `internal sealed record` con: + - `Result? Error` / `UploadIntent? Intent` + - `bool IsSuccess` + - Factory methods `Ok(intent)` / `Fail(error)` +- Los tres handlers (`CreatePlayListCommandHandler`, `CreateTrackCommandHandler`, `UpdatePlayListCommandHandler`) ahora usan `IntentValidationResult` en `ValidateAndLoadIntentAsync`. +- El tipo es `internal` (no `private`) porque es compartido entre 3 handlers de distintas carpetas. + +--- + +## 14) Fix: race condition en cuota de intents + +**Objetivo**: garantizar que el check de cuota + insert del intent sean atómicos y no admitan concurrent bypass. + +### Cambios +- Añadida `IDatabaseTransaction` en `Application/Abstractions/Infrastructure/IDatabaseTransaction.cs` — interfaz limpia (`CommitAsync`, `DisposeAsync`) que no expone tipos EF. +- Añadido `BeginTransactionAsync(IsolationLevel, CancellationToken)` a `IDatabase`. +- Implementado en `Infrastructure/Persistence/Database.cs` con un `private sealed class DatabaseTransaction` que envuelve `IDbContextTransaction` de EF. +- `RequestPlayListPictureUploadCommandHandler` y `RequestTrackUploadUrlsCommandHandler`: el bloque quota check + insert ahora se ejecuta dentro de una transacción `SERIALIZABLE`, eliminando la ventana de race condition entre el check y el commit. + +--- + +## 15) Fix: separación AddUploadIntentConfiguration / AddUploadIntentJobs + +**Objetivo**: `UploadIntentConfiguration` es necesaria tanto en el host del API (para los handlers) como en el worker (para los jobs). El método anterior mezclaba config + jobs en uno solo, haciendo imposible registrar la config sin registrar los `BackgroundService`. + +### Cambios +- `AddUploadIntentConfiguration(services, configuration)` — registra solo la config. Lo llama el host del API **y** el worker. +- `AddUploadIntentJobs(services, configuration)` — llama internamente a `AddUploadIntentConfiguration` y además registra `UploadIntentExpirationJob` + `TempUploadsCleanupJob`. Solo lo llama el worker. + +Archivo: `Infrastructure/Services/ServicesDependencyInjection.cs` + +--- + +## 16) Estado final + +- `dotnet build` ✅ — 0 errores, 0 advertencias.