using System.Text; using Application.Abstractions.YouTube; using Application.Abstractions.Messaging; using Web.Api.Extensions; namespace Web.Api.Endpoints.YouTube; /// /// YouTube PubSubHubbub(WebSub) 콜백 엔드포인트 /// - GET: hub.challenge 검증 (구독 확인) /// - POST: ATOM Feed 알림 수신 (새 영상/라이브) /// internal sealed class PubSubCallback : IEndpoint { public void MapEndpoint(IEndpointRouteBuilder app) { var group = app.MapGroup("api/youtube/pubsub/callback").WithTags("YouTube").AllowAnonymous().RequireFeature(c => c.Channel); group.MapGet("", HandleVerifyAsync); group.MapPost("", HandleNotifyAsync); } // ── GET: 구독 검증 ───────────────────────────────────────────── private static async Task HandleVerifyAsync( HttpContext context, IYouTubePubSubService pubSubService, ILogger logger, CancellationToken ct ) { var query = context.Request.Query; var mode = query["hub.mode"].ToString(); var topic = query["hub.topic"].ToString(); var challenge = query["hub.challenge"].ToString(); var lease = query["hub.lease_seconds"].ToString(); if (string.IsNullOrEmpty(mode) || string.IsNullOrEmpty(topic) || string.IsNullOrEmpty(challenge)) { logger.LogWarning("[PubSub] Invalid verify request: mode={Mode}, topic={Topic}", mode, topic); return Results.BadRequest(); } if (mode is not ("subscribe" or "unsubscribe")) { logger.LogWarning("[PubSub] Unsupported hub.mode: {Mode}", mode); return Results.BadRequest(); } // subscribe 시 lease_seconds 기반 만료 시각 Redis에 저장 (관리자 대시보드용) if (mode is "subscribe" && int.TryParse(lease, out var leaseSeconds) && leaseSeconds > 0) { var channelId = ExtractChannelIdFromTopic(topic); if (!string.IsNullOrEmpty(channelId)) { try { var expiresAt = DateTime.UtcNow.AddSeconds(leaseSeconds); await pubSubService.SetLeaseExpiryAsync(channelId, expiresAt, ct); } catch (Exception ex) { logger.LogWarning(ex, "[PubSub] Failed to persist lease expiry — channelId={ChannelId}", channelId); } } } logger.LogInformation( "[PubSub] Verify {Mode}: topic={Topic}, lease={Lease}s", mode, topic, lease ); // hub.challenge 평문 에코 (200 OK + text/plain) return Results.Text(challenge, "text/plain"); } // hub.topic 예: https://www.youtube.com/feeds/videos.xml?channel_id=UCxxx private static string? ExtractChannelIdFromTopic(string topic) { if (string.IsNullOrWhiteSpace(topic)) { return null; } const string marker = "channel_id="; var idx = topic.IndexOf(marker, StringComparison.OrdinalIgnoreCase); if (idx < 0) { return null; } var value = topic[(idx + marker.Length)..]; var amp = value.IndexOf('&'); return amp >= 0 ? value[..amp] : value; } // ── POST: Atom Feed 알림 수신 ────────────────────────────────── private static async Task HandleNotifyAsync( HttpContext context, IYouTubePubSubService pubSubService, ISender sender, ILogger logger, CancellationToken ct ) { using var reader = new StreamReader(context.Request.Body, Encoding.UTF8); var body = await reader.ReadToEndAsync(ct); if (string.IsNullOrWhiteSpace(body)) { logger.LogWarning("[PubSub] Empty notify body"); return Results.BadRequest(); } // HMAC 서명 검증 var signature = context.Request.Headers["X-Hub-Signature"].ToString(); if (string.IsNullOrEmpty(signature)) { logger.LogWarning("[PubSub] Missing X-Hub-Signature header"); return Results.Unauthorized(); } if (!pubSubService.VerifySignature(body, signature)) { logger.LogWarning("[PubSub] Signature verification failed"); return Results.Unauthorized(); } // Atom Feed 파싱 var notification = pubSubService.ParseNotification(body); if (notification is null) { logger.LogWarning("[PubSub] Failed to parse Atom feed"); return Results.Ok(); // 파싱 실패해도 200 반환 (재전송 방지) } // MediatR 커맨드로 라이브 판별 & BroadcastSession 생성 위임 try { await sender.Send(new Application.Features.Api.YouTube.PubSubNotify.Command( notification.VideoId, notification.ChannelId, notification.Title, notification.Published, notification.Updated ), ct); } catch (Exception ex) { logger.LogError(ex, "[PubSub] Notify handler error: videoId={VideoId}", notification.VideoId); // 예외 발생해도 200 반환 — 재전송은 dedupe로 막힘 } return Results.Ok(); } }