Add anonymous playback with an optional Spotify-style preview cap

New Playback:AllowAnonymousListening / Playback:AnonymousFragmentSeconds
settings control whether a request without a session can stream at all,
and if so, whether it's limited to a fragment instead of the full track:

- GET /tracks/{id}/stream and GET /youtube/tracks/{videoId}/stream no
  longer require authorization outright; the handlers gate on the config
  instead, so both keep working exactly as before when it's off (the
  default).
- The fragment cap is enforced by the Streaming Gateway, not the client:
  the stream ticket now carries an optional maxBytes claim (computed from
  AnonymousFragmentSeconds and the transcoder's known bitrate), and
  TicketValidationMiddleware clamps the Range it forwards to SeaweedFS to
  stay under it — synthesizing one if the request didn't send one, and
  answering 416 outright if the requested range starts past the cap.
- A YouTube track that hasn't been provisioned server-side yet streams
  straight from YouTube's CDN, bypassing the Gateway entirely — there's no
  way to cap that, so an anonymous request for one is refused (409) instead
  of silently handing out an uncapped stream.
- ListeningHistory has a non-nullable UserId, so anonymous plays simply
  don't get recorded rather than trying to force a row through the FK.

Also fixes a real, pre-existing bug this surfaced: the AppSettings.Development.json
of the Api, Worker, and StreamingGateway all pointed StreamTicket/Workers
paths at "../../deploy/..." — one directory short of deploy/'s actual
location at the repo root relative to each project's own folder. Docker
never hit this (it mounts an absolute /keys path), so it had apparently
never been exercised via `dotnet run` before.
This commit is contained in:
2026-09-10 09:56:57 +02:00
parent ca04a81e81
commit 356e5c1101
22 changed files with 486 additions and 86 deletions
@@ -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);
// 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 var newListeningHistory = new ListeningHistory
{ {
UserId = userId, UserId = userId.Value,
TrackId = trackId, TrackId = trackId,
}; };
await database.ListeningHistories.AddAsync(newListeningHistory, cancellationToken); await database.ListeningHistories.AddAsync(newListeningHistory, cancellationToken);
await database.SaveChangesAsync(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));
}
} }
} }
@@ -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()
{ {
@@ -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);
} }
} }
} }
@@ -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)
{
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, Issuer = options.Issuer,
Audience = options.Audience, Audience = options.Audience,
NotBefore = DateTime.UtcNow.AddMinutes(-1), NotBefore = DateTime.UtcNow.AddMinutes(-1),
Expires = DateTime.UtcNow.AddMinutes(5), 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),
}); });
}
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());
}
} }
} }