PubSubCallback.cs 5.4 KB

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