fix(552): mint a short-lived JWT so the SPA reaches /iptv/* under JWT auth #560
@@ -98,8 +98,7 @@ internal static class Mapper
|
||||
|
||||
internal static ChannelResponseModel ProjectToResponseModel(
|
||||
Channel channel,
|
||||
int playoutCount,
|
||||
bool iptvJwtEnabled) =>
|
||||
int playoutCount) =>
|
||||
new(
|
||||
channel.Id,
|
||||
channel.Number,
|
||||
@@ -114,7 +113,7 @@ internal static class Mapper
|
||||
channel.ShowInEpg,
|
||||
playoutCount,
|
||||
GetLogoUrl(channel),
|
||||
GetPreview(channel.StreamingMode, channel.Number, iptvJwtEnabled, channel.IsEnabled, playoutCount));
|
||||
GetPreview(channel.StreamingMode, channel.Number, channel.IsEnabled, playoutCount));
|
||||
|
||||
internal static ResolutionViewModel ProjectToViewModel(Resolution resolution) =>
|
||||
new(resolution.Height, resolution.Width);
|
||||
@@ -183,28 +182,19 @@ internal static class Mapper
|
||||
internal static ChannelPreviewResponseModel GetPreview(
|
||||
StreamingMode streamingMode,
|
||||
string channelNumber,
|
||||
bool iptvJwtEnabled,
|
||||
bool isEnabled,
|
||||
int playoutCount)
|
||||
{
|
||||
// Precedence among the three Unavailable causes (checked in this order; the first match wins):
|
||||
// 1. JWT enabled — a host-wide auth configuration; nothing the operator can fix per-channel.
|
||||
// 2. channel disabled — an explicit operator choice; IptvController 404s a disabled channel, so
|
||||
// Precedence among the two Unavailable causes (checked in this order; the first match wins):
|
||||
// 1. channel disabled — an explicit operator choice; IptvController 404s a disabled channel, so
|
||||
// preview must not even try.
|
||||
// 3. no playout — the channel could theoretically play once scheduled, but a manifest
|
||||
// 2. no playout — the channel could theoretically play once scheduled, but a manifest
|
||||
// request against it blocks indefinitely today; catch it before that happens.
|
||||
//
|
||||
// /iptv/* is gated by ConditionalIptvAuthorizeFilter only when JWT is configured, and the
|
||||
// "jwt" scheme does not accept the SPA's ctv-session cookie. Nothing mints a JWT for the
|
||||
// SPA today, so preview cannot run at all in that configuration.
|
||||
if (iptvJwtEnabled)
|
||||
{
|
||||
return new ChannelPreviewResponseModel(
|
||||
ChannelPreviewAvailability.Unavailable,
|
||||
null,
|
||||
"IPTV JWT authentication is enabled");
|
||||
}
|
||||
|
||||
// IPTV JWT auth (ConditionalIptvAuthorizeFilter, active only when JWT:IssuerSigningKey is set) is no
|
||||
// longer an Unavailable cause: the SPA mints a short-lived token via GET /api/v1/auth/iptv-token and
|
||||
// appends it as ?access_token= to the manifest URL below (issue #552). The token is global and the
|
||||
// ManifestUrl is identical with or without JWT, so this projection is JWT-agnostic.
|
||||
if (!isEnabled)
|
||||
{
|
||||
return new ChannelPreviewResponseModel(
|
||||
|
||||
@@ -2,4 +2,4 @@ using ErsatzTV.Core.Api.Channels;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetAllChannelsForApi(bool IptvJwtEnabled) : IRequest<List<ChannelResponseModel>>;
|
||||
public record GetAllChannelsForApi : IRequest<List<ChannelResponseModel>>;
|
||||
|
||||
@@ -13,6 +13,6 @@ public class GetAllChannelsForApiHandler(IChannelRepository channelRepository)
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
IEnumerable<Channel> channels = Optional(await channelRepository.GetAll(cancellationToken)).Flatten();
|
||||
return channels.Map(c => ProjectToResponseModel(c, GetPlayoutsCount(c), request.IptvJwtEnabled)).ToList();
|
||||
return channels.Map(c => ProjectToResponseModel(c, GetPlayoutsCount(c))).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ public class ChannelPreviewTests
|
||||
public void HlsModes_Are_Available_With_Plain_Manifest_Url(StreamingMode mode)
|
||||
{
|
||||
ChannelPreviewResponseModel result =
|
||||
Mapper.GetPreview(mode, "12.1", iptvJwtEnabled: false, isEnabled: true, playoutCount: 1);
|
||||
Mapper.GetPreview(mode, "12.1", isEnabled: true, playoutCount: 1);
|
||||
|
||||
result.Availability.ShouldBe("Available");
|
||||
result.ManifestUrl.ShouldBe("/iptv/channel/12.1.m3u8");
|
||||
@@ -26,34 +26,19 @@ public class ChannelPreviewTests
|
||||
public void TransportStream_Modes_Are_ForcedHlsOnly(StreamingMode mode)
|
||||
{
|
||||
ChannelPreviewResponseModel result =
|
||||
Mapper.GetPreview(mode, "12.1", iptvJwtEnabled: false, isEnabled: true, playoutCount: 1);
|
||||
Mapper.GetPreview(mode, "12.1", isEnabled: true, playoutCount: 1);
|
||||
|
||||
result.Availability.ShouldBe("ForcedHlsOnly");
|
||||
result.ManifestUrl.ShouldBe("/iptv/channel/12.1.m3u8?mode=segmenter");
|
||||
result.UnavailableReason.ShouldBeNull();
|
||||
}
|
||||
|
||||
[TestCase(StreamingMode.HttpLiveStreamingSegmenter)]
|
||||
[TestCase(StreamingMode.HttpLiveStreamingDirect)]
|
||||
[TestCase(StreamingMode.TransportStream)]
|
||||
[TestCase(StreamingMode.TransportStreamHybrid)]
|
||||
public void Jwt_Enabled_Makes_Every_Mode_Unavailable(StreamingMode mode)
|
||||
{
|
||||
ChannelPreviewResponseModel result =
|
||||
Mapper.GetPreview(mode, "12.1", iptvJwtEnabled: true, isEnabled: true, playoutCount: 1);
|
||||
|
||||
result.Availability.ShouldBe("Unavailable");
|
||||
result.ManifestUrl.ShouldBeNull();
|
||||
result.UnavailableReason.ShouldBe("IPTV JWT authentication is enabled");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Disabled_Channel_Is_Unavailable()
|
||||
{
|
||||
ChannelPreviewResponseModel result = Mapper.GetPreview(
|
||||
StreamingMode.HttpLiveStreamingSegmenter,
|
||||
"12.1",
|
||||
iptvJwtEnabled: false,
|
||||
isEnabled: false,
|
||||
playoutCount: 1);
|
||||
|
||||
@@ -68,7 +53,6 @@ public class ChannelPreviewTests
|
||||
ChannelPreviewResponseModel result = Mapper.GetPreview(
|
||||
StreamingMode.HttpLiveStreamingSegmenter,
|
||||
"12.1",
|
||||
iptvJwtEnabled: false,
|
||||
isEnabled: true,
|
||||
playoutCount: 0);
|
||||
|
||||
@@ -77,32 +61,14 @@ public class ChannelPreviewTests
|
||||
result.UnavailableReason.ShouldBe("Channel has no playout");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Jwt_Enabled_Takes_Precedence_Over_Disabled_Channel()
|
||||
{
|
||||
// Both causes are true at once: JWT enabled (checked first) must win over the channel being
|
||||
// disabled (checked second), per the precedence order documented on GetPreview.
|
||||
ChannelPreviewResponseModel result = Mapper.GetPreview(
|
||||
StreamingMode.HttpLiveStreamingSegmenter,
|
||||
"12.1",
|
||||
iptvJwtEnabled: true,
|
||||
isEnabled: false,
|
||||
playoutCount: 0);
|
||||
|
||||
result.Availability.ShouldBe("Unavailable");
|
||||
result.ManifestUrl.ShouldBeNull();
|
||||
result.UnavailableReason.ShouldBe("IPTV JWT authentication is enabled");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Disabled_Channel_Takes_Precedence_Over_Zero_Playout_Count()
|
||||
{
|
||||
// Both causes are true at once: channel disabled (checked second) must win over zero
|
||||
// playouts (checked third), per the precedence order documented on GetPreview.
|
||||
// Both causes are true at once: channel disabled (checked first) must win over zero
|
||||
// playouts (checked second), per the precedence order documented on GetPreview.
|
||||
ChannelPreviewResponseModel result = Mapper.GetPreview(
|
||||
StreamingMode.HttpLiveStreamingSegmenter,
|
||||
"12.1",
|
||||
iptvJwtEnabled: false,
|
||||
isEnabled: false,
|
||||
playoutCount: 0);
|
||||
|
||||
@@ -117,7 +83,6 @@ public class ChannelPreviewTests
|
||||
ChannelPreviewResponseModel result = Mapper.GetPreview(
|
||||
StreamingMode.HttpLiveStreamingSegmenter,
|
||||
"7",
|
||||
iptvJwtEnabled: false,
|
||||
isEnabled: true,
|
||||
playoutCount: 1);
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ public class GetAllChannelsForApiHandlerTests
|
||||
var handler = new GetAllChannelsForApiHandler(repository);
|
||||
|
||||
List<ChannelResponseModel> result = await handler.Handle(
|
||||
new GetAllChannelsForApi(IptvJwtEnabled: false),
|
||||
new GetAllChannelsForApi(),
|
||||
CancellationToken.None);
|
||||
|
||||
ChannelResponseModel channel = result.ShouldHaveSingleItem();
|
||||
@@ -64,7 +64,7 @@ public class GetAllChannelsForApiHandlerTests
|
||||
var handler = new GetAllChannelsForApiHandler(repository);
|
||||
|
||||
List<ChannelResponseModel> result = await handler.Handle(
|
||||
new GetAllChannelsForApi(IptvJwtEnabled: false),
|
||||
new GetAllChannelsForApi(),
|
||||
CancellationToken.None);
|
||||
|
||||
// Uploaded logos are addressed as "iptv/logos/{file}"; the browse DTO roots it (leading slash) so
|
||||
@@ -81,7 +81,7 @@ public class GetAllChannelsForApiHandlerTests
|
||||
var handler = new GetAllChannelsForApiHandler(repository);
|
||||
|
||||
List<ChannelResponseModel> result = await handler.Handle(
|
||||
new GetAllChannelsForApi(IptvJwtEnabled: false),
|
||||
new GetAllChannelsForApi(),
|
||||
CancellationToken.None);
|
||||
|
||||
// An absolute external URL is directly usable and must pass through unchanged (no leading slash added).
|
||||
|
||||
@@ -134,4 +134,99 @@ public class AuthControllerTests
|
||||
httpContext.Response.Headers.CacheControl.ToString().ShouldBe("no-store");
|
||||
httpContext.Response.Headers.Pragma.ToString().ShouldBe("no-cache");
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void ResetJwt() =>
|
||||
// JwtHelper is static global state; leave it disabled so tests don't leak JWT config into each other.
|
||||
JwtHelper.Init(new ConfigurationBuilder().Build());
|
||||
|
||||
private static AuthController AuthenticatedController(IMediator mediator) =>
|
||||
new(mediator, Config(envSeed: false), ApiKeyProvider())
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(
|
||||
new ClaimsIdentity([new Claim(ClaimTypes.Name, "admin")], AuthConstants.CookieScheme))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
[Test]
|
||||
public void IptvToken_Returns_401_When_Anonymous()
|
||||
{
|
||||
JwtHelper.Init(JwtConfig("this-is-a-sufficiently-long-signing-key-123456"));
|
||||
var controller = new AuthController(Substitute.For<IMediator>(), Config(envSeed: false), ApiKeyProvider())
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
|
||||
};
|
||||
|
||||
IActionResult result = controller.IptvToken();
|
||||
|
||||
result.ShouldBeOfType<UnauthorizedObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(401);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IptvToken_Returns_204_When_Jwt_Disabled()
|
||||
{
|
||||
// JWT off (TearDown default): /iptv/* is open, so there is nothing to mint — the SPA plays the plain URL.
|
||||
AuthController controller = AuthenticatedController(Substitute.For<IMediator>());
|
||||
|
||||
IActionResult result = controller.IptvToken();
|
||||
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IptvToken_Mints_A_Verifiable_Token_When_Jwt_Enabled()
|
||||
{
|
||||
const string SigningKey = "this-is-a-sufficiently-long-signing-key-123456";
|
||||
JwtHelper.Init(JwtConfig(SigningKey));
|
||||
AuthController controller = AuthenticatedController(Substitute.For<IMediator>());
|
||||
|
||||
var ok = controller.IptvToken().ShouldBeOfType<OkObjectResult>();
|
||||
var body = ok.Value.ShouldBeOfType<IptvTokenResponse>();
|
||||
|
||||
body.Token.ShouldNotBeNullOrWhiteSpace();
|
||||
body.ExpiresAt.ShouldBeGreaterThan(DateTimeOffset.UtcNow);
|
||||
|
||||
// The minted token must validate against the same key/params the "jwt" scheme enforces at runtime
|
||||
// (signature + lifetime; issuer/audience are not validated).
|
||||
var handler = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler();
|
||||
Should.NotThrow(() => handler.ValidateToken(
|
||||
body.Token,
|
||||
new Microsoft.IdentityModel.Tokens.TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = false,
|
||||
ValidateAudience = false,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new Microsoft.IdentityModel.Tokens.SymmetricSecurityKey(
|
||||
System.Text.Encoding.ASCII.GetBytes(SigningKey)),
|
||||
ValidateLifetime = true
|
||||
},
|
||||
out _));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IptvToken_Sets_CacheControl_NoStore_When_Jwt_Enabled()
|
||||
{
|
||||
JwtHelper.Init(JwtConfig("this-is-a-sufficiently-long-signing-key-123456"));
|
||||
AuthController controller = AuthenticatedController(Substitute.For<IMediator>());
|
||||
|
||||
controller.IptvToken();
|
||||
|
||||
HttpResponse response = controller.ControllerContext.HttpContext.Response;
|
||||
response.Headers.CacheControl.ToString().ShouldBe("no-store");
|
||||
response.Headers.Pragma.ToString().ShouldBe("no-cache");
|
||||
}
|
||||
|
||||
private static IConfiguration JwtConfig(string signingKey, string? lifetimeMinutes = null) =>
|
||||
new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["JWT:IssuerSigningKey"] = signingKey,
|
||||
["JWT:BrowserTokenLifetimeMinutes"] = lifetimeMinutes
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ public class OpenApiSerializerContractTests
|
||||
true,
|
||||
2,
|
||||
"/iptv/logos/logo.png",
|
||||
Mapper.GetPreview(StreamingMode.HttpLiveStreamingSegmenter, "1", false, true, 2));
|
||||
Mapper.GetPreview(StreamingMode.HttpLiveStreamingSegmenter, "1", true, 2));
|
||||
|
||||
private static string FindOpenApiDocument()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class JwtHelperTests
|
||||
{
|
||||
private const string SigningKey = "this-is-a-sufficiently-long-signing-key-123456";
|
||||
|
||||
[TearDown]
|
||||
public void ResetJwt() => JwtHelper.Init(new ConfigurationBuilder().Build());
|
||||
|
||||
private static IConfiguration Config(string? lifetimeMinutes) =>
|
||||
new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["JWT:IssuerSigningKey"] = SigningKey,
|
||||
["JWT:BrowserTokenLifetimeMinutes"] = lifetimeMinutes
|
||||
})
|
||||
.Build();
|
||||
|
||||
[Test]
|
||||
public void Init_Enables_When_Signing_Key_Present()
|
||||
{
|
||||
JwtHelper.Init(Config(null));
|
||||
JwtHelper.IsEnabled.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Browser_Token_Lifetime_Defaults_To_60_Minutes()
|
||||
{
|
||||
JwtHelper.Init(Config(null));
|
||||
JwtHelper.BrowserTokenLifetime.ShouldBe(TimeSpan.FromMinutes(60));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Browser_Token_Lifetime_Honors_Configured_Minutes()
|
||||
{
|
||||
JwtHelper.Init(Config("15"));
|
||||
JwtHelper.BrowserTokenLifetime.ShouldBe(TimeSpan.FromMinutes(15));
|
||||
}
|
||||
|
||||
[TestCase("0")]
|
||||
[TestCase("-5")]
|
||||
[TestCase("not-a-number")]
|
||||
public void Browser_Token_Lifetime_Falls_Back_To_Default_On_Invalid(string value)
|
||||
{
|
||||
// A non-positive / unparseable value must not mint an already-expired token.
|
||||
JwtHelper.Init(Config(value));
|
||||
JwtHelper.BrowserTokenLifetime.ShouldBe(TimeSpan.FromMinutes(60));
|
||||
}
|
||||
|
||||
[TestCase("1441")]
|
||||
[TestCase("600000")]
|
||||
[TestCase("2147483647")]
|
||||
public void Browser_Token_Lifetime_Is_Clamped_To_The_Maximum(string value)
|
||||
{
|
||||
// A value above the documented max (24h) — including a seconds-vs-minutes typo — is clamped, never
|
||||
// honored literally into a multi-year bearer token.
|
||||
JwtHelper.Init(Config(value));
|
||||
JwtHelper.BrowserTokenLifetime.ShouldBe(TimeSpan.FromMinutes(1440));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GenerateBrowserToken_Expiry_Reflects_Configured_Lifetime()
|
||||
{
|
||||
JwtHelper.Init(Config("30"));
|
||||
|
||||
(string token, DateTimeOffset expiresAt) = JwtHelper.GenerateBrowserToken();
|
||||
|
||||
token.ShouldNotBeNullOrWhiteSpace();
|
||||
// ~30 minutes out (allow a wide slack for slow CI without asserting an exact instant).
|
||||
expiresAt.ShouldBeGreaterThan(DateTimeOffset.UtcNow.AddMinutes(29));
|
||||
expiresAt.ShouldBeLessThan(DateTimeOffset.UtcNow.AddMinutes(31));
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,40 @@ public class AuthController(IMediator mediator, IConfiguration configuration, IA
|
||||
return Ok(new MachineKeyResponse(apiKeyProvider.ApiKey));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mint a short-lived, globally-scoped <c>/iptv</c> access token for the browser (issue #552), so the
|
||||
/// channel-preview and playback-troubleshooting screens can reach the JWT-gated <c>/iptv/*</c> manifest
|
||||
/// endpoints (which do not accept the <c>ctv-session</c> cookie) by appending <c>?access_token=</c>.
|
||||
/// Requires any authenticated session (401 otherwise). Returns <b>204 No Content</b> when IPTV JWT auth
|
||||
/// is not configured — <c>/iptv/*</c> is open then, so there is nothing to mint and the SPA plays the
|
||||
/// plain URL. Never CSRF-gated: it's a GET that writes no server state (the JWT is stateless), and SOP
|
||||
/// already blocks a cross-site page from reading the credentialed response body (same reasoning as
|
||||
/// <see cref="MachineKey" />).
|
||||
/// </summary>
|
||||
[HttpGet("/api/v1/auth/iptv-token")]
|
||||
public IActionResult IptvToken()
|
||||
{
|
||||
if (User.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
return Unauthorized(new ProblemDetails
|
||||
{
|
||||
Status = StatusCodes.Status401Unauthorized,
|
||||
Title = "Unauthorized",
|
||||
Detail = "An authenticated session is required."
|
||||
});
|
||||
}
|
||||
|
||||
if (!JwtHelper.IsEnabled)
|
||||
{
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
Response.Headers.CacheControl = "no-store";
|
||||
Response.Headers.Pragma = "no-cache";
|
||||
(string token, DateTimeOffset expiresAt) = JwtHelper.GenerateBrowserToken();
|
||||
return Ok(new IptvTokenResponse(token, expiresAt));
|
||||
}
|
||||
|
||||
/// <summary>First-run setup-claim: create the local admin. Fails 409 if one already exists.</summary>
|
||||
[HttpPost("/api/v1/auth/setup")]
|
||||
[EnableRateLimiting("auth")]
|
||||
@@ -260,3 +294,5 @@ public record AuthConfigResponse(bool OidcEnabled, bool LocalLoginEnabled, bool
|
||||
public record AuthSessionResponse(bool Authenticated, string Username, string Method);
|
||||
|
||||
public record MachineKeyResponse(string ApiKey);
|
||||
|
||||
public record IptvTokenResponse(string Token, DateTimeOffset ExpiresAt);
|
||||
|
||||
@@ -27,7 +27,7 @@ public class ChannelController(
|
||||
[HttpGet("/api/v1/channels")]
|
||||
[EndpointGroupName("general")]
|
||||
public async Task<List<ChannelResponseModel>> GetAll() =>
|
||||
await mediator.Send(new GetAllChannelsForApi(JwtHelper.IsEnabled));
|
||||
await mediator.Send(new GetAllChannelsForApi());
|
||||
|
||||
[HttpGet("/api/v1/channels/state")]
|
||||
[Tags("Channels")]
|
||||
|
||||
+34
-3
@@ -6,9 +6,21 @@ namespace ErsatzTV;
|
||||
|
||||
public static class JwtHelper
|
||||
{
|
||||
// Default lifetime for a browser-minted /iptv access token. Kept short: the token re-validates on every
|
||||
// /iptv/* request, so this is the maximum continuous watch before playback stalls and the operator must
|
||||
// Retry (which mints a fresh token). Operators can override via JWT:BrowserTokenLifetimeMinutes (#552).
|
||||
private static readonly TimeSpan DefaultBrowserTokenLifetime = TimeSpan.FromMinutes(60);
|
||||
|
||||
// Upper bound on the configurable lifetime (24h). A global bearer token that lives longer magnifies the
|
||||
// exposure of any log/cache leak, and a typo (e.g. a value meant as seconds) shouldn't mint a
|
||||
// multi-year token; a configured value above this is clamped down rather than honored literally.
|
||||
private const int MaxBrowserTokenLifetimeMinutes = 1440;
|
||||
|
||||
public static SymmetricSecurityKey IssuerSigningKey { get; private set; }
|
||||
public static bool IsEnabled { get; private set; }
|
||||
|
||||
public static TimeSpan BrowserTokenLifetime { get; private set; } = DefaultBrowserTokenLifetime;
|
||||
|
||||
public static void Init(IConfiguration configuration)
|
||||
{
|
||||
string issuerSigningKey = configuration["JWT:IssuerSigningKey"];
|
||||
@@ -17,17 +29,36 @@ public static class JwtHelper
|
||||
{
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(issuerSigningKey!));
|
||||
}
|
||||
|
||||
// A non-positive or unparseable value falls back to the default rather than minting a token that is
|
||||
// already expired (which would break preview/troubleshooting entirely under JWT); a value above the
|
||||
// documented maximum is clamped down (see MaxBrowserTokenLifetimeMinutes).
|
||||
if (int.TryParse(configuration["JWT:BrowserTokenLifetimeMinutes"], out int minutes) && minutes > 0)
|
||||
{
|
||||
BrowserTokenLifetime = TimeSpan.FromMinutes(Math.Min(minutes, MaxBrowserTokenLifetimeMinutes));
|
||||
}
|
||||
else
|
||||
{
|
||||
BrowserTokenLifetime = DefaultBrowserTokenLifetime;
|
||||
}
|
||||
}
|
||||
|
||||
public static string GenerateToken()
|
||||
/// <summary>
|
||||
/// Mint a short-lived, globally-scoped /iptv access token for the browser SPA (#552). The token carries
|
||||
/// no claims beyond expiry; the "jwt" scheme validates only signature + lifetime, matching the token
|
||||
/// Jellyfin/external clients pass as <c>?access_token=</c>. Returns the token and its absolute expiry so
|
||||
/// the caller can report it without re-parsing the JWT.
|
||||
/// </summary>
|
||||
public static (string Token, DateTimeOffset ExpiresAt) GenerateBrowserToken()
|
||||
{
|
||||
DateTime expires = DateTime.UtcNow.Add(BrowserTokenLifetime);
|
||||
var tokenHandler = new JwtSecurityTokenHandler();
|
||||
var tokenDescriptor = new SecurityTokenDescriptor
|
||||
{
|
||||
Expires = DateTime.UtcNow.AddDays(1),
|
||||
Expires = expires,
|
||||
SigningCredentials = new SigningCredentials(IssuerSigningKey, SecurityAlgorithms.HmacSha256Signature)
|
||||
};
|
||||
SecurityToken token = tokenHandler.CreateToken(tokenDescriptor);
|
||||
return tokenHandler.WriteToken(token);
|
||||
return (tokenHandler.WriteToken(token), new DateTimeOffset(expires, TimeSpan.Zero));
|
||||
}
|
||||
}
|
||||
|
||||
+16
-13
@@ -3445,19 +3445,20 @@ leaving the shared tree dirty is the one outcome that would make this script a n
|
||||
|
||||
`key: api.channel-preview-capability` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** Whether a channel can be previewed in the browser is declared by the server, not derived by the SPA, as an additive `Preview` field (`{Availability, ManifestUrl, UnavailableReason}`) on `ChannelResponseModel`.
|
||||
**Signals:** a Play button that does nothing; preview eligibility inferred from a display string; a green preview on a Transport Stream channel being read as validating its configured pipeline · paths: `ErsatzTV.Core/Api/Channels/ChannelPreviewResponseModel.cs`, `ErsatzTV.Application/Channels/Mapper.cs`, `web/src/screens/channels/ChannelPreviewPanel.tsx` · issues: #60
|
||||
**Mechanics:** `Mapper.GetPreview(StreamingMode, channelNumber, iptvJwtEnabled, isEnabled, playoutCount)` is pure; `JwtHelper.IsEnabled` is read only at `ChannelController.GetAll` and passed in
|
||||
**Signals:** a Play button that does nothing; preview eligibility inferred from a display string; a green preview on a Transport Stream channel being read as validating its configured pipeline · paths: `ErsatzTV.Core/Api/Channels/ChannelPreviewResponseModel.cs`, `ErsatzTV.Application/Channels/Mapper.cs`, `web/src/screens/channels/ChannelPreviewPanel.tsx` · issues: #60, #552
|
||||
**Mechanics:** `Mapper.GetPreview(StreamingMode, channelNumber, isEnabled, playoutCount)` is pure and JWT-agnostic
|
||||
|
||||
`ChannelPreviewAvailability` is one of `Available`, `ForcedHlsOnly`, or `Unavailable`, computed in one
|
||||
place from the real `StreamingMode` enum plus JWT status. The SPA renders and acts on it and derives
|
||||
nothing — deriving it client-side would mean keying behavior off `Mapper.GetStreamingMode`'s
|
||||
human-readable display label, where a copy tweak would silently break playback.
|
||||
place from the real `StreamingMode` enum plus the channel's enabled/playout state. The SPA renders and
|
||||
acts on it and derives nothing — deriving it client-side would mean keying behavior off
|
||||
`Mapper.GetStreamingMode`'s human-readable display label, where a copy tweak would silently break
|
||||
playback.
|
||||
|
||||
`Unavailable` now covers three causes, checked in this order (first match wins): JWT enabled, the
|
||||
channel is disabled (`IptvController` 404s a disabled channel, so preview must not even try), and
|
||||
the channel has zero playouts (a manifest request against one blocks indefinitely). Only the JWT
|
||||
case was in scope at first pass; the other two were keying preview on `StreamingMode` + JWT alone,
|
||||
so a disabled or playout-less channel was declared `Available` and then failed confusingly.
|
||||
`Unavailable` covers two causes, checked in this order (first match wins): the channel is disabled
|
||||
(`IptvController` 404s a disabled channel, so preview must not even try), and the channel has zero
|
||||
playouts (a manifest request against one blocks indefinitely). At first pass these two were keying
|
||||
preview on `StreamingMode` alone, so a disabled or playout-less channel was declared `Available` and
|
||||
then failed confusingly.
|
||||
|
||||
Only the two HLS modes are browser-playable; a browser cannot play the `video/mp2t` that the
|
||||
Transport Stream modes serve over `/iptv/*`. Those are declared `ForcedHlsOnly`: preview is offered
|
||||
@@ -3467,6 +3468,8 @@ are reported, never auto-recovered — a diagnostic surface must show the fault
|
||||
it; a user-initiated Retry re-issues the manifest request via a real `playToken` because the manifest
|
||||
GET starts a server-side session, so a byte-identical repeat URL would otherwise be a no-op.
|
||||
|
||||
`/iptv/*` does not accept the SPA's `ctv-session` cookie, and nothing mints a JWT for the SPA, so
|
||||
under a JWT-enabled deployment preview is declared `Unavailable` with the reason `IPTV JWT
|
||||
authentication is enabled` rather than failing silently against a 401.
|
||||
Originally, a JWT-enabled deployment made preview `Unavailable` (reason `IPTV JWT authentication is
|
||||
enabled`) because `/iptv/*` does not accept the SPA's `ctv-session` cookie and nothing minted a JWT
|
||||
for the browser. #552 closed that: the SPA now mints a short-lived token and appends it as
|
||||
`?access_token=`, so this projection no longer inspects JWT status at all. See
|
||||
`security.iptv-browser-token`.
|
||||
|
||||
@@ -127,6 +127,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
|
||||
| `security.corp-same-origin` | `SecurityHeadersMiddleware` sends `Cross-Origin-Resource-Policy: same-origin` on every response including `/docs`/`/openapi`, blocking cross-origin `no-cors` embedding without affecting allowed CORS-mode fetches or server-side Jellyfin `/iptv/*` requests. | 2026-07-13 | [link](api-auth-security.md#2026-07-13--cross-origin-resource-policy-same-origin-on-every-response-330) |
|
||||
| `security.csp-permissions-policy` | `SecurityHeadersMiddleware` sends an enforcing (not report-only) `Content-Security-Policy` (no `unsafe-inline`/`unsafe-eval`; the one inline theme-bootstrap script allow-listed by hash) and a deny-all `Permissions-Policy` on the SPA/`/api`/`/artwork`/`/iptv`; `/docs` and `/openapi` keep only the baseline headers, excluded from CSP because Scalar needs inline bootstrap. | 2026-07-12 | [link](api-auth-security.md#2026-07-12--enforcing-csp--permissions-policy-on-the-host-319-zap-baseline) |
|
||||
| `security.fail-closed-api-auth` | Every mutating `/api` request requires `X-Api-Key` (no open mode); reads are gated by `Api:RequireKeyForReads` (default true) OR `[RequiresApiKey]` on sensitive controllers; CORS is an exact-origin allowlist (`ApiCors`); `ForwardedHeaders` trust stays configurable but defaults to trust-all-with-warning. | 2026-07-12 | [link](api-auth-security.md#2026-07-12--fail-closed-api-auth--sensitive-read-tier--corsforwardedheaders-lockdown-197-bundle-a-pr-292) |
|
||||
| `security.iptv-browser-token` | Under a JWT-enabled deployment (`JWT:IssuerSigningKey` set), the browser SPA obtains a short-lived, globally-scoped `/iptv/*` access token from an authenticated `GET /api/v1/auth/iptv-token` and appends it as `?access_token=`; the endpoint answers 204 when JWT is disabled (nothing to mint). Lifetime defaults to 60 min, configurable via `JWT:BrowserTokenLifetimeMinutes`. | 2026-07-22 | [link](api-auth-security.md#2026-07-22--short-lived-browser-iptv-token-so-the-spa-reaches-iptv-under-jwt-auth-552) |
|
||||
| `security.session-auth-dual-credential` | `ApiAuthorizationFilter` accepts a request when a valid `X-Api-Key` matches OR the principal is an authenticated session (cookie `ctv-session`, `HttpOnly`/`SameSite=Lax`); session-authenticated mutations require the presence-only `X-CSRF` header or are rejected 403. This narrows the OIDC-inert sub-claim of `security.blazor-removal-auth-posture` (#206) — the rest of that record's auth-surface enumeration still holds. | 2026-07-12 | [link](api-auth-security.md#2026-07-12--browser-spa-session-auth-api-accepts-session-or-machine-key-295-pr1-server-only) |
|
||||
| `security.session-cutover-postify` | The browser SPA authenticates cookie-only (no more `X-Api-Key` from `web/`); the machine key is repurposed to external/MCP-only via `GET /api/auth/machine-key`; every side-effecting GET/HEAD under `/api` is converted to POST so the existing CSRF gate covers it (standing rule: never add a side-effecting GET/HEAD under `/api`). | 2026-07-12 | [link](api-auth-security.md#2026-07-12--295-pr2-spa-session-cutover--301-side-effecting-get-post-ification) |
|
||||
| `session.shared-checkout-refresh` | Session end runs `scripts/refresh-shared-checkout.sh`, which fast-forwards `/Users/timothy/ersatztv` to `origin/main` (and reinstalls `web/node_modules` when the lockfile moved), refusing to touch anything unless that tree is on a clean, non-ahead `main`. | 2026-07-21 | [link](../decisions.md#2026-07-21--session-end-fast-forwards-the-shared-checkout-a-stale-tree-serves-stale-files-541) |
|
||||
|
||||
@@ -22,6 +22,7 @@ contract-freeze), #206 (Blazor-removal auth posture), #283 (artwork content-type
|
||||
- [2026-07-12 — #295 PR2: SPA session cutover + #301 side-effecting-GET POST-ification](#2026-07-12--295-pr2-spa-session-cutover--301-side-effecting-get-post-ification)
|
||||
- [2026-07-12 — Enforcing CSP + Permissions-Policy on the host (#319, ZAP baseline)](#2026-07-12--enforcing-csp--permissions-policy-on-the-host-319-zap-baseline)
|
||||
- [2026-07-13 — Cross-origin resource policy: `same-origin` on every response (#330)](#2026-07-13--cross-origin-resource-policy-same-origin-on-every-response-330)
|
||||
- [2026-07-22 — Short-lived browser IPTV token so the SPA reaches `/iptv/*` under JWT auth (#552)](#2026-07-22--short-lived-browser-iptv-token-so-the-spa-reaches-iptv-under-jwt-auth-552)
|
||||
|
||||
---
|
||||
|
||||
@@ -426,3 +427,53 @@ explicit `Api:CorsAllowedOrigins` machine-client path continues to work. It is a
|
||||
server-side HTTP clients, so Jellyfin's `/iptv/*` requests are unaffected; same-origin SPA artwork and
|
||||
IPTV requests remain allowed. This is defense in depth for browser embedding and does not replace CORS
|
||||
or authentication. Refs #330 #319 #314.
|
||||
|
||||
## 2026-07-22 — Short-lived browser IPTV token so the SPA reaches `/iptv/*` under JWT auth (#552)
|
||||
`key: security.iptv-browser-token` · `status: active` · `since: 2026-07-22` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** Under a JWT-enabled deployment (`JWT:IssuerSigningKey` set), the browser SPA obtains a short-lived, globally-scoped `/iptv/*` access token from an authenticated `GET /api/v1/auth/iptv-token` and appends it as `?access_token=`; the endpoint answers 204 when JWT is disabled (nothing to mint). Lifetime defaults to 60 min, configurable via `JWT:BrowserTokenLifetimeMinutes`.
|
||||
**Signals:** channel preview / playback-troubleshooting 401ing under JWT; `/iptv/*` not accepting `ctv-session`; minting a JWT for the browser · paths: `ErsatzTV/JwtHelper.cs`, `ErsatzTV/Controllers/Api/AuthController.cs`, `web/src/media/iptvToken.ts` · issues: #552, #60
|
||||
**Mechanics:** `JwtHelper.GenerateBrowserToken()`; `AuthController.IptvToken`; `withIptvToken(url)` (SPA)
|
||||
|
||||
`/iptv/*` is gated by `ConditionalIptvAuthorizeFilter` only when `JWT:IssuerSigningKey` is configured,
|
||||
and the `"jwt"` scheme accepts only a bearer token or `?access_token=` — **not** the SPA's `ctv-session`
|
||||
cookie (a distinct scheme). Nothing minted a JWT for the browser, so under JWT the #60 channel preview
|
||||
was declared `Unavailable` and the pre-existing playback-troubleshooting screen was latently broken. This
|
||||
closes both with one seam.
|
||||
|
||||
- **Endpoint.** `GET /api/v1/auth/iptv-token` on `AuthController` (already `[SkipApiAuthorization]` +
|
||||
self-checks the principal, like `machine-key`). Requires any authenticated session (401 otherwise);
|
||||
returns `{ token, expiresAt }` when `JwtHelper.IsEnabled`, else **204 No Content** — `/iptv/*` is open
|
||||
then, so there is nothing to append and the SPA plays the plain URL. `Cache-Control: no-store`. Excluded
|
||||
from the OpenAPI document (`AuthController` is `[IgnoreApi]`, per the #295 F4 ruling — a
|
||||
browser-interactive credential is not something a generated client drives).
|
||||
- **A GET is correct here** despite the "no side-effecting GET under `/api`" rule (`api-conventions.md §9`):
|
||||
minting a JWT writes **no server state** (stateless token, no DB row, no revocation list), so it is not a
|
||||
CSRF-relevant side effect, and same-origin policy already blocks a cross-site page from reading the
|
||||
credentialed response body — identical reasoning to the `machine-key` GET.
|
||||
- **Scope: global.** The `"jwt"` scheme validates only signature + lifetime (no audience/channel claims),
|
||||
and the token is minted only to the already-authenticated admin who can reach every channel.
|
||||
Channel-scoping would mean adding claim-based auth to `ConditionalIptvAuthorizeFilter` and the streaming
|
||||
path — deferred until a non-admin preview audience exists.
|
||||
- **Lifetime: 60 min default, `JWT:BrowserTokenLifetimeMinutes` override, clamped to 24h.** The token
|
||||
re-validates on every `/iptv/*` request, so lifetime is the max continuous watch before playback stalls;
|
||||
60 min comfortably covers an operator verification session, an expired idle session just needs Retry
|
||||
(mints fresh), and a security-conscious operator can tighten it. A non-positive/unparseable value falls
|
||||
back to the default rather than minting an already-expired token; a value above 24h (a seconds-vs-minutes
|
||||
typo would otherwise mint a multi-year bearer token) is clamped down. **Revocation is by short lifetime
|
||||
only** — a stateless JWT has no per-token revocation; rotating `JWT:IssuerSigningKey` invalidates all
|
||||
tokens (the existing lever). The SPA's `resetIptvTokenCache()` (called on the preview panel's Retry and on
|
||||
each troubleshooting Play) makes a user-initiated retry re-mint, so a stale token or a stale "JWT disabled"
|
||||
latch from a since-reconfigured backend can't wedge a recovery attempt.
|
||||
- **Deferred hardening (broader than #552).** The `?access_token=` transport itself has two pre-existing
|
||||
weaknesses this feature inherits, now bounded by the short lifetime: Serilog's request log includes the
|
||||
full query (so a token can reach logs on an `/iptv` 5xx), and the token-bearing dynamic manifests carry no
|
||||
`Cache-Control: no-store`. Both predate this feature (Jellyfin and the M3U playlist already pass
|
||||
`access_token` in `/iptv` URLs) and their fixes are cross-cutting changes to shared request-logging /
|
||||
manifest behavior — tracked as a follow-up in #559, not folded here.
|
||||
- **Only the top-level manifest needs the token.** The multi-variant playlist embeds `access_token` into
|
||||
its variant URL (`IptvController.GetMultiVariantPlaylist`), and HLS segments are served by
|
||||
`UseStaticFiles` at `RequestPath=/iptv/session` — **outside** `ConditionalIptvAuthorizeFilter` (which is
|
||||
a `[ServiceFilter]` on `IptvController` only) — so segment GETs are ungated regardless. The SPA's
|
||||
`withIptvToken(url)` appends the token to the one manifest URL (a no-op when JWT is off; caches the token
|
||||
in memory until shortly before expiry) and is used by both the channel-preview panel and the
|
||||
playback-troubleshooting screen.
|
||||
|
||||
@@ -225,6 +225,17 @@ safe) and mirrors `onError`'s requirements exactly: pass a **stable** `onPlaying
|
||||
since it too sits in the attach effect's dependency array and an unstable identity would restart the
|
||||
stream every render.
|
||||
|
||||
**Resolving a `/iptv/*` src under JWT auth (#552).** Before feeding an `/iptv/*` manifest URL to
|
||||
`HlsPlayer`, pass it through `withIptvToken(url)` (`web/src/media/iptvToken.ts`): under a JWT-enabled
|
||||
deployment it appends the short-lived `?access_token=` the `/iptv/*` scheme requires (the `ctv-session`
|
||||
cookie does not satisfy it), and it is a **no-op** when JWT is off (the endpoint answers 204, cached).
|
||||
It is async, so resolve it into state and gate the player on the resolved src — the channel-preview
|
||||
panel does this in an effect keyed on `playToken`; the troubleshooting screen awaits it inline in its
|
||||
`onPlay` handler. Only the top-level manifest needs it (segments and the multi-variant→variant hop
|
||||
carry or bypass the token server-side). **In tests, mock `../media/iptvToken` to identity**
|
||||
(`withIptvToken: (url) => Promise.resolve(url)`) so opening a preview fires no real `iptv-token` fetch;
|
||||
the append logic itself is unit-tested in `media/iptvToken.test.ts`.
|
||||
|
||||
## 5c. Media "Add to…" affordances
|
||||
|
||||
Screens that let the user add media items to a collection/playlist/schedule use the shared layer in
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getIptvToken, resetIptvTokenCache, withIptvToken } from './iptvToken';
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status
|
||||
});
|
||||
}
|
||||
|
||||
function noContentResponse(): Response {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
function tokenBody(expiresInMinutes = 60): { token: string; expiresAt: string } {
|
||||
return {
|
||||
token: 'minted-jwt',
|
||||
expiresAt: new Date(Date.now() + expiresInMinutes * 60_000).toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
describe('getIptvToken', () => {
|
||||
beforeEach(() => {
|
||||
resetIptvTokenCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('returns the minted token when the endpoint answers 200', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse(tokenBody()));
|
||||
|
||||
await expect(getIptvToken()).resolves.toBe('minted-jwt');
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/v1/auth/iptv-token', expect.objectContaining({ method: 'GET' }));
|
||||
});
|
||||
|
||||
it('returns null on 204 (IPTV JWT auth disabled)', async () => {
|
||||
vi.spyOn(window, 'fetch').mockResolvedValue(noContentResponse());
|
||||
await expect(getIptvToken()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('caches a minted token across calls', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse(tokenBody()));
|
||||
|
||||
await getIptvToken();
|
||||
await getIptvToken();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not re-hit the endpoint once it has seen a 204', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContentResponse());
|
||||
|
||||
await getIptvToken();
|
||||
await getIptvToken();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('re-mints when the cached token is near expiry', async () => {
|
||||
// 1-minute lifetime is inside the 2-minute refresh skew, so the second call must re-fetch. A fresh
|
||||
// Response per call — a single Response body can only be read once.
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockImplementation(() => Promise.resolve(jsonResponse(tokenBody(1))));
|
||||
|
||||
await getIptvToken();
|
||||
await getIptvToken();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('withIptvToken', () => {
|
||||
beforeEach(() => {
|
||||
resetIptvTokenCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('appends ?access_token= to a query-less URL', async () => {
|
||||
vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse(tokenBody()));
|
||||
await expect(withIptvToken('/iptv/channel/12.1.m3u8')).resolves.toBe(
|
||||
'/iptv/channel/12.1.m3u8?access_token=minted-jwt'
|
||||
);
|
||||
});
|
||||
|
||||
it('appends &access_token= to a URL that already has a query', async () => {
|
||||
vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse(tokenBody()));
|
||||
await expect(withIptvToken('/iptv/channel/12.1.m3u8?mode=segmenter')).resolves.toBe(
|
||||
'/iptv/channel/12.1.m3u8?mode=segmenter&access_token=minted-jwt'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the URL unchanged when JWT auth is disabled (204)', async () => {
|
||||
vi.spyOn(window, 'fetch').mockResolvedValue(noContentResponse());
|
||||
await expect(withIptvToken('/iptv/channel/12.1.m3u8')).resolves.toBe('/iptv/channel/12.1.m3u8');
|
||||
});
|
||||
|
||||
it('url-encodes the token', async () => {
|
||||
vi.spyOn(window, 'fetch').mockResolvedValue(
|
||||
jsonResponse({ token: 'a b+c', expiresAt: new Date(Date.now() + 3_600_000).toISOString() })
|
||||
);
|
||||
await expect(withIptvToken('/iptv/channel/12.1.m3u8')).resolves.toBe(
|
||||
'/iptv/channel/12.1.m3u8?access_token=a%20b%2Bc'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { request } from '../api/client';
|
||||
|
||||
// The IPTV access-token endpoint (`GET /api/v1/auth/iptv-token`) lives on the [IgnoreApi] auth surface, so
|
||||
// like the rest of `auth.ts` its DTO is hand-written here rather than sourced from `./generated/v1`.
|
||||
// Wire fields are camelCase to match the server record `IptvTokenResponse(string Token, DateTimeOffset
|
||||
// ExpiresAt)`.
|
||||
export interface IptvToken {
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
interface CachedToken {
|
||||
token: string;
|
||||
expiresAtMs: number;
|
||||
}
|
||||
|
||||
// Refresh a little before expiry so an in-flight play doesn't begin with an about-to-expire token (which
|
||||
// would stall mid-stream). Well under the 60-minute default lifetime.
|
||||
const REFRESH_SKEW_MS = 120_000;
|
||||
|
||||
let cached: CachedToken | null = null;
|
||||
// Once the server answers 204 (IPTV JWT auth is off), it cannot turn on without a server restart — remember
|
||||
// it so we don't re-hit the endpoint on every play. Reset via resetIptvTokenCache (used by tests).
|
||||
let jwtDisabled = false;
|
||||
|
||||
export function resetIptvTokenCache(): void {
|
||||
cached = null;
|
||||
jwtDisabled = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a short-lived `/iptv/*` access token for the browser (#552), or `null` when IPTV JWT auth is
|
||||
* disabled (the endpoint answers 204 and `/iptv/*` is open — nothing to append). Cached in memory until
|
||||
* shortly before expiry so repeated plays reuse one token.
|
||||
*/
|
||||
export async function getIptvToken(): Promise<string | null> {
|
||||
if (jwtDisabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (cached && cached.expiresAtMs - Date.now() > REFRESH_SKEW_MS) {
|
||||
return cached.token;
|
||||
}
|
||||
|
||||
const result = await request<IptvToken | undefined>('/api/v1/auth/iptv-token');
|
||||
if (!result) {
|
||||
// 204 No Content: IPTV JWT auth is off, so `/iptv/*` needs no token.
|
||||
jwtDisabled = true;
|
||||
cached = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
cached = { token: result.token, expiresAtMs: Date.parse(result.expiresAt) };
|
||||
return cached.token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a `/iptv/*` URL to one the browser can actually fetch under a JWT-enabled deployment, appending
|
||||
* `?access_token=`/`&access_token=` when a token is required. A no-op when JWT auth is disabled. Only the
|
||||
* top-level manifest URL needs this: the multi-variant playlist propagates the token to its variant, and
|
||||
* HLS segments are served by static-file middleware outside the JWT filter.
|
||||
*/
|
||||
export async function withIptvToken(url: string): Promise<string> {
|
||||
const token = await getIptvToken();
|
||||
if (!token) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const separator = url.includes('?') ? '&' : '?';
|
||||
return `${url}${separator}access_token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
@@ -27,6 +27,13 @@ vi.mock('hls.js', () => {
|
||||
return { default: MockHls };
|
||||
});
|
||||
|
||||
// The preview panel resolves a tokened src via withIptvToken (#552); mock it to identity so opening the
|
||||
// panel doesn't fire a real /api/v1/auth/iptv-token fetch. Behavior is covered in media/iptvToken.test.ts.
|
||||
vi.mock('../media/iptvToken', () => ({
|
||||
withIptvToken: (url: string) => Promise.resolve(url),
|
||||
resetIptvTokenCache: () => {}
|
||||
}));
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
|
||||
}
|
||||
@@ -324,7 +331,7 @@ describe('ChannelsScreen — load + render', () => {
|
||||
preview: {
|
||||
availability: 'Unavailable',
|
||||
manifestUrl: null,
|
||||
unavailableReason: 'IPTV JWT authentication is enabled'
|
||||
unavailableReason: 'Channel has no playout'
|
||||
}
|
||||
})
|
||||
]
|
||||
@@ -332,7 +339,7 @@ describe('ChannelsScreen — load + render', () => {
|
||||
|
||||
render(<ChannelsScreen />);
|
||||
|
||||
const play = await screen.findByRole('button', { name: /IPTV JWT authentication is enabled/ });
|
||||
const play = await screen.findByRole('button', { name: /Channel has no playout/ });
|
||||
expect(play).toBeDisabled();
|
||||
});
|
||||
|
||||
|
||||
@@ -26,6 +26,14 @@ vi.mock('hls.js', () => {
|
||||
return { default: MockHls };
|
||||
});
|
||||
|
||||
// onPlay resolves the manifest through withIptvToken (#552, appends a token under a JWT-enabled
|
||||
// deployment); identity-mock it so the assertions below see the plain LIVE_MANIFEST and no real
|
||||
// /api/v1/auth/iptv-token fetch fires. Append behavior is covered in media/iptvToken.test.ts.
|
||||
vi.mock('../media/iptvToken', () => ({
|
||||
withIptvToken: (url: string) => Promise.resolve(url),
|
||||
resetIptvTokenCache: () => {}
|
||||
}));
|
||||
|
||||
interface Status {
|
||||
state: string;
|
||||
exitCode: null | number;
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type Watermark
|
||||
} from '../api';
|
||||
import { HlsPlayer } from '../media/HlsPlayer';
|
||||
import { resetIptvTokenCache, withIptvToken } from '../media/iptvToken';
|
||||
import { parseDurationSeconds } from '../media/mediaKinds';
|
||||
|
||||
// StreamingMode.HttpLiveStreamingSegmenter — mirrors PlaybackTroubleshooting.razor, which always
|
||||
@@ -448,6 +449,9 @@ export function PlaybackTroubleshootingScreen() {
|
||||
return;
|
||||
}
|
||||
setNotice(null);
|
||||
// Each explicit Play re-evaluates the IPTV token from scratch (drops any stale token / "JWT disabled"
|
||||
// latch cached from a prior config), so a manual retry can't be wedged by a stale credential (#552).
|
||||
resetIptvTokenCache();
|
||||
setPlaybackStatus((current) => (current ? { ...current, logs: null, speed: null } : current));
|
||||
setPhase('starting');
|
||||
startingSinceRef.current = Date.now();
|
||||
@@ -459,9 +463,15 @@ export function PlaybackTroubleshootingScreen() {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
// The start endpoint returns the open `/iptv` live manifest — feed it to HlsPlayer. The URL is
|
||||
// constant across plays, so bump the token to force a re-attach even on a repeat play.
|
||||
setPlayerSrc(result.url);
|
||||
// The start endpoint returns the `/iptv` live manifest — feed it to HlsPlayer. Under a JWT-enabled
|
||||
// deployment /iptv/* needs a short-lived access token appended (#552); withIptvToken is a no-op when
|
||||
// JWT is off. The URL is constant across plays, so bump the token to force a re-attach even on a
|
||||
// repeat play.
|
||||
const src = await withIptvToken(result.url);
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
setPlayerSrc(src);
|
||||
setPlayToken((current) => current + 1);
|
||||
// Fast-completion race (Codex MAJOR): the POST returns 200 only after segments exist, so a short
|
||||
// clip can settle between polls; applyStatus discards settled states while 'starting', turning a
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
import { act } from 'react';
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ChannelPreviewAvailability } from '../../api/channels';
|
||||
import { resetIptvTokenCache, withIptvToken } from '../../media/iptvToken';
|
||||
import { ChannelPreviewPanel } from './ChannelPreviewPanel';
|
||||
|
||||
// The panel resolves the tokened src via withIptvToken (#552) before mounting the player. Mock it to
|
||||
// identity by default so the assertions below see the plain manifest URLs; individual tests override the
|
||||
// implementation to exercise token-append / failure paths. The real append logic is unit-tested in
|
||||
// media/iptvToken.test.ts.
|
||||
vi.mock('../../media/iptvToken', () => ({
|
||||
withIptvToken: vi.fn((url: string) => Promise.resolve(url)),
|
||||
resetIptvTokenCache: vi.fn()
|
||||
}));
|
||||
|
||||
const withIptvTokenMock = vi.mocked(withIptvToken);
|
||||
const resetIptvTokenCacheMock = vi.mocked(resetIptvTokenCache);
|
||||
|
||||
const hlsMock = {
|
||||
isSupported: true,
|
||||
loadSource: vi.fn(),
|
||||
@@ -26,6 +39,19 @@ vi.mock('hls.js', () => {
|
||||
return { default: MockHls };
|
||||
});
|
||||
|
||||
// Flush the microtask the token-resolution effect awaits before mounting the player.
|
||||
async function flush() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
async function renderPanel(ui: React.ReactElement) {
|
||||
const result = render(ui);
|
||||
await flush();
|
||||
return result;
|
||||
}
|
||||
|
||||
function channel(preview: ChannelPreview, streamingMode = 'HLS Segmenter', id = 1) {
|
||||
return { id, name: 'Vaporwave', number: '12.1', preview, streamingMode };
|
||||
}
|
||||
@@ -57,7 +83,7 @@ const available2: ChannelPreview = {
|
||||
const unavailable: ChannelPreview = {
|
||||
availability: 'Unavailable',
|
||||
manifestUrl: null,
|
||||
unavailableReason: 'IPTV JWT authentication is enabled'
|
||||
unavailableReason: 'Channel is disabled'
|
||||
};
|
||||
|
||||
function getErrorHandler() {
|
||||
@@ -72,22 +98,47 @@ describe('ChannelPreviewPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
hlsMock.isSupported = true;
|
||||
withIptvTokenMock.mockImplementation((url: string) => Promise.resolve(url));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('loads the declared manifest url for an available channel', () => {
|
||||
render(
|
||||
it('loads the declared manifest url for an available channel', async () => {
|
||||
await renderPanel(
|
||||
<ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
|
||||
);
|
||||
|
||||
expect(hlsMock.loadSource).toHaveBeenCalledWith('/iptv/channel/12.1.m3u8');
|
||||
});
|
||||
|
||||
it('shows the now-playing title it was given', () => {
|
||||
render(
|
||||
it('appends the iptv access token to the manifest url before playing (JWT-enabled deployment)', async () => {
|
||||
withIptvTokenMock.mockImplementation((url: string) => Promise.resolve(`${url}?access_token=minted`));
|
||||
|
||||
await renderPanel(
|
||||
<ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
|
||||
);
|
||||
|
||||
expect(withIptvTokenMock).toHaveBeenCalledWith('/iptv/channel/12.1.m3u8');
|
||||
expect(hlsMock.loadSource).toHaveBeenCalledWith('/iptv/channel/12.1.m3u8?access_token=minted');
|
||||
// The token must not leak into the visible URL fact (kept token-free for curl reproducibility).
|
||||
expect(screen.getByText('/iptv/channel/12.1.m3u8')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces a fatal error when the access token cannot be obtained', async () => {
|
||||
withIptvTokenMock.mockRejectedValue(new Error('no session'));
|
||||
|
||||
await renderPanel(
|
||||
<ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
|
||||
);
|
||||
|
||||
expect(screen.getByText(/could not obtain an iptv access token/i)).toBeInTheDocument();
|
||||
expect(hlsMock.loadSource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows the now-playing title it was given', async () => {
|
||||
await renderPanel(
|
||||
<ChannelPreviewPanel
|
||||
channel={channel(available)}
|
||||
nowPlaying={{ finishUtc: '2026-07-21T21:00:00Z', startUtc: '2026-07-21T20:00:00Z', title: 'Neon Nights' }}
|
||||
@@ -99,16 +150,16 @@ describe('ChannelPreviewPanel', () => {
|
||||
expect(screen.getByText('Neon Nights')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows nothing-scheduled when there is no now-playing', () => {
|
||||
render(
|
||||
it('shows nothing-scheduled when there is no now-playing', async () => {
|
||||
await renderPanel(
|
||||
<ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
|
||||
);
|
||||
|
||||
expect(screen.getByText('Nothing scheduled')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not autoplay a forced-hls channel and shows the caveat both before and after opting in', () => {
|
||||
render(
|
||||
it('does not autoplay a forced-hls channel and shows the caveat both before and after opting in', async () => {
|
||||
await renderPanel(
|
||||
<ChannelPreviewPanel
|
||||
channel={channel(forced, 'MPEG-TS')}
|
||||
nowPlaying={null}
|
||||
@@ -123,31 +174,32 @@ describe('ChannelPreviewPanel', () => {
|
||||
expect(screen.getByText(/not the channel's configured pipeline/i)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /preview via hls anyway/i }));
|
||||
await flush();
|
||||
|
||||
// After opting in: the stream loads, and the caveat persists as a banner beside the result.
|
||||
expect(hlsMock.loadSource).toHaveBeenCalledWith('/iptv/channel/12.1.m3u8?mode=segmenter');
|
||||
expect(screen.getByText(/not the channel's configured pipeline/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('never shows the caveat for an available channel', () => {
|
||||
render(
|
||||
it('never shows the caveat for an available channel', async () => {
|
||||
await renderPanel(
|
||||
<ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/not the channel's configured pipeline/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the server reason and no player when unavailable', () => {
|
||||
render(
|
||||
it('renders the server reason and no player when unavailable', async () => {
|
||||
await renderPanel(
|
||||
<ChannelPreviewPanel channel={channel(unavailable)} nowPlaying={null} onClose={vi.fn()} open />
|
||||
);
|
||||
|
||||
expect(screen.getByText('IPTV JWT authentication is enabled')).toBeInTheDocument();
|
||||
expect(screen.getByText('Channel is disabled')).toBeInTheDocument();
|
||||
expect(hlsMock.loadSource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces a fatal playback error', () => {
|
||||
render(
|
||||
it('surfaces a fatal playback error', async () => {
|
||||
await renderPanel(
|
||||
<ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
|
||||
);
|
||||
|
||||
@@ -159,29 +211,55 @@ describe('ChannelPreviewPanel', () => {
|
||||
expect(screen.getByText(/networkError: manifestLoadError/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the manifest url so a failure can be reproduced with curl', () => {
|
||||
render(
|
||||
it('shows the manifest url so a failure can be reproduced with curl', async () => {
|
||||
await renderPanel(
|
||||
<ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
|
||||
);
|
||||
|
||||
expect(screen.getByText('/iptv/channel/12.1.m3u8')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking Retry re-issues the manifest request (a fresh play token)', () => {
|
||||
render(
|
||||
it('clicking Retry re-issues the manifest request (a fresh play token)', async () => {
|
||||
await renderPanel(
|
||||
<ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
|
||||
);
|
||||
|
||||
expect(hlsMock.loadSource).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /retry/i }));
|
||||
await flush();
|
||||
|
||||
expect(hlsMock.loadSource).toHaveBeenCalledTimes(2);
|
||||
expect(hlsMock.loadSource).toHaveBeenNthCalledWith(2, '/iptv/channel/12.1.m3u8');
|
||||
// Retry drops the token cache so a stale/expired token can't wedge the recovery attempt.
|
||||
expect(resetIptvTokenCacheMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clicking Retry clears a prior error', () => {
|
||||
render(
|
||||
it('on Retry re-mints the token and never reloads the stale-token URL', async () => {
|
||||
// withIptvToken returns a DIFFERENT token per call (simulating a remint after resetIptvTokenCache).
|
||||
let n = 0;
|
||||
withIptvTokenMock.mockImplementation((url: string) => Promise.resolve(`${url}?access_token=t${++n}`));
|
||||
|
||||
await renderPanel(
|
||||
<ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
|
||||
);
|
||||
|
||||
expect(hlsMock.loadSource).toHaveBeenCalledTimes(1);
|
||||
expect(hlsMock.loadSource).toHaveBeenNthCalledWith(1, '/iptv/channel/12.1.m3u8?access_token=t1');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /retry/i }));
|
||||
await flush();
|
||||
|
||||
// Exactly two loads total: the initial (t1) and the retry (t2). The stale-token URL (t1) must NOT be
|
||||
// reloaded when playToken bumps — the player unmounts until the fresh URL resolves. A regression that
|
||||
// kept resolvedSrc across the bump would produce a third loadSource with the stale t1 URL.
|
||||
expect(hlsMock.loadSource).toHaveBeenCalledTimes(2);
|
||||
expect(hlsMock.loadSource).toHaveBeenNthCalledWith(2, '/iptv/channel/12.1.m3u8?access_token=t2');
|
||||
expect(hlsMock.loadSource.mock.calls.filter((c) => c[0] === '/iptv/channel/12.1.m3u8?access_token=t1')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('clicking Retry clears a prior error', async () => {
|
||||
await renderPanel(
|
||||
<ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
|
||||
);
|
||||
|
||||
@@ -192,13 +270,14 @@ describe('ChannelPreviewPanel', () => {
|
||||
expect(screen.getByText(/networkError: manifestLoadError/)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /retry/i }));
|
||||
await flush();
|
||||
|
||||
expect(screen.queryByText(/networkError: manifestLoadError/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('stays in starting once hls.js merely reports MANIFEST_PARSED', () => {
|
||||
it('stays in starting once hls.js merely reports MANIFEST_PARSED', async () => {
|
||||
// MANIFEST_PARSED fires before any media has decoded, so it must not be enough to reach playing.
|
||||
render(
|
||||
await renderPanel(
|
||||
<ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
|
||||
);
|
||||
|
||||
@@ -212,8 +291,8 @@ describe('ChannelPreviewPanel', () => {
|
||||
expect(screen.getByText('starting')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reaches the playing state once the video element fires its real playing event', () => {
|
||||
render(
|
||||
it('reaches the playing state once the video element fires its real playing event', async () => {
|
||||
await renderPanel(
|
||||
<ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
|
||||
);
|
||||
|
||||
@@ -229,11 +308,11 @@ describe('ChannelPreviewPanel', () => {
|
||||
expect(screen.getByText('playing')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not let a subsequent playing event (e.g. a manual play-button click) erase a fatal error', () => {
|
||||
it('does not let a subsequent playing event (e.g. a manual play-button click) erase a fatal error', async () => {
|
||||
// A fatal error must be reported and stay reported: it must never be silently erased by a later
|
||||
// `playing` event, such as the one fired when the operator manually clicks the visible play
|
||||
// button on the <video controls> element after the failure banner is already showing.
|
||||
render(
|
||||
await renderPanel(
|
||||
<ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
|
||||
);
|
||||
|
||||
@@ -254,8 +333,8 @@ describe('ChannelPreviewPanel', () => {
|
||||
});
|
||||
|
||||
describe('switching to a different channel while mounted (render-phase reset)', () => {
|
||||
it('does not auto-start a forced-hls-only channel switched in from an available one', () => {
|
||||
const { rerender } = render(
|
||||
it('does not auto-start a forced-hls-only channel switched in from an available one', async () => {
|
||||
const { rerender } = await renderPanel(
|
||||
<ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
|
||||
);
|
||||
|
||||
@@ -264,14 +343,15 @@ describe('ChannelPreviewPanel', () => {
|
||||
rerender(
|
||||
<ChannelPreviewPanel channel={channel(forced, 'MPEG-TS', 2)} nowPlaying={null} onClose={vi.fn()} open />
|
||||
);
|
||||
await flush();
|
||||
|
||||
// No new load for the forced channel: it must stay opt-in, never auto-start.
|
||||
expect(hlsMock.loadSource).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByRole('button', { name: /preview via hls anyway/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears a prior error and starts the new channel when switching between two available channels', () => {
|
||||
const { rerender } = render(
|
||||
it('clears a prior error and starts the new channel when switching between two available channels', async () => {
|
||||
const { rerender } = await renderPanel(
|
||||
<ChannelPreviewPanel channel={channel(available, undefined, 1)} nowPlaying={null} onClose={vi.fn()} open />
|
||||
);
|
||||
|
||||
@@ -284,6 +364,7 @@ describe('ChannelPreviewPanel', () => {
|
||||
rerender(
|
||||
<ChannelPreviewPanel channel={channel(available2, undefined, 2)} nowPlaying={null} onClose={vi.fn()} open />
|
||||
);
|
||||
await flush();
|
||||
|
||||
expect(screen.queryByText(/networkError: manifestLoadError/)).not.toBeInTheDocument();
|
||||
expect(hlsMock.loadSource).toHaveBeenLastCalledWith('/iptv/channel/34.1.m3u8');
|
||||
@@ -300,7 +381,7 @@ describe('ChannelPreviewPanel', () => {
|
||||
expect(screen.getByText('playing')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not carry the retry play token over to the next channel', () => {
|
||||
it('does not carry the retry play token over to the next channel', async () => {
|
||||
// Both channels share a manifest URL so the only thing that could re-trigger the attach
|
||||
// effect across the switch is playToken. If the reset failed to zero it out, the token
|
||||
// would be unchanged (2 -> 2) and the effect (and loadSource) would NOT re-fire on switch.
|
||||
@@ -315,22 +396,21 @@ describe('ChannelPreviewPanel', () => {
|
||||
unavailableReason: null
|
||||
};
|
||||
|
||||
const { rerender } = render(
|
||||
const { rerender } = await renderPanel(
|
||||
<ChannelPreviewPanel channel={channel(sameUrlA, undefined, 1)} nowPlaying={null} onClose={vi.fn()} open />
|
||||
);
|
||||
expect(hlsMock.loadSource).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /retry/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole('button', { name: /retry/i }));
|
||||
await flush();
|
||||
expect(hlsMock.loadSource).toHaveBeenCalledTimes(3);
|
||||
|
||||
rerender(
|
||||
<ChannelPreviewPanel channel={channel(sameUrlB, undefined, 2)} nowPlaying={null} onClose={vi.fn()} open />
|
||||
);
|
||||
|
||||
// A fresh channel with playToken reset to 0 differs from the prior effective token (2),
|
||||
// so the attach effect re-fires exactly once more even though the manifest url is unchanged.
|
||||
expect(hlsMock.loadSource).toHaveBeenCalledTimes(4);
|
||||
await waitFor(() => expect(hlsMock.loadSource).toHaveBeenCalledTimes(4));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ChannelPreviewAvailability } from '../../api/channels';
|
||||
import { Button } from '../../components/forms';
|
||||
import { SlideOver } from '../../components/overlay';
|
||||
import { HlsPlayer } from '../../media/HlsPlayer';
|
||||
import { resetIptvTokenCache, withIptvToken } from '../../media/iptvToken';
|
||||
|
||||
export const FORCED_HLS_CAVEAT =
|
||||
"This channel is configured for Transport Stream, which browsers cannot play. This preview forces an HLS segmenter session — it checks the content, not the channel's configured pipeline.";
|
||||
@@ -61,6 +62,12 @@ export function ChannelPreviewPanel({ channel, nowPlaying, onClose, open }: Chan
|
||||
// render (React's documented "adjusting state when a prop changes" pattern) rather than in a
|
||||
// useEffect body, which would trip the react-hooks "no set-state-in-effect" rule (spa-conventions
|
||||
// §3) and cause an extra commit.
|
||||
// Under a JWT-enabled deployment, /iptv/* requires a short-lived access token appended as
|
||||
// ?access_token= (#552); withIptvToken resolves it (a no-op when JWT is off). Async, so it lands in
|
||||
// state and feeds HlsPlayer — manifestUrl itself stays token-free for the facts grid (curl-reproducible,
|
||||
// no token leak in the UI).
|
||||
const [resolvedSrc, setResolvedSrc] = useState<null | string>(null);
|
||||
|
||||
const [resetKey, setResetKey] = useState(channel.id);
|
||||
if (resetKey !== channel.id) {
|
||||
setResetKey(channel.id);
|
||||
@@ -68,6 +75,7 @@ export function ChannelPreviewPanel({ channel, nowPlaying, onClose, open }: Chan
|
||||
setState(availability === 'Available' ? 'starting' : 'idle');
|
||||
setError(null);
|
||||
setPlayToken(0);
|
||||
setResolvedSrc(null);
|
||||
}
|
||||
|
||||
// failedRef mutations are confined to event handlers and this effect — never render-phase — per
|
||||
@@ -106,13 +114,52 @@ export function ChannelPreviewPanel({ channel, nowPlaying, onClose, open }: Chan
|
||||
// retries hides the fault it exists to reveal). Retry re-issues the manifest request by bumping
|
||||
// playToken and clears the previous error so a stale failure doesn't linger over a fresh attempt.
|
||||
const onRetry = useCallback(() => {
|
||||
// Retry is the recovery affordance, so re-evaluate the token from scratch: drop the cache so a stale
|
||||
// token (key rotated) or a stale "JWT disabled" latch (backend reconfigured since load) can't wedge
|
||||
// playback. An actually-expired token would refresh on its own, but this also covers those edges (#552).
|
||||
resetIptvTokenCache();
|
||||
// Null resolvedSrc FIRST so the player unmounts and does not reload the OLD tokened URL when playToken
|
||||
// bumps below — the fresh URL comes from the async effect after withIptvToken re-mints. Skipping this
|
||||
// would load the stale token once (duplicate session / a stale 401 that sticks the panel as failed even
|
||||
// after the fresh stream succeeds).
|
||||
setResolvedSrc(null);
|
||||
failedRef.current = false;
|
||||
setError(null);
|
||||
setState('starting');
|
||||
setPlayToken((current) => current + 1);
|
||||
}, []);
|
||||
|
||||
const src = started ? manifestUrl : null;
|
||||
// Resolve the tokened src whenever a play begins or is retried. Keyed on playToken so a Retry (which may
|
||||
// follow a token expiry) re-mints; the cleanup flag drops a stale resolution if the channel/attempt
|
||||
// changes mid-flight. A token-fetch failure surfaces as a fatal error, same as any manifest failure.
|
||||
useEffect(() => {
|
||||
// Not playing yet (forced channel before opt-in, or just-reset): nothing to resolve. resolvedSrc is
|
||||
// already null here — it starts null and the render-phase reset above nulls it whenever `started`
|
||||
// flips false — so return without a synchronous setState (react-hooks/set-state-in-effect).
|
||||
if (!started || !manifestUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
void (async () => {
|
||||
try {
|
||||
const url = await withIptvToken(manifestUrl);
|
||||
if (active) {
|
||||
setResolvedSrc(url);
|
||||
}
|
||||
} catch {
|
||||
if (active) {
|
||||
failedRef.current = true;
|
||||
setError('Could not obtain an IPTV access token.');
|
||||
setState('failed');
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [started, manifestUrl, playToken]);
|
||||
|
||||
return (
|
||||
<SlideOver onClose={onClose} open={open} subtitle={`Channel ${channel.number}`} title={`Preview: ${channel.name}`}>
|
||||
@@ -130,14 +177,14 @@ export function ChannelPreviewPanel({ channel, nowPlaying, onClose, open }: Chan
|
||||
Preview via HLS anyway
|
||||
</Button>
|
||||
)}
|
||||
{src && (
|
||||
{resolvedSrc && (
|
||||
<>
|
||||
<HlsPlayer
|
||||
className="ctv-preview-video"
|
||||
onError={onError}
|
||||
onPlaying={onPlaying}
|
||||
playToken={playToken}
|
||||
src={src}
|
||||
src={resolvedSrc}
|
||||
style={{ aspectRatio: '16 / 9', background: '#000' }}
|
||||
/>
|
||||
<div className="ctv-detail-actions">
|
||||
|
||||
Reference in New Issue
Block a user