using System.Security.Claims; using System.Security.Cryptography; using EchoHub.Core.DTOs; using EchoHub.Core.Models; using EchoHub.Server.Data; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; namespace EchoHub.Server.Controllers; /// /// Invite-code management for invite-gated registration (Admin+ only). /// Codes live in this server's own database — there is no central service. /// [ApiController] [Route("api/invites")] [Authorize] [EnableRateLimiting("general")] public class InvitesController : ControllerBase { private const int MaxActiveInvites = 200; private readonly EchoHubDbContext _db; private readonly ILogger _logger; public InvitesController(EchoHubDbContext db, ILogger logger) { _db = db; _logger = logger; } [HttpPost] public async Task Create([FromBody] CreateInviteRequest request) { var (caller, error) = await GetCallerAsync(ServerRole.Admin); if (error is not null) return error; var maxUses = request.MaxUses ?? 1; if (maxUses is < 1 or > 1000) return BadRequest(new ErrorResponse("MaxUses must be between 1 and 1000.")); if (request.ExpiresInHours is < 1 or > 24 * 365) return BadRequest(new ErrorResponse("ExpiresInHours must be between 1 and 8760.")); if (await _db.InviteCodes.CountAsync(i => i.UseCount < i.MaxUses) >= MaxActiveInvites) return BadRequest(new ErrorResponse($"Too many active invites (max {MaxActiveInvites}). Revoke unused ones first.")); var invite = new InviteCode { Id = Guid.NewGuid(), Code = GenerateCode(), CreatedByUserId = caller!.Id, CreatedByUsername = caller.Username, CreatedAt = DateTimeOffset.UtcNow, ExpiresAt = request.ExpiresInHours is { } hours ? DateTimeOffset.UtcNow.AddHours(hours) : null, MaxUses = maxUses, }; _db.InviteCodes.Add(invite); await _db.SaveChangesAsync(); _logger.LogInformation("Invite code created by {User} (uses: {MaxUses}, expires: {Expires})", caller.Username, invite.MaxUses, invite.ExpiresAt?.ToString("u") ?? "never"); return Ok(ToDto(invite)); } [HttpGet] public async Task List() { var (_, error) = await GetCallerAsync(ServerRole.Admin); if (error is not null) return error; var invites = await _db.InviteCodes .OrderByDescending(i => i.CreatedAt) .ToListAsync(); return Ok(invites.Select(ToDto).ToList()); } [HttpDelete("{code}")] public async Task Revoke(string code) { var (caller, error) = await GetCallerAsync(ServerRole.Admin); if (error is not null) return error; var normalized = code.Trim().ToUpperInvariant(); var invite = await _db.InviteCodes.FirstOrDefaultAsync(i => i.Code == normalized); if (invite is null) return NotFound(new ErrorResponse("Invite code not found.")); _db.InviteCodes.Remove(invite); await _db.SaveChangesAsync(); _logger.LogInformation("Invite code revoked by {User}", caller!.Username); return Ok(); } /// Unguessable, unambiguous code like "K7QM-3XPF" (no 0/O/1/I). private static string GenerateCode() { const string alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; Span chars = stackalloc char[8]; for (var i = 0; i < chars.Length; i++) chars[i] = alphabet[RandomNumberGenerator.GetInt32(alphabet.Length)]; return $"{new string(chars[..4])}-{new string(chars[4..])}"; } private static InviteDto ToDto(InviteCode i) => new(i.Code, i.CreatedByUsername, i.CreatedAt, i.ExpiresAt, i.MaxUses, i.UseCount); private async Task<(User? Caller, IActionResult? Error)> GetCallerAsync(ServerRole minimumRole) { var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); if (userIdClaim is null) return (null, Unauthorized(new ErrorResponse("Authentication required."))); var caller = await _db.Users.FindAsync(Guid.Parse(userIdClaim)); if (caller is null) return (null, Unauthorized(new ErrorResponse("User not found."))); if (caller.Role < minimumRole) return (null, StatusCode(403, new ErrorResponse($"Requires {minimumRole} role or higher."))); return (caller, null); } }