Close data leaks: remove anonymous user creation, hide private playlists, validate uploads and clamp gateway ranges

This commit is contained in:
2026-09-24 05:00:21 +02:00
parent c8fe28b07b
commit c8e9c7790e
34 changed files with 374 additions and 553 deletions
@@ -43,7 +43,7 @@ public class CreateAlbumCommandHandler(
}
var pictureIntent = await uploadIntentValidator.ValidateAndLoadAsync(
uploadIntentConfiguration, request.PictureIntentId, request.UserId, cancellationToken);
uploadIntentConfiguration, request.PictureIntentId, request.UserId, UploadIntentPurpose.AlbumPicture, cancellationToken);
if (pictureIntent.IsError)
return pictureIntent.Errors;
@@ -62,7 +62,7 @@ public class UpdateAlbumCommandHandler(
{
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
uploadIntentConfiguration,
request.NewPictureIntentId.Value, request.UserId, cancellationToken);
request.NewPictureIntentId.Value, request.UserId, UploadIntentPurpose.AlbumPicture, cancellationToken);
if (validation.IsError)
return validation.Errors;
@@ -92,7 +92,7 @@ public class CreatePlayListCommandHandler(
}
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
uploadIntentConfiguration, request.PictureIntentId.Value, request.UserId, cancellationToken);
uploadIntentConfiguration, request.PictureIntentId.Value, request.UserId, UploadIntentPurpose.PlayListPicture, cancellationToken);
if (validation.IsError)
return validation.Errors;
@@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Tracks.Responses;
using Musify.Domain.ValueObjects;
using X.PagedList;
using X.PagedList.EF;
@@ -12,7 +13,8 @@ namespace Musify.Application.PlayLists;
public record GetPlayListTracksQuery(
Guid PlayListId,
int PageNumber,
int PageSize)
int PageSize,
long? ViewerId = null)
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
public class GetPlayListTracksQueryHandler(IDatabase database)
@@ -22,7 +24,10 @@ public class GetPlayListTracksQueryHandler(IDatabase database)
{
var playListExists = await database.PlayLists
.AsNoTracking()
.AnyAsync(p => p.Id == request.PlayListId, cancellationToken);
.AnyAsync(
p => p.Id == request.PlayListId
&& (p.Visibility == PlaylistVisibility.Public || p.UserId == request.ViewerId),
cancellationToken);
if (!playListExists)
return Error.NotFound();
@@ -1,42 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.PlayLists.Responses;
using Musify.Application.Shared;
using X.PagedList;
using X.PagedList.EF;
namespace Musify.Application.PlayLists;
public record GetPlayListsQuery(int PageNumber, int PageSize)
: IQuery<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>;
public class GetPlayListsQueryHandler(IDatabase database)
: IQueryHandler<GetPlayListsQuery, ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>
{
public async ValueTask<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>> Handle(GetPlayListsQuery request, CancellationToken cancellationToken)
{
var totalCount = await database.PlayLists.CountAsync(cancellationToken);
var pagedEntities = await database.PlayLists
.AsNoTracking()
.OrderBy(p => p.Id)
.Select(p => new
{
PlayList = p,
CoverTrackIds = p.PlayListTracks
.OrderBy(plt => plt.Position)
.Take(PlayListApplicationResponse.CoverTrackCount)
.Select(plt => plt.TrackId)
.ToList()
})
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
}
}
@@ -15,7 +15,7 @@ public record GetPlayListsByUserIdQuery(
string? Name,
int PageNumber,
int PageSize,
bool OnlyPublic = false)
long? ViewerId = null)
: IQuery<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>;
public class GetPlayListsByUserIdQueryHandler(IDatabase database)
@@ -25,12 +25,8 @@ public class GetPlayListsByUserIdQueryHandler(IDatabase database)
{
var playListsQuery = database.PlayLists
.AsNoTracking()
.Where(p => p.UserId == request.UserId);
if (request.OnlyPublic)
{
playListsQuery = playListsQuery.Where(p => p.Visibility == PlaylistVisibility.Public);
}
.Where(p => p.UserId == request.UserId
&& (p.Visibility == PlaylistVisibility.Public || p.UserId == request.ViewerId));
if (!string.IsNullOrEmpty(request.Name))
{
@@ -70,7 +70,7 @@ public class UpdatePlayListCommandHandler(
{
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
uploadIntentConfiguration,
request.NewPictureIntentId.Value, request.UserId, cancellationToken);
request.NewPictureIntentId.Value, request.UserId, UploadIntentPurpose.PlayListPicture, cancellationToken);
if (validation.IsError)
return validation.Errors;
@@ -37,6 +37,7 @@ public sealed class UploadIntentValidator(
UploadIntentConfiguration config,
Guid intentId,
long userId,
UploadIntentPurpose purpose,
CancellationToken cancellationToken)
{
var intent = await database.UploadIntents
@@ -45,6 +46,9 @@ public sealed class UploadIntentValidator(
if (intent == null || intent.UserId != userId)
return Error.NotFound(description: "Upload intent not found or not accessible.");
if (intent.Purpose != purpose)
return Error.Validation(description: "Upload intent was issued for a different purpose.");
if (intent.IsConsumed)
return Error.Conflict(description: "Upload intent has already been consumed.");
@@ -59,15 +59,18 @@ public class CreateTrackCommandHandler(
return Error.Validation(description: $"Tags '{conflict.First}' and '{conflict.Second}' are not compatible.");
}
if (request.PictureIntentId == request.AudioIntentId)
return Error.Validation(description: "Picture and audio must use different upload intents.");
var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync(
uploadIntentConfiguration,
request.PictureIntentId, request.UserId, cancellationToken);
request.PictureIntentId, request.UserId, UploadIntentPurpose.TrackPicture, cancellationToken);
if (pictureValidation.IsError)
return pictureValidation.Errors;
var audioValidation = await uploadIntentValidator.ValidateAndLoadAsync(
uploadIntentConfiguration,
request.AudioIntentId, request.UserId, cancellationToken);
request.AudioIntentId, request.UserId, UploadIntentPurpose.TrackAudio, cancellationToken);
if (audioValidation.IsError)
return audioValidation.Errors;
@@ -1,58 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Users.Responses;
using Musify.Domain.Entities;
namespace Musify.Application.Users;
public record CreateUserCommand(long Id,
string Name,
string? FirstName,
string? SecondName)
: ICommand<ErrorOr<UserApplicationResponse>>
{
public static User ToEntity(CreateUserCommand command)
{
return new User
{
Id = command.Id,
Name = command.Name,
NormalizedName = command.Name.ToUpperInvariant(),
FirstName = command.FirstName,
SecondName = command.SecondName
};
}
}
public class CreateUserCommandHandler(IDatabase database, ILogger<CreateUserCommandHandler> logger)
: ICommandHandler<CreateUserCommand, ErrorOr<UserApplicationResponse>>
{
public async ValueTask<ErrorOr<UserApplicationResponse>> Handle(CreateUserCommand request, CancellationToken cancellationToken)
{
var userExists = await database.Users.AnyAsync(u => u.Id == request.Id, cancellationToken);
if (userExists)
{
logger.LogInformation("User {UserId} already exists", request.Id);
return Error.Conflict(description: $"User {request.Id} already exists");
}
var newUser = CreateUserCommand.ToEntity(request);
try
{
await database.Users.AddAsync(newUser, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to create user {UserId}", request.Id);
return Error.Failure(description: $"Failed to create user {request.Id}");
}
logger.LogInformation("Created user {UserId}", newUser.Id);
return UserApplicationResponse.FromEntity(newUser);
}
}