From b87b4a7dd515a25d1fee8c4bc8a3c2882ccf214a Mon Sep 17 00:00:00 2001 From: XShine555 Date: Sat, 30 May 2026 03:29:21 +0200 Subject: [PATCH] Proxy --- .../ConfigurationDependencyInjection.cs | 12 +++++ .../StreamGatewayConfiguration.cs | 16 ++++++ Application/Contracts/IStreamTicketService.cs | 9 ++++ .../Handlers/GetTrackStreamQueryHandler.cs | 54 +++++++++++++++++++ .../Tracks/Queries/GetTrackStreamQuery.cs | 9 ++++ .../Tracks/Responses/TrackStreamResponse.cs | 7 +++ .../StreamTicketConfiguration.cs | 21 ++++++++ Infrastructure/Infrastructure.csproj | 1 + .../Services/ServicesDependencyInjection.cs | 14 +++++ .../Services/StreamTicketService.cs | 52 ++++++++++++++++++ 10 files changed, 195 insertions(+) create mode 100644 Application/Configuration/StreamGatewayConfiguration.cs create mode 100644 Application/Contracts/IStreamTicketService.cs create mode 100644 Application/Tracks/Handlers/GetTrackStreamQueryHandler.cs create mode 100644 Application/Tracks/Queries/GetTrackStreamQuery.cs create mode 100644 Application/Tracks/Responses/TrackStreamResponse.cs create mode 100644 Infrastructure/Configuration/StreamTicketConfiguration.cs create mode 100644 Infrastructure/Services/StreamTicketService.cs diff --git a/Application/Configuration/ConfigurationDependencyInjection.cs b/Application/Configuration/ConfigurationDependencyInjection.cs index c7e2504..a4687af 100644 --- a/Application/Configuration/ConfigurationDependencyInjection.cs +++ b/Application/Configuration/ConfigurationDependencyInjection.cs @@ -41,5 +41,17 @@ namespace Musify.Application.Configuration return serviceDescriptors; } + + public static IServiceCollection AddStreamGatewayConfiguration(this IServiceCollection serviceDescriptors, IConfiguration configuration) + { + serviceDescriptors + .AddOptionsWithValidateOnStart() + .Bind(configuration.GetRequiredSection(StreamGatewayConfiguration.SectionName)) + .ValidateDataAnnotations(); + serviceDescriptors.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService>().Value); + + return serviceDescriptors; + } } } \ No newline at end of file diff --git a/Application/Configuration/StreamGatewayConfiguration.cs b/Application/Configuration/StreamGatewayConfiguration.cs new file mode 100644 index 0000000..e19cad6 --- /dev/null +++ b/Application/Configuration/StreamGatewayConfiguration.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; + +namespace Musify.Application.Configuration +{ + public class StreamGatewayConfiguration + { + public const string SectionName = "StreamGateway"; + + [Url] + [Required] + public required string PublicBaseUrl { get; set; } + + [Required] + public string ManifestFileName { get; set; } = "manifest.mpd"; + } +} diff --git a/Application/Contracts/IStreamTicketService.cs b/Application/Contracts/IStreamTicketService.cs new file mode 100644 index 0000000..21b6f50 --- /dev/null +++ b/Application/Contracts/IStreamTicketService.cs @@ -0,0 +1,9 @@ +namespace Musify.Application.Contracts +{ + public interface IStreamTicketService + { + StreamTicket IssueTicket(Guid userId, string keyPrefix); + } + + public record StreamTicket(string Token, int ExpiresInSeconds); +} diff --git a/Application/Tracks/Handlers/GetTrackStreamQueryHandler.cs b/Application/Tracks/Handlers/GetTrackStreamQueryHandler.cs new file mode 100644 index 0000000..b9f4eff --- /dev/null +++ b/Application/Tracks/Handlers/GetTrackStreamQueryHandler.cs @@ -0,0 +1,54 @@ +using Ardalis.Result; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Configuration; +using Musify.Application.Contracts; +using Musify.Application.Tracks.Queries; +using Musify.Application.Tracks.Responses; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Tracks.Handlers +{ + public class GetTrackStreamQueryHandler( + IDatabase database, + IStreamTicketService ticketService, + TrackConfiguration trackConfiguration, + StreamGatewayConfiguration streamGatewayConfiguration, + ILogger logger) + : IQueryHandler> + { + public async ValueTask> Handle(GetTrackStreamQuery request, CancellationToken cancellationToken) + { + var track = await database.Tracks.AsNoTracking() + .Where(t => t.Id == request.TrackId) + .Select(t => new { t.Id, t.AudioTranscodeProcessingStatus, t.AudioFolderName }) + .SingleOrDefaultAsync(cancellationToken); + + if (track is null) + return Result.NotFound(); + + if (string.IsNullOrWhiteSpace(track.AudioFolderName) + || track.AudioTranscodeProcessingStatus != ProcessingStatus.Completed) + { + logger.LogInformation("Stream requested for track {TrackId} but audio is not ready", request.TrackId); + return Result.Conflict("Track audio is not available for streaming yet."); + } + + var folderPath = trackConfiguration.Routes.BuildProcessedAudioPath(track.AudioFolderName); + var keyPrefix = $"{folderPath}/"; + + var ticket = ticketService.IssueTicket(request.UserId, keyPrefix); + + var manifestUrl = string.Join('/', + streamGatewayConfiguration.PublicBaseUrl.TrimEnd('/'), + "media", + folderPath, + streamGatewayConfiguration.ManifestFileName); + + logger.LogInformation("Issued stream ticket for track {TrackId} to user {UserId}", request.TrackId, request.UserId); + + return Result.Success(new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds)); + } + } +} diff --git a/Application/Tracks/Queries/GetTrackStreamQuery.cs b/Application/Tracks/Queries/GetTrackStreamQuery.cs new file mode 100644 index 0000000..66733ad --- /dev/null +++ b/Application/Tracks/Queries/GetTrackStreamQuery.cs @@ -0,0 +1,9 @@ +using Ardalis.Result; +using Mediator; +using Musify.Application.Tracks.Responses; + +namespace Musify.Application.Tracks.Queries +{ + public record GetTrackStreamQuery(Guid TrackId, Guid UserId) + : IQuery>; +} diff --git a/Application/Tracks/Responses/TrackStreamResponse.cs b/Application/Tracks/Responses/TrackStreamResponse.cs new file mode 100644 index 0000000..c925a8b --- /dev/null +++ b/Application/Tracks/Responses/TrackStreamResponse.cs @@ -0,0 +1,7 @@ +namespace Musify.Application.Tracks.Responses +{ + public record TrackStreamResponse( + string ManifestUrl, + string Ticket, + int ExpiresInSeconds); +} diff --git a/Infrastructure/Configuration/StreamTicketConfiguration.cs b/Infrastructure/Configuration/StreamTicketConfiguration.cs new file mode 100644 index 0000000..0773c90 --- /dev/null +++ b/Infrastructure/Configuration/StreamTicketConfiguration.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace Musify.Infrastructure.Configuration +{ + public class StreamTicketConfiguration + { + public const string SectionName = "StreamTicket"; + + [Required] + public required string PrivateKeyPath { get; set; } + + [Required] + public string Audience { get; set; } = "media-gateway"; + + [Required] + public string Issuer { get; set; } = "musify-webapi"; + + [Range(60, 86400)] + public int TicketTtlSeconds { get; set; } = 3600; + } +} diff --git a/Infrastructure/Infrastructure.csproj b/Infrastructure/Infrastructure.csproj index 379581c..b64cac2 100644 --- a/Infrastructure/Infrastructure.csproj +++ b/Infrastructure/Infrastructure.csproj @@ -26,6 +26,7 @@ + diff --git a/Infrastructure/Services/ServicesDependencyInjection.cs b/Infrastructure/Services/ServicesDependencyInjection.cs index 36d9b5c..3597e68 100644 --- a/Infrastructure/Services/ServicesDependencyInjection.cs +++ b/Infrastructure/Services/ServicesDependencyInjection.cs @@ -71,6 +71,20 @@ namespace Musify.Infrastructure.Services return serviceDescriptors; } + public static IServiceCollection AddStreamTicketService(this IServiceCollection serviceDescriptors, IConfiguration configuration) + { + serviceDescriptors + .AddOptionsWithValidateOnStart() + .Bind(configuration.GetRequiredSection(StreamTicketConfiguration.SectionName)) + .ValidateDataAnnotations(); + + serviceDescriptors.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService>().Value); + + serviceDescriptors.AddSingleton(); + return serviceDescriptors; + } + public static IServiceCollection AddAudioTranscoder(this IServiceCollection serviceDescriptors, IConfiguration configuration) { serviceDescriptors diff --git a/Infrastructure/Services/StreamTicketService.cs b/Infrastructure/Services/StreamTicketService.cs new file mode 100644 index 0000000..2938ed4 --- /dev/null +++ b/Infrastructure/Services/StreamTicketService.cs @@ -0,0 +1,52 @@ +using System.Security.Cryptography; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using Musify.Application.Contracts; +using Musify.Infrastructure.Configuration; + +namespace Musify.Infrastructure.Services +{ + public sealed class StreamTicketService : IStreamTicketService, IDisposable + { + private readonly StreamTicketConfiguration configuration; + private readonly RSA rsa; + private readonly SigningCredentials signingCredentials; + private readonly JsonWebTokenHandler tokenHandler = new(); + + public StreamTicketService(StreamTicketConfiguration configuration) + { + this.configuration = configuration; + + rsa = RSA.Create(); + rsa.ImportFromPem(File.ReadAllText(configuration.PrivateKeyPath)); + + var key = new RsaSecurityKey(rsa); + signingCredentials = new SigningCredentials(key, SecurityAlgorithms.RsaSha256); + } + + public StreamTicket IssueTicket(Guid userId, string keyPrefix) + { + var now = DateTimeOffset.UtcNow; + + var descriptor = new SecurityTokenDescriptor + { + Issuer = configuration.Issuer, + Audience = configuration.Audience, + IssuedAt = now.UtcDateTime, + NotBefore = now.UtcDateTime, + Expires = now.AddSeconds(configuration.TicketTtlSeconds).UtcDateTime, + Claims = new Dictionary + { + ["sub"] = userId.ToString(), + ["prefix"] = keyPrefix, + }, + SigningCredentials = signingCredentials, + }; + + var token = tokenHandler.CreateToken(descriptor); + return new StreamTicket(token, configuration.TicketTtlSeconds); + } + + public void Dispose() => rsa.Dispose(); + } +}