PubSubCallback.cs 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
  1. using System.Text;
  2. using Application.Abstractions.YouTube;
  3. using Application.Abstractions.Messaging;
  4. using Web.Api.Extensions;
  5. namespace Web.Api.Endpoints.YouTube;
  6. /// <summary>
  7. /// YouTube PubSubHubbub(WebSub) 콜백 엔드포인트
  8. /// - GET: hub.challenge 검증 (구독 확인)
  9. /// - POST: ATOM Feed 알림 수신 (새 영상/라이브)
  10. /// </summary>
  11. internal sealed class PubSubCallback : IEndpoint
  12. {
  13. public void MapEndpoint(IEndpointRouteBuilder app)
  14. {
  15. var group = app.MapGroup("api/youtube/pubsub/callback").WithTags("YouTube").AllowAnonymous().RequireFeature(c => c.Channel);
  16. group.MapGet("", HandleVerifyAsync);
  17. group.MapPost("", HandleNotifyAsync);
  18. }
  19. // ── GET: 구독 검증 ─────────────────────────────────────────────
  20. private static async Task<IResult> HandleVerifyAsync(
  21. HttpContext context,
  22. IYouTubePubSubService pubSubService,
  23. ILogger<PubSubCallback> logger,
  24. CancellationToken ct
  25. ) {
  26. var query = context.Request.Query;
  27. var mode = query["hub.mode"].ToString();
  28. var topic = query["hub.topic"].ToString();
  29. var challenge = query["hub.challenge"].ToString();
  30. var lease = query["hub.lease_seconds"].ToString();
  31. if (string.IsNullOrEmpty(mode) || string.IsNullOrEmpty(topic) || string.IsNullOrEmpty(challenge))
  32. {
  33. logger.LogWarning("[PubSub] Invalid verify request: mode={Mode}, topic={Topic}", mode, topic);
  34. return Results.BadRequest();
  35. }
  36. if (mode is not ("subscribe" or "unsubscribe"))
  37. {
  38. logger.LogWarning("[PubSub] Unsupported hub.mode: {Mode}", mode);
  39. return Results.BadRequest();
  40. }
  41. // subscribe 시 lease_seconds 기반 만료 시각 Redis에 저장 (관리자 대시보드용)
  42. if (mode is "subscribe" && int.TryParse(lease, out var leaseSeconds) && leaseSeconds > 0)
  43. {
  44. var channelId = ExtractChannelIdFromTopic(topic);
  45. if (!string.IsNullOrEmpty(channelId))
  46. {
  47. try
  48. {
  49. var expiresAt = DateTime.UtcNow.AddSeconds(leaseSeconds);
  50. await pubSubService.SetLeaseExpiryAsync(channelId, expiresAt, ct);
  51. }
  52. catch (Exception ex)
  53. {
  54. logger.LogWarning(ex, "[PubSub] Failed to persist lease expiry — channelId={ChannelId}", channelId);
  55. }
  56. }
  57. }
  58. logger.LogInformation(
  59. "[PubSub] Verify {Mode}: topic={Topic}, lease={Lease}s",
  60. mode, topic, lease
  61. );
  62. // hub.challenge 평문 에코 (200 OK + text/plain)
  63. return Results.Text(challenge, "text/plain");
  64. }
  65. // hub.topic 예: https://www.youtube.com/feeds/videos.xml?channel_id=UCxxx
  66. private static string? ExtractChannelIdFromTopic(string topic)
  67. {
  68. if (string.IsNullOrWhiteSpace(topic))
  69. {
  70. return null;
  71. }
  72. const string marker = "channel_id=";
  73. var idx = topic.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
  74. if (idx < 0)
  75. {
  76. return null;
  77. }
  78. var value = topic[(idx + marker.Length)..];
  79. var amp = value.IndexOf('&');
  80. return amp >= 0 ? value[..amp] : value;
  81. }
  82. // ── POST: Atom Feed 알림 수신 ──────────────────────────────────
  83. private static async Task<IResult> HandleNotifyAsync(
  84. HttpContext context,
  85. IYouTubePubSubService pubSubService,
  86. ISender sender,
  87. ILogger<PubSubCallback> logger,
  88. CancellationToken ct
  89. ) {
  90. using var reader = new StreamReader(context.Request.Body, Encoding.UTF8);
  91. var body = await reader.ReadToEndAsync(ct);
  92. if (string.IsNullOrWhiteSpace(body))
  93. {
  94. logger.LogWarning("[PubSub] Empty notify body");
  95. return Results.BadRequest();
  96. }
  97. // HMAC 서명 검증
  98. var signature = context.Request.Headers["X-Hub-Signature"].ToString();
  99. if (string.IsNullOrEmpty(signature))
  100. {
  101. logger.LogWarning("[PubSub] Missing X-Hub-Signature header");
  102. return Results.Unauthorized();
  103. }
  104. if (!pubSubService.VerifySignature(body, signature))
  105. {
  106. logger.LogWarning("[PubSub] Signature verification failed");
  107. return Results.Unauthorized();
  108. }
  109. // Atom Feed 파싱
  110. var notification = pubSubService.ParseNotification(body);
  111. if (notification is null)
  112. {
  113. logger.LogWarning("[PubSub] Failed to parse Atom feed");
  114. return Results.Ok(); // 파싱 실패해도 200 반환 (재전송 방지)
  115. }
  116. // MediatR 커맨드로 라이브 판별 & BroadcastSession 생성 위임
  117. try
  118. {
  119. await sender.Send(new Application.Features.Api.YouTube.PubSubNotify.Command(
  120. notification.VideoId,
  121. notification.ChannelId,
  122. notification.Title,
  123. notification.Published,
  124. notification.Updated
  125. ), ct);
  126. }
  127. catch (Exception ex)
  128. {
  129. logger.LogError(ex, "[PubSub] Notify handler error: videoId={VideoId}", notification.VideoId);
  130. // 예외 발생해도 200 반환 — 재전송은 dedupe로 막힘
  131. }
  132. return Results.Ok();
  133. }
  134. }