Fix query and API bugs: 403 for non-owners, page bounds, lifecycle filters, cover fallback, race-safe inserts, async album delete

This commit is contained in:
2026-09-24 05:14:05 +02:00
parent ab388e6ad6
commit 0e3576922f
90 changed files with 2083 additions and 322 deletions
@@ -6,6 +6,7 @@ using Musify.Api.Filters;
using Musify.Application.Albums;
using Musify.Application.Albums.Responses;
using Musify.Application.Contracts;
using Musify.Api.Models;
using Musify.Application.Shared;
using Musify.Application.Tracks.Responses;
@@ -13,12 +14,15 @@ namespace Musify.Api.Endpoints;
public static class AlbumEndpoints
{
private const int MaxRecentAlbumsLimit = 50;
public static IEndpointRouteBuilder MapAlbumEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/albums")
.WithTags("Albums");
group.MapGet("/", GetAlbums)
.AddEndpointFilter<ValidationFilter<PageQuery>>()
.WithName("GetAlbums")
.WithSummary("Get Paginated Albums, Optionally Filtered By Title.")
.Produces<AlbumsSearchResponse>();
@@ -30,6 +34,7 @@ public static class AlbumEndpoints
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/users/{userId}", GetAlbumsByUserId)
.AddEndpointFilter<ValidationFilter<PageQuery>>()
.WithName("GetAlbumsByUserId")
.WithSummary("Get Paginated Albums For A User.")
.Produces<PaginatedResponse<AlbumApplicationResponse>>()
@@ -43,6 +48,7 @@ public static class AlbumEndpoints
.Produces(StatusCodes.Status401Unauthorized);
group.MapGet("/{albumId}/tracks", GetAlbumTracks)
.AddEndpointFilter<ValidationFilter<PageQuery>>()
.WithName("GetAlbumTracks")
.WithSummary("Get The Tracks Of An Album Ordered By Track Number.")
.Produces<PaginatedResponse<TrackApplicationResponse>>()
@@ -82,6 +88,7 @@ public static class AlbumEndpoints
.Produces<AlbumApplicationResponse>()
.ProducesValidationProblem()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
group.MapDelete("/{albumId}", DeleteAlbum)
@@ -90,6 +97,7 @@ public static class AlbumEndpoints
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/{albumId}/tracks/{trackId}", AddTrackToAlbum)
@@ -98,6 +106,7 @@ public static class AlbumEndpoints
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict);
@@ -107,6 +116,7 @@ public static class AlbumEndpoints
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
return app;
@@ -116,10 +126,9 @@ public static class AlbumEndpoints
IMediator mediator,
CancellationToken cancellationToken,
string? title,
int pageNumber = 1,
int pageSize = 10)
[AsParameters] PageQuery page)
{
var result = await mediator.Send(new GetAlbumsQuery(title, pageNumber, pageSize), cancellationToken);
var result = await mediator.Send(new GetAlbumsQuery(title, page.PageNumber, page.PageSize), cancellationToken);
return result.ToHttpResult();
}
@@ -136,10 +145,9 @@ public static class AlbumEndpoints
IMediator mediator,
long userId,
CancellationToken cancellationToken,
int pageNumber = 1,
int pageSize = 10)
[AsParameters] PageQuery page)
{
var result = await mediator.Send(new GetAlbumsByUserIdQuery(userId, pageNumber, pageSize), cancellationToken);
var result = await mediator.Send(new GetAlbumsByUserIdQuery(userId, page.PageNumber, page.PageSize), cancellationToken);
return result.ToHttpResult();
}
@@ -149,6 +157,14 @@ public static class AlbumEndpoints
CancellationToken cancellationToken,
int limit = 12)
{
if (limit is < 1 or > MaxRecentAlbumsLimit)
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
[nameof(limit)] = [$"'{nameof(limit)}' must be between 1 and {MaxRecentAlbumsLimit}."]
});
}
var result = await mediator.Send(new GetRecentlyListenedAlbumsQuery(currentUser.RequiredId, limit), cancellationToken);
return result.ToHttpResult();
}
@@ -157,10 +173,9 @@ public static class AlbumEndpoints
IMediator mediator,
Guid albumId,
CancellationToken cancellationToken,
int pageNumber = 1,
int pageSize = 50)
[AsParameters] PageQuery page)
{
var result = await mediator.Send(new GetAlbumTracksQuery(albumId, pageNumber, pageSize), cancellationToken);
var result = await mediator.Send(new GetAlbumTracksQuery(albumId, page.PageNumber, page.PageSize), cancellationToken);
return result.ToHttpResult();
}
@@ -178,6 +193,9 @@ public static class AlbumEndpoints
var location = result.Value;
var stream = await storageService.GetFileAsync(location.Bucket, location.Key, cancellationToken);
if (stream == null)
return Results.NotFound();
response.Headers.CacheControl = "public, max-age=31536000, immutable";
return Results.Stream(stream, location.ContentType);
}
@@ -3,8 +3,10 @@ using Musify.Api.Authentication;
using Musify.Api.DataTransferObjects.Likes;
using Musify.Api.Extensions;
using Musify.Application.Likes;
using Musify.Api.Models;
using Musify.Application.Shared;
using Musify.Application.Tracks.Responses;
using Musify.Api.Filters;
namespace Musify.Api.Endpoints;
@@ -17,6 +19,7 @@ public static class LikeEndpoints
.RequireAuthorization();
group.MapGet("/", GetLikedTracks)
.AddEndpointFilter<ValidationFilter<PageQuery>>()
.WithName("GetLikedTracks")
.WithSummary("Get The Current User'S Liked Tracks.")
.Produces<PaginatedResponse<TrackApplicationResponse>>()
@@ -36,10 +39,9 @@ public static class LikeEndpoints
IMediator mediator,
CurrentUser currentUser,
CancellationToken cancellationToken,
int pageNumber = 1,
int pageSize = 10)
[AsParameters] PageQuery page)
{
var result = await mediator.Send(new GetLikedTracksQuery(currentUser.RequiredId, pageNumber, pageSize), cancellationToken);
var result = await mediator.Send(new GetLikedTracksQuery(currentUser.RequiredId, page.PageNumber, page.PageSize), cancellationToken);
return result.ToHttpResult();
}
@@ -6,6 +6,7 @@ using Musify.Api.Filters;
using Musify.Application.Contracts;
using Musify.Application.PlayLists;
using Musify.Application.PlayLists.Responses;
using Musify.Api.Models;
using Musify.Application.Shared;
using Musify.Application.Tracks;
using Musify.Application.Tracks.Responses;
@@ -26,11 +27,13 @@ public static class PlayListEndpoints
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/users/{userId}", GetPlayListsByUserId)
.AddEndpointFilter<ValidationFilter<PageQuery>>()
.WithName("GetPlayListsByUserId")
.WithSummary("Get Paginated PlayLists For A User. Private PlayLists Are Only Returned To Their Owner.")
.Produces<PaginatedResponse<PlayListApplicationResponse>>();
group.MapGet("/{playlistId}/tracks", GetPlayListTracks)
.AddEndpointFilter<ValidationFilter<PageQuery>>()
.WithName("GetPlayListTracks")
.WithSummary("Get Paginated Tracks Of A PlayList.")
.Produces<PaginatedResponse<TrackApplicationResponse>>()
@@ -60,6 +63,7 @@ public static class PlayListEndpoints
.Produces<PlayListApplicationResponse>()
.ProducesValidationProblem()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
group.MapDelete("/{playlistId}", DeletePlayList)
@@ -68,6 +72,7 @@ public static class PlayListEndpoints
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/upload-picture", RequestPlayListPictureUpload)
@@ -86,6 +91,7 @@ public static class PlayListEndpoints
.RequireAuthorization()
.Produces<TrackApplicationResponse>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict);
@@ -95,6 +101,7 @@ public static class PlayListEndpoints
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
return app;
@@ -116,10 +123,9 @@ public static class PlayListEndpoints
long userId,
CancellationToken cancellationToken,
string? name,
int pageNumber = 1,
int pageSize = 10)
[AsParameters] PageQuery page)
{
var result = await mediator.Send(new GetPlayListsByUserIdQuery(userId, name, pageNumber, pageSize, currentUser.Id), cancellationToken);
var result = await mediator.Send(new GetPlayListsByUserIdQuery(userId, name, page.PageNumber, page.PageSize, currentUser.Id), cancellationToken);
return result.ToHttpResult();
}
@@ -128,10 +134,9 @@ public static class PlayListEndpoints
CurrentUser currentUser,
Guid playlistId,
CancellationToken cancellationToken,
int pageNumber = 1,
int pageSize = 10)
[AsParameters] PageQuery page)
{
var result = await mediator.Send(new GetPlayListTracksQuery(playlistId, pageNumber, pageSize, currentUser.Id), cancellationToken);
var result = await mediator.Send(new GetPlayListTracksQuery(playlistId, page.PageNumber, page.PageSize, currentUser.Id), cancellationToken);
return result.ToHttpResult();
}
@@ -149,6 +154,9 @@ public static class PlayListEndpoints
var location = result.Value;
var stream = await storageService.GetFileAsync(location.Bucket, location.Key, cancellationToken);
if (stream == null)
return Results.NotFound();
response.Headers.CacheControl = "public, max-age=31536000, immutable";
return Results.Stream(stream, location.ContentType);
}
@@ -4,6 +4,7 @@ using Musify.Api.DataTransferObjects.Tracks;
using Musify.Api.Extensions;
using Musify.Api.Filters;
using Musify.Application.Contracts;
using Musify.Api.Models;
using Musify.Application.Shared;
using Musify.Application.Tracks;
using Musify.Application.Tracks.Responses;
@@ -19,6 +20,7 @@ public static class TrackEndpoints
.WithTags("Tracks");
group.MapGet("/", GetTracks)
.AddEndpointFilter<ValidationFilter<PageQuery>>()
.WithName("GetTracks")
.WithSummary("Get Paginated Tracks, Optionally Filtered By Name And Genre.")
.Produces<TracksSearchResponse>()
@@ -52,6 +54,7 @@ public static class TrackEndpoints
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/users/{userId}", GetTracksByUserId)
.AddEndpointFilter<ValidationFilter<PageQuery>>()
.WithName("GetTracksByUserId")
.WithSummary("Get Paginated Tracks For A User.")
.Produces<PaginatedResponse<TrackApplicationResponse>>();
@@ -73,6 +76,7 @@ public static class TrackEndpoints
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict);
@@ -95,8 +99,7 @@ public static class TrackEndpoints
CancellationToken cancellationToken,
string? name,
string? genre,
int pageNumber = 1,
int pageSize = 10)
[AsParameters] PageQuery page)
{
Genre? parsedGenre = null;
if (!string.IsNullOrWhiteSpace(genre))
@@ -112,7 +115,7 @@ public static class TrackEndpoints
parsedGenre = value;
}
var result = await mediator.Send(new GetTracksQuery(name, pageNumber, pageSize, parsedGenre), cancellationToken);
var result = await mediator.Send(new GetTracksQuery(name, page.PageNumber, page.PageSize, parsedGenre), cancellationToken);
return result.ToHttpResult();
}
@@ -139,6 +142,9 @@ public static class TrackEndpoints
var location = result.Value;
var stream = await storageService.GetFileAsync(location.Bucket, location.Key, cancellationToken);
if (stream == null)
return Results.NotFound();
response.Headers.CacheControl = "public, max-age=31536000, immutable";
return Results.Stream(stream, location.ContentType);
}
@@ -171,10 +177,9 @@ public static class TrackEndpoints
long userId,
CancellationToken cancellationToken,
string? name,
int pageNumber = 1,
int pageSize = 10)
[AsParameters] PageQuery page)
{
var result = await mediator.Send(new GetTracksByUserIdQuery(userId, name, pageNumber, pageSize), cancellationToken);
var result = await mediator.Send(new GetTracksByUserIdQuery(userId, name, page.PageNumber, page.PageSize), cancellationToken);
return result.ToHttpResult();
}
@@ -1,11 +1,13 @@
using Mediator;
using Musify.Api.Authentication;
using Musify.Api.Extensions;
using Musify.Api.Models;
using Musify.Application.Shared;
using Musify.Application.Tracks;
using Musify.Application.Tracks.Responses;
using Musify.Application.Users;
using Musify.Application.Users.Responses;
using Musify.Api.Filters;
namespace Musify.Api.Endpoints;
@@ -17,6 +19,7 @@ public static class UserEndpoints
.WithTags("Users");
group.MapGet("/", GetUsers)
.AddEndpointFilter<ValidationFilter<PageQuery>>()
.WithName("GetUsers")
.WithSummary("Get Paginated Users.")
.Produces<PaginatedResponse<UserSummaryResponse>>();
@@ -45,7 +48,7 @@ public static class UserEndpoints
.WithName("GetLastTrackListenedByUserId")
.WithSummary("Get The Last Track A User Listened To.")
.Produces<TrackApplicationResponse>()
.Produces(StatusCodes.Status404NotFound);
.Produces(StatusCodes.Status204NoContent);
group.MapGet("/{id}/profile", GetUserProfile)
.WithName("GetUserProfile")
@@ -54,6 +57,7 @@ public static class UserEndpoints
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/{id}/followers", GetFollowers)
.AddEndpointFilter<ValidationFilter<PageQuery>>()
.WithName("GetUserFollowers")
.WithSummary("Get The Users Following A User. Visible To The User And To Mutual Followers.")
.Produces<PaginatedResponse<UserSummaryResponse>>()
@@ -61,6 +65,7 @@ public static class UserEndpoints
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/{id}/following", GetFollowing)
.AddEndpointFilter<ValidationFilter<PageQuery>>()
.WithName("GetUserFollowing")
.WithSummary("Get The Users A User Follows.")
.Produces<PaginatedResponse<UserSummaryResponse>>()
@@ -97,10 +102,9 @@ public static class UserEndpoints
CurrentUser currentUser,
CancellationToken cancellationToken,
string? usernameSearch,
int pageNumber = 1,
int pageSize = 10)
[AsParameters] PageQuery page)
{
var result = await mediator.Send(new GetUsersQuery(pageNumber, pageSize, usernameSearch, currentUser.Id), cancellationToken);
var result = await mediator.Send(new GetUsersQuery(page.PageNumber, page.PageSize, usernameSearch, currentUser.Id), cancellationToken);
return result.ToHttpResult();
}
@@ -138,8 +142,8 @@ public static class UserEndpoints
long id,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetLastTrackListenedByUserIdQuery(id), cancellationToken);
return result.ToHttpResult();
var track = await mediator.Send(new GetLastTrackListenedByUserIdQuery(id), cancellationToken);
return track == null ? Results.NoContent() : Results.Ok(track);
}
private static async Task<IResult> GetUserProfile(
@@ -157,10 +161,9 @@ public static class UserEndpoints
CurrentUser currentUser,
long id,
CancellationToken cancellationToken,
int pageNumber = 1,
int pageSize = 20)
[AsParameters] PageQuery page)
{
var result = await mediator.Send(new GetUserFollowersQuery(currentUser.Id, id, pageNumber, pageSize), cancellationToken);
var result = await mediator.Send(new GetUserFollowersQuery(currentUser.Id, id, page.PageNumber, page.PageSize), cancellationToken);
return result.ToHttpResult();
}
@@ -169,10 +172,9 @@ public static class UserEndpoints
CurrentUser currentUser,
long id,
CancellationToken cancellationToken,
int pageNumber = 1,
int pageSize = 20)
[AsParameters] PageQuery page)
{
var result = await mediator.Send(new GetUserFollowingQuery(currentUser.Id, id, pageNumber, pageSize), cancellationToken);
var result = await mediator.Send(new GetUserFollowingQuery(currentUser.Id, id, page.PageNumber, page.PageSize), cancellationToken);
return result.ToHttpResult();
}
@@ -28,10 +28,10 @@ public static class ErrorOrHttpExtensions
var error = errors[0];
return error.Type switch
{
ErrorType.NotFound => Results.NotFound(error.Description),
ErrorType.NotFound => Results.Problem(error.Description, statusCode: StatusCodes.Status404NotFound),
ErrorType.Unauthorized => Results.Unauthorized(),
ErrorType.Forbidden => Results.StatusCode(StatusCodes.Status403Forbidden),
ErrorType.Conflict => Results.Conflict(error.Description),
ErrorType.Forbidden => Results.Problem(error.Description, statusCode: StatusCodes.Status403Forbidden),
ErrorType.Conflict => Results.Problem(error.Description, statusCode: StatusCodes.Status409Conflict),
_ => Results.Problem(error.Description)
};
}
@@ -0,0 +1,9 @@
using Microsoft.AspNetCore.Mvc;
using Musify.Application.Shared;
namespace Musify.Api.Models;
/// <summary>Query-string binding for <see cref="PageRequest"/>, keeping the camelCase parameter names of the API.</summary>
public record PageQuery(
[property: FromQuery(Name = "pageNumber")] int PageNumber = 1,
[property: FromQuery(Name = "pageSize")] int PageSize = 20);
@@ -0,0 +1,17 @@
using FluentValidation;
using Musify.Api.Models;
using Musify.Application.Shared;
namespace Musify.Api.Validators;
public sealed class PageQueryValidator : AbstractValidator<PageQuery>
{
public PageQueryValidator()
{
RuleFor(x => x.PageNumber)
.GreaterThanOrEqualTo(1);
RuleFor(x => x.PageSize)
.InclusiveBetween(1, PageRequest.MaxPageSize);
}
}
@@ -13,5 +13,8 @@ public sealed class CreatePlayListRequestValidator : AbstractValidator<CreatePla
RuleFor(x => x.Description)
.MaximumLength(256);
RuleFor(x => x.Visibility)
.IsInEnum();
}
}
@@ -15,5 +15,9 @@ public sealed class UpdatePlayListRequestValidator : AbstractValidator<UpdatePla
RuleFor(x => x.NewDescription)
.MaximumLength(256)
.When(x => x.NewDescription != null);
RuleFor(x => x.NewVisibility)
.IsInEnum()
.When(x => x.NewVisibility != null);
}
}