using DeepDrftData; using DeepDrftModels.DTOs; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; namespace DeepDrftAPI.Controllers; /// /// Anonymous play/share telemetry intake (Phase 16 §2.2 / §4.3). Unauthenticated — same posture as the /// public reads — but IP rate-limited (the "events" limiter, registered in Program.cs) and payload- /// validated to make casual inflation annoying (§2.5). Both endpoints return 202 Accepted: these /// are fire-and-forget telemetry, not transactions, and the client (a sendBeacon) never reads the /// response. The release dimension on a play is resolved server-side from the track key (§2.3 / D4). /// The controller is a thin HTTP boundary; all write logic lives in . /// [ApiController] [Route("api/event")] [EnableRateLimiting("events")] public class EventController : ControllerBase { // Reject oversized bodies before deserialization — a coarse abuse guard (§2.5). The legitimate // payloads are a track key + an enum, well under 1 KB. private const int MaxBodyBytes = 1024; private readonly IEventService _eventService; private readonly ILogger _logger; public EventController(IEventService eventService, ILogger logger) { _eventService = eventService; _logger = logger; } // POST api/event/play (unauthenticated, rate-limited) [HttpPost("play")] [RequestSizeLimit(MaxBodyBytes)] public async Task RecordPlay([FromBody] PlayEventDto payload, CancellationToken ct = default) { // Reject a missing track key and an out-of-range bucket (§2.5). [ApiController] model binding // already 400s a malformed/oversized body and an undefined enum value, but the explicit guards // keep the contract obvious and cover the empty-string key the model binder lets through. if (string.IsNullOrWhiteSpace(payload.TrackEntryKey)) return BadRequest("trackEntryKey is required"); if (!Enum.IsDefined(payload.Bucket)) return BadRequest("bucket is invalid"); // Wave 16.1 writes no anonId — defend the substrate by dropping any the client sends early. var result = await _eventService.RecordPlay(payload.TrackEntryKey, payload.Bucket, anonId: null, ct); if (!result.Success) { // A telemetry failure must never surface to the listener as an error they can act on, but // we still log it and answer 5xx so a monitor can see the substrate is unhealthy. The // beacon ignores the status either way. _logger.LogWarning("RecordPlay failed: {Error}", result.Messages.FirstOrDefault()?.Message); return StatusCode(500); } return Accepted(); } // POST api/event/share (unauthenticated, rate-limited) [HttpPost("share")] [RequestSizeLimit(MaxBodyBytes)] public async Task RecordShare([FromBody] ShareEventDto payload, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(payload.TargetKey)) return BadRequest("targetKey is required"); if (!Enum.IsDefined(payload.TargetType)) return BadRequest("targetType is invalid"); if (!Enum.IsDefined(payload.Channel)) return BadRequest("channel is invalid"); var result = await _eventService.RecordShare(payload.TargetType, payload.TargetKey, payload.Channel, anonId: null, ct); if (!result.Success) { _logger.LogWarning("RecordShare failed: {Error}", result.Messages.FirstOrDefault()?.Message); return StatusCode(500); } return Accepted(); } }