From 20c0c33cdedb696f8b71968a15dce2b6e48d798d Mon Sep 17 00:00:00 2001 From: XShine555 Date: Sat, 30 May 2026 02:16:39 +0200 Subject: [PATCH] Add project files. --- Musify.StreamingGateway.slnx | 3 + .../Authentication/TicketValidator.cs | 52 +++++++++++++++++ .../StreamTicketValidationOptions.cs | 21 +++++++ .../Middleware/TicketValidationMiddleware.cs | 57 +++++++++++++++++++ StreamingGateway/Program.cs | 35 ++++++++++++ .../Properties/launchSettings.json | 14 +++++ StreamingGateway/StreamingGateway.csproj | 16 ++++++ StreamingGateway/appsettings.json | 41 +++++++++++++ 8 files changed, 239 insertions(+) create mode 100644 Musify.StreamingGateway.slnx create mode 100644 StreamingGateway/Authentication/TicketValidator.cs create mode 100644 StreamingGateway/Configuration/StreamTicketValidationOptions.cs create mode 100644 StreamingGateway/Middleware/TicketValidationMiddleware.cs create mode 100644 StreamingGateway/Program.cs create mode 100644 StreamingGateway/Properties/launchSettings.json create mode 100644 StreamingGateway/StreamingGateway.csproj create mode 100644 StreamingGateway/appsettings.json diff --git a/Musify.StreamingGateway.slnx b/Musify.StreamingGateway.slnx new file mode 100644 index 0000000..3626bbd --- /dev/null +++ b/Musify.StreamingGateway.slnx @@ -0,0 +1,3 @@ + + + diff --git a/StreamingGateway/Authentication/TicketValidator.cs b/StreamingGateway/Authentication/TicketValidator.cs new file mode 100644 index 0000000..5556e42 --- /dev/null +++ b/StreamingGateway/Authentication/TicketValidator.cs @@ -0,0 +1,52 @@ +using System.Security.Cryptography; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using Musify.StreamingGateway.Configuration; + +namespace Musify.StreamingGateway.Authentication; + +public sealed class TicketValidator : IDisposable +{ + private readonly StreamTicketValidationOptions options; + private readonly RSA rsa; + private readonly TokenValidationParameters validationParameters; + private readonly JsonWebTokenHandler handler = new(); + + public TicketValidator(IOptions options) + { + this.options = options.Value; + + rsa = RSA.Create(); + rsa.ImportFromPem(File.ReadAllText(this.options.PublicKeyPath)); + + validationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = this.options.Issuer, + ValidateAudience = true, + ValidAudience = this.options.Audience, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new RsaSecurityKey(rsa), + ValidAlgorithms = [SecurityAlgorithms.RsaSha256], + ClockSkew = TimeSpan.FromSeconds(30), + }; + } + + public async Task TryGetPrefixAsync(string? token) + { + if (string.IsNullOrWhiteSpace(token)) + return null; + + var result = await handler.ValidateTokenAsync(token, validationParameters); + if (!result.IsValid) + return null; + + return result.Claims.TryGetValue("prefix", out var prefix) + ? prefix as string + : null; + } + + public void Dispose() => rsa.Dispose(); +} diff --git a/StreamingGateway/Configuration/StreamTicketValidationOptions.cs b/StreamingGateway/Configuration/StreamTicketValidationOptions.cs new file mode 100644 index 0000000..7599c06 --- /dev/null +++ b/StreamingGateway/Configuration/StreamTicketValidationOptions.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace Musify.StreamingGateway.Configuration; + +public sealed class StreamTicketValidationOptions +{ + public const string SectionName = "StreamTicket"; + + [Required] + public required string PublicKeyPath { get; set; } + + [Required] + public string Audience { get; set; } = "media-gateway"; + + [Required] + public string Issuer { get; set; } = "musify-webapi"; + + public string QueryParameterName { get; set; } = "t"; + + public string MediaPathPrefix { get; set; } = "/media"; +} diff --git a/StreamingGateway/Middleware/TicketValidationMiddleware.cs b/StreamingGateway/Middleware/TicketValidationMiddleware.cs new file mode 100644 index 0000000..cba1fc3 --- /dev/null +++ b/StreamingGateway/Middleware/TicketValidationMiddleware.cs @@ -0,0 +1,57 @@ +using Microsoft.Extensions.Options; +using Musify.StreamingGateway.Authentication; +using Musify.StreamingGateway.Configuration; + +namespace Musify.StreamingGateway.Middleware; + +public sealed class TicketValidationMiddleware( + RequestDelegate next, + TicketValidator ticketValidator, + IOptions options, + ILogger logger) +{ + private readonly StreamTicketValidationOptions options = options.Value; + + public async Task InvokeAsync(HttpContext context) + { + var path = context.Request.Path; + + if (!path.StartsWithSegments(options.MediaPathPrefix, out var remaining)) + { + await next(context); + return; + } + + var token = context.Request.Query[options.QueryParameterName].ToString(); + var prefix = await ticketValidator.TryGetPrefixAsync(token); + + if (prefix is null) + { + logger.LogWarning("Rejected media request {Path}: missing or invalid ticket", path); + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + + var objectKey = remaining.Value?.TrimStart('/') ?? string.Empty; + if (!objectKey.StartsWith(prefix, StringComparison.Ordinal)) + { + logger.LogWarning("Rejected media request {ObjectKey}: outside authorized prefix {Prefix}", objectKey, prefix); + context.Response.StatusCode = StatusCodes.Status403Forbidden; + return; + } + + StripQueryParameter(context, options.QueryParameterName); + + await next(context); + } + + private static void StripQueryParameter(HttpContext context, string name) + { + var remaining = context.Request.Query + .Where(pair => !string.Equals(pair.Key, name, StringComparison.Ordinal)) + .SelectMany(pair => pair.Value.Select(value => new KeyValuePair(pair.Key, value))) + .ToList(); + + context.Request.QueryString = QueryString.Create(remaining); + } +} diff --git a/StreamingGateway/Program.cs b/StreamingGateway/Program.cs new file mode 100644 index 0000000..4e03270 --- /dev/null +++ b/StreamingGateway/Program.cs @@ -0,0 +1,35 @@ +using Musify.StreamingGateway.Authentication; +using Musify.StreamingGateway.Configuration; +using Musify.StreamingGateway.Middleware; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services + .AddOptions() + .Bind(builder.Configuration.GetRequiredSection(StreamTicketValidationOptions.SectionName)) + .ValidateDataAnnotations() + .ValidateOnStart(); + +builder.Services.AddSingleton(); + +const string corsPolicy = "media-clients"; +var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get() ?? []; +builder.Services.AddCors(options => options.AddPolicy(corsPolicy, policy => policy + .WithOrigins(allowedOrigins) + .AllowAnyHeader() + .WithMethods("GET", "HEAD") + .WithExposedHeaders("Content-Range", "Accept-Ranges", "Content-Length"))); + +builder.Services + .AddReverseProxy() + .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy")); + +var app = builder.Build(); + +app.MapGet("/health", () => Results.Ok("healthy")); + +app.UseCors(corsPolicy); +app.UseMiddleware(); +app.MapReverseProxy(); + +app.Run(); diff --git a/StreamingGateway/Properties/launchSettings.json b/StreamingGateway/Properties/launchSettings.json new file mode 100644 index 0000000..eaf1884 --- /dev/null +++ b/StreamingGateway/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "StreamingGateway": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:8081", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/StreamingGateway/StreamingGateway.csproj b/StreamingGateway/StreamingGateway.csproj new file mode 100644 index 0000000..bc16ead --- /dev/null +++ b/StreamingGateway/StreamingGateway.csproj @@ -0,0 +1,16 @@ + + + + net10.0 + enable + enable + Musify.StreamingGateway + media-gateway-2f1c9b6a-7d3e-4c0a-9a21-0b8e5d4c3f10 + + + + + + + + diff --git a/StreamingGateway/appsettings.json b/StreamingGateway/appsettings.json new file mode 100644 index 0000000..85e3e89 --- /dev/null +++ b/StreamingGateway/appsettings.json @@ -0,0 +1,41 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Yarp": "Information" + } + }, + "AllowedHosts": "*", + + "StreamTicket": { + "PublicKeyPath": "D:\\weed\\stream_public.pem", + "Audience": "media-gateway", + "Issuer": "musify-webapi", + "QueryParameterName": "t", + "MediaPathPrefix": "/media" + }, + + "Cors": { + "AllowedOrigins": [ "http://localhost:5173", "http://localhost:3000" ] + }, + + "ReverseProxy": { + "Routes": { + "media": { + "ClusterId": "seaweed-filer", + "Match": { "Path": "/media/{**rest}" }, + "Transforms": [ + { "PathPattern": "/buckets/webapi-storage/{**rest}" } + ] + } + }, + "Clusters": { + "seaweed-filer": { + "Destinations": { + "filer": { "Address": "http://localhost:8888/" } + } + } + } + } +}