Change editor rule
This commit is contained in:
@@ -1,40 +1,41 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Musify.Application.Configuration;
|
||||
|
||||
namespace Musify.Api.Authentication;
|
||||
|
||||
public sealed class AuthenticationConfiguration : IConfigurationOptions
|
||||
namespace Musify.Api.Authentication
|
||||
{
|
||||
public static string SectionName => "Authentication";
|
||||
public sealed class AuthenticationConfiguration : IConfigurationOptions
|
||||
{
|
||||
public static string SectionName => "Authentication";
|
||||
|
||||
[Required]
|
||||
[Url]
|
||||
public required string MetadataAddress { get; set; }
|
||||
[Required]
|
||||
[Url]
|
||||
public required string MetadataAddress { get; set; }
|
||||
|
||||
[Required]
|
||||
public required string IssuerAddress { get; set; }
|
||||
[Required]
|
||||
public required string IssuerAddress { get; set; }
|
||||
|
||||
[Required]
|
||||
public required string AudienceAddress { get; set; }
|
||||
[Required]
|
||||
public required string AudienceAddress { get; set; }
|
||||
|
||||
[Required]
|
||||
public required string ClientId { get; set; }
|
||||
[Required]
|
||||
public required string ClientId { get; set; }
|
||||
|
||||
public string? ClientSecret { get; set; }
|
||||
public string? ClientSecret { get; set; }
|
||||
|
||||
[Required]
|
||||
[Url]
|
||||
public required string AuthorizationEndpoint { get; set; }
|
||||
[Required]
|
||||
[Url]
|
||||
public required string AuthorizationEndpoint { get; set; }
|
||||
|
||||
[Required]
|
||||
[Url]
|
||||
public required string TokenEndpoint { get; set; }
|
||||
[Required]
|
||||
[Url]
|
||||
public required string TokenEndpoint { get; set; }
|
||||
|
||||
[Required]
|
||||
[MinLength(1)]
|
||||
public required string[] Scopes { get; set; }
|
||||
[Required]
|
||||
[MinLength(1)]
|
||||
public required string[] Scopes { get; set; }
|
||||
|
||||
public string? ScalarRedirectUri { get; set; }
|
||||
public string? ScalarRedirectUri { get; set; }
|
||||
|
||||
public bool RequireHttpsMetadata { get; set; } = true;
|
||||
public bool RequireHttpsMetadata { get; set; } = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Musify.Application.Configuration;
|
||||
|
||||
namespace Musify.Api.Authentication;
|
||||
|
||||
public static class AuthenticationExtensions
|
||||
namespace Musify.Api.Authentication
|
||||
{
|
||||
public static IServiceCollection AddAuthenticationConfiguration(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
public static class AuthenticationExtensions
|
||||
{
|
||||
services.AddValidatedOptions<AuthenticationConfiguration>(configuration);
|
||||
public static IServiceCollection AddAuthenticationConfiguration(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
services.AddValidatedOptions<AuthenticationConfiguration>(configuration);
|
||||
|
||||
services.AddMemoryCache();
|
||||
services.AddHttpClient();
|
||||
services.AddScoped<JwtBearerEventsHandler>();
|
||||
services.ConfigureOptions<JwtBearerOptionsSetup>();
|
||||
services.AddMemoryCache();
|
||||
services.AddHttpClient();
|
||||
services.AddScoped<JwtBearerEventsHandler>();
|
||||
services.ConfigureOptions<JwtBearerOptionsSetup>();
|
||||
|
||||
services
|
||||
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer();
|
||||
services
|
||||
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer();
|
||||
|
||||
services.AddAuthorization();
|
||||
services.AddAuthorization();
|
||||
|
||||
return services;
|
||||
return services;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +1,36 @@
|
||||
using System.Reflection;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Musify.Api.Authentication;
|
||||
|
||||
public sealed class CurrentUser : IBindableFromHttpContext<CurrentUser>
|
||||
namespace Musify.Api.Authentication
|
||||
{
|
||||
private CurrentUser(ClaimsPrincipal principal)
|
||||
public sealed class CurrentUser : IBindableFromHttpContext<CurrentUser>
|
||||
{
|
||||
IsAuthenticated = principal.Identity?.IsAuthenticated ?? false;
|
||||
private CurrentUser(ClaimsPrincipal principal)
|
||||
{
|
||||
IsAuthenticated = principal.Identity?.IsAuthenticated ?? false;
|
||||
|
||||
var rawId = principal.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (long.TryParse(rawId, out var id) && id != 0)
|
||||
Id = id;
|
||||
var rawId = principal.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (long.TryParse(rawId, out var id) && id != 0)
|
||||
Id = id;
|
||||
|
||||
Username = principal.FindFirstValue(ClaimTypes.Name);
|
||||
FirstName = principal.FindFirstValue(ClaimTypes.GivenName);
|
||||
LastName = principal.FindFirstValue(ClaimTypes.Surname);
|
||||
Username = principal.FindFirstValue(ClaimTypes.Name);
|
||||
FirstName = principal.FindFirstValue(ClaimTypes.GivenName);
|
||||
LastName = principal.FindFirstValue(ClaimTypes.Surname);
|
||||
}
|
||||
|
||||
public bool IsAuthenticated { get; }
|
||||
|
||||
public long? Id { get; }
|
||||
|
||||
public string? Username { get; }
|
||||
|
||||
public string? FirstName { get; }
|
||||
|
||||
public string? LastName { get; }
|
||||
|
||||
public long RequiredId => Id ?? throw new InvalidOperationException("User Id claim is missing or invalid.");
|
||||
|
||||
public static ValueTask<CurrentUser?> BindAsync(HttpContext context, ParameterInfo parameter) =>
|
||||
ValueTask.FromResult<CurrentUser?>(new CurrentUser(context.User));
|
||||
}
|
||||
|
||||
public bool IsAuthenticated { get; }
|
||||
|
||||
public long? Id { get; }
|
||||
|
||||
public string? Username { get; }
|
||||
|
||||
public string? FirstName { get; }
|
||||
|
||||
public string? LastName { get; }
|
||||
|
||||
public long RequiredId => Id ?? throw new InvalidOperationException("User Id claim is missing or invalid.");
|
||||
|
||||
public static ValueTask<CurrentUser?> BindAsync(HttpContext context, ParameterInfo parameter) =>
|
||||
ValueTask.FromResult<CurrentUser?>(new CurrentUser(context.User));
|
||||
}
|
||||
|
||||
@@ -7,135 +7,136 @@ using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
using Musify.Application.Users;
|
||||
|
||||
namespace Musify.Api.Authentication;
|
||||
|
||||
public sealed class JwtBearerEventsHandler(
|
||||
IMediator mediator,
|
||||
IMemoryCache cache,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
AuthenticationConfiguration authenticationConfiguration,
|
||||
ILogger<JwtBearerEventsHandler> logger) : JwtBearerEvents
|
||||
namespace Musify.Api.Authentication
|
||||
{
|
||||
private static readonly TimeSpan UserSyncCacheTtl = TimeSpan.FromMinutes(15);
|
||||
|
||||
private sealed record UserInfoResponse(
|
||||
[property: JsonPropertyName("name")] string? Name,
|
||||
[property: JsonPropertyName("preferred_username")] string? PreferredUsername,
|
||||
[property: JsonPropertyName("email")] string? Email,
|
||||
[property: JsonPropertyName("given_name")] string? GivenName,
|
||||
[property: JsonPropertyName("family_name")] string? FamilyName,
|
||||
[property: JsonPropertyName("picture")] string? Picture);
|
||||
|
||||
public override async Task TokenValidated(TokenValidatedContext context)
|
||||
public sealed class JwtBearerEventsHandler(
|
||||
IMediator mediator,
|
||||
IMemoryCache cache,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
AuthenticationConfiguration authenticationConfiguration,
|
||||
ILogger<JwtBearerEventsHandler> logger) : JwtBearerEvents
|
||||
{
|
||||
var principal = context.Principal!;
|
||||
private static readonly TimeSpan UserSyncCacheTtl = TimeSpan.FromMinutes(15);
|
||||
|
||||
var rawId = principal.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (!long.TryParse(rawId, out var userId))
|
||||
private sealed record UserInfoResponse(
|
||||
[property: JsonPropertyName("name")] string? Name,
|
||||
[property: JsonPropertyName("preferred_username")] string? PreferredUsername,
|
||||
[property: JsonPropertyName("email")] string? Email,
|
||||
[property: JsonPropertyName("given_name")] string? GivenName,
|
||||
[property: JsonPropertyName("family_name")] string? FamilyName,
|
||||
[property: JsonPropertyName("picture")] string? Picture);
|
||||
|
||||
public override async Task TokenValidated(TokenValidatedContext context)
|
||||
{
|
||||
logger.LogWarning("Token subject '{Subject}' is not a numeric id; skipping user provisioning", rawId);
|
||||
return;
|
||||
}
|
||||
var principal = context.Principal!;
|
||||
|
||||
var cacheKey = $"user-synced:{userId}";
|
||||
if (cache.TryGetValue(cacheKey, out _))
|
||||
return;
|
||||
|
||||
var userInfo = await FetchUserInfoAsync(context, context.HttpContext.RequestAborted);
|
||||
|
||||
var username = userInfo?.Name
|
||||
?? userInfo?.PreferredUsername
|
||||
?? principal.FindFirstValue("name")
|
||||
?? principal.FindFirstValue(ClaimTypes.Name)
|
||||
?? principal.FindFirstValue("preferred_username")
|
||||
?? userInfo?.Email
|
||||
?? principal.FindFirstValue("email")
|
||||
?? principal.FindFirstValue(ClaimTypes.Email)
|
||||
?? rawId!;
|
||||
|
||||
var firstName = userInfo?.GivenName
|
||||
?? principal.FindFirstValue("given_name")
|
||||
?? principal.FindFirstValue(ClaimTypes.GivenName);
|
||||
var lastName = userInfo?.FamilyName
|
||||
?? principal.FindFirstValue("family_name")
|
||||
?? principal.FindFirstValue(ClaimTypes.Surname);
|
||||
var profilePictureUrl = userInfo?.Picture ?? principal.FindFirstValue("picture");
|
||||
|
||||
try
|
||||
{
|
||||
await mediator.Send(
|
||||
new SyncUserCommand(userId, username, firstName, lastName, profilePictureUrl),
|
||||
context.HttpContext.RequestAborted);
|
||||
|
||||
cache.Set(cacheKey, true, UserSyncCacheTtl);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Failed to sync user {UserId} during token validation", userId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<UserInfoResponse?> FetchUserInfoAsync(
|
||||
TokenValidatedContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var authorizationHeader = context.HttpContext.Request.Headers.Authorization.ToString();
|
||||
if (!authorizationHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
|
||||
var accessToken = authorizationHeader["Bearer ".Length..].Trim();
|
||||
if (accessToken.Length == 0)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var userInfoEndpoint = await ResolveUserInfoEndpointAsync(context, cancellationToken);
|
||||
|
||||
var client = httpClientFactory.CreateClient();
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, userInfoEndpoint)
|
||||
var rawId = principal.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (!long.TryParse(rawId, out var userId))
|
||||
{
|
||||
Headers = { Authorization = new AuthenticationHeaderValue("Bearer", accessToken) }
|
||||
};
|
||||
|
||||
using var response = await client.SendAsync(request, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
logger.LogDebug("Userinfo request returned {StatusCode}", response.StatusCode);
|
||||
return null;
|
||||
logger.LogWarning("Token subject '{Subject}' is not a numeric id; skipping user provisioning", rawId);
|
||||
return;
|
||||
}
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<UserInfoResponse>(cancellationToken);
|
||||
var cacheKey = $"user-synced:{userId}";
|
||||
if (cache.TryGetValue(cacheKey, out _))
|
||||
return;
|
||||
|
||||
var userInfo = await FetchUserInfoAsync(context, context.HttpContext.RequestAborted);
|
||||
|
||||
var username = userInfo?.Name
|
||||
?? userInfo?.PreferredUsername
|
||||
?? principal.FindFirstValue("name")
|
||||
?? principal.FindFirstValue(ClaimTypes.Name)
|
||||
?? principal.FindFirstValue("preferred_username")
|
||||
?? userInfo?.Email
|
||||
?? principal.FindFirstValue("email")
|
||||
?? principal.FindFirstValue(ClaimTypes.Email)
|
||||
?? rawId!;
|
||||
|
||||
var firstName = userInfo?.GivenName
|
||||
?? principal.FindFirstValue("given_name")
|
||||
?? principal.FindFirstValue(ClaimTypes.GivenName);
|
||||
var lastName = userInfo?.FamilyName
|
||||
?? principal.FindFirstValue("family_name")
|
||||
?? principal.FindFirstValue(ClaimTypes.Surname);
|
||||
var profilePictureUrl = userInfo?.Picture ?? principal.FindFirstValue("picture");
|
||||
|
||||
try
|
||||
{
|
||||
await mediator.Send(
|
||||
new SyncUserCommand(userId, username, firstName, lastName, profilePictureUrl),
|
||||
context.HttpContext.RequestAborted);
|
||||
|
||||
cache.Set(cacheKey, true, UserSyncCacheTtl);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Failed to sync user {UserId} during token validation", userId);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
|
||||
private async Task<UserInfoResponse?> FetchUserInfoAsync(
|
||||
TokenValidatedContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
logger.LogWarning(exception, "Failed to fetch userinfo from identity provider");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
var authorizationHeader = context.HttpContext.Request.Headers.Authorization.ToString();
|
||||
if (!authorizationHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
|
||||
private async Task<string> ResolveUserInfoEndpointAsync(
|
||||
TokenValidatedContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var configurationManager = context.Options.ConfigurationManager;
|
||||
if (configurationManager != null)
|
||||
var accessToken = authorizationHeader["Bearer ".Length..].Trim();
|
||||
if (accessToken.Length == 0)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var userInfoEndpoint = await ResolveUserInfoEndpointAsync(context, cancellationToken);
|
||||
|
||||
var client = httpClientFactory.CreateClient();
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, userInfoEndpoint)
|
||||
{
|
||||
Headers = { Authorization = new AuthenticationHeaderValue("Bearer", accessToken) }
|
||||
};
|
||||
|
||||
using var response = await client.SendAsync(request, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
logger.LogDebug("Userinfo request returned {StatusCode}", response.StatusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<UserInfoResponse>(cancellationToken);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Failed to fetch userinfo from identity provider");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> ResolveUserInfoEndpointAsync(
|
||||
TokenValidatedContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var configuration = await configurationManager.GetConfigurationAsync(cancellationToken);
|
||||
if (configuration is OpenIdConnectConfiguration { UserInfoEndpoint: { Length: > 0 } endpoint })
|
||||
return endpoint;
|
||||
var configurationManager = context.Options.ConfigurationManager;
|
||||
if (configurationManager != null)
|
||||
{
|
||||
var configuration = await configurationManager.GetConfigurationAsync(cancellationToken);
|
||||
if (configuration is OpenIdConnectConfiguration { UserInfoEndpoint: { Length: > 0 } endpoint })
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
return $"{authenticationConfiguration.IssuerAddress.TrimEnd('/')}/oidc/v1/userinfo";
|
||||
}
|
||||
|
||||
return $"{authenticationConfiguration.IssuerAddress.TrimEnd('/')}/oidc/v1/userinfo";
|
||||
}
|
||||
public override Task AuthenticationFailed(AuthenticationFailedContext context)
|
||||
{
|
||||
logger.LogWarning(context.Exception, "JWT authentication failed");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override Task AuthenticationFailed(AuthenticationFailedContext context)
|
||||
{
|
||||
logger.LogWarning(context.Exception, "JWT authentication failed");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override Task Challenge(JwtBearerChallengeContext context)
|
||||
{
|
||||
if (context.AuthenticateFailure != null)
|
||||
logger.LogWarning("JWT challenge: {Error} - {Description}", context.Error, context.ErrorDescription);
|
||||
return Task.CompletedTask;
|
||||
public override Task Challenge(JwtBearerChallengeContext context)
|
||||
{
|
||||
if (context.AuthenticateFailure != null)
|
||||
logger.LogWarning("JWT challenge: {Error} - {Description}", context.Error, context.ErrorDescription);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,33 +3,34 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace Musify.Api.Authentication;
|
||||
|
||||
public sealed class JwtBearerOptionsSetup(IOptions<AuthenticationConfiguration> options)
|
||||
: IConfigureNamedOptions<JwtBearerOptions>
|
||||
namespace Musify.Api.Authentication
|
||||
{
|
||||
private readonly AuthenticationConfiguration configuration = options.Value;
|
||||
|
||||
public void Configure(string? name, JwtBearerOptions options)
|
||||
public sealed class JwtBearerOptionsSetup(IOptions<AuthenticationConfiguration> options)
|
||||
: IConfigureNamedOptions<JwtBearerOptions>
|
||||
{
|
||||
if (name != JwtBearerDefaults.AuthenticationScheme)
|
||||
return;
|
||||
private readonly AuthenticationConfiguration configuration = options.Value;
|
||||
|
||||
Configure(options);
|
||||
}
|
||||
|
||||
public void Configure(JwtBearerOptions options)
|
||||
{
|
||||
options.MetadataAddress = configuration.MetadataAddress;
|
||||
options.RequireHttpsMetadata = configuration.RequireHttpsMetadata;
|
||||
options.Audience = configuration.AudienceAddress;
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
public void Configure(string? name, JwtBearerOptions options)
|
||||
{
|
||||
ValidIssuer = configuration.IssuerAddress,
|
||||
ValidAudience = configuration.AudienceAddress,
|
||||
RoleClaimType = ClaimTypes.Role,
|
||||
NameClaimType = ClaimTypes.Name,
|
||||
};
|
||||
options.EventsType = typeof(JwtBearerEventsHandler);
|
||||
if (name != JwtBearerDefaults.AuthenticationScheme)
|
||||
return;
|
||||
|
||||
Configure(options);
|
||||
}
|
||||
|
||||
public void Configure(JwtBearerOptions options)
|
||||
{
|
||||
options.MetadataAddress = configuration.MetadataAddress;
|
||||
options.RequireHttpsMetadata = configuration.RequireHttpsMetadata;
|
||||
options.Audience = configuration.AudienceAddress;
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidIssuer = configuration.IssuerAddress,
|
||||
ValidAudience = configuration.AudienceAddress,
|
||||
RoleClaimType = ClaimTypes.Role,
|
||||
NameClaimType = ClaimTypes.Name,
|
||||
};
|
||||
options.EventsType = typeof(JwtBearerEventsHandler);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
namespace Musify.Api.DataTransferObjects;
|
||||
|
||||
public record AddTracksRequest(IReadOnlyList<Guid> TrackIds);
|
||||
namespace Musify.Api.DataTransferObjects
|
||||
{
|
||||
public record AddTracksRequest(IReadOnlyList<Guid> TrackIds);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
namespace Musify.Api.DataTransferObjects.Albums;
|
||||
|
||||
public record CreateAlbumRequest(
|
||||
string Title,
|
||||
string? Description,
|
||||
int? ReleaseYear,
|
||||
Guid PictureIntentId);
|
||||
namespace Musify.Api.DataTransferObjects.Albums
|
||||
{
|
||||
public record CreateAlbumRequest(
|
||||
string Title,
|
||||
string? Description,
|
||||
int? ReleaseYear,
|
||||
Guid PictureIntentId);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
namespace Musify.Api.DataTransferObjects.Albums;
|
||||
|
||||
public record UpdateAlbumRequest(
|
||||
string Title,
|
||||
string? Description,
|
||||
int? ReleaseYear,
|
||||
Guid? PictureIntentId);
|
||||
namespace Musify.Api.DataTransferObjects.Albums
|
||||
{
|
||||
public record UpdateAlbumRequest(
|
||||
string Title,
|
||||
string? Description,
|
||||
int? ReleaseYear,
|
||||
Guid? PictureIntentId);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
namespace Musify.Api.DataTransferObjects.Likes;
|
||||
|
||||
public record ToggleLikeRequest(Guid TrackId);
|
||||
namespace Musify.Api.DataTransferObjects.Likes
|
||||
{
|
||||
public record ToggleLikeRequest(Guid TrackId);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
namespace Musify.Api.DataTransferObjects;
|
||||
|
||||
public record PictureUploadRequest(
|
||||
string FileType,
|
||||
string ContentType,
|
||||
long? ExpectedSizeBytes = null);
|
||||
namespace Musify.Api.DataTransferObjects
|
||||
{
|
||||
public record PictureUploadRequest(
|
||||
string FileType,
|
||||
string ContentType,
|
||||
long? ExpectedSizeBytes = null);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Api.DataTransferObjects.PlayLists;
|
||||
|
||||
public record CreatePlayListRequest(
|
||||
string Name,
|
||||
string? Description,
|
||||
Guid? PictureIntentId,
|
||||
PlayListVisibility Visibility = PlayListVisibility.Private);
|
||||
namespace Musify.Api.DataTransferObjects.PlayLists
|
||||
{
|
||||
public record CreatePlayListRequest(
|
||||
string Name,
|
||||
string? Description,
|
||||
Guid? PictureIntentId,
|
||||
PlayListVisibility Visibility = PlayListVisibility.Private);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Api.DataTransferObjects.PlayLists;
|
||||
|
||||
public record UpdatePlayListRequest(
|
||||
string? Name,
|
||||
string? Description,
|
||||
Guid? PictureIntentId,
|
||||
PlayListVisibility? Visibility = null);
|
||||
namespace Musify.Api.DataTransferObjects.PlayLists
|
||||
{
|
||||
public record UpdatePlayListRequest(
|
||||
string? Name,
|
||||
string? Description,
|
||||
Guid? PictureIntentId,
|
||||
PlayListVisibility? Visibility = null);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Api.DataTransferObjects.Tracks;
|
||||
|
||||
public record CreateTrackRequest(
|
||||
string Title,
|
||||
Guid PictureIntentId,
|
||||
Guid AudioIntentId,
|
||||
IReadOnlyCollection<Genre> Tags,
|
||||
bool IsExplicit = false);
|
||||
namespace Musify.Api.DataTransferObjects.Tracks
|
||||
{
|
||||
public record CreateTrackRequest(
|
||||
string Title,
|
||||
Guid PictureIntentId,
|
||||
Guid AudioIntentId,
|
||||
IReadOnlyCollection<Genre> Tags,
|
||||
bool IsExplicit = false);
|
||||
}
|
||||
|
||||
+4
-3
@@ -1,3 +1,4 @@
|
||||
namespace Musify.Api.DataTransferObjects.Tracks;
|
||||
|
||||
public record RecordListeningProgressRequest(double PlayedSeconds);
|
||||
namespace Musify.Api.DataTransferObjects.Tracks
|
||||
{
|
||||
public record RecordListeningProgressRequest(double PlayedSeconds);
|
||||
}
|
||||
|
||||
+10
-9
@@ -1,9 +1,10 @@
|
||||
namespace Musify.Api.DataTransferObjects.Tracks;
|
||||
|
||||
public record RequestTrackUploadUrlsRequest(
|
||||
string PictureFileType,
|
||||
string PictureContentType,
|
||||
string AudioFileType,
|
||||
string AudioContentType,
|
||||
long? ExpectedPictureSizeBytes = null,
|
||||
long? ExpectedAudioSizeBytes = null);
|
||||
namespace Musify.Api.DataTransferObjects.Tracks
|
||||
{
|
||||
public record RequestTrackUploadUrlsRequest(
|
||||
string PictureFileType,
|
||||
string PictureContentType,
|
||||
string AudioFileType,
|
||||
string AudioContentType,
|
||||
long? ExpectedPictureSizeBytes = null,
|
||||
long? ExpectedAudioSizeBytes = null);
|
||||
}
|
||||
|
||||
@@ -13,263 +13,264 @@ using Musify.Application.Pictures.Responses;
|
||||
using Musify.Application.Shared;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
|
||||
namespace Musify.Api.Endpoints;
|
||||
|
||||
public static class AlbumEndpoints
|
||||
namespace Musify.Api.Endpoints
|
||||
{
|
||||
private const int MaxRecentAlbumsLimit = 50;
|
||||
|
||||
public static IEndpointRouteBuilder MapAlbumEndpoints(this IEndpointRouteBuilder app)
|
||||
public static class AlbumEndpoints
|
||||
{
|
||||
var group = app.MapGroup("/albums")
|
||||
.WithTags("Albums");
|
||||
private const int MaxRecentAlbumsLimit = 50;
|
||||
|
||||
group.MapGet("/", GetAlbums)
|
||||
.AddEndpointFilter<ValidationFilter<PageQuery>>()
|
||||
.WithName("GetAlbums")
|
||||
.WithSummary("Get paginated albums, optionally filtered by title")
|
||||
.Produces<PaginatedResponse<AlbumApplicationResponse>>();
|
||||
|
||||
group.MapGet("/{id:guid}", GetAlbumById)
|
||||
.WithName("GetAlbumById")
|
||||
.WithSummary("Get an album by id")
|
||||
.Produces<AlbumApplicationResponse>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/users/{userId:long}", GetAlbumsByUserId)
|
||||
.AddEndpointFilter<ValidationFilter<PageQuery>>()
|
||||
.WithName("GetAlbumsByUserId")
|
||||
.WithSummary("Get paginated albums for a user")
|
||||
.Produces<PaginatedResponse<AlbumApplicationResponse>>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/recent", GetRecentlyListenedAlbums)
|
||||
.WithName("GetRecentlyListenedAlbums")
|
||||
.WithSummary("Get the albums the current user listened to most recently")
|
||||
.RequireAuthorization()
|
||||
.Produces<IReadOnlyList<AlbumApplicationResponse>>()
|
||||
.Produces(StatusCodes.Status401Unauthorized);
|
||||
|
||||
group.MapGet("/{id:guid}/tracks", GetAlbumTracks)
|
||||
.AddEndpointFilter<ValidationFilter<PageQuery>>()
|
||||
.WithName("GetAlbumTracks")
|
||||
.WithSummary("Get the tracks of an album ordered by track number")
|
||||
.Produces<PaginatedResponse<TrackApplicationResponse>>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/{id:guid}/cover", GetAlbumCover)
|
||||
.WithName("GetAlbumCover")
|
||||
.WithSummary("Get an album cover image")
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/upload-picture", RequestAlbumPictureUpload)
|
||||
.WithName("RequestAlbumPictureUpload")
|
||||
.WithSummary("Request a pre-signed URL to upload an album picture")
|
||||
.AddEndpointFilter<ValidationFilter<PictureUploadRequest>>()
|
||||
.RequireAuthorization()
|
||||
.Produces<PictureUploadResponse>()
|
||||
.ProducesValidationProblem()
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/", CreateAlbum)
|
||||
.WithName("CreateAlbum")
|
||||
.WithSummary("Create a new album")
|
||||
.AddEndpointFilter<ValidationFilter<CreateAlbumRequest>>()
|
||||
.RequireAuthorization()
|
||||
.Produces<AlbumApplicationResponse>(StatusCodes.Status201Created)
|
||||
.ProducesValidationProblem()
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPut("/{id:guid}", UpdateAlbum)
|
||||
.WithName("UpdateAlbum")
|
||||
.WithSummary("Update an album")
|
||||
.AddEndpointFilter<ValidationFilter<UpdateAlbumRequest>>()
|
||||
.RequireAuthorization()
|
||||
.Produces<AlbumApplicationResponse>()
|
||||
.ProducesValidationProblem()
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapDelete("/{id:guid}", DeleteAlbum)
|
||||
.WithName("DeleteAlbum")
|
||||
.WithSummary("Delete an album")
|
||||
.RequireAuthorization()
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/{id:guid}/tracks", AddAlbumTracks)
|
||||
.WithName("AddAlbumTracks")
|
||||
.WithSummary("Add your uploaded tracks to an album, skipping the ones it already has")
|
||||
.AddEndpointFilter<ValidationFilter<AddTracksRequest>>()
|
||||
.RequireAuthorization()
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.ProducesValidationProblem()
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status409Conflict);
|
||||
|
||||
group.MapDelete("/{id:guid}/tracks/{trackId:guid}", RemoveTrackFromAlbum)
|
||||
.WithName("RemoveTrackFromAlbum")
|
||||
.WithSummary("Remove a track from an album")
|
||||
.RequireAuthorization()
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetAlbums(
|
||||
IMediator mediator,
|
||||
string? title,
|
||||
[AsParameters] PageQuery page,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetAlbumsQuery(title, page.PageNumber, page.PageSize), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetAlbumById(
|
||||
IMediator mediator,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetAlbumByIdQuery(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetAlbumsByUserId(
|
||||
IMediator mediator,
|
||||
long userId,
|
||||
[AsParameters] PageQuery page,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetAlbumsByUserIdQuery(userId, page.PageNumber, page.PageSize), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetRecentlyListenedAlbums(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
CancellationToken cancellationToken,
|
||||
int limit = 12)
|
||||
{
|
||||
if (limit is < 1 or > MaxRecentAlbumsLimit)
|
||||
public static IEndpointRouteBuilder MapAlbumEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
[nameof(limit)] = [$"'{nameof(limit)}' must be between 1 and {MaxRecentAlbumsLimit}."]
|
||||
});
|
||||
var group = app.MapGroup("/albums")
|
||||
.WithTags("Albums");
|
||||
|
||||
group.MapGet("/", GetAlbums)
|
||||
.AddEndpointFilter<ValidationFilter<PageQuery>>()
|
||||
.WithName("GetAlbums")
|
||||
.WithSummary("Get paginated albums, optionally filtered by title")
|
||||
.Produces<PaginatedResponse<AlbumApplicationResponse>>();
|
||||
|
||||
group.MapGet("/{id:guid}", GetAlbumById)
|
||||
.WithName("GetAlbumById")
|
||||
.WithSummary("Get an album by id")
|
||||
.Produces<AlbumApplicationResponse>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/users/{userId:long}", GetAlbumsByUserId)
|
||||
.AddEndpointFilter<ValidationFilter<PageQuery>>()
|
||||
.WithName("GetAlbumsByUserId")
|
||||
.WithSummary("Get paginated albums for a user")
|
||||
.Produces<PaginatedResponse<AlbumApplicationResponse>>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/recent", GetRecentlyListenedAlbums)
|
||||
.WithName("GetRecentlyListenedAlbums")
|
||||
.WithSummary("Get the albums the current user listened to most recently")
|
||||
.RequireAuthorization()
|
||||
.Produces<IReadOnlyList<AlbumApplicationResponse>>()
|
||||
.Produces(StatusCodes.Status401Unauthorized);
|
||||
|
||||
group.MapGet("/{id:guid}/tracks", GetAlbumTracks)
|
||||
.AddEndpointFilter<ValidationFilter<PageQuery>>()
|
||||
.WithName("GetAlbumTracks")
|
||||
.WithSummary("Get the tracks of an album ordered by track number")
|
||||
.Produces<PaginatedResponse<TrackApplicationResponse>>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/{id:guid}/cover", GetAlbumCover)
|
||||
.WithName("GetAlbumCover")
|
||||
.WithSummary("Get an album cover image")
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/upload-picture", RequestAlbumPictureUpload)
|
||||
.WithName("RequestAlbumPictureUpload")
|
||||
.WithSummary("Request a pre-signed URL to upload an album picture")
|
||||
.AddEndpointFilter<ValidationFilter<PictureUploadRequest>>()
|
||||
.RequireAuthorization()
|
||||
.Produces<PictureUploadResponse>()
|
||||
.ProducesValidationProblem()
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/", CreateAlbum)
|
||||
.WithName("CreateAlbum")
|
||||
.WithSummary("Create a new album")
|
||||
.AddEndpointFilter<ValidationFilter<CreateAlbumRequest>>()
|
||||
.RequireAuthorization()
|
||||
.Produces<AlbumApplicationResponse>(StatusCodes.Status201Created)
|
||||
.ProducesValidationProblem()
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPut("/{id:guid}", UpdateAlbum)
|
||||
.WithName("UpdateAlbum")
|
||||
.WithSummary("Update an album")
|
||||
.AddEndpointFilter<ValidationFilter<UpdateAlbumRequest>>()
|
||||
.RequireAuthorization()
|
||||
.Produces<AlbumApplicationResponse>()
|
||||
.ProducesValidationProblem()
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapDelete("/{id:guid}", DeleteAlbum)
|
||||
.WithName("DeleteAlbum")
|
||||
.WithSummary("Delete an album")
|
||||
.RequireAuthorization()
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/{id:guid}/tracks", AddAlbumTracks)
|
||||
.WithName("AddAlbumTracks")
|
||||
.WithSummary("Add your uploaded tracks to an album, skipping the ones it already has")
|
||||
.AddEndpointFilter<ValidationFilter<AddTracksRequest>>()
|
||||
.RequireAuthorization()
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.ProducesValidationProblem()
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status409Conflict);
|
||||
|
||||
group.MapDelete("/{id:guid}/tracks/{trackId:guid}", RemoveTrackFromAlbum)
|
||||
.WithName("RemoveTrackFromAlbum")
|
||||
.WithSummary("Remove a track from an album")
|
||||
.RequireAuthorization()
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
var result = await mediator.Send(new GetRecentlyListenedAlbumsQuery(currentUser.RequiredId, limit), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
private static async Task<IResult> GetAlbums(
|
||||
IMediator mediator,
|
||||
string? title,
|
||||
[AsParameters] PageQuery page,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetAlbumsQuery(title, page.PageNumber, page.PageSize), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetAlbumTracks(
|
||||
IMediator mediator,
|
||||
Guid id,
|
||||
[AsParameters] PageQuery page,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetAlbumTracksQuery(id, page.PageNumber, page.PageSize), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
private static async Task<IResult> GetAlbumById(
|
||||
IMediator mediator,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetAlbumByIdQuery(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static Task<IResult> GetAlbumCover(
|
||||
IMediator mediator,
|
||||
IStorageService storageService,
|
||||
HttpResponse response,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken,
|
||||
string size = "medium")
|
||||
{
|
||||
return CoverEndpoint.Stream(mediator, storageService, response, CoverOwner.Album, id, size, cancellationToken);
|
||||
}
|
||||
private static async Task<IResult> GetAlbumsByUserId(
|
||||
IMediator mediator,
|
||||
long userId,
|
||||
[AsParameters] PageQuery page,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetAlbumsByUserIdQuery(userId, page.PageNumber, page.PageSize), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> RequestAlbumPictureUpload(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
PictureUploadRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(
|
||||
new RequestAlbumPictureUploadCommand(currentUser.RequiredId, request.FileType, request.ContentType, request.ExpectedSizeBytes),
|
||||
cancellationToken);
|
||||
private static async Task<IResult> GetRecentlyListenedAlbums(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
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}."]
|
||||
});
|
||||
}
|
||||
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
var result = await mediator.Send(new GetRecentlyListenedAlbumsQuery(currentUser.RequiredId, limit), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateAlbum(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
CreateAlbumRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(
|
||||
new CreateAlbumCommand(currentUser.RequiredId, request.Title, request.Description, request.ReleaseYear, request.PictureIntentId),
|
||||
cancellationToken);
|
||||
private static async Task<IResult> GetAlbumTracks(
|
||||
IMediator mediator,
|
||||
Guid id,
|
||||
[AsParameters] PageQuery page,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetAlbumTracksQuery(id, page.PageNumber, page.PageSize), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
return result.ToCreatedResult(album => $"/albums/{album.Id}");
|
||||
}
|
||||
private static Task<IResult> GetAlbumCover(
|
||||
IMediator mediator,
|
||||
IStorageService storageService,
|
||||
HttpResponse response,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken,
|
||||
string size = "medium")
|
||||
{
|
||||
return CoverEndpoint.Stream(mediator, storageService, response, CoverOwner.Album, id, size, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateAlbum(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
Guid id,
|
||||
UpdateAlbumRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(
|
||||
new UpdateAlbumCommand(currentUser.RequiredId, id, request.Title, request.Description, request.ReleaseYear, request.PictureIntentId),
|
||||
cancellationToken);
|
||||
private static async Task<IResult> RequestAlbumPictureUpload(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
PictureUploadRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(
|
||||
new RequestAlbumPictureUploadCommand(currentUser.RequiredId, request.FileType, request.ContentType, request.ExpectedSizeBytes),
|
||||
cancellationToken);
|
||||
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteAlbum(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new DeleteAlbumCommand(currentUser.RequiredId, id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
private static async Task<IResult> CreateAlbum(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
CreateAlbumRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(
|
||||
new CreateAlbumCommand(currentUser.RequiredId, request.Title, request.Description, request.ReleaseYear, request.PictureIntentId),
|
||||
cancellationToken);
|
||||
|
||||
private static async Task<IResult> AddAlbumTracks(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
Guid id,
|
||||
AddTracksRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(
|
||||
new AddTracksToAlbumCommand(currentUser.RequiredId, id, request.TrackIds),
|
||||
cancellationToken);
|
||||
return result.ToCreatedResult(album => $"/albums/{album.Id}");
|
||||
}
|
||||
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
private static async Task<IResult> UpdateAlbum(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
Guid id,
|
||||
UpdateAlbumRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(
|
||||
new UpdateAlbumCommand(currentUser.RequiredId, id, request.Title, request.Description, request.ReleaseYear, request.PictureIntentId),
|
||||
cancellationToken);
|
||||
|
||||
private static async Task<IResult> RemoveTrackFromAlbum(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
Guid id,
|
||||
Guid trackId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(
|
||||
new RemoveTrackFromAlbumCommand(currentUser.RequiredId, id, trackId),
|
||||
cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
return result.ToHttpResult();
|
||||
private static async Task<IResult> DeleteAlbum(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new DeleteAlbumCommand(currentUser.RequiredId, id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> AddAlbumTracks(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
Guid id,
|
||||
AddTracksRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(
|
||||
new AddTracksToAlbumCommand(currentUser.RequiredId, id, request.TrackIds),
|
||||
cancellationToken);
|
||||
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> RemoveTrackFromAlbum(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
Guid id,
|
||||
Guid trackId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(
|
||||
new RemoveTrackFromAlbumCommand(currentUser.RequiredId, id, trackId),
|
||||
cancellationToken);
|
||||
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,28 +2,29 @@ using Mediator;
|
||||
using Musify.Application.Configuration;
|
||||
using Musify.Application.Configuration.Responses;
|
||||
|
||||
namespace Musify.Api.Endpoints;
|
||||
|
||||
public static class ConfigEndpoints
|
||||
namespace Musify.Api.Endpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapConfigEndpoints(this IEndpointRouteBuilder app)
|
||||
public static class ConfigEndpoints
|
||||
{
|
||||
var group = app.MapGroup("/config")
|
||||
.WithTags("Config");
|
||||
public static IEndpointRouteBuilder MapConfigEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/config")
|
||||
.WithTags("Config");
|
||||
|
||||
group.MapGet("/playback", GetPlaybackPublicConfig)
|
||||
.WithName("GetPlaybackPublicConfig")
|
||||
.WithSummary("Get the public playback configuration (whether anonymous listening is allowed). Works anonymously")
|
||||
.Produces<PlaybackPublicConfigResponse>();
|
||||
group.MapGet("/playback", GetPlaybackPublicConfig)
|
||||
.WithName("GetPlaybackPublicConfig")
|
||||
.WithSummary("Get the public playback configuration (whether anonymous listening is allowed). Works anonymously")
|
||||
.Produces<PlaybackPublicConfigResponse>();
|
||||
|
||||
return app;
|
||||
}
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetPlaybackPublicConfig(
|
||||
IMediator mediator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetPlaybackPublicConfigQuery(), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
private static async Task<IResult> GetPlaybackPublicConfig(
|
||||
IMediator mediator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetPlaybackPublicConfigQuery(), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,29 +3,30 @@ using Musify.Application.Configuration;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Pictures;
|
||||
|
||||
namespace Musify.Api.Endpoints;
|
||||
|
||||
internal static class CoverEndpoint
|
||||
namespace Musify.Api.Endpoints
|
||||
{
|
||||
public static async Task<IResult> Stream(
|
||||
IMediator mediator,
|
||||
IStorageService storageService,
|
||||
HttpResponse response,
|
||||
CoverOwner owner,
|
||||
Guid id,
|
||||
string size,
|
||||
CancellationToken cancellationToken)
|
||||
internal static class CoverEndpoint
|
||||
{
|
||||
var result = await mediator.Send(new GetCoverQuery(owner, id, PictureSizeParser.Parse(size)), cancellationToken);
|
||||
if (result.IsError)
|
||||
return Results.NotFound();
|
||||
public static async Task<IResult> Stream(
|
||||
IMediator mediator,
|
||||
IStorageService storageService,
|
||||
HttpResponse response,
|
||||
CoverOwner owner,
|
||||
Guid id,
|
||||
string size,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetCoverQuery(owner, id, PictureSizeParser.Parse(size)), cancellationToken);
|
||||
if (result.IsError)
|
||||
return Results.NotFound();
|
||||
|
||||
var location = result.Value;
|
||||
var stream = await storageService.GetFileAsync(location.Bucket, location.Key, cancellationToken);
|
||||
if (stream == null)
|
||||
return Results.NotFound();
|
||||
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);
|
||||
response.Headers.CacheControl = "public, max-age=31536000, immutable";
|
||||
return Results.Stream(stream, location.ContentType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,41 +2,42 @@ using Mediator;
|
||||
using Musify.Application.Genres;
|
||||
using Musify.Application.Genres.Responses;
|
||||
|
||||
namespace Musify.Api.Endpoints;
|
||||
|
||||
public static class GenreEndpoints
|
||||
namespace Musify.Api.Endpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapGenreEndpoints(this IEndpointRouteBuilder app)
|
||||
public static class GenreEndpoints
|
||||
{
|
||||
var group = app.MapGroup("/genres")
|
||||
.WithTags("Genres");
|
||||
public static IEndpointRouteBuilder MapGenreEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/genres")
|
||||
.WithTags("Genres");
|
||||
|
||||
group.MapGet("/", GetGenres)
|
||||
.WithName("GetGenres")
|
||||
.WithSummary("Get the genres that have at least one track, most populated first. Works anonymously")
|
||||
.Produces<IReadOnlyList<GenreResponse>>();
|
||||
group.MapGet("/", GetGenres)
|
||||
.WithName("GetGenres")
|
||||
.WithSummary("Get the genres that have at least one track, most populated first. Works anonymously")
|
||||
.Produces<IReadOnlyList<GenreResponse>>();
|
||||
|
||||
group.MapGet("/available", GetAvailableGenres)
|
||||
.WithName("GetAvailableGenres")
|
||||
.WithSummary("Get every genre a track can be tagged with, including the genres it cannot be combined with. Works anonymously")
|
||||
.Produces<IReadOnlyList<AvailableGenreResponse>>();
|
||||
group.MapGet("/available", GetAvailableGenres)
|
||||
.WithName("GetAvailableGenres")
|
||||
.WithSummary("Get every genre a track can be tagged with, including the genres it cannot be combined with. Works anonymously")
|
||||
.Produces<IReadOnlyList<AvailableGenreResponse>>();
|
||||
|
||||
return app;
|
||||
}
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetAvailableGenres(
|
||||
IMediator mediator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetAvailableGenresQuery(), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
private static async Task<IResult> GetAvailableGenres(
|
||||
IMediator mediator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetAvailableGenresQuery(), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetGenres(
|
||||
IMediator mediator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetGenresQuery(), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
private static async Task<IResult> GetGenres(
|
||||
IMediator mediator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetGenresQuery(), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,53 +8,54 @@ using Musify.Application.Likes;
|
||||
using Musify.Application.Shared;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
|
||||
namespace Musify.Api.Endpoints;
|
||||
|
||||
public static class LikeEndpoints
|
||||
namespace Musify.Api.Endpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapLikeEndpoints(this IEndpointRouteBuilder app)
|
||||
public static class LikeEndpoints
|
||||
{
|
||||
var group = app.MapGroup("/likes")
|
||||
.WithTags("Likes")
|
||||
.RequireAuthorization();
|
||||
public static IEndpointRouteBuilder MapLikeEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/likes")
|
||||
.WithTags("Likes")
|
||||
.RequireAuthorization();
|
||||
|
||||
group.MapGet("/", GetLikedTracks)
|
||||
.AddEndpointFilter<ValidationFilter<PageQuery>>()
|
||||
.WithName("GetLikedTracks")
|
||||
.WithSummary("Get the current user's liked tracks")
|
||||
.Produces<PaginatedResponse<TrackApplicationResponse>>()
|
||||
.Produces(StatusCodes.Status401Unauthorized);
|
||||
group.MapGet("/", GetLikedTracks)
|
||||
.AddEndpointFilter<ValidationFilter<PageQuery>>()
|
||||
.WithName("GetLikedTracks")
|
||||
.WithSummary("Get the current user's liked tracks")
|
||||
.Produces<PaginatedResponse<TrackApplicationResponse>>()
|
||||
.Produces(StatusCodes.Status401Unauthorized);
|
||||
|
||||
group.MapPost("/toggle", ToggleTrackLike)
|
||||
.WithName("ToggleTrackLike")
|
||||
.WithSummary("Like or unlike a track by id")
|
||||
.Produces<bool>()
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
group.MapPost("/toggle", ToggleTrackLike)
|
||||
.WithName("ToggleTrackLike")
|
||||
.WithSummary("Like or unlike a track by id")
|
||||
.Produces<bool>()
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
return app;
|
||||
}
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetLikedTracks(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
[AsParameters] PageQuery page,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetLikedTracksQuery(currentUser.RequiredId, page.PageNumber, page.PageSize), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
private static async Task<IResult> GetLikedTracks(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
[AsParameters] PageQuery page,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetLikedTracksQuery(currentUser.RequiredId, page.PageNumber, page.PageSize), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ToggleTrackLike(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
ToggleLikeRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(
|
||||
new ToggleTrackLikeCommand(currentUser.RequiredId, request.TrackId),
|
||||
cancellationToken);
|
||||
private static async Task<IResult> ToggleTrackLike(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
ToggleLikeRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(
|
||||
new ToggleTrackLikeCommand(currentUser.RequiredId, request.TrackId),
|
||||
cancellationToken);
|
||||
|
||||
return result.ToHttpResult();
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,48 +4,49 @@ using Musify.Api.Extensions;
|
||||
using Musify.Application.Mixes;
|
||||
using Musify.Application.Mixes.Responses;
|
||||
|
||||
namespace Musify.Api.Endpoints;
|
||||
|
||||
public static class MixEndpoints
|
||||
namespace Musify.Api.Endpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapMixEndpoints(this IEndpointRouteBuilder app)
|
||||
public static class MixEndpoints
|
||||
{
|
||||
var group = app.MapGroup("/mixes")
|
||||
.WithTags("Mixes")
|
||||
.RequireAuthorization();
|
||||
public static IEndpointRouteBuilder MapMixEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/mixes")
|
||||
.WithTags("Mixes")
|
||||
.RequireAuthorization();
|
||||
|
||||
group.MapGet("/", GetMixes)
|
||||
.WithName("GetMixes")
|
||||
.WithSummary("Get the server-generated mixes of the current user")
|
||||
.Produces<IReadOnlyList<MixApplicationResponse>>()
|
||||
.Produces(StatusCodes.Status401Unauthorized);
|
||||
group.MapGet("/", GetMixes)
|
||||
.WithName("GetMixes")
|
||||
.WithSummary("Get the server-generated mixes of the current user")
|
||||
.Produces<IReadOnlyList<MixApplicationResponse>>()
|
||||
.Produces(StatusCodes.Status401Unauthorized);
|
||||
|
||||
group.MapGet("/{id:guid}", GetMixById)
|
||||
.WithName("GetMixById")
|
||||
.WithSummary("Get a mix with all of its songs")
|
||||
.Produces<MixApplicationResponse>()
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
group.MapGet("/{id:guid}", GetMixById)
|
||||
.WithName("GetMixById")
|
||||
.WithSummary("Get a mix with all of its songs")
|
||||
.Produces<MixApplicationResponse>()
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
return app;
|
||||
}
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetMixes(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetMixesByUserIdQuery(currentUser.RequiredId), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
private static async Task<IResult> GetMixes(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetMixesByUserIdQuery(currentUser.RequiredId), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetMixById(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetMixByIdQuery(currentUser.RequiredId, id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
private static async Task<IResult> GetMixById(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetMixByIdQuery(currentUser.RequiredId, id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,223 +13,224 @@ using Musify.Application.PlayLists.Responses;
|
||||
using Musify.Application.Shared;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
|
||||
namespace Musify.Api.Endpoints;
|
||||
|
||||
public static class PlayListEndpoints
|
||||
namespace Musify.Api.Endpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapPlayListEndpoints(this IEndpointRouteBuilder app)
|
||||
public static class PlayListEndpoints
|
||||
{
|
||||
var group = app.MapGroup("/playlists")
|
||||
.WithTags("PlayLists");
|
||||
public static IEndpointRouteBuilder MapPlayListEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/playlists")
|
||||
.WithTags("PlayLists");
|
||||
|
||||
group.MapGet("/{id:guid}", GetPlayListById)
|
||||
.WithName("GetPlayListById")
|
||||
.WithSummary("Get a playlist by id")
|
||||
.Produces<PlayListApplicationResponse>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
group.MapGet("/{id:guid}", GetPlayListById)
|
||||
.WithName("GetPlayListById")
|
||||
.WithSummary("Get a playlist by id")
|
||||
.Produces<PlayListApplicationResponse>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/users/{userId:long}", 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("/users/{userId:long}", 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("/{id:guid}/tracks", GetPlayListTracks)
|
||||
.AddEndpointFilter<ValidationFilter<PageQuery>>()
|
||||
.WithName("GetPlayListTracks")
|
||||
.WithSummary("Get paginated tracks of a playlist")
|
||||
.Produces<PaginatedResponse<TrackApplicationResponse>>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
group.MapGet("/{id:guid}/tracks", GetPlayListTracks)
|
||||
.AddEndpointFilter<ValidationFilter<PageQuery>>()
|
||||
.WithName("GetPlayListTracks")
|
||||
.WithSummary("Get paginated tracks of a playlist")
|
||||
.Produces<PaginatedResponse<TrackApplicationResponse>>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/{id:guid}/cover", GetPlayListCover)
|
||||
.WithName("GetPlayListCover")
|
||||
.WithSummary("Get a playlist cover image")
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
group.MapGet("/{id:guid}/cover", GetPlayListCover)
|
||||
.WithName("GetPlayListCover")
|
||||
.WithSummary("Get a playlist cover image")
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/", CreatePlayList)
|
||||
.WithName("CreatePlayList")
|
||||
.WithSummary("Create a new playlist")
|
||||
.AddEndpointFilter<ValidationFilter<CreatePlayListRequest>>()
|
||||
.RequireAuthorization()
|
||||
.Produces<PlayListApplicationResponse>(StatusCodes.Status201Created)
|
||||
.ProducesValidationProblem()
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
group.MapPost("/", CreatePlayList)
|
||||
.WithName("CreatePlayList")
|
||||
.WithSummary("Create a new playlist")
|
||||
.AddEndpointFilter<ValidationFilter<CreatePlayListRequest>>()
|
||||
.RequireAuthorization()
|
||||
.Produces<PlayListApplicationResponse>(StatusCodes.Status201Created)
|
||||
.ProducesValidationProblem()
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPut("/{id:guid}", UpdatePlayList)
|
||||
.WithName("UpdatePlayList")
|
||||
.WithSummary("Update an existing playlist")
|
||||
.AddEndpointFilter<ValidationFilter<UpdatePlayListRequest>>()
|
||||
.RequireAuthorization()
|
||||
.Produces<PlayListApplicationResponse>()
|
||||
.ProducesValidationProblem()
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
group.MapPut("/{id:guid}", UpdatePlayList)
|
||||
.WithName("UpdatePlayList")
|
||||
.WithSummary("Update an existing playlist")
|
||||
.AddEndpointFilter<ValidationFilter<UpdatePlayListRequest>>()
|
||||
.RequireAuthorization()
|
||||
.Produces<PlayListApplicationResponse>()
|
||||
.ProducesValidationProblem()
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapDelete("/{id:guid}", DeletePlayList)
|
||||
.WithName("DeletePlayList")
|
||||
.WithSummary("Delete a playlist")
|
||||
.RequireAuthorization()
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
group.MapDelete("/{id:guid}", DeletePlayList)
|
||||
.WithName("DeletePlayList")
|
||||
.WithSummary("Delete a playlist")
|
||||
.RequireAuthorization()
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/upload-picture", RequestPlayListPictureUpload)
|
||||
.WithName("RequestPlayListPictureUpload")
|
||||
.WithSummary("Request a pre-signed URL to upload a playlist picture")
|
||||
.AddEndpointFilter<ValidationFilter<PictureUploadRequest>>()
|
||||
.RequireAuthorization()
|
||||
.Produces<PictureUploadResponse>()
|
||||
.ProducesValidationProblem()
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
group.MapPost("/upload-picture", RequestPlayListPictureUpload)
|
||||
.WithName("RequestPlayListPictureUpload")
|
||||
.WithSummary("Request a pre-signed URL to upload a playlist picture")
|
||||
.AddEndpointFilter<ValidationFilter<PictureUploadRequest>>()
|
||||
.RequireAuthorization()
|
||||
.Produces<PictureUploadResponse>()
|
||||
.ProducesValidationProblem()
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/{id:guid}/tracks", AddPlayListTracks)
|
||||
.WithName("AddPlayListTracks")
|
||||
.WithSummary("Add tracks to a playlist, skipping the ones it already has")
|
||||
.AddEndpointFilter<ValidationFilter<AddTracksRequest>>()
|
||||
.RequireAuthorization()
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.ProducesValidationProblem()
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status409Conflict);
|
||||
group.MapPost("/{id:guid}/tracks", AddPlayListTracks)
|
||||
.WithName("AddPlayListTracks")
|
||||
.WithSummary("Add tracks to a playlist, skipping the ones it already has")
|
||||
.AddEndpointFilter<ValidationFilter<AddTracksRequest>>()
|
||||
.RequireAuthorization()
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.ProducesValidationProblem()
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status409Conflict);
|
||||
|
||||
group.MapDelete("/{id:guid}/tracks/{trackId:guid}", RemoveTrackFromPlayList)
|
||||
.WithName("RemoveTrackFromPlayList")
|
||||
.WithSummary("Remove a track from a playlist")
|
||||
.RequireAuthorization()
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
group.MapDelete("/{id:guid}/tracks/{trackId:guid}", RemoveTrackFromPlayList)
|
||||
.WithName("RemoveTrackFromPlayList")
|
||||
.WithSummary("Remove a track from a playlist")
|
||||
.RequireAuthorization()
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
return app;
|
||||
}
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetPlayListById(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetPlayListByIdQuery(id, currentUser.Id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
private static async Task<IResult> GetPlayListById(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetPlayListByIdQuery(id, currentUser.Id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetPlayListsByUserId(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
long userId,
|
||||
string? name,
|
||||
[AsParameters] PageQuery page,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetPlayListsByUserIdQuery(userId, name, page.PageNumber, page.PageSize, currentUser.Id), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
private static async Task<IResult> GetPlayListsByUserId(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
long userId,
|
||||
string? name,
|
||||
[AsParameters] PageQuery page,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetPlayListsByUserIdQuery(userId, name, page.PageNumber, page.PageSize, currentUser.Id), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetPlayListTracks(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
Guid id,
|
||||
[AsParameters] PageQuery page,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetPlayListTracksQuery(id, page.PageNumber, page.PageSize, currentUser.Id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
private static async Task<IResult> GetPlayListTracks(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
Guid id,
|
||||
[AsParameters] PageQuery page,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetPlayListTracksQuery(id, page.PageNumber, page.PageSize, currentUser.Id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static Task<IResult> GetPlayListCover(
|
||||
IMediator mediator,
|
||||
IStorageService storageService,
|
||||
HttpResponse response,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken,
|
||||
string size = "medium")
|
||||
{
|
||||
return CoverEndpoint.Stream(mediator, storageService, response, CoverOwner.PlayList, id, size, cancellationToken);
|
||||
}
|
||||
private static Task<IResult> GetPlayListCover(
|
||||
IMediator mediator,
|
||||
IStorageService storageService,
|
||||
HttpResponse response,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken,
|
||||
string size = "medium")
|
||||
{
|
||||
return CoverEndpoint.Stream(mediator, storageService, response, CoverOwner.PlayList, id, size, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreatePlayList(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
CreatePlayListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(
|
||||
new CreatePlayListCommand(currentUser.RequiredId, request.Name, request.Description, request.PictureIntentId, request.Visibility),
|
||||
cancellationToken);
|
||||
private static async Task<IResult> CreatePlayList(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
CreatePlayListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(
|
||||
new CreatePlayListCommand(currentUser.RequiredId, request.Name, request.Description, request.PictureIntentId, request.Visibility),
|
||||
cancellationToken);
|
||||
|
||||
return result.ToCreatedResult(playList => $"/playlists/{playList.Id}");
|
||||
}
|
||||
return result.ToCreatedResult(playList => $"/playlists/{playList.Id}");
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdatePlayList(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
Guid id,
|
||||
UpdatePlayListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(
|
||||
new UpdatePlayListCommand(currentUser.RequiredId, id, request.Name, request.Description, request.PictureIntentId, request.Visibility),
|
||||
cancellationToken);
|
||||
private static async Task<IResult> UpdatePlayList(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
Guid id,
|
||||
UpdatePlayListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(
|
||||
new UpdatePlayListCommand(currentUser.RequiredId, id, request.Name, request.Description, request.PictureIntentId, request.Visibility),
|
||||
cancellationToken);
|
||||
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeletePlayList(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new DeletePlayListCommand(currentUser.RequiredId, id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
private static async Task<IResult> DeletePlayList(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new DeletePlayListCommand(currentUser.RequiredId, id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> RequestPlayListPictureUpload(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
PictureUploadRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(
|
||||
new RequestPlayListPictureUploadCommand(currentUser.RequiredId, request.FileType, request.ContentType, request.ExpectedSizeBytes),
|
||||
cancellationToken);
|
||||
private static async Task<IResult> RequestPlayListPictureUpload(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
PictureUploadRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(
|
||||
new RequestPlayListPictureUploadCommand(currentUser.RequiredId, request.FileType, request.ContentType, request.ExpectedSizeBytes),
|
||||
cancellationToken);
|
||||
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> AddPlayListTracks(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
Guid id,
|
||||
AddTracksRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(
|
||||
new AddTracksToPlayListCommand(currentUser.RequiredId, id, request.TrackIds),
|
||||
cancellationToken);
|
||||
private static async Task<IResult> AddPlayListTracks(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
Guid id,
|
||||
AddTracksRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(
|
||||
new AddTracksToPlayListCommand(currentUser.RequiredId, id, request.TrackIds),
|
||||
cancellationToken);
|
||||
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> RemoveTrackFromPlayList(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
Guid id,
|
||||
Guid trackId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(
|
||||
new RemoveTrackFromPlayListCommand(currentUser.RequiredId, id, trackId),
|
||||
cancellationToken);
|
||||
private static async Task<IResult> RemoveTrackFromPlayList(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
Guid id,
|
||||
Guid trackId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(
|
||||
new RemoveTrackFromPlayListCommand(currentUser.RequiredId, id, trackId),
|
||||
cancellationToken);
|
||||
|
||||
return result.ToHttpResult();
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,196 +10,197 @@ using Musify.Application.Shared;
|
||||
using Musify.Application.Tracks;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
|
||||
namespace Musify.Api.Endpoints;
|
||||
|
||||
public static class TrackEndpoints
|
||||
namespace Musify.Api.Endpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapTrackEndpoints(this IEndpointRouteBuilder app)
|
||||
public static class TrackEndpoints
|
||||
{
|
||||
var group = app.MapGroup("/tracks")
|
||||
.WithTags("Tracks");
|
||||
public static IEndpointRouteBuilder MapTrackEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/tracks")
|
||||
.WithTags("Tracks");
|
||||
|
||||
group.MapGet("/", GetTracks)
|
||||
.AddEndpointFilter<ValidationFilter<PageQuery>>()
|
||||
.WithName("GetTracks")
|
||||
.WithSummary("Get paginated tracks, optionally filtered by name and genre")
|
||||
.Produces<PaginatedResponse<TrackApplicationResponse>>()
|
||||
.ProducesValidationProblem();
|
||||
group.MapGet("/", GetTracks)
|
||||
.AddEndpointFilter<ValidationFilter<PageQuery>>()
|
||||
.WithName("GetTracks")
|
||||
.WithSummary("Get paginated tracks, optionally filtered by name and genre")
|
||||
.Produces<PaginatedResponse<TrackApplicationResponse>>()
|
||||
.ProducesValidationProblem();
|
||||
|
||||
group.MapGet("/{id:guid}", GetTrackById)
|
||||
.WithName("GetTrackById")
|
||||
.WithSummary("Get a track by id")
|
||||
.Produces<TrackApplicationResponse>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
group.MapGet("/{id:guid}", GetTrackById)
|
||||
.WithName("GetTrackById")
|
||||
.WithSummary("Get a track by id")
|
||||
.Produces<TrackApplicationResponse>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/{id:guid}/cover", GetTrackCover)
|
||||
.WithName("GetTrackCover")
|
||||
.WithSummary("Get a track cover image")
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
group.MapGet("/{id:guid}/cover", GetTrackCover)
|
||||
.WithName("GetTrackCover")
|
||||
.WithSummary("Get a track cover image")
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/{id:guid}/stream", GetTrackStream)
|
||||
.WithName("GetTrackStream")
|
||||
.WithSummary("Get a streaming manifest URL and ticket for a track. Works anonymously when the playback configuration allows it")
|
||||
.Produces<TrackStreamResponse>()
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
group.MapGet("/{id:guid}/stream", GetTrackStream)
|
||||
.WithName("GetTrackStream")
|
||||
.WithSummary("Get a streaming manifest URL and ticket for a track. Works anonymously when the playback configuration allows it")
|
||||
.Produces<TrackStreamResponse>()
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPut("/listens/{listenId:guid}/progress", RecordListeningProgress)
|
||||
.AddEndpointFilter<ValidationFilter<RecordListeningProgressRequest>>()
|
||||
.WithName("RecordListeningProgress")
|
||||
.WithSummary("Report the total seconds actually played for a listen started by the stream endpoint")
|
||||
.RequireAuthorization()
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
group.MapPut("/listens/{listenId:guid}/progress", RecordListeningProgress)
|
||||
.AddEndpointFilter<ValidationFilter<RecordListeningProgressRequest>>()
|
||||
.WithName("RecordListeningProgress")
|
||||
.WithSummary("Report the total seconds actually played for a listen started by the stream endpoint")
|
||||
.RequireAuthorization()
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/users/{userId:long}", GetTracksByUserId)
|
||||
.AddEndpointFilter<ValidationFilter<PageQuery>>()
|
||||
.WithName("GetTracksByUserId")
|
||||
.WithSummary("Get paginated tracks for a user")
|
||||
.Produces<PaginatedResponse<TrackApplicationResponse>>();
|
||||
group.MapGet("/users/{userId:long}", GetTracksByUserId)
|
||||
.AddEndpointFilter<ValidationFilter<PageQuery>>()
|
||||
.WithName("GetTracksByUserId")
|
||||
.WithSummary("Get paginated tracks for a user")
|
||||
.Produces<PaginatedResponse<TrackApplicationResponse>>();
|
||||
|
||||
group.MapPost("/", CreateTrack)
|
||||
.WithName("CreateTrack")
|
||||
.WithSummary("Create a new track")
|
||||
.AddEndpointFilter<ValidationFilter<CreateTrackRequest>>()
|
||||
.RequireAuthorization()
|
||||
.Produces<TrackApplicationResponse>(StatusCodes.Status201Created)
|
||||
.ProducesValidationProblem()
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status409Conflict);
|
||||
group.MapPost("/", CreateTrack)
|
||||
.WithName("CreateTrack")
|
||||
.WithSummary("Create a new track")
|
||||
.AddEndpointFilter<ValidationFilter<CreateTrackRequest>>()
|
||||
.RequireAuthorization()
|
||||
.Produces<TrackApplicationResponse>(StatusCodes.Status201Created)
|
||||
.ProducesValidationProblem()
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status409Conflict);
|
||||
|
||||
group.MapDelete("/{id:guid}", DeleteTrack)
|
||||
.WithName("DeleteTrack")
|
||||
.WithSummary("Delete a track")
|
||||
.RequireAuthorization()
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status409Conflict);
|
||||
group.MapDelete("/{id:guid}", DeleteTrack)
|
||||
.WithName("DeleteTrack")
|
||||
.WithSummary("Delete a track")
|
||||
.RequireAuthorization()
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status409Conflict);
|
||||
|
||||
group.MapPost("/upload-urls", RequestTrackUploadUrls)
|
||||
.WithName("RequestTrackUploadUrls")
|
||||
.WithSummary("Request pre-signed URLs to upload track picture and audio")
|
||||
.AddEndpointFilter<ValidationFilter<RequestTrackUploadUrlsRequest>>()
|
||||
.RequireAuthorization()
|
||||
.Produces<TrackUploadUrlsResponse>()
|
||||
.ProducesValidationProblem()
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status409Conflict);
|
||||
group.MapPost("/upload-urls", RequestTrackUploadUrls)
|
||||
.WithName("RequestTrackUploadUrls")
|
||||
.WithSummary("Request pre-signed URLs to upload track picture and audio")
|
||||
.AddEndpointFilter<ValidationFilter<RequestTrackUploadUrlsRequest>>()
|
||||
.RequireAuthorization()
|
||||
.Produces<TrackUploadUrlsResponse>()
|
||||
.ProducesValidationProblem()
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status409Conflict);
|
||||
|
||||
return app;
|
||||
}
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetTracks(
|
||||
IMediator mediator,
|
||||
string? name,
|
||||
GenreParameter? genre,
|
||||
[AsParameters] PageQuery page,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetTracksQuery(name, page.PageNumber, page.PageSize, genre?.Value), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
private static async Task<IResult> GetTracks(
|
||||
IMediator mediator,
|
||||
string? name,
|
||||
GenreParameter? genre,
|
||||
[AsParameters] PageQuery page,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetTracksQuery(name, page.PageNumber, page.PageSize, genre?.Value), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetTrackById(
|
||||
IMediator mediator,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetTrackByIdQuery(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
private static async Task<IResult> GetTrackById(
|
||||
IMediator mediator,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetTrackByIdQuery(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static Task<IResult> GetTrackCover(
|
||||
IMediator mediator,
|
||||
IStorageService storageService,
|
||||
HttpResponse response,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken,
|
||||
string size = "medium")
|
||||
{
|
||||
return CoverEndpoint.Stream(mediator, storageService, response, CoverOwner.Track, id, size, cancellationToken);
|
||||
}
|
||||
private static Task<IResult> GetTrackCover(
|
||||
IMediator mediator,
|
||||
IStorageService storageService,
|
||||
HttpResponse response,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken,
|
||||
string size = "medium")
|
||||
{
|
||||
return CoverEndpoint.Stream(mediator, storageService, response, CoverOwner.Track, id, size, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetTrackStream(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new StartListeningCommand(id, currentUser.Id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
private static async Task<IResult> GetTrackStream(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new StartListeningCommand(id, currentUser.Id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> RecordListeningProgress(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
Guid listenId,
|
||||
RecordListeningProgressRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(
|
||||
new RecordListeningProgressCommand(currentUser.RequiredId, listenId, request.PlayedSeconds),
|
||||
cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
private static async Task<IResult> RecordListeningProgress(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
Guid listenId,
|
||||
RecordListeningProgressRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(
|
||||
new RecordListeningProgressCommand(currentUser.RequiredId, listenId, request.PlayedSeconds),
|
||||
cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetTracksByUserId(
|
||||
IMediator mediator,
|
||||
long userId,
|
||||
string? name,
|
||||
[AsParameters] PageQuery page,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetTracksByUserIdQuery(userId, name, page.PageNumber, page.PageSize), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
private static async Task<IResult> GetTracksByUserId(
|
||||
IMediator mediator,
|
||||
long userId,
|
||||
string? name,
|
||||
[AsParameters] PageQuery page,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetTracksByUserIdQuery(userId, name, page.PageNumber, page.PageSize), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateTrack(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
CreateTrackRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(
|
||||
new CreateTrackCommand(currentUser.RequiredId, request.Title, request.PictureIntentId, request.AudioIntentId, request.Tags, request.IsExplicit),
|
||||
cancellationToken);
|
||||
private static async Task<IResult> CreateTrack(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
CreateTrackRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(
|
||||
new CreateTrackCommand(currentUser.RequiredId, request.Title, request.PictureIntentId, request.AudioIntentId, request.Tags, request.IsExplicit),
|
||||
cancellationToken);
|
||||
|
||||
return result.ToCreatedResult(track => $"/tracks/{track.Id}");
|
||||
}
|
||||
return result.ToCreatedResult(track => $"/tracks/{track.Id}");
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteTrack(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new DeleteTrackCommand(currentUser.RequiredId, id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
private static async Task<IResult> DeleteTrack(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new DeleteTrackCommand(currentUser.RequiredId, id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> RequestTrackUploadUrls(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
RequestTrackUploadUrlsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(
|
||||
new RequestTrackUploadUrlsCommand(
|
||||
currentUser.RequiredId,
|
||||
request.PictureFileType,
|
||||
request.PictureContentType,
|
||||
request.AudioFileType,
|
||||
request.AudioContentType,
|
||||
request.ExpectedPictureSizeBytes,
|
||||
request.ExpectedAudioSizeBytes),
|
||||
cancellationToken);
|
||||
private static async Task<IResult> RequestTrackUploadUrls(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
RequestTrackUploadUrlsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(
|
||||
new RequestTrackUploadUrlsCommand(
|
||||
currentUser.RequiredId,
|
||||
request.PictureFileType,
|
||||
request.PictureContentType,
|
||||
request.AudioFileType,
|
||||
request.AudioContentType,
|
||||
request.ExpectedPictureSizeBytes,
|
||||
request.ExpectedAudioSizeBytes),
|
||||
cancellationToken);
|
||||
|
||||
return result.ToHttpResult();
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,204 +9,205 @@ using Musify.Application.Tracks.Responses;
|
||||
using Musify.Application.Users;
|
||||
using Musify.Application.Users.Responses;
|
||||
|
||||
namespace Musify.Api.Endpoints;
|
||||
|
||||
public static class UserEndpoints
|
||||
namespace Musify.Api.Endpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder app)
|
||||
public static class UserEndpoints
|
||||
{
|
||||
var group = app.MapGroup("/users")
|
||||
.WithTags("Users");
|
||||
public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/users")
|
||||
.WithTags("Users");
|
||||
|
||||
group.MapGet("/", GetUsers)
|
||||
.AddEndpointFilter<ValidationFilter<PageQuery>>()
|
||||
.WithName("GetUsers")
|
||||
.WithSummary("Get paginated users")
|
||||
.Produces<PaginatedResponse<UserSummaryResponse>>();
|
||||
group.MapGet("/", GetUsers)
|
||||
.AddEndpointFilter<ValidationFilter<PageQuery>>()
|
||||
.WithName("GetUsers")
|
||||
.WithSummary("Get paginated users")
|
||||
.Produces<PaginatedResponse<UserSummaryResponse>>();
|
||||
|
||||
group.MapGet("/{id:long}", GetUserById)
|
||||
.WithName("GetUserById")
|
||||
.WithSummary("Get a user by id")
|
||||
.Produces<UserApplicationResponse>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
group.MapGet("/{id:long}", GetUserById)
|
||||
.WithName("GetUserById")
|
||||
.WithSummary("Get a user by id")
|
||||
.Produces<UserApplicationResponse>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/{id:long}/listening-history", GetListeningHistory)
|
||||
.WithName("GetListeningHistory")
|
||||
.WithSummary("Get a user's listening history")
|
||||
.Produces<IEnumerable<TrackApplicationResponse>>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
group.MapGet("/{id:long}/listening-history", GetListeningHistory)
|
||||
.WithName("GetListeningHistory")
|
||||
.WithSummary("Get a user's listening history")
|
||||
.Produces<IEnumerable<TrackApplicationResponse>>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/{id:long}/listening-stats", GetListeningStats)
|
||||
.WithName("GetListeningStats")
|
||||
.WithSummary("Get a user's listening stats for the current week and streak")
|
||||
.RequireAuthorization()
|
||||
.Produces<ListeningStatsResponse>()
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden);
|
||||
group.MapGet("/{id:long}/listening-stats", GetListeningStats)
|
||||
.WithName("GetListeningStats")
|
||||
.WithSummary("Get a user's listening stats for the current week and streak")
|
||||
.RequireAuthorization()
|
||||
.Produces<ListeningStatsResponse>()
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden);
|
||||
|
||||
group.MapGet("/{id:long}/last-listened-track", GetLastTrackListenedByUserId)
|
||||
.WithName("GetLastTrackListenedByUserId")
|
||||
.WithSummary("Get the last track a user listened to")
|
||||
.Produces<TrackApplicationResponse>()
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
group.MapGet("/{id:long}/last-listened-track", GetLastTrackListenedByUserId)
|
||||
.WithName("GetLastTrackListenedByUserId")
|
||||
.WithSummary("Get the last track a user listened to")
|
||||
.Produces<TrackApplicationResponse>()
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
group.MapGet("/{id:long}/profile", GetUserProfile)
|
||||
.WithName("GetUserProfile")
|
||||
.WithSummary("Get a user's public profile, including follower counts")
|
||||
.Produces<UserProfileResponse>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
group.MapGet("/{id:long}/profile", GetUserProfile)
|
||||
.WithName("GetUserProfile")
|
||||
.WithSummary("Get a user's public profile, including follower counts")
|
||||
.Produces<UserProfileResponse>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/{id:long}/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>>()
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
group.MapGet("/{id:long}/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>>()
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/{id:long}/following", GetFollowing)
|
||||
.AddEndpointFilter<ValidationFilter<PageQuery>>()
|
||||
.WithName("GetUserFollowing")
|
||||
.WithSummary("Get the users a user follows")
|
||||
.Produces<PaginatedResponse<UserSummaryResponse>>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
group.MapGet("/{id:long}/following", GetFollowing)
|
||||
.AddEndpointFilter<ValidationFilter<PageQuery>>()
|
||||
.WithName("GetUserFollowing")
|
||||
.WithSummary("Get the users a user follows")
|
||||
.Produces<PaginatedResponse<UserSummaryResponse>>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/{id:long}/is-following", IsFollowing)
|
||||
.WithName("IsFollowingUser")
|
||||
.WithSummary("Check whether the current user follows another user")
|
||||
.RequireAuthorization()
|
||||
.Produces<bool>()
|
||||
.Produces(StatusCodes.Status401Unauthorized);
|
||||
group.MapGet("/{id:long}/is-following", IsFollowing)
|
||||
.WithName("IsFollowingUser")
|
||||
.WithSummary("Check whether the current user follows another user")
|
||||
.RequireAuthorization()
|
||||
.Produces<bool>()
|
||||
.Produces(StatusCodes.Status401Unauthorized);
|
||||
|
||||
group.MapPost("/{id:long}/follow", FollowUser)
|
||||
.WithName("FollowUser")
|
||||
.WithSummary("Follow a user")
|
||||
.RequireAuthorization()
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.ProducesValidationProblem();
|
||||
group.MapPost("/{id:long}/follow", FollowUser)
|
||||
.WithName("FollowUser")
|
||||
.WithSummary("Follow a user")
|
||||
.RequireAuthorization()
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.ProducesValidationProblem();
|
||||
|
||||
group.MapDelete("/{id:long}/follow", UnfollowUser)
|
||||
.WithName("UnfollowUser")
|
||||
.WithSummary("Unfollow a user")
|
||||
.RequireAuthorization()
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status401Unauthorized);
|
||||
group.MapDelete("/{id:long}/follow", UnfollowUser)
|
||||
.WithName("UnfollowUser")
|
||||
.WithSummary("Unfollow a user")
|
||||
.RequireAuthorization()
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status401Unauthorized);
|
||||
|
||||
return app;
|
||||
}
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetUsers(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
string? usernameSearch,
|
||||
[AsParameters] PageQuery page,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetUsersQuery(page.PageNumber, page.PageSize, usernameSearch, currentUser.Id), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
private static async Task<IResult> GetUsers(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
string? usernameSearch,
|
||||
[AsParameters] PageQuery page,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetUsersQuery(page.PageNumber, page.PageSize, usernameSearch, currentUser.Id), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetUserById(
|
||||
IMediator mediator,
|
||||
long id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetUserByIdQuery(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
private static async Task<IResult> GetUserById(
|
||||
IMediator mediator,
|
||||
long id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetUserByIdQuery(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetListeningHistory(
|
||||
IMediator mediator,
|
||||
long id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetListeningHistoryQuery(id), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
private static async Task<IResult> GetListeningHistory(
|
||||
IMediator mediator,
|
||||
long id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetListeningHistoryQuery(id), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetListeningStats(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
long id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.RequiredId != id)
|
||||
return Results.Problem(statusCode: StatusCodes.Status403Forbidden);
|
||||
private static async Task<IResult> GetListeningStats(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
long id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.RequiredId != id)
|
||||
return Results.Problem(statusCode: StatusCodes.Status403Forbidden);
|
||||
|
||||
var result = await mediator.Send(new GetListeningStatsQuery(id), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
var result = await mediator.Send(new GetListeningStatsQuery(id), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetLastTrackListenedByUserId(
|
||||
IMediator mediator,
|
||||
long id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var track = await mediator.Send(new GetLastTrackListenedByUserIdQuery(id), cancellationToken);
|
||||
return track == null ? Results.NoContent() : Results.Ok(track);
|
||||
}
|
||||
private static async Task<IResult> GetLastTrackListenedByUserId(
|
||||
IMediator mediator,
|
||||
long id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var track = await mediator.Send(new GetLastTrackListenedByUserIdQuery(id), cancellationToken);
|
||||
return track == null ? Results.NoContent() : Results.Ok(track);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetUserProfile(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
long id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetUserProfileQuery(id, currentUser.Id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
private static async Task<IResult> GetUserProfile(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
long id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetUserProfileQuery(id, currentUser.Id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetFollowers(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
long id,
|
||||
[AsParameters] PageQuery page,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetUserFollowersQuery(currentUser.Id, id, page.PageNumber, page.PageSize), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
private static async Task<IResult> GetFollowers(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
long id,
|
||||
[AsParameters] PageQuery page,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetUserFollowersQuery(currentUser.Id, id, page.PageNumber, page.PageSize), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetFollowing(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
long id,
|
||||
[AsParameters] PageQuery page,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetUserFollowingQuery(currentUser.Id, id, page.PageNumber, page.PageSize), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
private static async Task<IResult> GetFollowing(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
long id,
|
||||
[AsParameters] PageQuery page,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetUserFollowingQuery(currentUser.Id, id, page.PageNumber, page.PageSize), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> IsFollowing(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
long id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new IsFollowingUserQuery(currentUser.RequiredId, id), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
private static async Task<IResult> IsFollowing(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
long id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new IsFollowingUserQuery(currentUser.RequiredId, id), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> FollowUser(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
long id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new FollowUserCommand(currentUser.RequiredId, id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
private static async Task<IResult> FollowUser(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
long id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new FollowUserCommand(currentUser.RequiredId, id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> UnfollowUser(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
long id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new UnfollowUserCommand(currentUser.RequiredId, id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
private static async Task<IResult> UnfollowUser(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
long id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new UnfollowUserCommand(currentUser.RequiredId, id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +1,44 @@
|
||||
using ErrorOr;
|
||||
using IResult = Microsoft.AspNetCore.Http.IResult;
|
||||
|
||||
namespace Musify.Api.Extensions;
|
||||
|
||||
public static class ErrorOrHttpExtensions
|
||||
namespace Musify.Api.Extensions
|
||||
{
|
||||
public static IResult ToHttpResult<T>(this ErrorOr<T> result)
|
||||
public static class ErrorOrHttpExtensions
|
||||
{
|
||||
if (result.IsError)
|
||||
return Problem(result.Errors);
|
||||
|
||||
return result.Value is Success
|
||||
? Results.NoContent()
|
||||
: Results.Ok(result.Value);
|
||||
}
|
||||
|
||||
public static IResult ToCreatedResult<T>(this ErrorOr<T> result, Func<T, string> locationFactory) =>
|
||||
result.IsError
|
||||
? Problem(result.Errors)
|
||||
: Results.Created(locationFactory(result.Value), result.Value);
|
||||
|
||||
private static IResult Problem(List<Error> errors)
|
||||
{
|
||||
if (errors.All(e => e.Type == ErrorType.Validation))
|
||||
return Results.ValidationProblem(ToValidationDictionary(errors));
|
||||
|
||||
var error = errors[0];
|
||||
return error.Type switch
|
||||
public static IResult ToHttpResult<T>(this ErrorOr<T> result)
|
||||
{
|
||||
ErrorType.NotFound => Results.Problem(error.Description, statusCode: StatusCodes.Status404NotFound),
|
||||
ErrorType.Unauthorized => Results.Unauthorized(),
|
||||
ErrorType.Forbidden => Results.Problem(error.Description, statusCode: StatusCodes.Status403Forbidden),
|
||||
ErrorType.Conflict => Results.Problem(error.Description, statusCode: StatusCodes.Status409Conflict),
|
||||
_ => Results.Problem(error.Description)
|
||||
};
|
||||
}
|
||||
if (result.IsError)
|
||||
return Problem(result.Errors);
|
||||
|
||||
private static Dictionary<string, string[]> ToValidationDictionary(IEnumerable<Error> errors) =>
|
||||
errors
|
||||
.GroupBy(e => string.IsNullOrEmpty(e.Code) ? "general" : e.Code)
|
||||
.ToDictionary(g => g.Key, g => g.Select(e => e.Description).ToArray());
|
||||
return result.Value is Success
|
||||
? Results.NoContent()
|
||||
: Results.Ok(result.Value);
|
||||
}
|
||||
|
||||
public static IResult ToCreatedResult<T>(this ErrorOr<T> result, Func<T, string> locationFactory) =>
|
||||
result.IsError
|
||||
? Problem(result.Errors)
|
||||
: Results.Created(locationFactory(result.Value), result.Value);
|
||||
|
||||
private static IResult Problem(List<Error> errors)
|
||||
{
|
||||
if (errors.All(e => e.Type == ErrorType.Validation))
|
||||
return Results.ValidationProblem(ToValidationDictionary(errors));
|
||||
|
||||
var error = errors[0];
|
||||
return error.Type switch
|
||||
{
|
||||
ErrorType.NotFound => Results.Problem(error.Description, statusCode: StatusCodes.Status404NotFound),
|
||||
ErrorType.Unauthorized => Results.Unauthorized(),
|
||||
ErrorType.Forbidden => Results.Problem(error.Description, statusCode: StatusCodes.Status403Forbidden),
|
||||
ErrorType.Conflict => Results.Problem(error.Description, statusCode: StatusCodes.Status409Conflict),
|
||||
_ => Results.Problem(error.Description)
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, string[]> ToValidationDictionary(IEnumerable<Error> errors) =>
|
||||
errors
|
||||
.GroupBy(e => string.IsNullOrEmpty(e.Code) ? "general" : e.Code)
|
||||
.ToDictionary(g => g.Key, g => g.Select(e => e.Description).ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,22 +8,23 @@ using Musify.Infrastructure.MassTransit;
|
||||
using Musify.Infrastructure.Persistence;
|
||||
using Musify.Infrastructure.Services;
|
||||
|
||||
namespace Musify.Api.Extensions;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
namespace Musify.Api.Extensions
|
||||
{
|
||||
public static IServiceCollection AddApplicationServices(this IServiceCollection services, IConfiguration configuration)
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
services.AddApplication(configuration);
|
||||
services.AddDatabase(configuration);
|
||||
services.AddStorageService(configuration);
|
||||
services.AddStreamTicketService(configuration);
|
||||
services.AddMassTransitClient(configuration);
|
||||
services.AddAuthenticationConfiguration(configuration);
|
||||
services.AddOpenApiConfiguration();
|
||||
services.AddScalarConfiguration();
|
||||
services.AddValidatorsFromAssemblyContaining<CreatePlayListRequest>();
|
||||
public static IServiceCollection AddApplicationServices(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddApplication(configuration);
|
||||
services.AddDatabase(configuration);
|
||||
services.AddStorageService(configuration);
|
||||
services.AddStreamTicketService(configuration);
|
||||
services.AddMassTransitClient(configuration);
|
||||
services.AddAuthenticationConfiguration(configuration);
|
||||
services.AddOpenApiConfiguration();
|
||||
services.AddScalarConfiguration();
|
||||
services.AddValidatorsFromAssemblyContaining<CreatePlayListRequest>();
|
||||
|
||||
return services;
|
||||
return services;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Musify.Api.Filters;
|
||||
|
||||
public sealed class ValidationFilter<T>(IValidator<T> validator) : IEndpointFilter where T : class
|
||||
namespace Musify.Api.Filters
|
||||
{
|
||||
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
||||
public sealed class ValidationFilter<T>(IValidator<T> validator) : IEndpointFilter where T : class
|
||||
{
|
||||
var argument = context.Arguments.OfType<T>().FirstOrDefault();
|
||||
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
||||
{
|
||||
var argument = context.Arguments.OfType<T>().FirstOrDefault();
|
||||
|
||||
if (argument == null)
|
||||
return await next(context);
|
||||
if (argument == null)
|
||||
return await next(context);
|
||||
|
||||
var result = await validator.ValidateAsync(argument, context.HttpContext.RequestAborted);
|
||||
var result = await validator.ValidateAsync(argument, context.HttpContext.RequestAborted);
|
||||
|
||||
return result.IsValid
|
||||
? await next(context)
|
||||
: Results.ValidationProblem(result.ToDictionary());
|
||||
return result.IsValid
|
||||
? await next(context)
|
||||
: Results.ValidationProblem(result.ToDictionary());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Api.Models;
|
||||
|
||||
/// <summary>Query-string binding for a genre name (case-insensitive); unknown names are rejected with a 400.</summary>
|
||||
public readonly record struct GenreParameter(Genre Value)
|
||||
namespace Musify.Api.Models
|
||||
{
|
||||
public static bool TryParse(string? value, out GenreParameter result)
|
||||
/// <summary>Query-string binding for a genre name (case-insensitive); unknown names are rejected with a 400.</summary>
|
||||
public readonly record struct GenreParameter(Genre Value)
|
||||
{
|
||||
if (Enum.TryParse<Genre>(value, ignoreCase: true, out var genre) && Enum.IsDefined(genre))
|
||||
public static bool TryParse(string? value, out GenreParameter result)
|
||||
{
|
||||
result = new GenreParameter(genre);
|
||||
return true;
|
||||
}
|
||||
if (Enum.TryParse<Genre>(value, ignoreCase: true, out var genre) && Enum.IsDefined(genre))
|
||||
{
|
||||
result = new GenreParameter(genre);
|
||||
return true;
|
||||
}
|
||||
|
||||
result = default;
|
||||
return false;
|
||||
result = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
namespace Musify.Api.OpenApi;
|
||||
|
||||
public static class OpenApiExtensions
|
||||
namespace Musify.Api.OpenApi
|
||||
{
|
||||
public static IServiceCollection AddOpenApiConfiguration(this IServiceCollection services)
|
||||
public static class OpenApiExtensions
|
||||
{
|
||||
services.AddOpenApi();
|
||||
services.ConfigureOptions<OpenApiOptionsSetup>();
|
||||
public static IServiceCollection AddOpenApiConfiguration(this IServiceCollection services)
|
||||
{
|
||||
services.AddOpenApi();
|
||||
services.ConfigureOptions<OpenApiOptionsSetup>();
|
||||
|
||||
return services;
|
||||
return services;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,88 +4,89 @@ using Microsoft.Extensions.Options;
|
||||
using Microsoft.OpenApi;
|
||||
using Musify.Api.Authentication;
|
||||
|
||||
namespace Musify.Api.OpenApi;
|
||||
|
||||
public sealed class OpenApiOptionsSetup(IOptions<AuthenticationConfiguration> options)
|
||||
: IConfigureNamedOptions<OpenApiOptions>
|
||||
namespace Musify.Api.OpenApi
|
||||
{
|
||||
internal const string SecuritySchemeId = "OAuth2";
|
||||
|
||||
private readonly AuthenticationConfiguration configuration = options.Value;
|
||||
|
||||
public void Configure(string? name, OpenApiOptions options) => Configure(options);
|
||||
|
||||
public void Configure(OpenApiOptions options)
|
||||
public sealed class OpenApiOptionsSetup(IOptions<AuthenticationConfiguration> options)
|
||||
: IConfigureNamedOptions<OpenApiOptions>
|
||||
{
|
||||
options.AddDocumentTransformer((document, _, _) =>
|
||||
internal const string SecuritySchemeId = "OAuth2";
|
||||
|
||||
private readonly AuthenticationConfiguration configuration = options.Value;
|
||||
|
||||
public void Configure(string? name, OpenApiOptions options) => Configure(options);
|
||||
|
||||
public void Configure(OpenApiOptions options)
|
||||
{
|
||||
document.Components ??= new OpenApiComponents();
|
||||
document.Components.SecuritySchemes ??= new Dictionary<string, IOpenApiSecurityScheme>();
|
||||
document.Components.SecuritySchemes[SecuritySchemeId] = new OpenApiSecurityScheme
|
||||
options.AddDocumentTransformer((document, _, _) =>
|
||||
{
|
||||
Type = SecuritySchemeType.OAuth2,
|
||||
Flows = new OpenApiOAuthFlows
|
||||
document.Components ??= new OpenApiComponents();
|
||||
document.Components.SecuritySchemes ??= new Dictionary<string, IOpenApiSecurityScheme>();
|
||||
document.Components.SecuritySchemes[SecuritySchemeId] = new OpenApiSecurityScheme
|
||||
{
|
||||
AuthorizationCode = new OpenApiOAuthFlow
|
||||
Type = SecuritySchemeType.OAuth2,
|
||||
Flows = new OpenApiOAuthFlows
|
||||
{
|
||||
AuthorizationUrl = new Uri(configuration.AuthorizationEndpoint),
|
||||
TokenUrl = new Uri(configuration.TokenEndpoint),
|
||||
Scopes = configuration.Scopes.ToDictionary(scope => scope, scope => scope),
|
||||
AuthorizationCode = new OpenApiOAuthFlow
|
||||
{
|
||||
AuthorizationUrl = new Uri(configuration.AuthorizationEndpoint),
|
||||
TokenUrl = new Uri(configuration.TokenEndpoint),
|
||||
Scopes = configuration.Scopes.ToDictionary(scope => scope, scope => scope),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
options.AddSchemaTransformer((schema, context, _) =>
|
||||
{
|
||||
// Every long the API returns is a user id, serialized as a string (JS loses precision above 2^53).
|
||||
// Request DTOs only carry byte sizes, which may be sent as numbers or strings.
|
||||
if (context.JsonPropertyInfo is { } property
|
||||
&& (property.PropertyType == typeof(long) || property.PropertyType == typeof(long?)))
|
||||
{
|
||||
var isRequest = property.DeclaringType?.Name.EndsWith("Request", StringComparison.Ordinal) == true;
|
||||
schema.Type = isRequest ? JsonSchemaType.Integer | JsonSchemaType.String : JsonSchemaType.String;
|
||||
if (property.PropertyType == typeof(long?))
|
||||
schema.Type |= JsonSchemaType.Null;
|
||||
schema.Format = isRequest ? "int64" : null;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
options.AddOperationTransformer((operation, context, _) =>
|
||||
{
|
||||
// Route parameters constrained with :long are user ids, which travel as strings like every other long.
|
||||
foreach (var parameter in operation.Parameters?.OfType<OpenApiParameter>() ?? [])
|
||||
{
|
||||
if (parameter.Schema is OpenApiSchema { Type: JsonSchemaType.Integer, Format: "int64" } schema)
|
||||
{
|
||||
schema.Type = JsonSchemaType.String;
|
||||
schema.Format = null;
|
||||
}
|
||||
}
|
||||
|
||||
var requiresAuthorization = context.Description.ActionDescriptor.EndpointMetadata
|
||||
.OfType<IAuthorizeData>()
|
||||
.Any();
|
||||
|
||||
var allowsAnonymous = context.Description.ActionDescriptor.EndpointMetadata
|
||||
.OfType<IAllowAnonymous>()
|
||||
.Any();
|
||||
|
||||
if (!requiresAuthorization || allowsAnonymous)
|
||||
return Task.CompletedTask;
|
||||
|
||||
operation.Security ??= new List<OpenApiSecurityRequirement>();
|
||||
operation.Security.Add(new OpenApiSecurityRequirement
|
||||
{
|
||||
[new OpenApiSecuritySchemeReference(SecuritySchemeId, context.Document)] =
|
||||
configuration.Scopes.ToList(),
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
options.AddSchemaTransformer((schema, context, _) =>
|
||||
{
|
||||
// Every long the API returns is a user id, serialized as a string (JS loses precision above 2^53).
|
||||
// Request DTOs only carry byte sizes, which may be sent as numbers or strings.
|
||||
if (context.JsonPropertyInfo is { } property
|
||||
&& (property.PropertyType == typeof(long) || property.PropertyType == typeof(long?)))
|
||||
{
|
||||
var isRequest = property.DeclaringType?.Name.EndsWith("Request", StringComparison.Ordinal) == true;
|
||||
schema.Type = isRequest ? JsonSchemaType.Integer | JsonSchemaType.String : JsonSchemaType.String;
|
||||
if (property.PropertyType == typeof(long?))
|
||||
schema.Type |= JsonSchemaType.Null;
|
||||
schema.Format = isRequest ? "int64" : null;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
options.AddOperationTransformer((operation, context, _) =>
|
||||
{
|
||||
// Route parameters constrained with :long are user ids, which travel as strings like every other long.
|
||||
foreach (var parameter in operation.Parameters?.OfType<OpenApiParameter>() ?? [])
|
||||
{
|
||||
if (parameter.Schema is OpenApiSchema { Type: JsonSchemaType.Integer, Format: "int64" } schema)
|
||||
{
|
||||
schema.Type = JsonSchemaType.String;
|
||||
schema.Format = null;
|
||||
}
|
||||
}
|
||||
|
||||
var requiresAuthorization = context.Description.ActionDescriptor.EndpointMetadata
|
||||
.OfType<IAuthorizeData>()
|
||||
.Any();
|
||||
|
||||
var allowsAnonymous = context.Description.ActionDescriptor.EndpointMetadata
|
||||
.OfType<IAllowAnonymous>()
|
||||
.Any();
|
||||
|
||||
if (!requiresAuthorization || allowsAnonymous)
|
||||
return Task.CompletedTask;
|
||||
|
||||
operation.Security ??= new List<OpenApiSecurityRequirement>();
|
||||
operation.Security.Add(new OpenApiSecurityRequirement
|
||||
{
|
||||
[new OpenApiSecuritySchemeReference(SecuritySchemeId, context.Document)] =
|
||||
configuration.Scopes.ToList(),
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
namespace Musify.Api.Scalar;
|
||||
|
||||
public static class ScalarExtensions
|
||||
namespace Musify.Api.Scalar
|
||||
{
|
||||
public static IServiceCollection AddScalarConfiguration(this IServiceCollection services)
|
||||
public static class ScalarExtensions
|
||||
{
|
||||
services.ConfigureOptions<ScalarOptionsSetup>();
|
||||
public static IServiceCollection AddScalarConfiguration(this IServiceCollection services)
|
||||
{
|
||||
services.ConfigureOptions<ScalarOptionsSetup>();
|
||||
|
||||
return services;
|
||||
return services;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,29 +3,30 @@ using Musify.Api.Authentication;
|
||||
using Musify.Api.OpenApi;
|
||||
using Scalar.AspNetCore;
|
||||
|
||||
namespace Musify.Api.Scalar;
|
||||
|
||||
public sealed class ScalarOptionsSetup(IOptions<AuthenticationConfiguration> options)
|
||||
: IConfigureOptions<ScalarOptions>
|
||||
namespace Musify.Api.Scalar
|
||||
{
|
||||
private readonly AuthenticationConfiguration configuration = options.Value;
|
||||
|
||||
public void Configure(ScalarOptions scalarOptions)
|
||||
public sealed class ScalarOptionsSetup(IOptions<AuthenticationConfiguration> options)
|
||||
: IConfigureOptions<ScalarOptions>
|
||||
{
|
||||
scalarOptions
|
||||
.WithTitle("Musify API")
|
||||
.AddPreferredSecuritySchemes(OpenApiOptionsSetup.SecuritySchemeId)
|
||||
.AddAuthorizationCodeFlow(OpenApiOptionsSetup.SecuritySchemeId, flow =>
|
||||
{
|
||||
flow.ClientId = configuration.ClientId;
|
||||
flow.ClientSecret = configuration.ClientSecret;
|
||||
flow.AuthorizationUrl = configuration.AuthorizationEndpoint;
|
||||
flow.TokenUrl = configuration.TokenEndpoint;
|
||||
flow.Pkce = Pkce.Sha256;
|
||||
flow.SelectedScopes = configuration.Scopes;
|
||||
flow.RedirectUri = configuration.ScalarRedirectUri;
|
||||
flow.RefreshUrl = configuration.TokenEndpoint;
|
||||
})
|
||||
.HideModels();
|
||||
private readonly AuthenticationConfiguration configuration = options.Value;
|
||||
|
||||
public void Configure(ScalarOptions scalarOptions)
|
||||
{
|
||||
scalarOptions
|
||||
.WithTitle("Musify API")
|
||||
.AddPreferredSecuritySchemes(OpenApiOptionsSetup.SecuritySchemeId)
|
||||
.AddAuthorizationCodeFlow(OpenApiOptionsSetup.SecuritySchemeId, flow =>
|
||||
{
|
||||
flow.ClientId = configuration.ClientId;
|
||||
flow.ClientSecret = configuration.ClientSecret;
|
||||
flow.AuthorizationUrl = configuration.AuthorizationEndpoint;
|
||||
flow.TokenUrl = configuration.TokenEndpoint;
|
||||
flow.Pkce = Pkce.Sha256;
|
||||
flow.SelectedScopes = configuration.Scopes;
|
||||
flow.RedirectUri = configuration.ScalarRedirectUri;
|
||||
flow.RefreshUrl = configuration.TokenEndpoint;
|
||||
})
|
||||
.HideModels();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
using FluentValidation;
|
||||
using Musify.Api.DataTransferObjects;
|
||||
|
||||
namespace Musify.Api.Validators;
|
||||
|
||||
public sealed class AddTracksRequestValidator : AbstractValidator<AddTracksRequest>
|
||||
namespace Musify.Api.Validators
|
||||
{
|
||||
public const int MaxTracksPerRequest = 500;
|
||||
|
||||
public AddTracksRequestValidator()
|
||||
public sealed class AddTracksRequestValidator : AbstractValidator<AddTracksRequest>
|
||||
{
|
||||
RuleFor(x => x.TrackIds)
|
||||
.NotEmpty()
|
||||
.Must(ids => ids.Count <= MaxTracksPerRequest)
|
||||
.WithMessage($"At most {MaxTracksPerRequest} tracks can be added at once.");
|
||||
public const int MaxTracksPerRequest = 500;
|
||||
|
||||
RuleForEach(x => x.TrackIds)
|
||||
.NotEmpty();
|
||||
public AddTracksRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.TrackIds)
|
||||
.NotEmpty()
|
||||
.Must(ids => ids.Count <= MaxTracksPerRequest)
|
||||
.WithMessage($"At most {MaxTracksPerRequest} tracks can be added at once.");
|
||||
|
||||
RuleForEach(x => x.TrackIds)
|
||||
.NotEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
using FluentValidation;
|
||||
using Musify.Api.DataTransferObjects.Albums;
|
||||
|
||||
namespace Musify.Api.Validators.Albums;
|
||||
|
||||
public sealed class CreateAlbumRequestValidator : AbstractValidator<CreateAlbumRequest>
|
||||
namespace Musify.Api.Validators.Albums
|
||||
{
|
||||
public CreateAlbumRequestValidator()
|
||||
public sealed class CreateAlbumRequestValidator : AbstractValidator<CreateAlbumRequest>
|
||||
{
|
||||
RuleFor(x => x.Title)
|
||||
.NotEmpty()
|
||||
.MaximumLength(Limits.AlbumTitle);
|
||||
public CreateAlbumRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Title)
|
||||
.NotEmpty()
|
||||
.MaximumLength(Limits.AlbumTitle);
|
||||
|
||||
RuleFor(x => x.Description)
|
||||
.MaximumLength(Limits.Description);
|
||||
RuleFor(x => x.Description)
|
||||
.MaximumLength(Limits.Description);
|
||||
|
||||
RuleFor(x => x.ReleaseYear)
|
||||
.InclusiveBetween(Limits.EarliestReleaseYear, Limits.LatestReleaseYear)
|
||||
.When(x => x.ReleaseYear.HasValue);
|
||||
RuleFor(x => x.ReleaseYear)
|
||||
.InclusiveBetween(Limits.EarliestReleaseYear, Limits.LatestReleaseYear)
|
||||
.When(x => x.ReleaseYear.HasValue);
|
||||
|
||||
RuleFor(x => x.PictureIntentId)
|
||||
.NotEmpty();
|
||||
RuleFor(x => x.PictureIntentId)
|
||||
.NotEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
using FluentValidation;
|
||||
using Musify.Api.DataTransferObjects.Albums;
|
||||
|
||||
namespace Musify.Api.Validators.Albums;
|
||||
|
||||
public sealed class UpdateAlbumRequestValidator : AbstractValidator<UpdateAlbumRequest>
|
||||
namespace Musify.Api.Validators.Albums
|
||||
{
|
||||
public UpdateAlbumRequestValidator()
|
||||
public sealed class UpdateAlbumRequestValidator : AbstractValidator<UpdateAlbumRequest>
|
||||
{
|
||||
RuleFor(x => x.Title)
|
||||
.NotEmpty()
|
||||
.MaximumLength(Limits.AlbumTitle);
|
||||
public UpdateAlbumRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Title)
|
||||
.NotEmpty()
|
||||
.MaximumLength(Limits.AlbumTitle);
|
||||
|
||||
RuleFor(x => x.Description)
|
||||
.MaximumLength(Limits.Description);
|
||||
RuleFor(x => x.Description)
|
||||
.MaximumLength(Limits.Description);
|
||||
|
||||
RuleFor(x => x.ReleaseYear)
|
||||
.InclusiveBetween(Limits.EarliestReleaseYear, Limits.LatestReleaseYear)
|
||||
.When(x => x.ReleaseYear.HasValue);
|
||||
RuleFor(x => x.ReleaseYear)
|
||||
.InclusiveBetween(Limits.EarliestReleaseYear, Limits.LatestReleaseYear)
|
||||
.When(x => x.ReleaseYear.HasValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
namespace Musify.Api.Validators;
|
||||
|
||||
public static class Limits
|
||||
namespace Musify.Api.Validators
|
||||
{
|
||||
public const int TrackTitle = 200;
|
||||
public const int AlbumTitle = 200;
|
||||
public const int PlayListName = 50;
|
||||
public const int Description = 256;
|
||||
public const int EarliestReleaseYear = 1877;
|
||||
public static class Limits
|
||||
{
|
||||
public const int TrackTitle = 200;
|
||||
public const int AlbumTitle = 200;
|
||||
public const int PlayListName = 50;
|
||||
public const int Description = 256;
|
||||
public const int EarliestReleaseYear = 1877;
|
||||
|
||||
public static int LatestReleaseYear => DateTime.UtcNow.Year + 1;
|
||||
public static int LatestReleaseYear => DateTime.UtcNow.Year + 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,17 @@ using FluentValidation;
|
||||
using Musify.Api.Models;
|
||||
using Musify.Application.Shared;
|
||||
|
||||
namespace Musify.Api.Validators;
|
||||
|
||||
public sealed class PageQueryValidator : AbstractValidator<PageQuery>
|
||||
namespace Musify.Api.Validators
|
||||
{
|
||||
public PageQueryValidator()
|
||||
public sealed class PageQueryValidator : AbstractValidator<PageQuery>
|
||||
{
|
||||
RuleFor(x => x.PageNumber)
|
||||
.GreaterThanOrEqualTo(1);
|
||||
public PageQueryValidator()
|
||||
{
|
||||
RuleFor(x => x.PageNumber)
|
||||
.GreaterThanOrEqualTo(1);
|
||||
|
||||
RuleFor(x => x.PageSize)
|
||||
.InclusiveBetween(1, PageRequest.MaxPageSize);
|
||||
RuleFor(x => x.PageSize)
|
||||
.InclusiveBetween(1, PageRequest.MaxPageSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,19 +2,20 @@ using FluentValidation;
|
||||
using Musify.Api.DataTransferObjects;
|
||||
using Musify.Application.Configuration;
|
||||
|
||||
namespace Musify.Api.Validators;
|
||||
|
||||
public sealed class PictureUploadRequestValidator : AbstractValidator<PictureUploadRequest>
|
||||
namespace Musify.Api.Validators
|
||||
{
|
||||
public PictureUploadRequestValidator(UploadIntentConfiguration uploadConfiguration)
|
||||
public sealed class PictureUploadRequestValidator : AbstractValidator<PictureUploadRequest>
|
||||
{
|
||||
RuleFor(x => x.FileType)
|
||||
.MustBeFileType(Uploads.PictureFileTypes);
|
||||
public PictureUploadRequestValidator(UploadIntentConfiguration uploadConfiguration)
|
||||
{
|
||||
RuleFor(x => x.FileType)
|
||||
.MustBeFileType(Uploads.PictureFileTypes);
|
||||
|
||||
RuleFor(x => x.ContentType)
|
||||
.MustBeContentType(Uploads.PictureContentTypes);
|
||||
RuleFor(x => x.ContentType)
|
||||
.MustBeContentType(Uploads.PictureContentTypes);
|
||||
|
||||
RuleFor(x => x.ExpectedSizeBytes)
|
||||
.MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes);
|
||||
RuleFor(x => x.ExpectedSizeBytes)
|
||||
.MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
using FluentValidation;
|
||||
using Musify.Api.DataTransferObjects.PlayLists;
|
||||
|
||||
namespace Musify.Api.Validators.PlayLists;
|
||||
|
||||
public sealed class CreatePlayListRequestValidator : AbstractValidator<CreatePlayListRequest>
|
||||
namespace Musify.Api.Validators.PlayLists
|
||||
{
|
||||
public CreatePlayListRequestValidator()
|
||||
public sealed class CreatePlayListRequestValidator : AbstractValidator<CreatePlayListRequest>
|
||||
{
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty()
|
||||
.MaximumLength(Limits.PlayListName);
|
||||
public CreatePlayListRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty()
|
||||
.MaximumLength(Limits.PlayListName);
|
||||
|
||||
RuleFor(x => x.Description)
|
||||
.MaximumLength(Limits.Description);
|
||||
RuleFor(x => x.Description)
|
||||
.MaximumLength(Limits.Description);
|
||||
|
||||
RuleFor(x => x.Visibility)
|
||||
.IsInEnum();
|
||||
RuleFor(x => x.Visibility)
|
||||
.IsInEnum();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
using FluentValidation;
|
||||
using Musify.Api.DataTransferObjects.PlayLists;
|
||||
|
||||
namespace Musify.Api.Validators.PlayLists;
|
||||
|
||||
public sealed class UpdatePlayListRequestValidator : AbstractValidator<UpdatePlayListRequest>
|
||||
namespace Musify.Api.Validators.PlayLists
|
||||
{
|
||||
public UpdatePlayListRequestValidator()
|
||||
public sealed class UpdatePlayListRequestValidator : AbstractValidator<UpdatePlayListRequest>
|
||||
{
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty()
|
||||
.MaximumLength(Limits.PlayListName)
|
||||
.When(x => x.Name != null);
|
||||
public UpdatePlayListRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty()
|
||||
.MaximumLength(Limits.PlayListName)
|
||||
.When(x => x.Name != null);
|
||||
|
||||
RuleFor(x => x.Description)
|
||||
.MaximumLength(Limits.Description)
|
||||
.When(x => x.Description != null);
|
||||
RuleFor(x => x.Description)
|
||||
.MaximumLength(Limits.Description)
|
||||
.When(x => x.Description != null);
|
||||
|
||||
RuleFor(x => x.Visibility)
|
||||
.IsInEnum()
|
||||
.When(x => x.Visibility != null);
|
||||
RuleFor(x => x.Visibility)
|
||||
.IsInEnum()
|
||||
.When(x => x.Visibility != null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,33 +2,34 @@ using FluentValidation;
|
||||
using Musify.Api.DataTransferObjects.Tracks;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Api.Validators.Tracks;
|
||||
|
||||
public sealed class CreateTrackRequestValidator : AbstractValidator<CreateTrackRequest>
|
||||
namespace Musify.Api.Validators.Tracks
|
||||
{
|
||||
public CreateTrackRequestValidator()
|
||||
public sealed class CreateTrackRequestValidator : AbstractValidator<CreateTrackRequest>
|
||||
{
|
||||
RuleFor(x => x.Title)
|
||||
.NotEmpty()
|
||||
.MaximumLength(Limits.TrackTitle);
|
||||
public CreateTrackRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Title)
|
||||
.NotEmpty()
|
||||
.MaximumLength(Limits.TrackTitle);
|
||||
|
||||
RuleFor(x => x.PictureIntentId)
|
||||
.NotEmpty();
|
||||
RuleFor(x => x.PictureIntentId)
|
||||
.NotEmpty();
|
||||
|
||||
RuleFor(x => x.AudioIntentId)
|
||||
.NotEmpty();
|
||||
RuleFor(x => x.AudioIntentId)
|
||||
.NotEmpty();
|
||||
|
||||
RuleFor(x => x.Tags)
|
||||
.NotEmpty()
|
||||
.WithMessage("At least one tag is required.");
|
||||
RuleFor(x => x.Tags)
|
||||
.NotEmpty()
|
||||
.WithMessage("At least one tag is required.");
|
||||
|
||||
RuleFor(x => x.Tags)
|
||||
.Must(tags => GenreCompatibility.FindConflicts(tags).Count == 0)
|
||||
.When(x => x.Tags is { Count: > 0 })
|
||||
.WithMessage(x =>
|
||||
{
|
||||
var conflict = GenreCompatibility.FindConflicts(x.Tags).First();
|
||||
return $"Tags '{conflict.First}' and '{conflict.Second}' are not compatible.";
|
||||
});
|
||||
RuleFor(x => x.Tags)
|
||||
.Must(tags => GenreCompatibility.FindConflicts(tags).Count == 0)
|
||||
.When(x => x.Tags is { Count: > 0 })
|
||||
.WithMessage(x =>
|
||||
{
|
||||
var conflict = GenreCompatibility.FindConflicts(x.Tags).First();
|
||||
return $"Tags '{conflict.First}' and '{conflict.Second}' are not compatible.";
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-7
@@ -1,14 +1,15 @@
|
||||
using FluentValidation;
|
||||
using Musify.Api.DataTransferObjects.Tracks;
|
||||
|
||||
namespace Musify.Api.Validators.Tracks;
|
||||
|
||||
public sealed class RecordListeningProgressRequestValidator : AbstractValidator<RecordListeningProgressRequest>
|
||||
namespace Musify.Api.Validators.Tracks
|
||||
{
|
||||
public RecordListeningProgressRequestValidator()
|
||||
public sealed class RecordListeningProgressRequestValidator : AbstractValidator<RecordListeningProgressRequest>
|
||||
{
|
||||
RuleFor(x => x.PlayedSeconds)
|
||||
.Must(seconds => double.IsFinite(seconds) && seconds >= 0)
|
||||
.WithMessage("'Played Seconds' must be a non-negative number.");
|
||||
public RecordListeningProgressRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.PlayedSeconds)
|
||||
.Must(seconds => double.IsFinite(seconds) && seconds >= 0)
|
||||
.WithMessage("'Played Seconds' must be a non-negative number.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+17
-16
@@ -2,28 +2,29 @@ using FluentValidation;
|
||||
using Musify.Api.DataTransferObjects.Tracks;
|
||||
using Musify.Application.Configuration;
|
||||
|
||||
namespace Musify.Api.Validators.Tracks;
|
||||
|
||||
public sealed class RequestTrackUploadUrlsRequestValidator : AbstractValidator<RequestTrackUploadUrlsRequest>
|
||||
namespace Musify.Api.Validators.Tracks
|
||||
{
|
||||
public RequestTrackUploadUrlsRequestValidator(UploadIntentConfiguration uploadConfiguration)
|
||||
public sealed class RequestTrackUploadUrlsRequestValidator : AbstractValidator<RequestTrackUploadUrlsRequest>
|
||||
{
|
||||
RuleFor(x => x.PictureFileType)
|
||||
.MustBeFileType(Uploads.PictureFileTypes);
|
||||
public RequestTrackUploadUrlsRequestValidator(UploadIntentConfiguration uploadConfiguration)
|
||||
{
|
||||
RuleFor(x => x.PictureFileType)
|
||||
.MustBeFileType(Uploads.PictureFileTypes);
|
||||
|
||||
RuleFor(x => x.PictureContentType)
|
||||
.MustBeContentType(Uploads.PictureContentTypes);
|
||||
RuleFor(x => x.PictureContentType)
|
||||
.MustBeContentType(Uploads.PictureContentTypes);
|
||||
|
||||
RuleFor(x => x.AudioFileType)
|
||||
.MustBeFileType(Uploads.AudioFileTypes);
|
||||
RuleFor(x => x.AudioFileType)
|
||||
.MustBeFileType(Uploads.AudioFileTypes);
|
||||
|
||||
RuleFor(x => x.AudioContentType)
|
||||
.MustBeContentType(Uploads.AudioContentTypes);
|
||||
RuleFor(x => x.AudioContentType)
|
||||
.MustBeContentType(Uploads.AudioContentTypes);
|
||||
|
||||
RuleFor(x => x.ExpectedPictureSizeBytes)
|
||||
.MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes);
|
||||
RuleFor(x => x.ExpectedPictureSizeBytes)
|
||||
.MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes);
|
||||
|
||||
RuleFor(x => x.ExpectedAudioSizeBytes)
|
||||
.MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes);
|
||||
RuleFor(x => x.ExpectedAudioSizeBytes)
|
||||
.MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +1,43 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Musify.Api.Validators;
|
||||
|
||||
public static class Uploads
|
||||
namespace Musify.Api.Validators
|
||||
{
|
||||
public static readonly IReadOnlySet<string> PictureFileTypes =
|
||||
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "jpg", "jpeg", "png", "webp" };
|
||||
public static class Uploads
|
||||
{
|
||||
public static readonly IReadOnlySet<string> PictureFileTypes =
|
||||
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "jpg", "jpeg", "png", "webp" };
|
||||
|
||||
public static readonly IReadOnlySet<string> PictureContentTypes =
|
||||
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "image/jpeg", "image/png", "image/webp" };
|
||||
public static readonly IReadOnlySet<string> PictureContentTypes =
|
||||
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "image/jpeg", "image/png", "image/webp" };
|
||||
|
||||
public static readonly IReadOnlySet<string> AudioFileTypes =
|
||||
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "mp3", "wav", "flac", "ogg", "m4a", "aac" };
|
||||
public static readonly IReadOnlySet<string> AudioFileTypes =
|
||||
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "mp3", "wav", "flac", "ogg", "m4a", "aac" };
|
||||
|
||||
public static readonly IReadOnlySet<string> AudioContentTypes =
|
||||
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"audio/mpeg", "audio/mp3", "audio/wav", "audio/x-wav", "audio/wave", "audio/flac", "audio/x-flac",
|
||||
"audio/ogg", "audio/mp4", "audio/x-m4a", "audio/m4a", "audio/aac"
|
||||
};
|
||||
public static readonly IReadOnlySet<string> AudioContentTypes =
|
||||
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"audio/mpeg", "audio/mp3", "audio/wav", "audio/x-wav", "audio/wave", "audio/flac", "audio/x-flac",
|
||||
"audio/ogg", "audio/mp4", "audio/x-m4a", "audio/m4a", "audio/aac"
|
||||
};
|
||||
|
||||
public static IRuleBuilderOptions<T, string> MustBeFileType<T>(
|
||||
this IRuleBuilder<T, string> rule, IReadOnlySet<string> allowed) =>
|
||||
rule
|
||||
.NotEmpty()
|
||||
.Length(1, 10)
|
||||
.Matches("^[A-Za-z0-9]+$").WithMessage("'{PropertyName}' must contain only letters and digits.")
|
||||
.Must(allowed.Contains).WithMessage("'{PropertyName}' must be one of: " + string.Join(", ", allowed) + ".");
|
||||
public static IRuleBuilderOptions<T, string> MustBeFileType<T>(
|
||||
this IRuleBuilder<T, string> rule, IReadOnlySet<string> allowed) =>
|
||||
rule
|
||||
.NotEmpty()
|
||||
.Length(1, 10)
|
||||
.Matches("^[A-Za-z0-9]+$").WithMessage("'{PropertyName}' must contain only letters and digits.")
|
||||
.Must(allowed.Contains).WithMessage("'{PropertyName}' must be one of: " + string.Join(", ", allowed) + ".");
|
||||
|
||||
public static IRuleBuilderOptions<T, string> MustBeContentType<T>(
|
||||
this IRuleBuilder<T, string> rule, IReadOnlySet<string> allowed) =>
|
||||
rule
|
||||
.NotEmpty()
|
||||
.Must(allowed.Contains).WithMessage("'{PropertyName}' is not an allowed content type.");
|
||||
public static IRuleBuilderOptions<T, string> MustBeContentType<T>(
|
||||
this IRuleBuilder<T, string> rule, IReadOnlySet<string> allowed) =>
|
||||
rule
|
||||
.NotEmpty()
|
||||
.Must(allowed.Contains).WithMessage("'{PropertyName}' is not an allowed content type.");
|
||||
|
||||
public static IRuleBuilderOptions<T, long?> MustBeValidUploadSize<T>(
|
||||
this IRuleBuilder<T, long?> rule, long maxUploadBytes) =>
|
||||
rule
|
||||
.Must(size => size is null || (size > 0 && size <= maxUploadBytes))
|
||||
.WithMessage($"'{{PropertyName}}' must be greater than 0 and at most {maxUploadBytes} bytes.");
|
||||
public static IRuleBuilderOptions<T, long?> MustBeValidUploadSize<T>(
|
||||
this IRuleBuilder<T, long?> rule, long maxUploadBytes) =>
|
||||
rule
|
||||
.Must(size => size is null || (size > 0 && size <= maxUploadBytes))
|
||||
.WithMessage($"'{{PropertyName}}' must be greater than 0 and at most {maxUploadBytes} bytes.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,58 +3,59 @@ using Microsoft.IdentityModel.JsonWebTokens;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Musify.StreamingGateway.Configuration;
|
||||
|
||||
namespace Musify.StreamingGateway.Authentication;
|
||||
|
||||
public sealed record TicketValidationResult(string Prefix, long? MaxBytes)
|
||||
namespace Musify.StreamingGateway.Authentication
|
||||
{
|
||||
public static readonly TicketValidationResult Invalid = new(string.Empty, null);
|
||||
|
||||
public bool IsValid => Prefix.Length > 0;
|
||||
}
|
||||
|
||||
public sealed class TicketValidator : IDisposable
|
||||
{
|
||||
private readonly RSA rsa;
|
||||
private readonly TokenValidationParameters validationParameters;
|
||||
private readonly JsonWebTokenHandler handler = new();
|
||||
|
||||
public TicketValidator(StreamTicketValidationConfiguration configuration)
|
||||
public sealed record TicketValidationResult(string Prefix, long? MaxBytes)
|
||||
{
|
||||
rsa = RSA.Create();
|
||||
rsa.ImportFromPem(File.ReadAllText(configuration.PublicKeyPath));
|
||||
public static readonly TicketValidationResult Invalid = new(string.Empty, null);
|
||||
|
||||
validationParameters = new TokenValidationParameters
|
||||
public bool IsValid => Prefix.Length > 0;
|
||||
}
|
||||
|
||||
public sealed class TicketValidator : IDisposable
|
||||
{
|
||||
private readonly RSA rsa;
|
||||
private readonly TokenValidationParameters validationParameters;
|
||||
private readonly JsonWebTokenHandler handler = new();
|
||||
|
||||
public TicketValidator(StreamTicketValidationConfiguration configuration)
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = configuration.Issuer,
|
||||
ValidateAudience = true,
|
||||
ValidAudience = configuration.Audience,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new RsaSecurityKey(rsa),
|
||||
ValidAlgorithms = [SecurityAlgorithms.RsaSha256],
|
||||
ClockSkew = TimeSpan.FromSeconds(30),
|
||||
};
|
||||
rsa = RSA.Create();
|
||||
rsa.ImportFromPem(File.ReadAllText(configuration.PublicKeyPath));
|
||||
|
||||
validationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = configuration.Issuer,
|
||||
ValidateAudience = true,
|
||||
ValidAudience = configuration.Audience,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new RsaSecurityKey(rsa),
|
||||
ValidAlgorithms = [SecurityAlgorithms.RsaSha256],
|
||||
ClockSkew = TimeSpan.FromSeconds(30),
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<TicketValidationResult> ValidateTicketAsync(string? token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
return TicketValidationResult.Invalid;
|
||||
|
||||
var result = await handler.ValidateTokenAsync(token, validationParameters);
|
||||
if (!result.IsValid)
|
||||
return TicketValidationResult.Invalid;
|
||||
|
||||
var prefix = result.ClaimsIdentity?.FindFirst("prefix")?.Value;
|
||||
if (string.IsNullOrEmpty(prefix))
|
||||
return TicketValidationResult.Invalid;
|
||||
|
||||
var maxBytesClaim = result.ClaimsIdentity?.FindFirst("maxBytes")?.Value;
|
||||
var maxBytes = long.TryParse(maxBytesClaim, out var parsed) ? parsed : (long?)null;
|
||||
|
||||
return new TicketValidationResult(prefix, maxBytes);
|
||||
}
|
||||
|
||||
public void Dispose() => rsa.Dispose();
|
||||
}
|
||||
|
||||
public async Task<TicketValidationResult> ValidateTicketAsync(string? token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
return TicketValidationResult.Invalid;
|
||||
|
||||
var result = await handler.ValidateTokenAsync(token, validationParameters);
|
||||
if (!result.IsValid)
|
||||
return TicketValidationResult.Invalid;
|
||||
|
||||
var prefix = result.ClaimsIdentity?.FindFirst("prefix")?.Value;
|
||||
if (string.IsNullOrEmpty(prefix))
|
||||
return TicketValidationResult.Invalid;
|
||||
|
||||
var maxBytesClaim = result.ClaimsIdentity?.FindFirst("maxBytes")?.Value;
|
||||
var maxBytes = long.TryParse(maxBytesClaim, out var parsed) ? parsed : (long?)null;
|
||||
|
||||
return new TicketValidationResult(prefix, maxBytes);
|
||||
}
|
||||
|
||||
public void Dispose() => rsa.Dispose();
|
||||
}
|
||||
|
||||
+14
-13
@@ -1,23 +1,24 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Musify.StreamingGateway.Configuration;
|
||||
|
||||
public sealed class StreamTicketValidationConfiguration
|
||||
namespace Musify.StreamingGateway.Configuration
|
||||
{
|
||||
public const string SectionName = "StreamTicket";
|
||||
public sealed class StreamTicketValidationConfiguration
|
||||
{
|
||||
public const string SectionName = "StreamTicket";
|
||||
|
||||
[Required]
|
||||
public required string PublicKeyPath { get; set; }
|
||||
[Required]
|
||||
public required string PublicKeyPath { get; set; }
|
||||
|
||||
[Required]
|
||||
public string Audience { get; set; } = "media-gateway";
|
||||
[Required]
|
||||
public string Audience { get; set; } = "media-gateway";
|
||||
|
||||
[Required]
|
||||
public string Issuer { get; set; } = "musify-webapi";
|
||||
[Required]
|
||||
public string Issuer { get; set; } = "musify-webapi";
|
||||
|
||||
public string QueryParameterName { get; set; } = "t";
|
||||
public string QueryParameterName { get; set; } = "t";
|
||||
|
||||
public string HeaderName { get; set; } = "X-Stream-Ticket";
|
||||
public string HeaderName { get; set; } = "X-Stream-Ticket";
|
||||
|
||||
public string MediaPathPrefix { get; set; } = "/media";
|
||||
public string MediaPathPrefix { get; set; } = "/media";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,112 +3,113 @@ using Microsoft.Net.Http.Headers;
|
||||
using Musify.StreamingGateway.Authentication;
|
||||
using Musify.StreamingGateway.Configuration;
|
||||
|
||||
namespace Musify.StreamingGateway.Middleware;
|
||||
|
||||
public sealed class TicketValidationMiddleware(
|
||||
RequestDelegate next,
|
||||
TicketValidator ticketValidator,
|
||||
StreamTicketValidationConfiguration options,
|
||||
ILogger<TicketValidationMiddleware> logger)
|
||||
namespace Musify.StreamingGateway.Middleware
|
||||
{
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
public sealed class TicketValidationMiddleware(
|
||||
RequestDelegate next,
|
||||
TicketValidator ticketValidator,
|
||||
StreamTicketValidationConfiguration options,
|
||||
ILogger<TicketValidationMiddleware> logger)
|
||||
{
|
||||
var path = context.Request.Path;
|
||||
|
||||
if (!path.StartsWithSegments(options.MediaPathPrefix, out var remaining))
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
var path = context.Request.Path;
|
||||
|
||||
if (!path.StartsWithSegments(options.MediaPathPrefix, out var remaining))
|
||||
{
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var token = context.Request.Query[options.QueryParameterName].ToString();
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
token = context.Request.Headers[options.HeaderName].ToString();
|
||||
}
|
||||
|
||||
var validation = await ticketValidator.ValidateTicketAsync(token);
|
||||
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
logger.LogWarning("Rejected media request {Path}: missing or invalid ticket", path);
|
||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
return;
|
||||
}
|
||||
|
||||
var prefix = validation.Prefix;
|
||||
var objectKey = remaining.Value?.TrimStart('/') ?? string.Empty;
|
||||
|
||||
if (HasTraversalSegment(objectKey))
|
||||
{
|
||||
logger.LogWarning("Rejected media request {ObjectKey}: contains a path traversal segment", objectKey);
|
||||
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
return;
|
||||
}
|
||||
|
||||
var prefixBoundary = prefix.EndsWith('/') ? prefix : prefix + "/";
|
||||
if (!string.Equals(objectKey, prefix, StringComparison.Ordinal)
|
||||
&& !objectKey.StartsWith(prefixBoundary, StringComparison.Ordinal))
|
||||
{
|
||||
logger.LogWarning("Rejected media request {ObjectKey}: outside authorized prefix {Prefix}", objectKey, prefix);
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
return;
|
||||
}
|
||||
|
||||
if (validation.MaxBytes is { } maxBytes && !TryClampRange(context, maxBytes))
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Rejected media request {ObjectKey}: requested range is a suffix range or starts beyond the {MaxBytes}-byte preview limit",
|
||||
objectKey, maxBytes);
|
||||
context.Response.StatusCode = StatusCodes.Status416RangeNotSatisfiable;
|
||||
context.Response.Headers[HeaderNames.ContentRange] = $"bytes */{maxBytes}";
|
||||
return;
|
||||
}
|
||||
|
||||
StripQueryParameter(context, options.QueryParameterName);
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var token = context.Request.Query[options.QueryParameterName].ToString();
|
||||
if (string.IsNullOrEmpty(token))
|
||||
private static bool TryClampRange(HttpContext context, long maxBytes)
|
||||
{
|
||||
token = context.Request.Headers[options.HeaderName].ToString();
|
||||
}
|
||||
var rangeHeader = context.Request.Headers.Range;
|
||||
if (StringValues.IsNullOrEmpty(rangeHeader))
|
||||
{
|
||||
context.Request.Headers.Range = $"bytes=0-{maxBytes - 1}";
|
||||
return true;
|
||||
}
|
||||
|
||||
var validation = await ticketValidator.ValidateTicketAsync(token);
|
||||
if (!RangeHeaderValue.TryParse(rangeHeader.ToString(), out var range)
|
||||
|| range.Ranges.Count != 1
|
||||
|| !string.Equals(range.Unit.ToString(), "bytes", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
context.Request.Headers.Range = $"bytes=0-{maxBytes - 1}";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
logger.LogWarning("Rejected media request {Path}: missing or invalid ticket", path);
|
||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
return;
|
||||
}
|
||||
var requested = range.Ranges.Single();
|
||||
if (requested.From is null)
|
||||
return false;
|
||||
|
||||
var prefix = validation.Prefix;
|
||||
var objectKey = remaining.Value?.TrimStart('/') ?? string.Empty;
|
||||
var start = requested.From.Value;
|
||||
if (start >= maxBytes)
|
||||
return false;
|
||||
|
||||
if (HasTraversalSegment(objectKey))
|
||||
{
|
||||
logger.LogWarning("Rejected media request {ObjectKey}: contains a path traversal segment", objectKey);
|
||||
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
return;
|
||||
}
|
||||
|
||||
var prefixBoundary = prefix.EndsWith('/') ? prefix : prefix + "/";
|
||||
if (!string.Equals(objectKey, prefix, StringComparison.Ordinal)
|
||||
&& !objectKey.StartsWith(prefixBoundary, StringComparison.Ordinal))
|
||||
{
|
||||
logger.LogWarning("Rejected media request {ObjectKey}: outside authorized prefix {Prefix}", objectKey, prefix);
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
return;
|
||||
}
|
||||
|
||||
if (validation.MaxBytes is { } maxBytes && !TryClampRange(context, maxBytes))
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Rejected media request {ObjectKey}: requested range is a suffix range or starts beyond the {MaxBytes}-byte preview limit",
|
||||
objectKey, maxBytes);
|
||||
context.Response.StatusCode = StatusCodes.Status416RangeNotSatisfiable;
|
||||
context.Response.Headers[HeaderNames.ContentRange] = $"bytes */{maxBytes}";
|
||||
return;
|
||||
}
|
||||
|
||||
StripQueryParameter(context, options.QueryParameterName);
|
||||
await next(context);
|
||||
}
|
||||
|
||||
private static bool TryClampRange(HttpContext context, long maxBytes)
|
||||
{
|
||||
var rangeHeader = context.Request.Headers.Range;
|
||||
if (StringValues.IsNullOrEmpty(rangeHeader))
|
||||
{
|
||||
context.Request.Headers.Range = $"bytes=0-{maxBytes - 1}";
|
||||
var end = requested.To is { } requestedEnd ? Math.Min(requestedEnd, maxBytes - 1) : maxBytes - 1;
|
||||
context.Request.Headers.Range = $"bytes={start}-{end}";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!RangeHeaderValue.TryParse(rangeHeader.ToString(), out var range)
|
||||
|| range.Ranges.Count != 1
|
||||
|| !string.Equals(range.Unit.ToString(), "bytes", StringComparison.OrdinalIgnoreCase))
|
||||
private static bool HasTraversalSegment(string objectKey) =>
|
||||
objectKey.Split('/').Any(segment => segment is ".." or ".");
|
||||
|
||||
private static void StripQueryParameter(HttpContext context, string name)
|
||||
{
|
||||
context.Request.Headers.Range = $"bytes=0-{maxBytes - 1}";
|
||||
return true;
|
||||
var remaining = context.Request.Query
|
||||
.Where(pair => !string.Equals(pair.Key, name, StringComparison.Ordinal))
|
||||
.SelectMany(pair => pair.Value.Select(value => new KeyValuePair<string, string?>(pair.Key, value)))
|
||||
.ToList();
|
||||
|
||||
context.Request.QueryString = QueryString.Create(remaining);
|
||||
}
|
||||
|
||||
var requested = range.Ranges.Single();
|
||||
if (requested.From is null)
|
||||
return false;
|
||||
|
||||
var start = requested.From.Value;
|
||||
if (start >= maxBytes)
|
||||
return false;
|
||||
|
||||
var end = requested.To is { } requestedEnd ? Math.Min(requestedEnd, maxBytes - 1) : maxBytes - 1;
|
||||
context.Request.Headers.Range = $"bytes={start}-{end}";
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool HasTraversalSegment(string objectKey) =>
|
||||
objectKey.Split('/').Any(segment => segment is ".." or ".");
|
||||
|
||||
private static void StripQueryParameter(HttpContext context, string name)
|
||||
{
|
||||
var remaining = context.Request.Query
|
||||
.Where(pair => !string.Equals(pair.Key, name, StringComparison.Ordinal))
|
||||
.SelectMany(pair => pair.Value.Select(value => new KeyValuePair<string, string?>(pair.Key, value)))
|
||||
.ToList();
|
||||
|
||||
context.Request.QueryString = QueryString.Create(remaining);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user