Generated response
This commit is contained in:
@@ -17,6 +17,10 @@ namespace Musify.Application.Abstractions.Infrastructure
|
|||||||
|
|
||||||
DbSet<Upload> Uploads { get; }
|
DbSet<Upload> Uploads { get; }
|
||||||
|
|
||||||
|
DbSet<UploadIntent> UploadIntents { get; }
|
||||||
|
|
||||||
|
Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task<int> SaveChangesAsync(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);
|
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> GetUrlAsync(string bucket, string key, TimeSpan ExpirationTime, CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task<string> GetUploadUrlAsync(string bucket, string key, string contentType, TimeSpan expirationTime, CancellationToken cancellationToken);
|
Task<string> GetUploadUrlAsync(string bucket, string key, string contentType, TimeSpan expirationTime, CancellationToken cancellationToken);
|
||||||
@@ -20,5 +23,8 @@
|
|||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task RemoveFolderAsync(string bucket, string folderKey, 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);
|
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)
|
static string CombineKey(params string[] segments)
|
||||||
{
|
{
|
||||||
return string.Join('/', segments
|
return string.Join('/', segments
|
||||||
|
|||||||
@@ -85,6 +85,13 @@ namespace Musify.Application.Configuration
|
|||||||
|
|
||||||
public string BuildProcessedAudioPath(string audioName) => CombineKey(ProcessedAudiosPath, audioName);
|
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)
|
static string CombineKey(params string[] segments)
|
||||||
{
|
{
|
||||||
return string.Join('/', 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 Mediator;
|
||||||
using Musify.Application.PlayLists.Responses;
|
using Musify.Application.PlayLists.Responses;
|
||||||
|
|
||||||
@@ -8,6 +8,6 @@ namespace Musify.Application.PlayLists.Commands
|
|||||||
Guid UserId,
|
Guid UserId,
|
||||||
string Name,
|
string Name,
|
||||||
string Description,
|
string Description,
|
||||||
string? OriginalPictureName)
|
Guid? PictureIntentId)
|
||||||
: ICommand<Result<PlayListApplicationResponse>>;
|
: ICommand<Result<PlayListApplicationResponse>>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace Musify.Application.PlayLists.Commands
|
|||||||
public record RequestPlayListPictureUploadCommand(
|
public record RequestPlayListPictureUploadCommand(
|
||||||
Guid UserId,
|
Guid UserId,
|
||||||
string FileType,
|
string FileType,
|
||||||
string ContentType)
|
string ContentType,
|
||||||
|
long? ExpectedSizeBytes = null)
|
||||||
: ICommand<Result<PlayListPictureUploadResponse>>;
|
: ICommand<Result<PlayListPictureUploadResponse>>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Ardalis.Result;
|
using Ardalis.Result;
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Musify.Application.PlayLists.Responses;
|
using Musify.Application.PlayLists.Responses;
|
||||||
|
|
||||||
@@ -9,6 +9,6 @@ namespace Musify.Application.PlayLists.Commands
|
|||||||
Guid PlayListId,
|
Guid PlayListId,
|
||||||
string? NewName,
|
string? NewName,
|
||||||
string? NewDescription,
|
string? NewDescription,
|
||||||
string? NewOriginalPictureName)
|
Guid? NewPictureIntentId)
|
||||||
: ICommand<Result<PlayListApplicationResponse>>;
|
: ICommand<Result<PlayListApplicationResponse>>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using Musify.Application.Abstractions.Infrastructure;
|
|||||||
using Musify.Application.Events;
|
using Musify.Application.Events;
|
||||||
using Musify.Application.PlayLists.Commands;
|
using Musify.Application.PlayLists.Commands;
|
||||||
using Musify.Application.PlayLists.Responses;
|
using Musify.Application.PlayLists.Responses;
|
||||||
|
using Musify.Application.UploadIntents;
|
||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists.Handlers
|
namespace Musify.Application.PlayLists.Handlers
|
||||||
@@ -14,9 +15,11 @@ namespace Musify.Application.PlayLists.Handlers
|
|||||||
public class CreatePlayListCommandHandler(
|
public class CreatePlayListCommandHandler(
|
||||||
IEventBus eventBus,
|
IEventBus eventBus,
|
||||||
IDatabase database,
|
IDatabase database,
|
||||||
|
IStorageService storageService,
|
||||||
ILogger<CreatePlayListCommandHandler> logger,
|
ILogger<CreatePlayListCommandHandler> logger,
|
||||||
ApplicationStorageConfiguration storageConfiguration,
|
ApplicationStorageConfiguration storageConfiguration,
|
||||||
PlayListConfiguration playListConfiguration)
|
PlayListConfiguration playListConfiguration,
|
||||||
|
UploadIntentConfiguration uploadIntentConfiguration)
|
||||||
: ICommandHandler<CreatePlayListCommand, Result<PlayListApplicationResponse>>
|
: ICommandHandler<CreatePlayListCommand, Result<PlayListApplicationResponse>>
|
||||||
{
|
{
|
||||||
public async ValueTask<Result<PlayListApplicationResponse>> Handle(CreatePlayListCommand request, CancellationToken cancellationToken)
|
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");
|
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
|
var playList = new PlayList
|
||||||
{
|
{
|
||||||
@@ -46,8 +77,10 @@ namespace Musify.Application.PlayLists.Handlers
|
|||||||
|
|
||||||
await database.PlayLists.AddAsync(playList, cancellationToken);
|
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);
|
var publishResult = await PublishPictureUpdateEventAsync(playList, cancellationToken);
|
||||||
if (!publishResult.IsSuccess)
|
if (!publishResult.IsSuccess)
|
||||||
return publishResult;
|
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.Configuration;
|
||||||
using Musify.Application.PlayLists.Commands;
|
using Musify.Application.PlayLists.Commands;
|
||||||
using Musify.Application.PlayLists.Responses;
|
using Musify.Application.PlayLists.Responses;
|
||||||
|
using Musify.Application.UploadIntents;
|
||||||
|
using Musify.Domain.Entities;
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists.Handlers
|
namespace Musify.Application.PlayLists.Handlers
|
||||||
{
|
{
|
||||||
@@ -14,11 +16,11 @@ namespace Musify.Application.PlayLists.Handlers
|
|||||||
IStorageService storageService,
|
IStorageService storageService,
|
||||||
ILogger<RequestPlayListPictureUploadCommandHandler> logger,
|
ILogger<RequestPlayListPictureUploadCommandHandler> logger,
|
||||||
ApplicationStorageConfiguration storageConfiguration,
|
ApplicationStorageConfiguration storageConfiguration,
|
||||||
PlayListConfiguration playListConfiguration)
|
PlayListConfiguration playListConfiguration,
|
||||||
|
UploadIntentConfiguration uploadIntentConfiguration)
|
||||||
: ICommandHandler<RequestPlayListPictureUploadCommand, Result<PlayListPictureUploadResponse>>
|
: ICommandHandler<RequestPlayListPictureUploadCommand, Result<PlayListPictureUploadResponse>>
|
||||||
{
|
{
|
||||||
static readonly string[] AllowedExtensions = [".webp", ".png", ".jpg", ".jpeg"];
|
static readonly string[] AllowedExtensions = [".webp", ".png", ".jpg", ".jpeg"];
|
||||||
const int ExpiresInSeconds = 600;
|
|
||||||
|
|
||||||
public async ValueTask<Result<PlayListPictureUploadResponse>> Handle(RequestPlayListPictureUploadCommand request, CancellationToken cancellationToken)
|
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");
|
return Result.NotFound($"User {request.UserId} not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
var extensionResult = ValidateExtension(request.FileType);
|
var extensionResult = UploadIntentHelpers.ValidateExtension(request.FileType, AllowedExtensions);
|
||||||
if (!extensionResult.IsSuccess)
|
if (!extensionResult.IsSuccess)
|
||||||
return Result.Invalid(extensionResult.Errors.Select(error => new ValidationError(error)).ToArray());
|
return Result.Invalid(extensionResult.Errors.Select(error => new ValidationError(error)).ToArray());
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(request.ContentType))
|
var effectiveSizeBytes = request.ExpectedSizeBytes
|
||||||
return Result.Invalid(new ValidationError("ContentType is required"));
|
?? uploadIntentConfiguration.DefaultExpectedPictureSizeBytes;
|
||||||
|
|
||||||
var pictureName = Guid.NewGuid() + extensionResult.Value;
|
var objectName = Guid.NewGuid() + extensionResult.Value;
|
||||||
var key = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureName);
|
|
||||||
|
var tempKey = playListConfiguration.Routes.BuildTempPicturePath(
|
||||||
|
uploadIntentConfiguration.TempRootPrefix, request.UserId, objectName);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var uploadUrl = await storageService.GetUploadUrlAsync(
|
var uploadUrl = await storageService.GetUploadUrlAsync(
|
||||||
storageConfiguration.Bucket,
|
storageConfiguration.Bucket,
|
||||||
key,
|
tempKey,
|
||||||
request.ContentType,
|
request.ContentType,
|
||||||
TimeSpan.FromSeconds(ExpiresInSeconds),
|
TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds),
|
||||||
cancellationToken);
|
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(
|
return Result.Success(new PlayListPictureUploadResponse(
|
||||||
|
intent.Id,
|
||||||
storageConfiguration.Bucket,
|
storageConfiguration.Bucket,
|
||||||
key,
|
tempKey,
|
||||||
pictureName,
|
objectName,
|
||||||
request.ContentType,
|
request.ContentType,
|
||||||
ExpiresInSeconds,
|
uploadIntentConfiguration.UploadUrlExpiresInSeconds,
|
||||||
uploadUrl));
|
uploadUrl));
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
@@ -64,25 +96,5 @@ namespace Musify.Application.PlayLists.Handlers
|
|||||||
return Result.Error("Failed to generate upload URL");
|
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.Events;
|
||||||
using Musify.Application.PlayLists.Commands;
|
using Musify.Application.PlayLists.Commands;
|
||||||
using Musify.Application.PlayLists.Responses;
|
using Musify.Application.PlayLists.Responses;
|
||||||
|
using Musify.Application.UploadIntents;
|
||||||
|
using Musify.Domain.Entities;
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists.Handlers
|
namespace Musify.Application.PlayLists.Handlers
|
||||||
{
|
{
|
||||||
public class UpdatePlayListCommandHandler(
|
public class UpdatePlayListCommandHandler(
|
||||||
IEventBus eventBus,
|
IEventBus eventBus,
|
||||||
IDatabase database,
|
IDatabase database,
|
||||||
|
IStorageService storageService,
|
||||||
ILogger<UpdatePlayListCommandHandler> logger,
|
ILogger<UpdatePlayListCommandHandler> logger,
|
||||||
ApplicationStorageConfiguration storageConfiguration,
|
ApplicationStorageConfiguration storageConfiguration,
|
||||||
PlayListConfiguration playListConfiguration)
|
PlayListConfiguration playListConfiguration,
|
||||||
|
UploadIntentConfiguration uploadIntentConfiguration)
|
||||||
: ICommandHandler<UpdatePlayListCommand, Result<PlayListApplicationResponse>>
|
: ICommandHandler<UpdatePlayListCommand, Result<PlayListApplicationResponse>>
|
||||||
{
|
{
|
||||||
public async ValueTask<Result<PlayListApplicationResponse>> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken)
|
public async ValueTask<Result<PlayListApplicationResponse>> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken)
|
||||||
@@ -44,9 +48,28 @@ namespace Musify.Application.PlayLists.Handlers
|
|||||||
playListEntity.Description = request.NewDescription;
|
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);
|
var publishResult = await PublishPlayListPictureEvent(playListEntity.UserId, playListEntity.Id, playListEntity.OriginalPictureName, cancellationToken);
|
||||||
if (!publishResult.IsSuccess)
|
if (!publishResult.IsSuccess)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
namespace Musify.Application.PlayLists.Responses
|
namespace Musify.Application.PlayLists.Responses
|
||||||
{
|
{
|
||||||
public record PlayListPictureUploadResponse(
|
public record PlayListPictureUploadResponse(
|
||||||
|
Guid IntentId,
|
||||||
string Bucket,
|
string Bucket,
|
||||||
string Key,
|
string Key,
|
||||||
string PictureName,
|
string PictureName,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Ardalis.Result;
|
using Ardalis.Result;
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Musify.Application.Tracks.Responses;
|
using Musify.Application.Tracks.Responses;
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ namespace Musify.Application.Tracks.Commands
|
|||||||
public record CreateTrackCommand(
|
public record CreateTrackCommand(
|
||||||
Guid UserId,
|
Guid UserId,
|
||||||
string Title,
|
string Title,
|
||||||
string OriginalPictureName,
|
Guid PictureIntentId,
|
||||||
string OriginalAudioName)
|
Guid AudioIntentId)
|
||||||
: ICommand<Result<TrackApplicationResponse>>;
|
: ICommand<Result<TrackApplicationResponse>>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ namespace Musify.Application.Tracks.Commands
|
|||||||
string PictureFileType,
|
string PictureFileType,
|
||||||
string PictureContentType,
|
string PictureContentType,
|
||||||
string AudioFileType,
|
string AudioFileType,
|
||||||
string AudioContentType)
|
string AudioContentType,
|
||||||
|
long? ExpectedPictureSizeBytes = null,
|
||||||
|
long? ExpectedAudioSizeBytes = null)
|
||||||
: ICommand<Result<TrackUploadUrlsResponse>>;
|
: ICommand<Result<TrackUploadUrlsResponse>>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using Musify.Application.Abstractions.Infrastructure;
|
|||||||
using Musify.Application.Events;
|
using Musify.Application.Events;
|
||||||
using Musify.Application.Tracks.Commands;
|
using Musify.Application.Tracks.Commands;
|
||||||
using Musify.Application.Tracks.Responses;
|
using Musify.Application.Tracks.Responses;
|
||||||
|
using Musify.Application.UploadIntents;
|
||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
|
|
||||||
namespace Musify.Application.Tracks.Handler
|
namespace Musify.Application.Tracks.Handler
|
||||||
@@ -14,9 +15,11 @@ namespace Musify.Application.Tracks.Handler
|
|||||||
public class CreateTrackCommandHandler(
|
public class CreateTrackCommandHandler(
|
||||||
IDatabase database,
|
IDatabase database,
|
||||||
IEventBus eventBus,
|
IEventBus eventBus,
|
||||||
|
IStorageService storageService,
|
||||||
ILogger<CreateTrackCommandHandler> logger,
|
ILogger<CreateTrackCommandHandler> logger,
|
||||||
ApplicationStorageConfiguration storageConfiguration,
|
ApplicationStorageConfiguration storageConfiguration,
|
||||||
TrackConfiguration trackConfiguration)
|
TrackConfiguration trackConfiguration,
|
||||||
|
UploadIntentConfiguration uploadIntentConfiguration)
|
||||||
: ICommandHandler<CreateTrackCommand, Result<TrackApplicationResponse>>
|
: ICommandHandler<CreateTrackCommand, Result<TrackApplicationResponse>>
|
||||||
{
|
{
|
||||||
public async ValueTask<Result<TrackApplicationResponse>> Handle(CreateTrackCommand request, CancellationToken cancellationToken)
|
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");
|
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
|
var trackEntity = new Track
|
||||||
{
|
{
|
||||||
Title = request.Title,
|
Title = request.Title,
|
||||||
NormalizedTitle = request.Title.ToUpperInvariant(),
|
NormalizedTitle = request.Title.ToUpperInvariant(),
|
||||||
OriginalPictureName = request.OriginalPictureName,
|
OriginalPictureName = pictureIntent.ObjectName,
|
||||||
OriginalAudioName = request.OriginalAudioName,
|
OriginalAudioName = audioIntent.ObjectName,
|
||||||
SmallPictureName = trackConfiguration.Routes.PresetSmallPicture,
|
SmallPictureName = trackConfiguration.Routes.PresetSmallPicture,
|
||||||
MediumPictureName = trackConfiguration.Routes.PresetMediumPicture,
|
MediumPictureName = trackConfiguration.Routes.PresetMediumPicture,
|
||||||
LargePictureName = trackConfiguration.Routes.PresetLargePicture,
|
LargePictureName = trackConfiguration.Routes.PresetLargePicture,
|
||||||
@@ -44,6 +76,9 @@ namespace Musify.Application.Tracks.Handler
|
|||||||
|
|
||||||
await database.Tracks.AddAsync(trackEntity, cancellationToken);
|
await database.Tracks.AddAsync(trackEntity, cancellationToken);
|
||||||
|
|
||||||
|
pictureIntent.Status = UploadIntentStatus.Consumed;
|
||||||
|
audioIntent.Status = UploadIntentStatus.Consumed;
|
||||||
|
|
||||||
var publishPictureResult = await PublishUpdateEvent(request.UserId, trackEntity, cancellationToken);
|
var publishPictureResult = await PublishUpdateEvent(request.UserId, trackEntity, cancellationToken);
|
||||||
if (!publishPictureResult.IsSuccess)
|
if (!publishPictureResult.IsSuccess)
|
||||||
return Result.Error($"Failed to create track for {request.Title}");
|
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}");
|
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.Configuration;
|
||||||
using Musify.Application.Tracks.Commands;
|
using Musify.Application.Tracks.Commands;
|
||||||
using Musify.Application.Tracks.Responses;
|
using Musify.Application.Tracks.Responses;
|
||||||
|
using Musify.Application.UploadIntents;
|
||||||
|
using Musify.Domain.Entities;
|
||||||
|
|
||||||
namespace Musify.Application.Tracks.Handler
|
namespace Musify.Application.Tracks.Handler
|
||||||
{
|
{
|
||||||
@@ -14,12 +16,12 @@ namespace Musify.Application.Tracks.Handler
|
|||||||
IStorageService storageService,
|
IStorageService storageService,
|
||||||
ILogger<RequestTrackUploadUrlsCommandHandler> logger,
|
ILogger<RequestTrackUploadUrlsCommandHandler> logger,
|
||||||
ApplicationStorageConfiguration storageConfiguration,
|
ApplicationStorageConfiguration storageConfiguration,
|
||||||
TrackConfiguration trackConfiguration)
|
TrackConfiguration trackConfiguration,
|
||||||
|
UploadIntentConfiguration uploadIntentConfiguration)
|
||||||
: ICommandHandler<RequestTrackUploadUrlsCommand, Result<TrackUploadUrlsResponse>>
|
: ICommandHandler<RequestTrackUploadUrlsCommand, Result<TrackUploadUrlsResponse>>
|
||||||
{
|
{
|
||||||
static readonly string[] AllowedPictureExtensions = [".webp", ".png", ".jpg", ".jpeg"];
|
static readonly string[] AllowedPictureExtensions = [".webp", ".png", ".jpg", ".jpeg"];
|
||||||
static readonly string[] AllowedAudioExtensions = [".mp3", ".wav", ".m4a", ".flac", ".ogg", ".aac", ".opus"];
|
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)
|
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");
|
return Result.NotFound($"User {request.UserId} not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
var pictureExtensionResult = ValidateExtension(request.PictureFileType, AllowedPictureExtensions);
|
var pictureExtensionResult = UploadIntentHelpers.ValidateExtension(request.PictureFileType, AllowedPictureExtensions);
|
||||||
if (!pictureExtensionResult.IsSuccess)
|
if (!pictureExtensionResult.IsSuccess)
|
||||||
return Result.Invalid(pictureExtensionResult.Errors.Select(error => new ValidationError(error)).ToArray());
|
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)
|
if (!audioExtensionResult.IsSuccess)
|
||||||
return Result.Invalid(audioExtensionResult.Errors.Select(error => new ValidationError(error)).ToArray());
|
return Result.Invalid(audioExtensionResult.Errors.Select(error => new ValidationError(error)).ToArray());
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(request.PictureContentType))
|
var effectivePictureSize = request.ExpectedPictureSizeBytes
|
||||||
return Result.Invalid(new ValidationError("PictureContentType is required"));
|
?? uploadIntentConfiguration.DefaultExpectedPictureSizeBytes;
|
||||||
|
var effectiveAudioSize = request.ExpectedAudioSizeBytes
|
||||||
|
?? uploadIntentConfiguration.DefaultExpectedAudioSizeBytes;
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(request.AudioContentType))
|
var pictureObjectName = Guid.NewGuid() + pictureExtensionResult.Value;
|
||||||
return Result.Invalid(new ValidationError("AudioContentType is required"));
|
var audioObjectName = Guid.NewGuid() + audioExtensionResult.Value;
|
||||||
|
|
||||||
var pictureName = Guid.NewGuid() + pictureExtensionResult.Value;
|
var tempPictureKey = trackConfiguration.Routes.BuildTempPicturePath(
|
||||||
var audioName = Guid.NewGuid() + audioExtensionResult.Value;
|
uploadIntentConfiguration.TempRootPrefix, request.UserId, pictureObjectName);
|
||||||
|
var tempAudioKey = trackConfiguration.Routes.BuildTempAudioPath(
|
||||||
var pictureKey = trackConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureName);
|
uploadIntentConfiguration.TempRootPrefix, request.UserId, audioObjectName);
|
||||||
var audioKey = trackConfiguration.Routes.BuildOriginalAudioPath(request.UserId, audioName);
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var expiresIn = TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds);
|
||||||
|
|
||||||
var pictureUploadUrl = await storageService.GetUploadUrlAsync(
|
var pictureUploadUrl = await storageService.GetUploadUrlAsync(
|
||||||
storageConfiguration.Bucket,
|
storageConfiguration.Bucket,
|
||||||
pictureKey,
|
tempPictureKey,
|
||||||
request.PictureContentType,
|
request.PictureContentType,
|
||||||
TimeSpan.FromSeconds(ExpiresInSeconds),
|
expiresIn,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
var audioUploadUrl = await storageService.GetUploadUrlAsync(
|
var audioUploadUrl = await storageService.GetUploadUrlAsync(
|
||||||
storageConfiguration.Bucket,
|
storageConfiguration.Bucket,
|
||||||
audioKey,
|
tempAudioKey,
|
||||||
request.AudioContentType,
|
request.AudioContentType,
|
||||||
TimeSpan.FromSeconds(ExpiresInSeconds),
|
expiresIn,
|
||||||
cancellationToken);
|
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(
|
return Result.Success(new TrackUploadUrlsResponse(
|
||||||
|
pictureIntent.Id,
|
||||||
|
audioIntent.Id,
|
||||||
storageConfiguration.Bucket,
|
storageConfiguration.Bucket,
|
||||||
pictureKey,
|
tempPictureKey,
|
||||||
pictureName,
|
pictureObjectName,
|
||||||
request.PictureContentType,
|
request.PictureContentType,
|
||||||
pictureUploadUrl,
|
pictureUploadUrl,
|
||||||
audioKey,
|
tempAudioKey,
|
||||||
audioName,
|
audioObjectName,
|
||||||
request.AudioContentType,
|
request.AudioContentType,
|
||||||
audioUploadUrl,
|
audioUploadUrl,
|
||||||
ExpiresInSeconds));
|
uploadIntentConfiguration.UploadUrlExpiresInSeconds));
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
@@ -86,25 +136,5 @@ namespace Musify.Application.Tracks.Handler
|
|||||||
return Result.Error("Failed to generate upload URLs");
|
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
|
namespace Musify.Application.Tracks.Responses
|
||||||
{
|
{
|
||||||
public record TrackUploadUrlsResponse(
|
public record TrackUploadUrlsResponse(
|
||||||
|
Guid PictureIntentId,
|
||||||
|
Guid AudioIntentId,
|
||||||
string Bucket,
|
string Bucket,
|
||||||
string PictureKey,
|
string PictureKey,
|
||||||
string PictureName,
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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!;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Musify.Domain.Entities
|
||||||
|
{
|
||||||
|
public enum UploadIntentPurpose
|
||||||
|
{
|
||||||
|
PlayListPicture,
|
||||||
|
TrackPicture,
|
||||||
|
TrackAudio,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Musify.Domain.Entities
|
||||||
|
{
|
||||||
|
public enum UploadIntentStatus
|
||||||
|
{
|
||||||
|
Issued,
|
||||||
|
Consumed,
|
||||||
|
Expired,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TempUploadsCleanupJob> 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<IStorageService>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<UploadIntentExpirationJob> 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<IDatabase>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage;
|
||||||
using MassTransit;
|
using MassTransit;
|
||||||
using Musify.Application.Abstractions.Infrastructure;
|
using Musify.Application.Abstractions.Infrastructure;
|
||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
using Musify.Infrastructure.Configuration;
|
using Musify.Infrastructure.Configuration;
|
||||||
|
using AppIDatabase = Musify.Application.Abstractions.Infrastructure.IDatabase;
|
||||||
|
|
||||||
namespace Musify.Infrastructure.Persistence
|
namespace Musify.Infrastructure.Persistence
|
||||||
{
|
{
|
||||||
public class Database(DatabaseConfiguration configuration)
|
public class Database(DatabaseConfiguration configuration)
|
||||||
: DbContext, IDatabase
|
: DbContext, AppIDatabase
|
||||||
{
|
{
|
||||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
{
|
{
|
||||||
@@ -34,5 +36,21 @@ namespace Musify.Infrastructure.Persistence
|
|||||||
public DbSet<PlayListHasTrack> PlayListHasTracks => Set<PlayListHasTrack>();
|
public DbSet<PlayListHasTrack> PlayListHasTracks => Set<PlayListHasTrack>();
|
||||||
|
|
||||||
public DbSet<Upload> Uploads => Set<Upload>();
|
public DbSet<Upload> Uploads => Set<Upload>();
|
||||||
|
|
||||||
|
public DbSet<UploadIntent> UploadIntents => Set<UploadIntent>();
|
||||||
|
|
||||||
|
public async Task<IDatabaseTransaction> 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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+580
@@ -0,0 +1,580 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
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
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
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<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime?>("Consumed")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("ConsumerId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("Delivered")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ExpirationTime")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<long?>("LastSequenceNumber")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<Guid>("LockId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("MessageId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("ReceiveCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime>("Received")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<byte[]>("RowVersion")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.ValueGeneratedOnAddOrUpdate()
|
||||||
|
.HasColumnType("bytea");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Delivered");
|
||||||
|
|
||||||
|
b.ToTable("InboxState");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("SequenceNumber")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("SequenceNumber"));
|
||||||
|
|
||||||
|
b.Property<string>("Body")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ContentType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ConversationId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CorrelationId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("DestinationAddress")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("EnqueueTime")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ExpirationTime")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("FaultAddress")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("Headers")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid?>("InboxConsumerId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("InboxMessageId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("InitiatorId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("MessageId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("MessageType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid?>("OutboxId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Properties")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid?>("RequestId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("ResponseAddress")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("SentTime")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("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<Guid>("OutboxId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("Created")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("Delivered")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<long?>("LastSequenceNumber")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<Guid>("LockId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<byte[]>("RowVersion")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.ValueGeneratedOnAddOrUpdate()
|
||||||
|
.HasColumnType("bytea");
|
||||||
|
|
||||||
|
b.HasKey("OutboxId");
|
||||||
|
|
||||||
|
b.HasIndex("Created");
|
||||||
|
|
||||||
|
b.ToTable("OutboxState");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Musify.Domain.Entities.PlayList", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedDate")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("LargePictureName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<int>("LifeCycleStatus")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("MediumPictureName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("OriginalPictureName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("SmallPictureName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedDate")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("PlayLists");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("AddedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("PlayListId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("Position")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<Guid>("TrackId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("PlayListId");
|
||||||
|
|
||||||
|
b.HasIndex("TrackId");
|
||||||
|
|
||||||
|
b.ToTable("PlayListHasTrack");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Musify.Domain.Entities.Track", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("AudioFolderName")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<int>("AudioTranscodeProcessingStatus")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedDate")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("LargePictureName")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastRetryAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("LifeCycleStatus")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("MediumPictureName")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedTitle")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("OriginalAudioName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("OriginalPictureName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<int>("PicturesProcessingStatus")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("RetryCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("SmallPictureName")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedDate")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Tracks");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Musify.Domain.Entities.Upload", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Bucket")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ContentType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedTime")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("State")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Upload");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Bucket")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<string>("ContentType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<long?>("ExpectedSizeBytes")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)");
|
||||||
|
|
||||||
|
b.Property<string>("ObjectName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<int>("Purpose")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("UploadIntents");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Musify.Domain.Entities.User", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("FirstName")
|
||||||
|
.HasMaxLength(48)
|
||||||
|
.HasColumnType("character varying(48)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(48)
|
||||||
|
.HasColumnType("character varying(48)");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(48)
|
||||||
|
.HasColumnType("character varying(48)");
|
||||||
|
|
||||||
|
b.Property<string>("SecondName")
|
||||||
|
.HasMaxLength(48)
|
||||||
|
.HasColumnType("character varying(48)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("TrackId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Musify.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddUploadIntentTable : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "UploadIntents",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Bucket = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||||
|
Key = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
|
||||||
|
ObjectName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||||
|
ContentType = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||||
|
ExpectedSizeBytes = table.Column<long>(type: "bigint", nullable: true),
|
||||||
|
Purpose = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
Status = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "UploadIntents");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -374,6 +374,57 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("Upload");
|
b.ToTable("Upload");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Bucket")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<string>("ContentType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<long?>("ExpectedSizeBytes")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)");
|
||||||
|
|
||||||
|
b.Property<string>("ObjectName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<int>("Purpose")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("UploadIntents");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Musify.Domain.Entities.User", b =>
|
modelBuilder.Entity("Musify.Domain.Entities.User", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -472,6 +523,17 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
|||||||
b.Navigation("Track");
|
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 =>
|
modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Musify.Domain.Entities.Track", "Track")
|
b.HasOne("Musify.Domain.Entities.Track", "Track")
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ using Microsoft.Extensions.Configuration;
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Musify.Application.Abstractions.Infrastructure;
|
using Musify.Application.Abstractions.Infrastructure;
|
||||||
|
using Musify.Application.Configuration;
|
||||||
using Musify.Infrastructure.Configuration;
|
using Musify.Infrastructure.Configuration;
|
||||||
|
using Musify.Infrastructure.Jobs;
|
||||||
|
|
||||||
namespace Musify.Infrastructure.Services
|
namespace Musify.Infrastructure.Services
|
||||||
{
|
{
|
||||||
@@ -41,6 +43,31 @@ namespace Musify.Infrastructure.Services
|
|||||||
return serviceDescriptors;
|
return serviceDescriptors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Called by both the web API host and the worker
|
||||||
|
public static IServiceCollection AddUploadIntentConfiguration(this IServiceCollection serviceDescriptors, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
serviceDescriptors
|
||||||
|
.AddOptionsWithValidateOnStart<UploadIntentConfiguration>()
|
||||||
|
.Bind(configuration.GetRequiredSection(UploadIntentConfiguration.SectionName))
|
||||||
|
.ValidateDataAnnotations();
|
||||||
|
|
||||||
|
serviceDescriptors.AddSingleton(serviceProvider =>
|
||||||
|
serviceProvider.GetRequiredService<IOptions<UploadIntentConfiguration>>().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<UploadIntentExpirationJob>();
|
||||||
|
serviceDescriptors.AddHostedService<TempUploadsCleanupJob>();
|
||||||
|
|
||||||
|
return serviceDescriptors;
|
||||||
|
}
|
||||||
|
|
||||||
public static IServiceCollection AddAudioTranscoder(this IServiceCollection serviceDescriptors, IConfiguration configuration)
|
public static IServiceCollection AddAudioTranscoder(this IServiceCollection serviceDescriptors, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
serviceDescriptors
|
serviceDescriptors
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using MimeMapping;
|
|||||||
using Musify.Application.Abstractions.Infrastructure;
|
using Musify.Application.Abstractions.Infrastructure;
|
||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
using Musify.Infrastructure.Configuration;
|
using Musify.Infrastructure.Configuration;
|
||||||
|
using ObjectMetadata = Musify.Application.Abstractions.Infrastructure.ObjectMetadata;
|
||||||
|
|
||||||
namespace Musify.Infrastructure.Services
|
namespace Musify.Infrastructure.Services
|
||||||
{
|
{
|
||||||
@@ -69,6 +70,24 @@ namespace Musify.Infrastructure.Services
|
|||||||
return await amazonS3.GetPreSignedURLAsync(request);
|
return await amazonS3.GetPreSignedURLAsync(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<ObjectMetadata?> 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)
|
public async Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var request = new DeleteObjectRequest
|
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)
|
public async Task RemoveFolderAsync(string bucket, string folderKey, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var prefix = folderKey.EndsWith('/') ? folderKey : $"{folderKey}/";
|
var prefix = folderKey.EndsWith('/') ? folderKey : $"{folderKey}/";
|
||||||
|
|||||||
+140
-1
@@ -159,7 +159,7 @@ Archivo:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10) Estado final
|
## 10) Estado final (iteración anterior)
|
||||||
|
|
||||||
- `dotnet build MusifyBackend.slnx -c Release` ✅
|
- `dotnet build MusifyBackend.slnx -c Release` ✅
|
||||||
|
|
||||||
@@ -192,3 +192,142 @@ Archivos clave:
|
|||||||
- `Infrastructure/MassTransit/Activities/Files/RemoveFileFromBucketActivity.cs`
|
- `Infrastructure/MassTransit/Activities/Files/RemoveFileFromBucketActivity.cs`
|
||||||
- `Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs`
|
- `Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs`
|
||||||
- `Infrastructure/Persistence/Migrations/20260514161518_AddLifeCycleStatus.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<UploadIntent> 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<UploadIntent>`.
|
||||||
|
- `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.
|
||||||
|
|||||||
Reference in New Issue
Block a user