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();
}
}