using CharacterApi.Models; using CharacterApi.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using System.Net.Http.Headers; using System.Security.Claims; using System.Text; using System.Text.Json; namespace CharacterApi.Controllers; [ApiController] [Route("api/[controller]")] public class CharactersController : ControllerBase { private readonly CharacterStore _characters; private readonly IHttpClientFactory _httpClientFactory; private readonly IConfiguration _configuration; private readonly ILogger _logger; public CharactersController(CharacterStore characters, IHttpClientFactory httpClientFactory, IConfiguration configuration, ILogger logger) { _characters = characters; _httpClientFactory = httpClientFactory; _configuration = configuration; _logger = logger; } [HttpPost] [Authorize(Roles = "USER,SUPER")] public async Task Create([FromBody] CreateCharacterRequest req) { if (string.IsNullOrWhiteSpace(req.Name)) return BadRequest("Name required"); var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); var character = new Character { OwnerUserId = userId, Name = req.Name.Trim(), Coord = new Coord { X = 0, Y = 0 }, VisionRadius = 3, CreatedUtc = DateTime.UtcNow }; await _characters.CreateAsync(character); return Ok(character); } [HttpGet] [Authorize(Roles = "USER,SUPER")] public async Task ListMine() { var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); var characters = await _characters.GetForOwnerAsync(userId); return Ok(characters); } [HttpGet("{id}/visible-locations")] [Authorize(Roles = "USER,SUPER")] public async Task VisibleLocations(string id) { var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); var allowAnyOwner = User.IsInRole("SUPER"); var character = await _characters.GetByIdAsync(id); if (character is null) { _logger.LogWarning("Visible locations request failed: character {CharacterId} was not found.", id); return NotFound(); } if (!allowAnyOwner && character.OwnerUserId != userId) { _logger.LogWarning( "Visible locations request denied: character {CharacterId} belongs to owner {OwnerUserId}, request user was {UserId}", id, character.OwnerUserId, userId ); return Forbid(); } _logger.LogInformation( "Visible locations requested for character {CharacterId} at ({X},{Y}) radius {VisionRadius} by user {UserId}", character.Id, character.Coord.X, character.Coord.Y, character.VisionRadius, userId ); var locationsBaseUrl = (_configuration["Services:LocationsApiBaseUrl"] ?? "http://localhost:5002").TrimEnd('/'); var internalApiKey = (_configuration["InternalApi:Key"] ?? _configuration["Jwt:Key"] ?? string.Empty).Trim(); var body = JsonSerializer.Serialize(new { x = character.Coord.X, y = character.Coord.Y, radius = character.VisionRadius > 0 ? character.VisionRadius : 3 }); var client = _httpClientFactory.CreateClient(); using var request = new HttpRequestMessage(HttpMethod.Post, $"{locationsBaseUrl}/api/locations/internal/visible-window"); request.Content = new StringContent(body, Encoding.UTF8, "application/json"); request.Headers.Add("X-Internal-Api-Key", internalApiKey); HttpResponseMessage response; try { response = await client.SendAsync(request); } catch (HttpRequestException ex) { _logger.LogError(ex, "Failed to reach LocationsApi while resolving visible locations for character {CharacterId}", character.Id); return StatusCode(StatusCodes.Status502BadGateway, new { type = "https://httpstatuses.com/502", title = "Bad Gateway", status = 502, detail = $"Failed to reach LocationsApi at {locationsBaseUrl}.", traceId = HttpContext.TraceIdentifier }); } using (response) { var responseBody = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) return StatusCode((int)response.StatusCode, responseBody); var generation = JsonSerializer.Deserialize( responseBody, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); if (generation is null) { return StatusCode(StatusCodes.Status502BadGateway, new { type = "https://httpstatuses.com/502", title = "Bad Gateway", status = 502, detail = "LocationsApi returned an unreadable visible locations payload.", traceId = HttpContext.TraceIdentifier }); } var locations = generation.Locations; _logger.LogInformation( "Visible locations resolved for character {CharacterId}: generated {GeneratedCount}, returned {ReturnedCount}", character.Id, generation.GeneratedCount, locations.Count ); return Ok(locations); } } [HttpPut("{id}/coord")] [Authorize(Roles = "USER,SUPER")] public async Task UpdateCoord(string id, [FromBody] UpdateCharacterCoordRequest req) { var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); if (req.Coord is null) return BadRequest("Coord required"); var allowAnyOwner = User.IsInRole("SUPER"); var character = await _characters.GetByIdAsync(id); if (character is null) return NotFound(); if (!allowAnyOwner && character.OwnerUserId != userId) return Forbid(); character.Coord = req.Coord; var updated = await _characters.UpdateCoordAsync(id, req.Coord); if (!updated) return NotFound(); return Ok(character); } [HttpDelete("{id}")] [Authorize(Roles = "USER,SUPER")] public async Task Delete(string id) { var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); var allowAnyOwner = User.IsInRole("SUPER"); var deleted = await _characters.DeleteForOwnerAsync(id, userId, allowAnyOwner); if (!deleted) return NotFound(); return Ok("Deleted"); } }