Design Prot Merge #1
@@ -20,6 +20,7 @@ public static class ApplicationDependencyInjection
|
|||||||
services.AddValidatedOptions<MixConfiguration>(configuration, MixConfiguration.SectionName);
|
services.AddValidatedOptions<MixConfiguration>(configuration, MixConfiguration.SectionName);
|
||||||
services.AddValidatedOptions<StreamGatewayConfiguration>(configuration, StreamGatewayConfiguration.SectionName);
|
services.AddValidatedOptions<StreamGatewayConfiguration>(configuration, StreamGatewayConfiguration.SectionName);
|
||||||
services.AddValidatedOptions<UploadIntentConfiguration>(configuration, UploadIntentConfiguration.SectionName);
|
services.AddValidatedOptions<UploadIntentConfiguration>(configuration, UploadIntentConfiguration.SectionName);
|
||||||
|
services.AddValidatedOptions<PlaybackConfiguration>(configuration, PlaybackConfiguration.SectionName);
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Musify.Application.Configuration
|
||||||
|
{
|
||||||
|
/// <summary>Controls whether listeners without a session can stream music at all, and if so,
|
||||||
|
/// how much of a track they get to hear before being cut off (a Spotify-style preview).</summary>
|
||||||
|
public sealed class PlaybackConfiguration
|
||||||
|
{
|
||||||
|
public const string SectionName = "Playback";
|
||||||
|
|
||||||
|
/// <summary>When false (the default), every stream endpoint requires a signed-in user, exactly
|
||||||
|
/// like before this setting existed. When true, an anonymous request is issued a stream ticket
|
||||||
|
/// too, capped by <see cref="AnonymousFragmentSeconds"/> if that is greater than zero.</summary>
|
||||||
|
public bool AllowAnonymousListening { get; set; }
|
||||||
|
|
||||||
|
/// <summary>How many seconds of a track an anonymous listener may hear, enforced by the
|
||||||
|
/// Streaming Gateway clamping the byte range it will serve for that ticket. Zero (the default)
|
||||||
|
/// means no cap: anonymous listeners hear the full track, same as a signed-in user. Ignored
|
||||||
|
/// when <see cref="AllowAnonymousListening"/> is false.</summary>
|
||||||
|
[Range(0, 3600)]
|
||||||
|
public int AnonymousFragmentSeconds { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Bytes per second of the transcoded audio, used to turn <see cref="AnonymousFragmentSeconds"/>
|
||||||
|
/// into a byte cap for the stream ticket. Defaults to the app's own transcoder output (128 kbps
|
||||||
|
/// AAC, <c>AudioTranscoder:Ffmpeg:AudioBitrate</c>) — update this alongside that setting if it
|
||||||
|
/// ever changes, since the two aren't wired together.</summary>
|
||||||
|
[Range(1, int.MaxValue)]
|
||||||
|
public int EstimatedAudioBytesPerSecond { get; set; } = 16_000;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,12 @@ namespace Musify.Application.Contracts
|
|||||||
{
|
{
|
||||||
public interface IStreamTicketService
|
public interface IStreamTicketService
|
||||||
{
|
{
|
||||||
StreamTicket IssueTicket(long userId, string keyPrefix);
|
/// <param name="userId">Null for an anonymous listener — the ticket is issued without a
|
||||||
|
/// <c>sub</c> claim.</param>
|
||||||
|
/// <param name="keyPrefix">The storage key (prefix) the ticket authorizes reading from.</param>
|
||||||
|
/// <param name="maxBytes">When set, the Streaming Gateway clamps the byte range it will serve
|
||||||
|
/// for this ticket to <c>[0, maxBytes)</c> — used for the anonymous preview fragment.</param>
|
||||||
|
StreamTicket IssueTicket(long? userId, string keyPrefix, long? maxBytes = null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public record StreamTicket(string Token, int ExpiresInSeconds);
|
public record StreamTicket(string Token, int ExpiresInSeconds);
|
||||||
|
|||||||
@@ -9,18 +9,20 @@ public sealed class TrackStreamIssuer(
|
|||||||
IDatabase database,
|
IDatabase database,
|
||||||
IStreamTicketService ticketService,
|
IStreamTicketService ticketService,
|
||||||
TrackConfiguration trackConfiguration,
|
TrackConfiguration trackConfiguration,
|
||||||
StreamGatewayConfiguration streamGatewayConfiguration)
|
StreamGatewayConfiguration streamGatewayConfiguration,
|
||||||
|
PlaybackConfiguration playbackConfiguration)
|
||||||
{
|
{
|
||||||
public async Task<TrackStreamResponse> IssueAsync(
|
public async Task<TrackStreamResponse> IssueAsync(
|
||||||
Guid trackId,
|
Guid trackId,
|
||||||
string audioFolderName,
|
string audioFolderName,
|
||||||
long userId,
|
long? userId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var folderPath = trackConfiguration.Routes.BuildProcessedAudioPath(audioFolderName);
|
var folderPath = trackConfiguration.Routes.BuildProcessedAudioPath(audioFolderName);
|
||||||
var keyPrefix = $"{folderPath}/";
|
var keyPrefix = $"{folderPath}/";
|
||||||
|
|
||||||
var ticket = ticketService.IssueTicket(userId, keyPrefix);
|
var maxBytes = AnonymousFragmentBytes(userId);
|
||||||
|
var ticket = ticketService.IssueTicket(userId, keyPrefix, maxBytes);
|
||||||
|
|
||||||
var manifestUrl = string.Join('/',
|
var manifestUrl = string.Join('/',
|
||||||
streamGatewayConfiguration.PublicBaseUrl.TrimEnd('/'),
|
streamGatewayConfiguration.PublicBaseUrl.TrimEnd('/'),
|
||||||
@@ -28,14 +30,27 @@ public sealed class TrackStreamIssuer(
|
|||||||
folderPath,
|
folderPath,
|
||||||
streamGatewayConfiguration.AudioFileName);
|
streamGatewayConfiguration.AudioFileName);
|
||||||
|
|
||||||
var newListeningHistory = new ListeningHistory
|
// Anonymous listeners have no User row to satisfy ListeningHistory's foreign key against, and
|
||||||
|
// their plays aren't "history" anyone can look back on anyway, so there's nothing to record.
|
||||||
|
if (userId is not null)
|
||||||
{
|
{
|
||||||
UserId = userId,
|
var newListeningHistory = new ListeningHistory
|
||||||
TrackId = trackId,
|
{
|
||||||
};
|
UserId = userId.Value,
|
||||||
await database.ListeningHistories.AddAsync(newListeningHistory, cancellationToken);
|
TrackId = trackId,
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
};
|
||||||
|
await database.ListeningHistories.AddAsync(newListeningHistory, cancellationToken);
|
||||||
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
return new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds);
|
return new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private long? AnonymousFragmentBytes(long? userId)
|
||||||
|
{
|
||||||
|
if (userId is not null || playbackConfiguration.AnonymousFragmentSeconds <= 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return (long)playbackConfiguration.AnonymousFragmentSeconds * playbackConfiguration.EstimatedAudioBytesPerSecond;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,23 +2,30 @@ using ErrorOr;
|
|||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Musify.Application.Configuration;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Services;
|
using Musify.Application.Services;
|
||||||
using Musify.Application.Tracks.Responses;
|
using Musify.Application.Tracks.Responses;
|
||||||
|
|
||||||
namespace Musify.Application.Tracks
|
namespace Musify.Application.Tracks
|
||||||
{
|
{
|
||||||
public record GetTrackStreamQuery(Guid TrackId, long UserId)
|
/// <param name="UserId">Null for an anonymous request — allowed only when
|
||||||
|
/// <see cref="PlaybackConfiguration.AllowAnonymousListening"/> is enabled.</param>
|
||||||
|
public record GetTrackStreamQuery(Guid TrackId, long? UserId)
|
||||||
: IQuery<ErrorOr<TrackStreamResponse>>;
|
: IQuery<ErrorOr<TrackStreamResponse>>;
|
||||||
|
|
||||||
public class GetTrackStreamQueryHandler(
|
public class GetTrackStreamQueryHandler(
|
||||||
IDatabase database,
|
IDatabase database,
|
||||||
TrackStreamIssuer streamIssuer,
|
TrackStreamIssuer streamIssuer,
|
||||||
|
PlaybackConfiguration playbackConfiguration,
|
||||||
ILogger<GetTrackStreamQueryHandler> logger)
|
ILogger<GetTrackStreamQueryHandler> logger)
|
||||||
: IQueryHandler<GetTrackStreamQuery, ErrorOr<TrackStreamResponse>>
|
: IQueryHandler<GetTrackStreamQuery, ErrorOr<TrackStreamResponse>>
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<TrackStreamResponse>> Handle(GetTrackStreamQuery request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<TrackStreamResponse>> Handle(GetTrackStreamQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (request.UserId is null && !playbackConfiguration.AllowAnonymousListening)
|
||||||
|
return Error.Unauthorized(description: "Sign in to stream music, or ask an administrator to enable anonymous listening.");
|
||||||
|
|
||||||
var track = await database.Tracks.AsNoTracking()
|
var track = await database.Tracks.AsNoTracking()
|
||||||
.Where(t => t.Id == request.TrackId)
|
.Where(t => t.Id == request.TrackId)
|
||||||
.Select(t => new { t.Id, t.Audio })
|
.Select(t => new { t.Id, t.Audio })
|
||||||
@@ -35,7 +42,7 @@ namespace Musify.Application.Tracks
|
|||||||
|
|
||||||
var response = await streamIssuer.IssueAsync(track.Id, track.Audio.FolderName, request.UserId, cancellationToken);
|
var response = await streamIssuer.IssueAsync(track.Id, track.Audio.FolderName, request.UserId, cancellationToken);
|
||||||
|
|
||||||
logger.LogInformation("Issued stream ticket for track {TrackId} to user {UserId}", request.TrackId, request.UserId);
|
logger.LogInformation("Issued stream ticket for track {TrackId} to user {UserId}", request.TrackId, request.UserId?.ToString() ?? "(anonymous)");
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using ErrorOr;
|
|||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Musify.Application.Configuration;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Services;
|
using Musify.Application.Services;
|
||||||
using Musify.Application.YouTube.Responses;
|
using Musify.Application.YouTube.Responses;
|
||||||
@@ -9,7 +10,9 @@ using Musify.Domain.Entities;
|
|||||||
|
|
||||||
namespace Musify.Application.YouTube
|
namespace Musify.Application.YouTube
|
||||||
{
|
{
|
||||||
public record ResolveYouTubeTrackStreamCommand(string VideoId, long UserId)
|
/// <param name="UserId">Null for an anonymous request — allowed only when
|
||||||
|
/// <see cref="PlaybackConfiguration.AllowAnonymousListening"/> is enabled.</param>
|
||||||
|
public record ResolveYouTubeTrackStreamCommand(string VideoId, long? UserId)
|
||||||
: ICommand<ErrorOr<YouTubeStreamResponse>>;
|
: ICommand<ErrorOr<YouTubeStreamResponse>>;
|
||||||
|
|
||||||
public class ResolveYouTubeTrackStreamCommandHandler(
|
public class ResolveYouTubeTrackStreamCommandHandler(
|
||||||
@@ -17,14 +20,19 @@ namespace Musify.Application.YouTube
|
|||||||
IYouTubeMusicService youTubeMusicService,
|
IYouTubeMusicService youTubeMusicService,
|
||||||
YouTubeTrackProvisioner provisioner,
|
YouTubeTrackProvisioner provisioner,
|
||||||
TrackStreamIssuer streamIssuer,
|
TrackStreamIssuer streamIssuer,
|
||||||
|
PlaybackConfiguration playbackConfiguration,
|
||||||
ILogger<ResolveYouTubeTrackStreamCommandHandler> logger)
|
ILogger<ResolveYouTubeTrackStreamCommandHandler> logger)
|
||||||
: ICommandHandler<ResolveYouTubeTrackStreamCommand, ErrorOr<YouTubeStreamResponse>>
|
: ICommandHandler<ResolveYouTubeTrackStreamCommand, ErrorOr<YouTubeStreamResponse>>
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<YouTubeStreamResponse>> Handle(ResolveYouTubeTrackStreamCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<YouTubeStreamResponse>> Handle(ResolveYouTubeTrackStreamCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
var isAnonymous = request.UserId is null;
|
||||||
|
if (isAnonymous && !playbackConfiguration.AllowAnonymousListening)
|
||||||
|
return Error.Unauthorized(description: "Sign in to stream music, or ask an administrator to enable anonymous listening.");
|
||||||
|
|
||||||
var track = await provisioner.FindAsync(request.VideoId, cancellationToken);
|
var track = await provisioner.FindAsync(request.VideoId, cancellationToken);
|
||||||
|
|
||||||
if (track != null&& track.Audio.IsProcessed)
|
if (track != null && track.Audio.IsProcessed)
|
||||||
{
|
{
|
||||||
var serverStream = await streamIssuer.IssueAsync(track.Id, track.Audio.FolderName, request.UserId, cancellationToken);
|
var serverStream = await streamIssuer.IssueAsync(track.Id, track.Audio.FolderName, request.UserId, cancellationToken);
|
||||||
return new YouTubeStreamResponse(
|
return new YouTubeStreamResponse(
|
||||||
@@ -35,6 +43,13 @@ namespace Musify.Application.YouTube
|
|||||||
serverStream.ExpiresInSeconds);
|
serverStream.ExpiresInSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Below this point the only option left is streaming straight from YouTube's own CDN,
|
||||||
|
// which bypasses our Gateway (and its stream tickets) entirely — there's no way to cap that
|
||||||
|
// to the configured anonymous fragment, so an anonymous listener simply doesn't get it: they
|
||||||
|
// can retry once the track has been provisioned server-side, where the cap above applies.
|
||||||
|
if (isAnonymous && playbackConfiguration.AnonymousFragmentSeconds > 0)
|
||||||
|
return Error.Conflict(description: "This track isn't available for anonymous preview yet. Try again in a moment.");
|
||||||
|
|
||||||
var streamInfo = await youTubeMusicService.GetAudioStreamAsync(request.VideoId, cancellationToken);
|
var streamInfo = await youTubeMusicService.GetAudioStreamAsync(request.VideoId, cancellationToken);
|
||||||
if (streamInfo.IsError)
|
if (streamInfo.IsError)
|
||||||
return streamInfo.Errors;
|
return streamInfo.Errors;
|
||||||
@@ -48,11 +63,11 @@ namespace Musify.Application.YouTube
|
|||||||
logger.LogWarning("Could not provision YouTube track {VideoId}: {Error}", request.VideoId, provisionResult.FirstError.Description);
|
logger.LogWarning("Could not provision YouTube track {VideoId}: {Error}", request.VideoId, provisionResult.FirstError.Description);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (track != null)
|
if (track != null && request.UserId is not null)
|
||||||
{
|
{
|
||||||
await database.ListeningHistories.AddAsync(new ListeningHistory
|
await database.ListeningHistories.AddAsync(new ListeningHistory
|
||||||
{
|
{
|
||||||
UserId = request.UserId,
|
UserId = request.UserId.Value,
|
||||||
TrackId = track.Id,
|
TrackId = track.Id,
|
||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -24,10 +24,16 @@ namespace Musify.Infrastructure.Services
|
|||||||
signingCredentials = new SigningCredentials(key, SecurityAlgorithms.RsaSha256);
|
signingCredentials = new SigningCredentials(key, SecurityAlgorithms.RsaSha256);
|
||||||
}
|
}
|
||||||
|
|
||||||
public StreamTicket IssueTicket(long userId, string keyPrefix)
|
public StreamTicket IssueTicket(long? userId, string keyPrefix, long? maxBytes = null)
|
||||||
{
|
{
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
var claims = new Dictionary<string, object> { ["prefix"] = keyPrefix };
|
||||||
|
if (userId is not null)
|
||||||
|
claims["sub"] = userId.Value.ToString();
|
||||||
|
if (maxBytes is not null)
|
||||||
|
claims["maxBytes"] = maxBytes.Value.ToString();
|
||||||
|
|
||||||
var descriptor = new SecurityTokenDescriptor
|
var descriptor = new SecurityTokenDescriptor
|
||||||
{
|
{
|
||||||
Issuer = configuration.Issuer,
|
Issuer = configuration.Issuer,
|
||||||
@@ -35,11 +41,7 @@ namespace Musify.Infrastructure.Services
|
|||||||
IssuedAt = now.UtcDateTime,
|
IssuedAt = now.UtcDateTime,
|
||||||
NotBefore = now.UtcDateTime,
|
NotBefore = now.UtcDateTime,
|
||||||
Expires = now.AddSeconds(configuration.TicketTtlSeconds).UtcDateTime,
|
Expires = now.AddSeconds(configuration.TicketTtlSeconds).UtcDateTime,
|
||||||
Claims = new Dictionary<string, object>
|
Claims = claims,
|
||||||
{
|
|
||||||
["sub"] = userId.ToString(),
|
|
||||||
["prefix"] = keyPrefix,
|
|
||||||
},
|
|
||||||
SigningCredentials = signingCredentials,
|
SigningCredentials = signingCredentials,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
"PublicBaseUrl": "http://localhost:8081"
|
"PublicBaseUrl": "http://localhost:8081"
|
||||||
},
|
},
|
||||||
"StreamTicket": {
|
"StreamTicket": {
|
||||||
"PrivateKeyPath": "../../deploy/keys/stream_private.pem"
|
"PrivateKeyPath": "../../../deploy/keys/stream_private.pem"
|
||||||
},
|
},
|
||||||
"OpenTelemetry": {
|
"OpenTelemetry": {
|
||||||
"OtlpEndpoint": "http://localhost:4317"
|
"OtlpEndpoint": "http://localhost:4317"
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
},
|
},
|
||||||
"Workers": {
|
"Workers": {
|
||||||
"Routes": {
|
"Routes": {
|
||||||
"TemporaryFilesDirectory": "../../deploy/.tmp"
|
"TemporaryFilesDirectory": "../../../deploy/.tmp"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,11 @@
|
|||||||
"OpenTelemetry": {
|
"OpenTelemetry": {
|
||||||
"OtlpEndpoint": ""
|
"OtlpEndpoint": ""
|
||||||
},
|
},
|
||||||
|
"Playback": {
|
||||||
|
"AllowAnonymousListening": false,
|
||||||
|
"AnonymousFragmentSeconds": 0,
|
||||||
|
"EstimatedAudioBytesPerSecond": 16000
|
||||||
|
},
|
||||||
"YouTube": {
|
"YouTube": {
|
||||||
"Enabled": true,
|
"Enabled": true,
|
||||||
"GeographicalLocation": "US",
|
"GeographicalLocation": "US",
|
||||||
|
|||||||
@@ -36,8 +36,7 @@ public static class TrackEndpoints
|
|||||||
|
|
||||||
group.MapGet("/{id}/stream", GetTrackStream)
|
group.MapGet("/{id}/stream", GetTrackStream)
|
||||||
.WithName("GetTrackStream")
|
.WithName("GetTrackStream")
|
||||||
.WithSummary("Get A Streaming Manifest URL And Ticket For A Track.")
|
.WithSummary("Get A Streaming Manifest URL And Ticket For A Track. Works Anonymously When The Playback Configuration Allows It.")
|
||||||
.RequireAuthorization()
|
|
||||||
.Produces<TrackStreamResponse>()
|
.Produces<TrackStreamResponse>()
|
||||||
.Produces(StatusCodes.Status401Unauthorized)
|
.Produces(StatusCodes.Status401Unauthorized)
|
||||||
.Produces(StatusCodes.Status404NotFound);
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
@@ -126,7 +125,7 @@ public static class TrackEndpoints
|
|||||||
Guid id,
|
Guid id,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var result = await mediator.Send(new GetTrackStreamQuery(id, currentUser.RequiredId), cancellationToken);
|
var result = await mediator.Send(new GetTrackStreamQuery(id, currentUser.Id), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,11 +32,11 @@ public static class YouTubeEndpoints
|
|||||||
|
|
||||||
group.MapGet("/tracks/{videoId}/stream", GetTrackStream)
|
group.MapGet("/tracks/{videoId}/stream", GetTrackStream)
|
||||||
.WithName("GetYouTubeTrackStream")
|
.WithName("GetYouTubeTrackStream")
|
||||||
.WithSummary("Resolve Playback For A YouTube Track: Server Stream If Downloaded, Direct YouTube Stream Otherwise.")
|
.WithSummary("Resolve Playback For A YouTube Track: Server Stream If Downloaded, Direct YouTube Stream Otherwise. Works Anonymously When The Playback Configuration Allows It.")
|
||||||
.RequireAuthorization()
|
|
||||||
.Produces<YouTubeStreamResponse>()
|
.Produces<YouTubeStreamResponse>()
|
||||||
.Produces(StatusCodes.Status401Unauthorized)
|
.Produces(StatusCodes.Status401Unauthorized)
|
||||||
.Produces(StatusCodes.Status404NotFound);
|
.Produces(StatusCodes.Status404NotFound)
|
||||||
|
.Produces(StatusCodes.Status409Conflict);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
@@ -48,7 +48,7 @@ public static class YouTubeEndpoints
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var result = await mediator.Send(
|
var result = await mediator.Send(
|
||||||
new ResolveYouTubeTrackStreamCommand(videoId, currentUser.RequiredId),
|
new ResolveYouTubeTrackStreamCommand(videoId, currentUser.Id),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
|
|||||||
@@ -6,6 +6,17 @@ using Musify.StreamingGateway.Configuration;
|
|||||||
|
|
||||||
namespace Musify.StreamingGateway.Authentication;
|
namespace Musify.StreamingGateway.Authentication;
|
||||||
|
|
||||||
|
/// <param name="Prefix">The storage key prefix the ticket authorizes reading from. Empty means the
|
||||||
|
/// ticket was missing, malformed, expired, or otherwise invalid.</param>
|
||||||
|
/// <param name="MaxBytes">When set, the caller is limited to the first <c>MaxBytes</c> bytes of the
|
||||||
|
/// object — the anonymous preview fragment cap carried by the <c>maxBytes</c> claim.</param>
|
||||||
|
public sealed record TicketValidationResult(string Prefix, long? MaxBytes)
|
||||||
|
{
|
||||||
|
public static readonly TicketValidationResult Invalid = new(string.Empty, null);
|
||||||
|
|
||||||
|
public bool IsValid => Prefix.Length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class TicketValidator : IDisposable
|
public sealed class TicketValidator : IDisposable
|
||||||
{
|
{
|
||||||
private readonly StreamTicketValidationOptions _options;
|
private readonly StreamTicketValidationOptions _options;
|
||||||
@@ -34,17 +45,23 @@ public sealed class TicketValidator : IDisposable
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> ValidateTicketAsync(string? token)
|
public async Task<TicketValidationResult> ValidateTicketAsync(string? token)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(token))
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
return string.Empty;
|
return TicketValidationResult.Invalid;
|
||||||
|
|
||||||
var result = await _handler.ValidateTokenAsync(token, _validationParameters);
|
var result = await _handler.ValidateTokenAsync(token, _validationParameters);
|
||||||
if (!result.IsValid)
|
if (!result.IsValid)
|
||||||
return string.Empty;
|
return TicketValidationResult.Invalid;
|
||||||
|
|
||||||
var prefix = result.ClaimsIdentity?.FindFirst("prefix")?.Value;
|
var prefix = result.ClaimsIdentity?.FindFirst("prefix")?.Value;
|
||||||
return string.IsNullOrEmpty(prefix)? string.Empty : prefix;
|
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 void Dispose() => _rsa.Dispose();
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using Microsoft.Extensions.Primitives;
|
||||||
|
using Microsoft.Net.Http.Headers;
|
||||||
using Musify.StreamingGateway.Authentication;
|
using Musify.StreamingGateway.Authentication;
|
||||||
using Musify.StreamingGateway.Configuration;
|
using Musify.StreamingGateway.Configuration;
|
||||||
|
|
||||||
@@ -28,15 +30,16 @@ public sealed class TicketValidationMiddleware(
|
|||||||
token = context.Request.Headers[options.HeaderName].ToString();
|
token = context.Request.Headers[options.HeaderName].ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
var prefix = await ticketValidator.ValidateTicketAsync(token);
|
var validation = await ticketValidator.ValidateTicketAsync(token);
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(prefix))
|
if (!validation.IsValid)
|
||||||
{
|
{
|
||||||
logger.LogWarning("Rejected media request {Path}: missing or invalid ticket", path);
|
logger.LogWarning("Rejected media request {Path}: missing or invalid ticket", path);
|
||||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var prefix = validation.Prefix;
|
||||||
var objectKey = remaining.Value?.TrimStart('/') ?? string.Empty;
|
var objectKey = remaining.Value?.TrimStart('/') ?? string.Empty;
|
||||||
|
|
||||||
if (HasTraversalSegment(objectKey))
|
if (HasTraversalSegment(objectKey))
|
||||||
@@ -55,10 +58,48 @@ public sealed class TicketValidationMiddleware(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (validation.MaxBytes is { } maxBytes && !TryClampRange(context, maxBytes))
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"Rejected media request {ObjectKey}: requested range 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);
|
StripQueryParameter(context, options.QueryParameterName);
|
||||||
await next(context);
|
await next(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Rewrites the request's <c>Range</c> header so nothing past <paramref name="maxBytes"/>
|
||||||
|
/// is ever asked of the filer — this is what actually enforces the anonymous preview length,
|
||||||
|
/// downstream of the ticket carrying that cap. A request with no <c>Range</c> header (a plain,
|
||||||
|
/// whole-file GET) gets one synthesized, so the filer always responds with a bounded 206 instead of
|
||||||
|
/// streaming the full object. Returns false when the requested range starts at or past the cap, in
|
||||||
|
/// which case the caller should answer 416 directly instead of proxying anything.</summary>
|
||||||
|
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}";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!RangeHeaderValue.TryParse(rangeHeader.ToString(), out var range) || range.Ranges.Count != 1)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
var requested = range.Ranges.Single();
|
||||||
|
var start = requested.From ?? 0;
|
||||||
|
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) =>
|
private static bool HasTraversalSegment(string objectKey) =>
|
||||||
objectKey.Split('/').Any(segment => segment is ".." or ".");
|
objectKey.Split('/').Any(segment => segment is ".." or ".");
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"StreamTicket": {
|
"StreamTicket": {
|
||||||
"PublicKeyPath": "../../deploy/keys/stream_public.pem"
|
"PublicKeyPath": "../../../deploy/keys/stream_public.pem"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,12 +31,12 @@
|
|||||||
},
|
},
|
||||||
"AudioTranscoder": {
|
"AudioTranscoder": {
|
||||||
"Routes": {
|
"Routes": {
|
||||||
"WorkingDirectory": "../../deploy/.tmp"
|
"WorkingDirectory": "../../../deploy/.tmp"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Workers": {
|
"Workers": {
|
||||||
"Routes": {
|
"Routes": {
|
||||||
"TemporaryFilesDirectory": "../../deploy/.tmp"
|
"TemporaryFilesDirectory": "../../../deploy/.tmp"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Musify.Application.Configuration;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Services;
|
using Musify.Application.Services;
|
||||||
using Musify.Application.Tests.TestSupport;
|
using Musify.Application.Tests.TestSupport;
|
||||||
@@ -11,13 +12,17 @@ namespace Musify.Application.Tests.Services
|
|||||||
{
|
{
|
||||||
private readonly IStreamTicketService ticketService = Substitute.For<IStreamTicketService>();
|
private readonly IStreamTicketService ticketService = Substitute.For<IStreamTicketService>();
|
||||||
|
|
||||||
private TrackStreamIssuer CreateIssuer() => new(
|
private TrackStreamIssuer CreateIssuer(PlaybackConfiguration? playback = null) => new(
|
||||||
Database, ticketService, TestConfigurations.Track(), TestConfigurations.StreamGateway("https://stream.musify.test/"));
|
Database,
|
||||||
|
ticketService,
|
||||||
|
TestConfigurations.Track(),
|
||||||
|
TestConfigurations.StreamGateway("https://stream.musify.test/"),
|
||||||
|
playback ?? TestConfigurations.Playback());
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task IssueAsync_BuildsManifestUrlAndRecordsListeningHistory()
|
public async Task IssueAsync_BuildsManifestUrlAndRecordsListeningHistory()
|
||||||
{
|
{
|
||||||
ticketService.IssueTicket(7, Arg.Any<string>()).Returns(new StreamTicket("signed-token", 120));
|
ticketService.IssueTicket(7, Arg.Any<string>(), Arg.Any<long?>()).Returns(new StreamTicket("signed-token", 120));
|
||||||
var owner = TestEntities.User(7);
|
var owner = TestEntities.User(7);
|
||||||
var track = TestEntities.LocalTrack(owner);
|
var track = TestEntities.LocalTrack(owner);
|
||||||
await SeedAsync(owner, track);
|
await SeedAsync(owner, track);
|
||||||
@@ -38,14 +43,57 @@ namespace Musify.Application.Tests.Services
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task IssueAsync_PassesTheProcessedAudioKeyPrefixToTheTicketService()
|
public async Task IssueAsync_PassesTheProcessedAudioKeyPrefixToTheTicketService()
|
||||||
{
|
{
|
||||||
ticketService.IssueTicket(Arg.Any<long>(), Arg.Any<string>()).Returns(new StreamTicket("token", 60));
|
ticketService.IssueTicket(Arg.Any<long?>(), Arg.Any<string>(), Arg.Any<long?>()).Returns(new StreamTicket("token", 60));
|
||||||
var owner = TestEntities.User(1);
|
var owner = TestEntities.User(1);
|
||||||
var track = TestEntities.LocalTrack(owner);
|
var track = TestEntities.LocalTrack(owner);
|
||||||
await SeedAsync(owner, track);
|
await SeedAsync(owner, track);
|
||||||
|
|
||||||
await CreateIssuer().IssueAsync(track.Id, "folder-42", userId: 1, TestContext.Current.CancellationToken);
|
await CreateIssuer().IssueAsync(track.Id, "folder-42", userId: 1, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
ticketService.Received(1).IssueTicket(1, "Tracks/ProcessedAudios/folder-42/");
|
ticketService.Received(1).IssueTicket(1, "Tracks/ProcessedAudios/folder-42/", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task IssueAsync_AnonymousUser_SkipsListeningHistoryAndNeverThrows()
|
||||||
|
{
|
||||||
|
ticketService.IssueTicket(Arg.Any<long?>(), Arg.Any<string>(), Arg.Any<long?>()).Returns(new StreamTicket("token", 60));
|
||||||
|
var owner = TestEntities.User(1);
|
||||||
|
var track = TestEntities.LocalTrack(owner);
|
||||||
|
await SeedAsync(owner, track);
|
||||||
|
|
||||||
|
var response = await CreateIssuer(TestConfigurations.Playback(allowAnonymousListening: true))
|
||||||
|
.IssueAsync(track.Id, "folder-42", userId: null, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal("token", response.Ticket);
|
||||||
|
Assert.Empty(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task IssueAsync_AnonymousUserWithFragmentConfigured_PassesTheByteCapToTheTicketService()
|
||||||
|
{
|
||||||
|
ticketService.IssueTicket(Arg.Any<long?>(), Arg.Any<string>(), Arg.Any<long?>()).Returns(new StreamTicket("token", 60));
|
||||||
|
var owner = TestEntities.User(1);
|
||||||
|
var track = TestEntities.LocalTrack(owner);
|
||||||
|
await SeedAsync(owner, track);
|
||||||
|
|
||||||
|
var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30);
|
||||||
|
await CreateIssuer(playback).IssueAsync(track.Id, "folder-42", userId: null, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
ticketService.Received(1).IssueTicket(null, "Tracks/ProcessedAudios/folder-42/", 30L * playback.EstimatedAudioBytesPerSecond);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task IssueAsync_AuthenticatedUser_IgnoresTheAnonymousFragmentCap()
|
||||||
|
{
|
||||||
|
ticketService.IssueTicket(Arg.Any<long?>(), Arg.Any<string>(), Arg.Any<long?>()).Returns(new StreamTicket("token", 60));
|
||||||
|
var owner = TestEntities.User(1);
|
||||||
|
var track = TestEntities.LocalTrack(owner);
|
||||||
|
await SeedAsync(owner, track);
|
||||||
|
|
||||||
|
var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30);
|
||||||
|
await CreateIssuer(playback).IssueAsync(track.Id, "folder-42", userId: 1, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
ticketService.Received(1).IssueTicket(1, "Tracks/ProcessedAudios/folder-42/", null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,5 +17,8 @@ namespace Musify.Application.Tests.TestSupport
|
|||||||
|
|
||||||
public static StreamGatewayConfiguration StreamGateway(string publicBaseUrl = "https://stream.musify.test") =>
|
public static StreamGatewayConfiguration StreamGateway(string publicBaseUrl = "https://stream.musify.test") =>
|
||||||
new() { PublicBaseUrl = publicBaseUrl };
|
new() { PublicBaseUrl = publicBaseUrl };
|
||||||
|
|
||||||
|
public static PlaybackConfiguration Playback(bool allowAnonymousListening = false, int anonymousFragmentSeconds = 0) =>
|
||||||
|
new() { AllowAnonymousListening = allowAnonymousListening, AnonymousFragmentSeconds = anonymousFragmentSeconds };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using ErrorOr;
|
using ErrorOr;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Musify.Application.Configuration;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Services;
|
using Musify.Application.Services;
|
||||||
using Musify.Application.Tests.TestSupport;
|
using Musify.Application.Tests.TestSupport;
|
||||||
@@ -13,17 +14,18 @@ namespace Musify.Application.Tests.Tracks
|
|||||||
{
|
{
|
||||||
private readonly IStreamTicketService ticketService = Substitute.For<IStreamTicketService>();
|
private readonly IStreamTicketService ticketService = Substitute.For<IStreamTicketService>();
|
||||||
|
|
||||||
private GetTrackStreamQueryHandler CreateHandler()
|
private GetTrackStreamQueryHandler CreateHandler(PlaybackConfiguration? playback = null)
|
||||||
{
|
{
|
||||||
ticketService.IssueTicket(Arg.Any<long>(), Arg.Any<string>()).Returns(new StreamTicket("ticket-token", 60));
|
ticketService.IssueTicket(Arg.Any<long?>(), Arg.Any<string>(), Arg.Any<long?>()).Returns(new StreamTicket("ticket-token", 60));
|
||||||
|
|
||||||
var issuer = new TrackStreamIssuer(
|
var issuer = new TrackStreamIssuer(
|
||||||
Database,
|
Database,
|
||||||
ticketService,
|
ticketService,
|
||||||
TestConfigurations.Track(),
|
TestConfigurations.Track(),
|
||||||
TestConfigurations.StreamGateway("https://stream.musify.test"));
|
TestConfigurations.StreamGateway("https://stream.musify.test"),
|
||||||
|
playback ?? TestConfigurations.Playback());
|
||||||
|
|
||||||
return new GetTrackStreamQueryHandler(Database, issuer, NoOpLogger<GetTrackStreamQueryHandler>());
|
return new GetTrackStreamQueryHandler(Database, issuer, playback ?? TestConfigurations.Playback(), NoOpLogger<GetTrackStreamQueryHandler>());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -64,5 +66,35 @@ namespace Musify.Application.Tests.Tracks
|
|||||||
|
|
||||||
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
|
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_AnonymousUser_AnonymousListeningDisabled_ReturnsUnauthorized()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var track = TestEntities.LocalTrack(owner);
|
||||||
|
await SeedAsync(owner, track);
|
||||||
|
|
||||||
|
var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: false));
|
||||||
|
|
||||||
|
var result = await handler.Handle(new GetTrackStreamQuery(track.Id, UserId: null), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_AnonymousUser_AnonymousListeningEnabled_IssuesTicketWithoutRecordingHistory()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var track = TestEntities.LocalTrack(owner);
|
||||||
|
await SeedAsync(owner, track);
|
||||||
|
|
||||||
|
var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: true));
|
||||||
|
|
||||||
|
var result = await handler.Handle(new GetTrackStreamQuery(track.Id, UserId: null), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.False(result.IsError);
|
||||||
|
Assert.Equal("ticket-token", result.Value.Ticket);
|
||||||
|
Assert.Empty(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+63
-4
@@ -1,4 +1,6 @@
|
|||||||
|
using ErrorOr;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Musify.Application.Configuration;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Services;
|
using Musify.Application.Services;
|
||||||
using Musify.Application.Tests.TestSupport;
|
using Musify.Application.Tests.TestSupport;
|
||||||
@@ -14,21 +16,22 @@ namespace Musify.Application.Tests.YouTube
|
|||||||
private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
|
private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
|
||||||
private readonly IStreamTicketService ticketService = Substitute.For<IStreamTicketService>();
|
private readonly IStreamTicketService ticketService = Substitute.For<IStreamTicketService>();
|
||||||
|
|
||||||
private ResolveYouTubeTrackStreamCommandHandler CreateHandler()
|
private ResolveYouTubeTrackStreamCommandHandler CreateHandler(PlaybackConfiguration? playback = null)
|
||||||
{
|
{
|
||||||
|
playback ??= TestConfigurations.Playback();
|
||||||
var provisioner = new YouTubeTrackProvisioner(
|
var provisioner = new YouTubeTrackProvisioner(
|
||||||
Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger<YouTubeTrackProvisioner>());
|
Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger<YouTubeTrackProvisioner>());
|
||||||
var issuer = new TrackStreamIssuer(
|
var issuer = new TrackStreamIssuer(
|
||||||
Database, ticketService, TestConfigurations.Track(), TestConfigurations.StreamGateway());
|
Database, ticketService, TestConfigurations.Track(), TestConfigurations.StreamGateway(), playback);
|
||||||
|
|
||||||
return new ResolveYouTubeTrackStreamCommandHandler(
|
return new ResolveYouTubeTrackStreamCommandHandler(
|
||||||
Database, youTubeMusicService, provisioner, issuer, NoOpLogger<ResolveYouTubeTrackStreamCommandHandler>());
|
Database, youTubeMusicService, provisioner, issuer, playback, NoOpLogger<ResolveYouTubeTrackStreamCommandHandler>());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_AlreadyProcessedTrack_IssuesServerStreamWithoutCallingYouTubeForAudio()
|
public async Task Handle_AlreadyProcessedTrack_IssuesServerStreamWithoutCallingYouTubeForAudio()
|
||||||
{
|
{
|
||||||
ticketService.IssueTicket(Arg.Any<long>(), Arg.Any<string>()).Returns(new StreamTicket("token", 60));
|
ticketService.IssueTicket(Arg.Any<long?>(), Arg.Any<string>(), Arg.Any<long?>()).Returns(new StreamTicket("token", 60));
|
||||||
|
|
||||||
var user = TestEntities.User(1);
|
var user = TestEntities.User(1);
|
||||||
var track = TestEntities.ExternalTrack(externalId: "abc123");
|
var track = TestEntities.ExternalTrack(externalId: "abc123");
|
||||||
@@ -62,5 +65,61 @@ namespace Musify.Application.Tests.YouTube
|
|||||||
var history = Assert.Single(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken));
|
var history = Assert.Single(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken));
|
||||||
Assert.Equal(42, history.UserId);
|
Assert.Equal(42, history.UserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_AnonymousUser_AnonymousListeningDisabled_ReturnsUnauthorized()
|
||||||
|
{
|
||||||
|
var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: false));
|
||||||
|
|
||||||
|
var result = await handler.Handle(new ResolveYouTubeTrackStreamCommand("abc123", UserId: null), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
||||||
|
await youTubeMusicService.DidNotReceiveWithAnyArgs().GetAudioStreamAsync(default!, TestContext.Current.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_AnonymousUser_AlreadyProcessedTrack_IssuesServerStreamWithoutRecordingHistory()
|
||||||
|
{
|
||||||
|
ticketService.IssueTicket(Arg.Any<long?>(), Arg.Any<string>(), Arg.Any<long?>()).Returns(new StreamTicket("token", 60));
|
||||||
|
var track = TestEntities.ExternalTrack(externalId: "abc123");
|
||||||
|
await SeedAsync(track);
|
||||||
|
|
||||||
|
var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: true));
|
||||||
|
|
||||||
|
var result = await handler.Handle(new ResolveYouTubeTrackStreamCommand("abc123", UserId: null), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.False(result.IsError);
|
||||||
|
Assert.Equal(YouTubeStreamMode.Server, result.Value.Mode);
|
||||||
|
Assert.Empty(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_AnonymousUser_FragmentCapConfigured_UnprovisionedTrack_ReturnsConflictWithoutCallingYouTube()
|
||||||
|
{
|
||||||
|
var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30));
|
||||||
|
|
||||||
|
var result = await handler.Handle(new ResolveYouTubeTrackStreamCommand("not-provisioned-yet", UserId: null), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
|
||||||
|
await youTubeMusicService.DidNotReceiveWithAnyArgs().GetAudioStreamAsync(default!, TestContext.Current.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_AnonymousUser_NoFragmentCap_UnprovisionedTrack_FallsBackToDirectYouTubeStream()
|
||||||
|
{
|
||||||
|
youTubeMusicService.GetAudioStreamAsync("new-video", Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new YouTubeStreamInfo("https://youtube.example/stream", 300));
|
||||||
|
youTubeMusicService.GetSongAsync("new-video", Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new YouTubeSongResult("new-video", "Title", "Artist", "Album", 200, "https://img/thumb.jpg", false, []));
|
||||||
|
youTubeMusicService.ResolveArtworkUrl(Arg.Any<string>()).Returns(callInfo => callInfo.Arg<string>());
|
||||||
|
|
||||||
|
var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 0));
|
||||||
|
|
||||||
|
var result = await handler.Handle(new ResolveYouTubeTrackStreamCommand("new-video", UserId: null), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.False(result.IsError);
|
||||||
|
Assert.Equal(YouTubeStreamMode.YouTube, result.Value.Mode);
|
||||||
|
Assert.Empty(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,43 @@ namespace Musify.Infrastructure.Tests.Services
|
|||||||
Assert.Contains("media-gateway", jwt.Audiences);
|
Assert.Contains("media-gateway", jwt.Audiences);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IssueTicket_AnonymousUser_OmitsTheSubClaim()
|
||||||
|
{
|
||||||
|
using var service = CreateService();
|
||||||
|
|
||||||
|
var ticket = service.IssueTicket(userId: null, keyPrefix: "Tracks/ProcessedAudios/abc/");
|
||||||
|
|
||||||
|
var handler = new JsonWebTokenHandler();
|
||||||
|
var jwt = handler.ReadJsonWebToken(ticket.Token);
|
||||||
|
Assert.DoesNotContain(jwt.Claims, c => c.Type == "sub");
|
||||||
|
Assert.Equal("Tracks/ProcessedAudios/abc/", jwt.GetClaim("prefix").Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IssueTicket_WithMaxBytes_IncludesTheMaxBytesClaim()
|
||||||
|
{
|
||||||
|
using var service = CreateService();
|
||||||
|
|
||||||
|
var ticket = service.IssueTicket(userId: null, keyPrefix: "Tracks/ProcessedAudios/abc/", maxBytes: 480_000);
|
||||||
|
|
||||||
|
var handler = new JsonWebTokenHandler();
|
||||||
|
var jwt = handler.ReadJsonWebToken(ticket.Token);
|
||||||
|
Assert.Equal("480000", jwt.GetClaim("maxBytes").Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IssueTicket_WithoutMaxBytes_OmitsTheMaxBytesClaim()
|
||||||
|
{
|
||||||
|
using var service = CreateService();
|
||||||
|
|
||||||
|
var ticket = service.IssueTicket(userId: 1, keyPrefix: "Tracks/ProcessedAudios/abc/");
|
||||||
|
|
||||||
|
var handler = new JsonWebTokenHandler();
|
||||||
|
var jwt = handler.ReadJsonWebToken(ticket.Token);
|
||||||
|
Assert.DoesNotContain(jwt.Claims, c => c.Type == "maxBytes");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task IssueTicket_TokenValidatesAgainstTheMatchingPublicKey()
|
public async Task IssueTicket_TokenValidatesAgainstTheMatchingPublicKey()
|
||||||
{
|
{
|
||||||
|
|||||||
+43
-25
@@ -33,15 +33,19 @@ namespace Musify.StreamingGateway.Tests.Authentication
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
private string IssueToken(string prefix = "Tracks/ProcessedAudios/abc/", string issuer = Issuer, string audience = Audience,
|
private string IssueToken(string prefix = "Tracks/ProcessedAudios/abc/", string issuer = Issuer, string audience = Audience,
|
||||||
DateTime? expires = null, DateTime? notBefore = null)
|
DateTime? expires = null, DateTime? notBefore = null, long? maxBytes = null)
|
||||||
{
|
{
|
||||||
|
var claims = new Dictionary<string, object> { ["prefix"] = prefix };
|
||||||
|
if (maxBytes is not null)
|
||||||
|
claims["maxBytes"] = maxBytes.Value.ToString();
|
||||||
|
|
||||||
var descriptor = new SecurityTokenDescriptor
|
var descriptor = new SecurityTokenDescriptor
|
||||||
{
|
{
|
||||||
Issuer = issuer,
|
Issuer = issuer,
|
||||||
Audience = audience,
|
Audience = audience,
|
||||||
NotBefore = notBefore ?? DateTime.UtcNow.AddMinutes(-1),
|
NotBefore = notBefore ?? DateTime.UtcNow.AddMinutes(-1),
|
||||||
Expires = expires ?? DateTime.UtcNow.AddMinutes(5),
|
Expires = expires ?? DateTime.UtcNow.AddMinutes(5),
|
||||||
Claims = new Dictionary<string, object> { ["prefix"] = prefix },
|
Claims = claims,
|
||||||
SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256),
|
SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256),
|
||||||
};
|
};
|
||||||
return tokenHandler.CreateToken(descriptor);
|
return tokenHandler.CreateToken(descriptor);
|
||||||
@@ -52,62 +56,76 @@ namespace Musify.StreamingGateway.Tests.Authentication
|
|||||||
{
|
{
|
||||||
var token = IssueToken(prefix: "Tracks/ProcessedAudios/xyz/");
|
var token = IssueToken(prefix: "Tracks/ProcessedAudios/xyz/");
|
||||||
|
|
||||||
var prefix = await CreateValidator().ValidateTicketAsync(token);
|
var result = await CreateValidator().ValidateTicketAsync(token);
|
||||||
|
|
||||||
Assert.Equal("Tracks/ProcessedAudios/xyz/", prefix);
|
Assert.True(result.IsValid);
|
||||||
|
Assert.Equal("Tracks/ProcessedAudios/xyz/", result.Prefix);
|
||||||
|
Assert.Null(result.MaxBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateTicketAsync_TokenWithMaxBytesClaim_ReturnsIt()
|
||||||
|
{
|
||||||
|
var token = IssueToken(maxBytes: 480_000);
|
||||||
|
|
||||||
|
var result = await CreateValidator().ValidateTicketAsync(token);
|
||||||
|
|
||||||
|
Assert.True(result.IsValid);
|
||||||
|
Assert.Equal(480_000, result.MaxBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(null)]
|
[InlineData(null)]
|
||||||
[InlineData("")]
|
[InlineData("")]
|
||||||
[InlineData(" ")]
|
[InlineData(" ")]
|
||||||
public async Task ValidateTicketAsync_BlankToken_ReturnsEmptyString(string? token)
|
public async Task ValidateTicketAsync_BlankToken_ReturnsInvalid(string? token)
|
||||||
{
|
{
|
||||||
var prefix = await CreateValidator().ValidateTicketAsync(token);
|
var result = await CreateValidator().ValidateTicketAsync(token);
|
||||||
|
|
||||||
Assert.Equal(string.Empty, prefix);
|
Assert.False(result.IsValid);
|
||||||
|
Assert.Equal(string.Empty, result.Prefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ValidateTicketAsync_MalformedToken_ReturnsEmptyString()
|
public async Task ValidateTicketAsync_MalformedToken_ReturnsInvalid()
|
||||||
{
|
{
|
||||||
var prefix = await CreateValidator().ValidateTicketAsync("not-a-real-jwt");
|
var result = await CreateValidator().ValidateTicketAsync("not-a-real-jwt");
|
||||||
|
|
||||||
Assert.Equal(string.Empty, prefix);
|
Assert.False(result.IsValid);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ValidateTicketAsync_ExpiredToken_ReturnsEmptyString()
|
public async Task ValidateTicketAsync_ExpiredToken_ReturnsInvalid()
|
||||||
{
|
{
|
||||||
var token = IssueToken(expires: DateTime.UtcNow.AddMinutes(-40), notBefore: DateTime.UtcNow.AddMinutes(-50));
|
var token = IssueToken(expires: DateTime.UtcNow.AddMinutes(-40), notBefore: DateTime.UtcNow.AddMinutes(-50));
|
||||||
|
|
||||||
var prefix = await CreateValidator().ValidateTicketAsync(token);
|
var result = await CreateValidator().ValidateTicketAsync(token);
|
||||||
|
|
||||||
Assert.Equal(string.Empty, prefix);
|
Assert.False(result.IsValid);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ValidateTicketAsync_WrongIssuer_ReturnsEmptyString()
|
public async Task ValidateTicketAsync_WrongIssuer_ReturnsInvalid()
|
||||||
{
|
{
|
||||||
var token = IssueToken(issuer: "someone-else");
|
var token = IssueToken(issuer: "someone-else");
|
||||||
|
|
||||||
var prefix = await CreateValidator().ValidateTicketAsync(token);
|
var result = await CreateValidator().ValidateTicketAsync(token);
|
||||||
|
|
||||||
Assert.Equal(string.Empty, prefix);
|
Assert.False(result.IsValid);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ValidateTicketAsync_WrongAudience_ReturnsEmptyString()
|
public async Task ValidateTicketAsync_WrongAudience_ReturnsInvalid()
|
||||||
{
|
{
|
||||||
var token = IssueToken(audience: "someone-else");
|
var token = IssueToken(audience: "someone-else");
|
||||||
|
|
||||||
var prefix = await CreateValidator().ValidateTicketAsync(token);
|
var result = await CreateValidator().ValidateTicketAsync(token);
|
||||||
|
|
||||||
Assert.Equal(string.Empty, prefix);
|
Assert.False(result.IsValid);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ValidateTicketAsync_SignedWithADifferentKey_ReturnsEmptyString()
|
public async Task ValidateTicketAsync_SignedWithADifferentKey_ReturnsInvalid()
|
||||||
{
|
{
|
||||||
using var otherKey = RSA.Create(2048);
|
using var otherKey = RSA.Create(2048);
|
||||||
var descriptor = new SecurityTokenDescriptor
|
var descriptor = new SecurityTokenDescriptor
|
||||||
@@ -121,13 +139,13 @@ namespace Musify.StreamingGateway.Tests.Authentication
|
|||||||
};
|
};
|
||||||
var token = tokenHandler.CreateToken(descriptor);
|
var token = tokenHandler.CreateToken(descriptor);
|
||||||
|
|
||||||
var prefix = await CreateValidator().ValidateTicketAsync(token);
|
var result = await CreateValidator().ValidateTicketAsync(token);
|
||||||
|
|
||||||
Assert.Equal(string.Empty, prefix);
|
Assert.False(result.IsValid);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ValidateTicketAsync_MissingPrefixClaim_ReturnsEmptyString()
|
public async Task ValidateTicketAsync_MissingPrefixClaim_ReturnsInvalid()
|
||||||
{
|
{
|
||||||
var descriptor = new SecurityTokenDescriptor
|
var descriptor = new SecurityTokenDescriptor
|
||||||
{
|
{
|
||||||
@@ -139,9 +157,9 @@ namespace Musify.StreamingGateway.Tests.Authentication
|
|||||||
};
|
};
|
||||||
var token = tokenHandler.CreateToken(descriptor);
|
var token = tokenHandler.CreateToken(descriptor);
|
||||||
|
|
||||||
var prefix = await CreateValidator().ValidateTicketAsync(token);
|
var result = await CreateValidator().ValidateTicketAsync(token);
|
||||||
|
|
||||||
Assert.Equal(string.Empty, prefix);
|
Assert.False(result.IsValid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+74
-8
@@ -36,15 +36,22 @@ namespace Musify.StreamingGateway.Tests.Middleware
|
|||||||
File.Delete(publicKeyPath);
|
File.Delete(publicKeyPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
private string IssueValidToken(string prefix = Prefix) => new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor
|
private string IssueValidToken(string prefix = Prefix, long? maxBytes = null)
|
||||||
{
|
{
|
||||||
Issuer = options.Issuer,
|
var claims = new Dictionary<string, object> { ["prefix"] = prefix };
|
||||||
Audience = options.Audience,
|
if (maxBytes is not null)
|
||||||
NotBefore = DateTime.UtcNow.AddMinutes(-1),
|
claims["maxBytes"] = maxBytes.Value.ToString();
|
||||||
Expires = DateTime.UtcNow.AddMinutes(5),
|
|
||||||
Claims = new Dictionary<string, object> { ["prefix"] = prefix },
|
return new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor
|
||||||
SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256),
|
{
|
||||||
});
|
Issuer = options.Issuer,
|
||||||
|
Audience = options.Audience,
|
||||||
|
NotBefore = DateTime.UtcNow.AddMinutes(-1),
|
||||||
|
Expires = DateTime.UtcNow.AddMinutes(5),
|
||||||
|
Claims = claims,
|
||||||
|
SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private (TicketValidationMiddleware Middleware, Func<bool> WasNextCalled) CreateMiddleware()
|
private (TicketValidationMiddleware Middleware, Func<bool> WasNextCalled) CreateMiddleware()
|
||||||
{
|
{
|
||||||
@@ -150,5 +157,64 @@ namespace Musify.StreamingGateway.Tests.Middleware
|
|||||||
|
|
||||||
Assert.True(wasNextCalled());
|
Assert.True(wasNextCalled());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_TicketWithMaxBytes_NoRangeRequested_SynthesizesABoundedRange()
|
||||||
|
{
|
||||||
|
var (middleware, wasNextCalled) = CreateMiddleware();
|
||||||
|
var context = new DefaultHttpContext();
|
||||||
|
context.Request.Path = $"/media/{Prefix}/audio.m4a";
|
||||||
|
context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}");
|
||||||
|
|
||||||
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
|
Assert.True(wasNextCalled());
|
||||||
|
Assert.Equal("bytes=0-479999", context.Request.Headers.Range.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_TicketWithMaxBytes_RangeWithinCap_ClampsTheEndOnly()
|
||||||
|
{
|
||||||
|
var (middleware, wasNextCalled) = CreateMiddleware();
|
||||||
|
var context = new DefaultHttpContext();
|
||||||
|
context.Request.Path = $"/media/{Prefix}/audio.m4a";
|
||||||
|
context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}");
|
||||||
|
context.Request.Headers.Range = "bytes=1000-";
|
||||||
|
|
||||||
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
|
Assert.True(wasNextCalled());
|
||||||
|
Assert.Equal("bytes=1000-479999", context.Request.Headers.Range.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_TicketWithMaxBytes_RangeStartsAtOrPastTheCap_ReturnsRangeNotSatisfiable()
|
||||||
|
{
|
||||||
|
var (middleware, wasNextCalled) = CreateMiddleware();
|
||||||
|
var context = new DefaultHttpContext();
|
||||||
|
context.Request.Path = $"/media/{Prefix}/audio.m4a";
|
||||||
|
context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}");
|
||||||
|
context.Request.Headers.Range = "bytes=480000-";
|
||||||
|
|
||||||
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
|
Assert.False(wasNextCalled());
|
||||||
|
Assert.Equal(StatusCodes.Status416RangeNotSatisfiable, context.Response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_TicketWithoutMaxBytes_LeavesTheRequestedRangeUntouched()
|
||||||
|
{
|
||||||
|
var (middleware, wasNextCalled) = CreateMiddleware();
|
||||||
|
var context = new DefaultHttpContext();
|
||||||
|
context.Request.Path = $"/media/{Prefix}/audio.m4a";
|
||||||
|
context.Request.QueryString = new QueryString($"?t={IssueValidToken()}");
|
||||||
|
context.Request.Headers.Range = "bytes=1000-2000";
|
||||||
|
|
||||||
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
|
Assert.True(wasNextCalled());
|
||||||
|
Assert.Equal("bytes=1000-2000", context.Request.Headers.Range.ToString());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user