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; // The anonId is a client-minted GUID string (~36 chars); the anon_id column is varchar(64). Reject // anything longer as malformed rather than silently truncating — an over-long token is either a bug // or an inflation attempt, and a truncated id would corrupt the distinct-listener count by colliding // distinct listeners onto one prefix. Whitespace-only is treated as absent. private const int MaxAnonIdLength = 64; 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"); if (!TryNormalizeAnonId(payload.AnonId, out var anonId)) return BadRequest("anonId is invalid"); var result = await _eventService.RecordPlay(payload.TrackEntryKey, payload.Bucket, anonId, 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"); if (!TryNormalizeAnonId(payload.AnonId, out var anonId)) return BadRequest("anonId is invalid"); var result = await _eventService.RecordShare(payload.TargetType, payload.TargetKey, payload.Channel, anonId, ct); if (!result.Success) { _logger.LogWarning("RecordShare failed: {Error}", result.Messages.FirstOrDefault()?.Message); return StatusCode(500); } return Accepted(); } // Normalize an incoming anonId (wave 16.3): whitespace-only / empty / null collapses to a null token // (the listener didn't send one, or storage was unavailable — a valid, anonId-less event). A token // over the column width is rejected (400) rather than truncated, since truncation would collide // distinct listeners. Returns false only on the over-long case; null and a valid token both pass. private static bool TryNormalizeAnonId(string? raw, out string? anonId) { if (string.IsNullOrWhiteSpace(raw)) { anonId = null; return true; } var trimmed = raw.Trim(); if (trimmed.Length > MaxAnonIdLength) { anonId = null; return false; } anonId = trimmed; return true; } }