diff --git a/src/statichost/StaticHost/Live/LiveEndpoints.cs b/src/statichost/StaticHost/Live/LiveEndpoints.cs index 3ca8280de5..4054c249b4 100644 --- a/src/statichost/StaticHost/Live/LiveEndpoints.cs +++ b/src/statichost/StaticHost/Live/LiveEndpoints.cs @@ -264,8 +264,12 @@ private static async Task YouTubeWebhook( { try { - var live = await ytClient.GetCurrentLiveAsync(youtube.ChannelId, CancellationToken.None).ConfigureAwait(false); - broadcaster.Update(new LiveStatusUpdate { YouTube = new YouTubeStatus(live.Live, live.VideoId) }); + await ConfirmYouTubeLiveStatusAsync( + youtube, + ytClient, + broadcaster, + logger, + CancellationToken.None).ConfigureAwait(false); } catch (Exception ex) { @@ -276,6 +280,29 @@ private static async Task YouTubeWebhook( return Results.Ok(); } + internal static async Task ConfirmYouTubeLiveStatusAsync( + YouTubeOptions youtube, + IYouTubeClient ytClient, + LiveStatusBroadcaster broadcaster, + ILogger logger, + CancellationToken cancellationToken) + { + var channelId = youtube.ChannelId; + if (string.IsNullOrWhiteSpace(channelId)) + { + channelId = await ytClient.ResolveChannelIdAsync(youtube.ChannelHandle, cancellationToken).ConfigureAwait(false); + } + + if (string.IsNullOrWhiteSpace(channelId)) + { + logger.LogWarning("Could not resolve YouTube channel id for webhook confirmation."); + return; + } + + var live = await ytClient.GetCurrentLiveAsync(channelId, cancellationToken).ConfigureAwait(false); + broadcaster.Update(new LiveStatusUpdate { YouTube = new YouTubeStatus(live.Live, live.VideoId) }); + } + // --- Dev-only ----------------------------------------------------------- private static IResult DevSet( diff --git a/src/statichost/StaticHost/Live/Twitch/ITwitchClient.cs b/src/statichost/StaticHost/Live/Twitch/ITwitchClient.cs index 26757e8ab8..d8fd7fe267 100644 --- a/src/statichost/StaticHost/Live/Twitch/ITwitchClient.cs +++ b/src/statichost/StaticHost/Live/Twitch/ITwitchClient.cs @@ -7,7 +7,12 @@ public sealed record TwitchUser(string Id, string Login, string DisplayName); public sealed record TwitchStreamInfo(bool Live, string? Title); /// Existing EventSub subscription record. -public sealed record TwitchEventSubSubscription(string Id, string Type, string Status, string CallbackUrl); +public sealed record TwitchEventSubSubscription( + string Id, + string Type, + string Status, + string CallbackUrl, + string? BroadcasterUserId); /// Abstraction over the Twitch Helix API used by the live-status feature. public interface ITwitchClient diff --git a/src/statichost/StaticHost/Live/Twitch/TwitchClient.cs b/src/statichost/StaticHost/Live/Twitch/TwitchClient.cs index f0864abaa9..bd04cc432f 100644 --- a/src/statichost/StaticHost/Live/Twitch/TwitchClient.cs +++ b/src/statichost/StaticHost/Live/Twitch/TwitchClient.cs @@ -88,8 +88,13 @@ public async Task> ListEventSubAsync(C var status = sub.GetProperty("status").GetString() ?? ""; var transport = sub.GetProperty("transport"); var callback = transport.TryGetProperty("callback", out var cb) ? cb.GetString() ?? "" : ""; + var condition = sub.TryGetProperty("condition", out var conditionEl) ? conditionEl : default; + var broadcasterUserId = condition.ValueKind == JsonValueKind.Object && + condition.TryGetProperty("broadcaster_user_id", out var broadcasterUserIdEl) + ? broadcasterUserIdEl.GetString() + : null; - list.Add(new TwitchEventSubSubscription(id, type, status, callback)); + list.Add(new TwitchEventSubSubscription(id, type, status, callback, broadcasterUserId)); } return list; diff --git a/src/statichost/StaticHost/Live/Twitch/TwitchEventSubService.cs b/src/statichost/StaticHost/Live/Twitch/TwitchEventSubService.cs index d496ee2d2b..5ca7af7665 100644 --- a/src/statichost/StaticHost/Live/Twitch/TwitchEventSubService.cs +++ b/src/statichost/StaticHost/Live/Twitch/TwitchEventSubService.cs @@ -99,6 +99,12 @@ internal async Task ReconcileAsync(CancellationToken cancellationToken) Twitch = new TwitchStatus(stream.Live, twitch.ChannelLogin, stream.Title) }); + if (string.IsNullOrWhiteSpace(twitch.WebhookSecret)) + { + logger.LogWarning("Twitch WebhookSecret not configured; skipping EventSub subscription reconciliation."); + return; + } + // Ensure subs. var callback = $"{opts.PublicBaseUrl.TrimEnd('/')}/api/live/twitch/webhook"; var existing = await client.ListEventSubAsync(cancellationToken).ConfigureAwait(false); @@ -106,11 +112,12 @@ internal async Task ReconcileAsync(CancellationToken cancellationToken) await EnsureSubAsync(existing, "stream.online", channelId, callback, twitch.WebhookSecret, cancellationToken).ConfigureAwait(false); await EnsureSubAsync(existing, "stream.offline", channelId, callback, twitch.WebhookSecret, cancellationToken).ConfigureAwait(false); - // Drop stale subs (different callback or revoked). + // Drop stale subs (different callback/channel or revoked). foreach (var sub in existing) { var stale = sub.Type is "stream.online" or "stream.offline" && (!string.Equals(sub.CallbackUrl, callback, StringComparison.OrdinalIgnoreCase) + || !string.Equals(sub.BroadcasterUserId, channelId, StringComparison.Ordinal) || sub.Status is "authorization_revoked" or "user_removed" or "notification_failures_exceeded"); if (stale) { @@ -124,6 +131,7 @@ private async Task EnsureSubAsync(IReadOnlyList exis { var found = existing.FirstOrDefault(s => s.Type == type && string.Equals(s.CallbackUrl, callback, StringComparison.OrdinalIgnoreCase) + && string.Equals(s.BroadcasterUserId, channelId, StringComparison.Ordinal) && s.Status is "enabled" or "webhook_callback_verification_pending"); if (found is not null) return; diff --git a/src/statichost/StaticHost/Live/Twitch/TwitchWebhookHandler.cs b/src/statichost/StaticHost/Live/Twitch/TwitchWebhookHandler.cs index 27a75fd65e..cd3645803f 100644 --- a/src/statichost/StaticHost/Live/Twitch/TwitchWebhookHandler.cs +++ b/src/statichost/StaticHost/Live/Twitch/TwitchWebhookHandler.cs @@ -23,7 +23,8 @@ public static bool IsValidSignature(string secret, string messageId, string time var expectedHex = signatureHeader[prefix.Length..]; using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); - hmac.TransformBlock(Encoding.UTF8.GetBytes(messageId), 0, messageId.Length, null, 0); + var messageIdBytes = Encoding.UTF8.GetBytes(messageId); + hmac.TransformBlock(messageIdBytes, 0, messageIdBytes.Length, null, 0); var tsBytes = Encoding.UTF8.GetBytes(timestamp); hmac.TransformBlock(tsBytes, 0, tsBytes.Length, null, 0); hmac.TransformFinalBlock(body, 0, body.Length); diff --git a/src/statichost/StaticHost/StaticHost.csproj b/src/statichost/StaticHost/StaticHost.csproj index 7b599f52e7..13601699a9 100644 --- a/src/statichost/StaticHost/StaticHost.csproj +++ b/src/statichost/StaticHost/StaticHost.csproj @@ -26,7 +26,7 @@ - + diff --git a/tests/StaticHost.Tests/Live/TwitchEventSubServiceTests.cs b/tests/StaticHost.Tests/Live/TwitchEventSubServiceTests.cs index 7d4044cf4a..0442468df3 100644 --- a/tests/StaticHost.Tests/Live/TwitchEventSubServiceTests.cs +++ b/tests/StaticHost.Tests/Live/TwitchEventSubServiceTests.cs @@ -15,12 +15,14 @@ public async Task ReconcileAsync_CreatesMissingSubscriptionAndDeletesStaleSubscr "online-existing", "stream.online", "enabled", - "https://example.com/api/live/twitch/webhook"), + "https://example.com/api/live/twitch/webhook", + "user-123"), new TwitchEventSubSubscription( "offline-stale", "stream.offline", "authorization_revoked", - "https://old.example.com/api/live/twitch/webhook"), + "https://old.example.com/api/live/twitch/webhook", + "user-123"), ], }; var broadcaster = LiveTestHelpers.CreateBroadcaster(); @@ -83,6 +85,84 @@ public async Task ReconcileAsync_CachesResolvedChannelIdAcrossReconciles() Assert.Equal(["user-123", "user-123"], client.StreamLookups); } + [Fact] + public async Task ReconcileAsync_SkipsEventSubReconciliationWhenWebhookSecretMissing() + { + var client = new TestTwitchClient + { + User = new TwitchUser("user-123", "aspiredotdev", "Aspire"), + Stream = new TwitchStreamInfo(true, "Live now"), + }; + var broadcaster = LiveTestHelpers.CreateBroadcaster(); + var service = new TwitchEventSubService( + client, + broadcaster, + new TestOptionsMonitor(new LiveStatusOptions + { + PublicBaseUrl = "https://example.com/", + Twitch = new TwitchOptions + { + ClientId = "client-id", + ClientSecret = "client-secret", + ChannelLogin = "aspiredotdev", + }, + }), + NullLogger.Instance); + + await service.ReconcileAsync(CancellationToken.None); + + Assert.True(broadcaster.Current.Twitch.Live); + Assert.Empty(client.CreatedSubscriptions); + Assert.Empty(client.DeletedSubscriptions); + Assert.Equal(0, client.ListEventSubCount); + } + + [Fact] + public async Task ReconcileAsync_ReplacesSubscriptionForDifferentBroadcaster() + { + var client = new TestTwitchClient + { + User = new TwitchUser("user-123", "aspiredotdev", "Aspire"), + Stream = new TwitchStreamInfo(false, null), + Subscriptions = + [ + new TwitchEventSubSubscription( + "wrong-online", + "stream.online", + "enabled", + "https://example.com/api/live/twitch/webhook", + "other-user"), + new TwitchEventSubSubscription( + "offline-existing", + "stream.offline", + "enabled", + "https://example.com/api/live/twitch/webhook", + "user-123"), + ], + }; + var service = new TwitchEventSubService( + client, + LiveTestHelpers.CreateBroadcaster(), + new TestOptionsMonitor(new LiveStatusOptions + { + PublicBaseUrl = "https://example.com/", + Twitch = new TwitchOptions + { + ClientId = "client-id", + ClientSecret = "client-secret", + WebhookSecret = "webhook-secret", + ChannelLogin = "aspiredotdev", + }, + }), + NullLogger.Instance); + + await service.ReconcileAsync(CancellationToken.None); + + var created = Assert.Single(client.CreatedSubscriptions); + Assert.Equal("stream.online", created.Type); + Assert.Equal(["wrong-online"], client.DeletedSubscriptions); + } + private sealed class TestTwitchClient : ITwitchClient { public TwitchUser? User { get; set; } @@ -99,6 +179,8 @@ private sealed class TestTwitchClient : ITwitchClient public List StreamLookups { get; } = []; + public int ListEventSubCount { get; private set; } + public Task GetUserByLoginAsync(string login, CancellationToken cancellationToken) { UserLookupCount++; @@ -111,8 +193,11 @@ public Task GetStreamAsync(string userId, CancellationToken ca return Task.FromResult(Stream); } - public Task> ListEventSubAsync(CancellationToken cancellationToken) => - Task.FromResult(Subscriptions); + public Task> ListEventSubAsync(CancellationToken cancellationToken) + { + ListEventSubCount++; + return Task.FromResult(Subscriptions); + } public Task CreateEventSubAsync(string type, string condition, string callbackUrl, string secret, CancellationToken cancellationToken) { diff --git a/tests/StaticHost.Tests/Live/YouTubeWebSubServiceTests.cs b/tests/StaticHost.Tests/Live/YouTubeWebSubServiceTests.cs index 7557092fd9..73a673f58a 100644 --- a/tests/StaticHost.Tests/Live/YouTubeWebSubServiceTests.cs +++ b/tests/StaticHost.Tests/Live/YouTubeWebSubServiceTests.cs @@ -101,6 +101,32 @@ public async Task TickAsync_RequiresConfiguredOfflineConfirmationCountBeforeBroa Assert.Null(broadcaster.Current.YouTube.VideoId); } + [Fact] + public async Task ConfirmYouTubeLiveStatusAsync_ResolvesChannelIdBeforePollingWhenOptionEmpty() + { + var client = new TestYouTubeClient + { + ResolvedChannelId = "channel-123", + }; + client.LiveResults.Enqueue(new YouTubeLiveResult(true, "video-123")); + var broadcaster = LiveTestHelpers.CreateBroadcaster(); + + await LiveStatusEndpointRouteBuilderExtensions.ConfirmYouTubeLiveStatusAsync( + new YouTubeOptions + { + ChannelHandle = "@aspiredotdev", + }, + client, + broadcaster, + NullLogger.Instance, + CancellationToken.None); + + Assert.True(broadcaster.Current.YouTube.Live); + Assert.Equal("video-123", broadcaster.Current.YouTube.VideoId); + Assert.Equal(1, client.ResolveChannelIdCalls); + Assert.Equal(["channel-123"], client.LiveLookups); + } + private sealed class TestYouTubeClient : IYouTubeClient { public string? ResolvedChannelId { get; set; } = "channel-123";