| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155 |
- using System.Text;
- using Application.Abstractions.YouTube;
- using Application.Abstractions.Messaging;
- using Web.Api.Extensions;
- namespace Web.Api.Endpoints.YouTube;
- /// <summary>
- /// YouTube PubSubHubbub(WebSub) 콜백 엔드포인트
- /// - GET: hub.challenge 검증 (구독 확인)
- /// - POST: ATOM Feed 알림 수신 (새 영상/라이브)
- /// </summary>
- 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<IResult> HandleVerifyAsync(
- HttpContext context,
- IYouTubePubSubService pubSubService,
- ILogger<PubSubCallback> 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<IResult> HandleNotifyAsync(
- HttpContext context,
- IYouTubePubSubService pubSubService,
- ISender sender,
- ILogger<PubSubCallback> 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();
- }
- }
|