using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using System.Text.Json; using System.Text.Json.Serialization; using DorkNet.Server.Auth; using DorkNet.Server.Data; using DorkNet.Server.Data.Entities; namespace DorkNet.Server.Controllers.API.RoomKeys; [ApiController] [Authorize] public class RoomKeysController(DorkNetDbContext db) : ControllerBase { private long Me => this.RequireCurrentPlayerId(); // Boot-sequence call — the 2020.12 client hits this with roomId=-1 // before the user has authenticated. Class-level [Authorize] would // 401 and the client treats that as fatal, kicking back to LogoutScene. [AllowAnonymous] [HttpGet("api/roomkeys/v1/room")] public async Task ForRoom([FromQuery] long roomId) { if (roomId <= 0) return Ok(Array.Empty()); var rows = await db.RoomKeys .Where(k => k.RoomId == roomId && !k.IsDeleted) .OrderBy(k => k.Price) .ThenBy(k => k.Name) .ToListAsync(); return Ok(rows.Select(ToWire)); } /// The watch key ring — keys the caller HOLDS. This used to return /// keys the caller CREATED, which is a different set: a creator never buys /// their own key, so players saw none of the keys they had actually /// purchased. Keys the caller created are unioned in so room owners still /// see their own. [HttpGet("api/roomkeys/v1/mine")] public async Task Mine() { var pid = Me; var purchasedIds = await db.RoomKeyPurchases .Where(p => p.PlayerId == pid) .Select(p => p.RoomKeyId) .ToListAsync(); var rows = await db.RoomKeys .Where(k => !k.IsDeleted && (purchasedIds.Contains(k.Id) || k.CreatorPlayerId == pid)) .OrderByDescending(k => k.UpdatedAt) .ToListAsync(); return Ok(rows.Select(ToWire)); } [HttpGet("api/roomkeys")] [HttpGet("api/roomkeys/v1")] public async Task List([FromQuery] long? roomId = null) { var rows = db.RoomKeys.Where(k => !k.IsDeleted); if (roomId is long id && id > 0) rows = rows.Where(k => k.RoomId == id); var result = await rows .OrderBy(k => k.RoomId) .ThenBy(k => k.Price) .Take(200) .ToListAsync(); return Ok(result.Select(ToWire)); } [HttpPost("api/roomkeys/v1/create")] public async Task Create() { var req = await ReadBodyAsync(); var status = ValidateText(req.Name, req.Description, req.Price); if (status != RoomKeyStatus.Success) return Ok(RoomKeyResponse(status, null)); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == req.RoomId); if (room is null) return Ok(RoomKeyResponse(RoomKeyStatus.RoomDoesNotExist, null)); if (!await CanManageRoomKeysAsync(room, Me)) return Ok(RoomKeyResponse(RoomKeyStatus.PermissionDenied, null)); var activeCount = await db.RoomKeys.CountAsync(k => k.RoomId == room.Id && !k.IsDeleted); if (activeCount >= 100) return Ok(RoomKeyResponse(RoomKeyStatus.RoomKeyLimitReached, null)); var duplicate = await db.RoomKeys.AnyAsync(k => k.RoomId == room.Id && !k.IsDeleted && k.Name == req.Name!.Trim()); if (duplicate) return Ok(RoomKeyResponse(RoomKeyStatus.DuplicateName, null)); var key = new RoomKeyEntity { RoomId = room.Id, CreatorPlayerId = Me, Name = req.Name!.Trim(), Description = req.Description!.Trim(), Price = req.Price, ReplicationId = Guid.NewGuid().ToString("D"), }; db.RoomKeys.Add(key); await db.SaveChangesAsync(); return Ok(RoomKeyResponse(RoomKeyStatus.Success, key)); } /// The 2023-03-21 client does not call update. It builds /// "api/roomkeys/v1/" + action with verb PUT and a RoomKeyId /// field, where action is one of updateAll, updateName, /// updateDescription or updatePrice /// (RecNet.Runtime/AHMBBJNANBP.txt: prefix concat at instr 058, verb 3 at /// 067, "RoomKeyId" at 079; action literals at :1345, :1559, :1772, /// :2043). None were registered, so every room-key edit 405'd. /// /// All four share this handler: the request DTO's fields are already /// optional, so a partial update naturally leaves the others untouched. [HttpPut("api/roomkeys/v1/updateAll")] [HttpPost("api/roomkeys/v1/updateAll")] [HttpPut("api/roomkeys/v1/updateName")] [HttpPost("api/roomkeys/v1/updateName")] [HttpPut("api/roomkeys/v1/updateDescription")] [HttpPost("api/roomkeys/v1/updateDescription")] [HttpPut("api/roomkeys/v1/updatePrice")] [HttpPost("api/roomkeys/v1/updatePrice")] [HttpPut("api/roomkeys/v1/update")] [HttpPost("api/roomkeys/v1/update")] public async Task Update() { var req = await ReadBodyAsync(); var key = await db.RoomKeys.FirstOrDefaultAsync(k => k.Id == req.RoomKeyId && !k.IsDeleted); if (key is null) return Ok(RoomKeyResponse(RoomKeyStatus.DoesNotExist, null)); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == key.RoomId); if (room is null) return Ok(RoomKeyResponse(RoomKeyStatus.RoomDoesNotExist, null)); if (!await CanManageRoomKeysAsync(room, Me)) return Ok(RoomKeyResponse(RoomKeyStatus.PermissionDenied, key)); var nextName = req.Name?.Trim() ?? key.Name; var nextDescription = req.Description?.Trim() ?? key.Description; var nextPrice = req.Price ?? key.Price; var status = ValidateText(nextName, nextDescription, nextPrice); if (status != RoomKeyStatus.Success) return Ok(RoomKeyResponse(status, key)); var duplicate = await db.RoomKeys.AnyAsync(k => k.Id != key.Id && k.RoomId == key.RoomId && !k.IsDeleted && k.Name == nextName); if (duplicate) return Ok(RoomKeyResponse(RoomKeyStatus.DuplicateName, key)); key.Name = nextName; key.Description = nextDescription; key.Price = nextPrice; key.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return Ok(RoomKeyResponse(RoomKeyStatus.Success, key)); } [HttpDelete("api/roomkeys/v1/delete/{roomKeyId:long}")] [HttpPost("api/roomkeys/v1/delete/{roomKeyId:long}")] public async Task Delete(long roomKeyId) { var key = await db.RoomKeys.FirstOrDefaultAsync(k => k.Id == roomKeyId && !k.IsDeleted); if (key is null) return Content(((int)RoomKeyStatus.DoesNotExist).ToString(), "application/json"); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == key.RoomId); if (room is null) return Content(((int)RoomKeyStatus.RoomDoesNotExist).ToString(), "application/json"); if (!await CanManageRoomKeysAsync(room, Me)) return Content(((int)RoomKeyStatus.PermissionDenied).ToString(), "application/json"); key.IsDeleted = true; key.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return Content(((int)RoomKeyStatus.Success).ToString(), "application/json"); } /// "Has anyone bought this key?" — the creator calls this before a /// destructive edit. It previously answered "has the CALLER bought it", /// which is always false for the creator, so the guard never fired and /// creators could silently invalidate keys players had paid for. [HttpGet("api/roomkeys/v1/purchased/{roomKeyId:long}")] public async Task Purchased(long roomKeyId) { var anyPurchase = await db.RoomKeyPurchases.AnyAsync(p => p.RoomKeyId == roomKeyId); return Content(anyPurchase ? "true" : "false", "application/json"); } /// The client sends both roomKeyId and playerId — key /// doors evaluate OTHER players' ownership, not just the caller's. Ignoring /// playerId meant a door asked about someone else and got the caller's /// answer. [HttpGet("api/roomkeys/v1/owns")] public async Task Owns([FromQuery] long roomKeyId, [FromQuery] long? playerId = null) { var subject = playerId is long p && p > 0 ? p : Me; var owns = await db.RoomKeyPurchases.AnyAsync(x => x.RoomKeyId == roomKeyId && x.PlayerId == subject); return Content(owns ? "true" : "false", "application/json"); } /// Bulk key-ownership probe, used by key doors to gate a whole /// room of players at once. /// /// The 2023-03-21 client POSTs a JSON array of /// {RoomKeyId, AccountId} pairs as a raw body /// (RecNet.Runtime/AHMBBJNANBP_NestedType_JIPDBBEFAPN.txt instrs 044-065), /// so each element names the player it is asking about. The old /// query/form-only reader saw an empty id list and answered for the caller, /// meaning key doors evaluated the wrong player — or nobody at all. [HttpGet("api/roomkeys/v1/owns/bulk")] [HttpPost("api/roomkeys/v1/owns/bulk")] [Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")] public async Task OwnsBulk() { var pairs = await ReadOwnershipPairsAsync(); if (pairs.Count == 0) { // Legacy query/form path: ids only, always about the caller. var ids = await ReadRoomKeyIdsAsync(); if (ids.Count == 0) return Ok(Array.Empty()); pairs = ids.Select(id => (RoomKeyId: id, AccountId: Me)).ToList(); } var keyIds = pairs.Select(p => p.RoomKeyId).Distinct().ToList(); var accountIds = pairs.Select(p => p.AccountId).Distinct().ToList(); var owned = (await db.RoomKeyPurchases .Where(p => keyIds.Contains(p.RoomKeyId) && accountIds.Contains(p.PlayerId)) .Select(p => new { p.RoomKeyId, p.PlayerId }) .ToListAsync()) .Select(p => (p.RoomKeyId, p.PlayerId)) .ToHashSet(); return Ok(pairs.Select(p => new { p.RoomKeyId, AccountId = (int)p.AccountId, DoesPlayerOwnRoomKey = owned.Contains((p.RoomKeyId, p.AccountId)), // legacy alias Owns = owned.Contains((p.RoomKeyId, p.AccountId)), }).ToList()); } /// Parse the 2023 client's JSON array of /// {RoomKeyId, AccountId} pairs. Returns empty for a form post or a /// body that isn't an array, so the legacy path still applies. private async Task> ReadOwnershipPairsAsync() { var pairs = new List<(long, long)>(); if (Request.HasFormContentType) return pairs; try { Request.EnableBuffering(); Request.Body.Position = 0; using var doc = await System.Text.Json.JsonDocument.ParseAsync(Request.Body); Request.Body.Position = 0; if (doc.RootElement.ValueKind != System.Text.Json.JsonValueKind.Array) return pairs; foreach (var el in doc.RootElement.EnumerateArray()) { if (el.ValueKind != System.Text.Json.JsonValueKind.Object) continue; long? Read(params string[] names) { foreach (var n in names) if (el.TryGetProperty(n, out var v) && v.ValueKind == System.Text.Json.JsonValueKind.Number && v.TryGetInt64(out var x)) return x; return null; } var keyId = Read("RoomKeyId", "roomKeyId"); if (keyId is not long k || k <= 0) continue; pairs.Add((k, Read("AccountId", "accountId", "PlayerId", "playerId") ?? Me)); } } catch (System.Text.Json.JsonException) { /* not a JSON array body */ } return pairs; } public static object RoomKeyResponse(RoomKeyStatus status, RoomKeyEntity? key) => new { Status = (int)status, RoomKey = key is null ? null : ToWire(key), }; public static object ToWire(RoomKeyEntity key) => new { RoomKeyId = key.Id, ReplicationId = Guid.TryParse(key.ReplicationId, out var guid) ? guid : Guid.Empty, key.RoomId, key.Name, key.Description, key.Price, }; private static RoomKeyStatus ValidateText(string? name, string? description, int price) { if (string.IsNullOrWhiteSpace(name) || name.Trim().Length < 3) return RoomKeyStatus.NameTooShort; if (name.Trim().Length > 40) return RoomKeyStatus.NameTooLong; if (string.IsNullOrWhiteSpace(description) || description.Trim().Length < 10) return RoomKeyStatus.DescriptionTooShort; if (description.Trim().Length > 174) return RoomKeyStatus.DescriptionTooLong; if (price < 10) return RoomKeyStatus.PriceTooLow; if (price > 10000) return RoomKeyStatus.PriceTooHigh; return RoomKeyStatus.Success; } private async Task CanManageRoomKeysAsync(RoomEntity room, long playerId) { if (room.CreatorPlayerId == playerId) return true; var isCoOwner = await db.RoomRoles.AnyAsync(r => r.RoomId == room.Id && r.PlayerId == playerId && r.Role == 0 && r.Accepted); if (isCoOwner) return true; return await db.Players .Where(p => p.Id == playerId) .Select(p => p.IsAdmin) .FirstOrDefaultAsync(); } private async Task> ReadRoomKeyIdsAsync() { var ids = new List(); foreach (var value in Request.Query.SelectMany(q => q.Value)) foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) if (long.TryParse(part, out var id) && id > 0) ids.Add(id); if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); foreach (var key in new[] { "roomKeyIds", "RoomKeyIds", "ids", "Ids" }) foreach (var value in form[key]) foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) if (long.TryParse(part, out var id) && id > 0) ids.Add(id); } return ids.Distinct().Take(200).ToList(); } private async Task ReadBodyAsync() where T : new() { if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); if (typeof(T) == typeof(NewRoomKeyRequest)) { object req = new NewRoomKeyRequest { RoomId = long.TryParse(form["roomId"].FirstOrDefault() ?? form["RoomId"].FirstOrDefault(), out var roomId) ? roomId : 0, Name = form["name"].FirstOrDefault() ?? form["Name"].FirstOrDefault(), Description = form["description"].FirstOrDefault() ?? form["Description"].FirstOrDefault(), Price = int.TryParse(form["price"].FirstOrDefault() ?? form["Price"].FirstOrDefault(), out var price) ? price : 0, }; return (T)req; } if (typeof(T) == typeof(UpdateRoomKeyRequest)) { object req = new UpdateRoomKeyRequest { RoomKeyId = long.TryParse(form["roomKeyId"].FirstOrDefault() ?? form["RoomKeyId"].FirstOrDefault(), out var roomKeyId) ? roomKeyId : 0, Name = form["name"].FirstOrDefault() ?? form["Name"].FirstOrDefault(), Description = form["description"].FirstOrDefault() ?? form["Description"].FirstOrDefault(), Price = int.TryParse(form["price"].FirstOrDefault() ?? form["Price"].FirstOrDefault(), out var price) ? price : null, }; return (T)req; } return new T(); } try { return await JsonSerializer.DeserializeAsync(Request.Body, JsonOptions) ?? new T(); } catch (JsonException) { return new T(); } } private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true, }; public sealed class NewRoomKeyRequest { [JsonPropertyName("roomId")] public long RoomId { get; set; } [JsonPropertyName("name")] public string? Name { get; set; } [JsonPropertyName("description")] public string? Description { get; set; } [JsonPropertyName("price")] public int Price { get; set; } } public sealed class UpdateRoomKeyRequest { // The 2023 client spells this "RoomKeyId"; System.Text.Json is // configured case-insensitively so both land on this property. [JsonPropertyName("roomKeyId")] public long RoomKeyId { get; set; } [JsonPropertyName("name")] public string? Name { get; set; } [JsonPropertyName("description")] public string? Description { get; set; } [JsonPropertyName("price")] public int? Price { get; set; } } } public enum RoomKeyStatus { Success = 0, InvalidParameters = 1, DoesNotExist = 2, NameTooShort = 3, NameTooLong = 4, DuplicateName = 5, InappropriateName = 6, DescriptionTooShort = 7, DescriptionTooLong = 8, InappropriateDescription = 9, PriceTooLow = 10, PriceTooHigh = 11, PermissionDenied = 12, PlayerHasRoomUnderModerationReview = 13, JuniorStatusFail = 14, PlayerIsNotCoOwner = 15, RoomKeyLimitReached = 16, PlayerAlreadyOwns = 17, RoomUnderModerationReview = 18, PurchaseFailed = 19, RoomDoesNotExist = 20, PaidKeyPurchasingDisabled = 21, CreateOrModifyKeysDisabled = 22, RoomKeyUnderModerationReview = 23, PlayerRestrictedFromP2PSelling = 24, PlayerNotRecRoomPlusMember = 25, }