Design Prot Merge #1

Merged
NotS1mp merged 217 commits from design-prot into master 2026-09-21 15:48:16 +00:00
22 changed files with 486 additions and 86 deletions
Showing only changes of commit 356e5c1101 - Show all commits
@@ -20,6 +20,7 @@ public static class ApplicationDependencyInjection
services.AddValidatedOptions<MixConfiguration>(configuration, MixConfiguration.SectionName);
services.AddValidatedOptions<StreamGatewayConfiguration>(configuration, StreamGatewayConfiguration.SectionName);
services.AddValidatedOptions<UploadIntentConfiguration>(configuration, UploadIntentConfiguration.SectionName);
services.AddValidatedOptions<PlaybackConfiguration>(configuration, PlaybackConfiguration.SectionName);
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
{
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);
@@ -9,18 +9,20 @@ public sealed class TrackStreamIssuer(
IDatabase database,
IStreamTicketService ticketService,
TrackConfiguration trackConfiguration,
StreamGatewayConfiguration streamGatewayConfiguration)
StreamGatewayConfiguration streamGatewayConfiguration,
PlaybackConfiguration playbackConfiguration)
{
public async Task<TrackStreamResponse> IssueAsync(
Guid trackId,
string audioFolderName,
long userId,
long? userId,
CancellationToken cancellationToken)
{
var folderPath = trackConfiguration.Routes.BuildProcessedAudioPath(audioFolderName);
var keyPrefix = $"{folderPath}/";
var ticket = ticketService.IssueTicket(userId, keyPrefix);
var maxBytes = AnonymousFragmentBytes(userId);
var ticket = ticketService.IssueTicket(userId, keyPrefix, maxBytes);
var manifestUrl = string.Join('/',
streamGatewayConfiguration.PublicBaseUrl.TrimEnd('/'),
@@ -28,14 +30,27 @@ public sealed class TrackStreamIssuer(
folderPath,
streamGatewayConfiguration.AudioFileName);
// 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)
{
var newListeningHistory = new ListeningHistory
{
UserId = userId,
UserId = userId.Value,
TrackId = trackId,
};
await database.ListeningHistories.AddAsync(newListeningHistory, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
}
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 Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Services;
using Musify.Application.Tracks.Responses;
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>>;
public class GetTrackStreamQueryHandler(
IDatabase database,
TrackStreamIssuer streamIssuer,
PlaybackConfiguration playbackConfiguration,
ILogger<GetTrackStreamQueryHandler> logger)
: IQueryHandler<GetTrackStreamQuery, ErrorOr<TrackStreamResponse>>
{
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()
.Where(t => t.Id == request.TrackId)
.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);
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;
}
@@ -2,6 +2,7 @@ using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Services;
using Musify.Application.YouTube.Responses;
@@ -9,7 +10,9 @@ using Musify.Domain.Entities;
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>>;
public class ResolveYouTubeTrackStreamCommandHandler(
@@ -17,14 +20,19 @@ namespace Musify.Application.YouTube
IYouTubeMusicService youTubeMusicService,
YouTubeTrackProvisioner provisioner,
TrackStreamIssuer streamIssuer,
PlaybackConfiguration playbackConfiguration,
ILogger<ResolveYouTubeTrackStreamCommandHandler> logger)
: ICommandHandler<ResolveYouTubeTrackStreamCommand, ErrorOr<YouTubeStreamResponse>>
{
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);
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);
return new YouTubeStreamResponse(
@@ -35,6 +43,13 @@ namespace Musify.Application.YouTube
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);
if (streamInfo.IsError)
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);
}
if (track != null)
if (track != null && request.UserId is not null)
{
await database.ListeningHistories.AddAsync(new ListeningHistory
{
UserId = request.UserId,
UserId = request.UserId.Value,
TrackId = track.Id,
}, cancellationToken);
@@ -24,10 +24,16 @@ namespace Musify.Infrastructure.Services
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 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
{
Issuer = configuration.Issuer,
@@ -35,11 +41,7 @@ namespace Musify.Infrastructure.Services
IssuedAt = now.UtcDateTime,
NotBefore = now.UtcDateTime,
Expires = now.AddSeconds(configuration.TicketTtlSeconds).UtcDateTime,
Claims = new Dictionary<string, object>
{
["sub"] = userId.ToString(),
["prefix"] = keyPrefix,
},
Claims = claims,
SigningCredentials = signingCredentials,
};
@@ -31,7 +31,7 @@
"PublicBaseUrl": "http://localhost:8081"
},
"StreamTicket": {
"PrivateKeyPath": "../../deploy/keys/stream_private.pem"
"PrivateKeyPath": "../../../deploy/keys/stream_private.pem"
},
"OpenTelemetry": {
"OtlpEndpoint": "http://localhost:4317"
@@ -41,7 +41,7 @@
},
"Workers": {
"Routes": {
"TemporaryFilesDirectory": "../../deploy/.tmp"
"TemporaryFilesDirectory": "../../../deploy/.tmp"
}
}
}
@@ -48,6 +48,11 @@
"OpenTelemetry": {
"OtlpEndpoint": ""
},
"Playback": {
"AllowAnonymousListening": false,
"AnonymousFragmentSeconds": 0,
"EstimatedAudioBytesPerSecond": 16000
},
"YouTube": {
"Enabled": true,
"GeographicalLocation": "US",
@@ -36,8 +36,7 @@ public static class TrackEndpoints
group.MapGet("/{id}/stream", GetTrackStream)
.WithName("GetTrackStream")
.WithSummary("Get A Streaming Manifest URL And Ticket For A Track.")
.RequireAuthorization()
.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);
@@ -126,7 +125,7 @@ public static class TrackEndpoints
Guid id,
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();
}
@@ -32,11 +32,11 @@ public static class YouTubeEndpoints
group.MapGet("/tracks/{videoId}/stream", GetTrackStream)
.WithName("GetYouTubeTrackStream")
.WithSummary("Resolve Playback For A YouTube Track: Server Stream If Downloaded, Direct YouTube Stream Otherwise.")
.RequireAuthorization()
.WithSummary("Resolve Playback For A YouTube Track: Server Stream If Downloaded, Direct YouTube Stream Otherwise. Works Anonymously When The Playback Configuration Allows It.")
.Produces<YouTubeStreamResponse>()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict);
return app;
}
@@ -48,7 +48,7 @@ public static class YouTubeEndpoints
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new ResolveYouTubeTrackStreamCommand(videoId, currentUser.RequiredId),
new ResolveYouTubeTrackStreamCommand(videoId, currentUser.Id),
cancellationToken);
return result.ToHttpResult();
@@ -6,6 +6,17 @@ using Musify.StreamingGateway.Configuration;
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
{
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))
return string.Empty;
return TicketValidationResult.Invalid;
var result = await _handler.ValidateTokenAsync(token, _validationParameters);
if (!result.IsValid)
return string.Empty;
return TicketValidationResult.Invalid;
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();
@@ -1,4 +1,6 @@
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
using Microsoft.Net.Http.Headers;
using Musify.StreamingGateway.Authentication;
using Musify.StreamingGateway.Configuration;
@@ -28,15 +30,16 @@ public sealed class TicketValidationMiddleware(
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);
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
var prefix = validation.Prefix;
var objectKey = remaining.Value?.TrimStart('/') ?? string.Empty;
if (HasTraversalSegment(objectKey))
@@ -55,10 +58,48 @@ public sealed class TicketValidationMiddleware(
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);
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) =>
objectKey.Split('/').Any(segment => segment is ".." or ".");
@@ -1,5 +1,5 @@
{
"StreamTicket": {
"PublicKeyPath": "../../deploy/keys/stream_public.pem"
"PublicKeyPath": "../../../deploy/keys/stream_public.pem"
}
}
@@ -31,12 +31,12 @@
},
"AudioTranscoder": {
"Routes": {
"WorkingDirectory": "../../deploy/.tmp"
"WorkingDirectory": "../../../deploy/.tmp"
}
},
"Workers": {
"Routes": {
"TemporaryFilesDirectory": "../../deploy/.tmp"
"TemporaryFilesDirectory": "../../../deploy/.tmp"
}
}
}
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Services;
using Musify.Application.Tests.TestSupport;
@@ -11,13 +12,17 @@ namespace Musify.Application.Tests.Services
{
private readonly IStreamTicketService ticketService = Substitute.For<IStreamTicketService>();
private TrackStreamIssuer CreateIssuer() => new(
Database, ticketService, TestConfigurations.Track(), TestConfigurations.StreamGateway("https://stream.musify.test/"));
private TrackStreamIssuer CreateIssuer(PlaybackConfiguration? playback = null) => new(
Database,
ticketService,
TestConfigurations.Track(),
TestConfigurations.StreamGateway("https://stream.musify.test/"),
playback ?? TestConfigurations.Playback());
[Fact]
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 track = TestEntities.LocalTrack(owner);
await SeedAsync(owner, track);
@@ -38,14 +43,57 @@ namespace Musify.Application.Tests.Services
[Fact]
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 track = TestEntities.LocalTrack(owner);
await SeedAsync(owner, track);
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") =>
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 Microsoft.EntityFrameworkCore;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Services;
using Musify.Application.Tests.TestSupport;
@@ -13,17 +14,18 @@ namespace Musify.Application.Tests.Tracks
{
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(
Database,
ticketService,
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]
@@ -64,5 +66,35 @@ namespace Musify.Application.Tests.Tracks
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));
}
}
}
@@ -1,4 +1,6 @@
using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Services;
using Musify.Application.Tests.TestSupport;
@@ -14,21 +16,22 @@ namespace Musify.Application.Tests.YouTube
private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
private readonly IStreamTicketService ticketService = Substitute.For<IStreamTicketService>();
private ResolveYouTubeTrackStreamCommandHandler CreateHandler()
private ResolveYouTubeTrackStreamCommandHandler CreateHandler(PlaybackConfiguration? playback = null)
{
playback ??= TestConfigurations.Playback();
var provisioner = new YouTubeTrackProvisioner(
Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger<YouTubeTrackProvisioner>());
var issuer = new TrackStreamIssuer(
Database, ticketService, TestConfigurations.Track(), TestConfigurations.StreamGateway());
Database, ticketService, TestConfigurations.Track(), TestConfigurations.StreamGateway(), playback);
return new ResolveYouTubeTrackStreamCommandHandler(
Database, youTubeMusicService, provisioner, issuer, NoOpLogger<ResolveYouTubeTrackStreamCommandHandler>());
Database, youTubeMusicService, provisioner, issuer, playback, NoOpLogger<ResolveYouTubeTrackStreamCommandHandler>());
}
[Fact]
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 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));
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);
}
[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]
public async Task IssueTicket_TokenValidatesAgainstTheMatchingPublicKey()
{
@@ -33,15 +33,19 @@ namespace Musify.StreamingGateway.Tests.Authentication
}));
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
{
Issuer = issuer,
Audience = audience,
NotBefore = notBefore ?? DateTime.UtcNow.AddMinutes(-1),
Expires = expires ?? DateTime.UtcNow.AddMinutes(5),
Claims = new Dictionary<string, object> { ["prefix"] = prefix },
Claims = claims,
SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256),
};
return tokenHandler.CreateToken(descriptor);
@@ -52,62 +56,76 @@ namespace Musify.StreamingGateway.Tests.Authentication
{
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]
[InlineData(null)]
[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]
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]
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 prefix = await CreateValidator().ValidateTicketAsync(token);
var result = await CreateValidator().ValidateTicketAsync(token);
Assert.Equal(string.Empty, prefix);
Assert.False(result.IsValid);
}
[Fact]
public async Task ValidateTicketAsync_WrongIssuer_ReturnsEmptyString()
public async Task ValidateTicketAsync_WrongIssuer_ReturnsInvalid()
{
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]
public async Task ValidateTicketAsync_WrongAudience_ReturnsEmptyString()
public async Task ValidateTicketAsync_WrongAudience_ReturnsInvalid()
{
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]
public async Task ValidateTicketAsync_SignedWithADifferentKey_ReturnsEmptyString()
public async Task ValidateTicketAsync_SignedWithADifferentKey_ReturnsInvalid()
{
using var otherKey = RSA.Create(2048);
var descriptor = new SecurityTokenDescriptor
@@ -121,13 +139,13 @@ namespace Musify.StreamingGateway.Tests.Authentication
};
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]
public async Task ValidateTicketAsync_MissingPrefixClaim_ReturnsEmptyString()
public async Task ValidateTicketAsync_MissingPrefixClaim_ReturnsInvalid()
{
var descriptor = new SecurityTokenDescriptor
{
@@ -139,9 +157,9 @@ namespace Musify.StreamingGateway.Tests.Authentication
};
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);
}
}
}
@@ -36,15 +36,22 @@ namespace Musify.StreamingGateway.Tests.Middleware
File.Delete(publicKeyPath);
}
private string IssueValidToken(string prefix = Prefix) => new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor
private string IssueValidToken(string prefix = Prefix, long? maxBytes = null)
{
var claims = new Dictionary<string, object> { ["prefix"] = prefix };
if (maxBytes is not null)
claims["maxBytes"] = maxBytes.Value.ToString();
return new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor
{
Issuer = options.Issuer,
Audience = options.Audience,
NotBefore = DateTime.UtcNow.AddMinutes(-1),
Expires = DateTime.UtcNow.AddMinutes(5),
Claims = new Dictionary<string, object> { ["prefix"] = prefix },
Claims = claims,
SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256),
});
}
private (TicketValidationMiddleware Middleware, Func<bool> WasNextCalled) CreateMiddleware()
{
@@ -150,5 +157,64 @@ namespace Musify.StreamingGateway.Tests.Middleware
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());
}
}
}