using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using System.Text; using System.Text.Json.Nodes; using RadicadeServer.API.Notifications; using RadicadeServer.DB; using RadicadeServer.Extensions; using RadicadeServer.RecNet.Enums; using RadicadeServer.RecNet.Enums.Rooms; using RadicadeServer.RecNet.Notifications; using RadicadeServer.RecNet.Responses; using RadicadeServer.RecNet.Rooms; using RadicadeServer.Utils; namespace RadicadeServer.RoomServer { [Route("rooms/rooms/")] public class V2_RoomsAPI(RecDBContext db) : Controller { [HttpGet("{id:long}/similar"), Authorize] public async Task GetSimilarRooms(long id, [FromQuery] int skip = 0, [FromQuery] int take = 100) { var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == id); if (room == null) return Ok(EmptyPage()); var userId = User.GetUserId(); skip = Math.Max(skip, 0); take = Math.Clamp(take, 1, 100); var ownTags = StringTools.ReadTags(room.Tags) .Select(t => t.Tag?.Trim().ToLowerInvariant()) .Where(t => !string.IsNullOrEmpty(t)) .ToHashSet(); var candidates = await db.Rooms .Where(x => x.RoomId != id && x.Accessibility == Accessibility.Public) .ToListAsync(); var scored = new List<(RoomData Room, int Score, int Visits)>(); foreach (var candidate in candidates) { if (!await RoomUtils.CanSeeRoom(candidate, userId)) continue; int score; if (ownTags.Count == 0) { score = candidate.CreatorAccountId == room.CreatorAccountId ? 1 : 0; } else { score = StringTools.ReadTags(candidate.Tags) .Count(t => ownTags.Contains(t.Tag?.Trim().ToLowerInvariant())); } if (score <= 0) continue; var stats = await db.RoomStats.FirstOrDefaultAsync(x => x.RoomId == candidate.RoomId); scored.Add((candidate, score, stats?.VisitCount ?? 0)); } var ordered = scored .OrderByDescending(x => x.Score) .ThenByDescending(x => x.Visits) .Select(x => x.Room) .ToList(); var picked = ordered.Skip(skip).Take(take).ToList(); var details = await RoomUtils.BulkConvertRoomDataToRoomDetail(picked); return Ok(new RoomSearchResult { TotalResults = ordered.Count, Results = details }); } private static RoomSearchResult EmptyPage() => new() { TotalResults = 0, Results = new List() }; [HttpGet("{id:long}/experience")] public IActionResult GetRoomExperience(long id) => Ok(new { Enabled = false, DailyLimit = 1000 }); [HttpGet("{id:long}/experience/player")] public IActionResult GetRoomExperienceForPlayer(long id) => Ok(Array.Empty()); [HttpPut("{id}/description"), Authorize] public async Task SetDescription(long? id, string description) { var userId = User.GetUserId(); if (userId == null) return new Success(false, "Invalid auth", null); var canModify = await MatchmakingUtils.IsAtLeastRole((long)userId, (long)id!, RoleType.CoOwner); if (!canModify) return Success.Error("Insufficient permissions"); var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == id); if (room == null) return Success.Error("Room not found"); if (!description.IsPure()) return new Success(false, "Description contains offensive language.", null); room.Description = description; await db.SaveChangesAsync(); var fullRoom = await RoomUtils.GetRoom((long)id!); await Notify.SendNotify((long)userId, new Notification { Id = "RoomUpdate", Msg = fullRoom }); return new Success(true, null, fullRoom); } [HttpPut("{id}/name"), Authorize] public async Task SetName(long? id, string name) { var userId = User.GetUserId(); if (userId == null) return new Success(false, "Invalid auth", null); var canModify = await MatchmakingUtils.IsAtLeastRole((long)userId, (long)id!, RoleType.CoOwner); if (!canModify) return Success.Error("Insufficient permissions"); var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == id); if (room == null) return Success.Error("Room not found"); if (!name.IsPure()) return new Success(false, "Name contains offensive language.", null); var sameRoomWithName = await db.Rooms.FirstOrDefaultAsync(x => x.Name == name); if (sameRoomWithName != null) return new Success(false, "A room already exists with that name.", null); room.Name = name; await db.SaveChangesAsync(); var fullRoom = await RoomUtils.GetRoom((long)id!); await Notify.SendNotify((long)userId, new Notification { Id = "RoomUpdate", Msg = fullRoom }); return new Success(true, null, fullRoom); } [HttpPut("{id}/warning"), Authorize] public async Task SetWarning(long? id, WarningMask? warningMask, string? customWarning) { var userId = User.GetUserId(); if (userId == null) return new Success(false, "Invalid auth", null); var canModify = await MatchmakingUtils.IsAtLeastRole((long)userId, (long)id!, RoleType.CoOwner); if (!canModify) return Success.Error("Insufficient permissions"); var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == id); if (room == null) return Success.Error("Room not found"); if (warningMask != null) room.WarningMask = warningMask.Value; if (customWarning != null) room.CustomWarning = customWarning; await db.SaveChangesAsync(); var fullRoom = await RoomUtils.GetRoom((long)id!); await Notify.SendNotify((long)userId, new Notification { Id = "RoomUpdate", Msg = fullRoom }); return new Success(true, null, fullRoom); } [HttpPut("{id}/cloning"), Authorize] public async Task SetAllowedCloning(long? id, bool cloningAllowed) { var userId = User.GetUserId(); if (userId == null) return new Success(false, "Invalid auth", null); var canModify = await MatchmakingUtils.IsAtLeastRole((long)userId, (long)id!, RoleType.CoOwner); if (!canModify) return Success.Error("Insufficient permissions"); var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == id); if (room == null) return Success.Error("Room not found"); room.CloningAllowed = cloningAllowed; await db.SaveChangesAsync(); var fullRoom = await RoomUtils.GetRoom((long)id!); await Notify.SendNotify((long)userId, new Notification { Id = "RoomUpdate", Msg = fullRoom }); return new Success(true, null, fullRoom); } [HttpPut("{id}/restrictions"), Authorize] public async Task SetRestrictions(long? id, bool supportsScreens, bool supportsWalkVR, bool supportsTeleportVR, bool supportsJuniors) { var userId = User.GetUserId(); if (userId == null) return new Success(false, "Invalid auth", null); var canModify = await MatchmakingUtils.IsAtLeastRole((long)userId, (long)id!, RoleType.CoOwner); if (!canModify) return Success.Error("Insufficient permissions"); var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == id); if (room == null) return Success.Error("Room not found"); room.SupportsTeleportVR = supportsTeleportVR; room.SupportsWalkVR = supportsWalkVR; room.SupportsScreens = supportsScreens; room.SupportsJuniors = supportsJuniors; await db.SaveChangesAsync(); var fullRoom = await RoomUtils.GetRoom((long)id!); await Notify.SendNotify((long)userId, new Notification { Id = "RoomUpdate", Msg = fullRoom }); return new Success(true, null, fullRoom); } [HttpPut("{id}/automute"), Authorize] public async Task SetDisableAutoMute(long? id, bool disable) { var userId = User.GetUserId(); if (userId == null) return new Success(false, "Invalid auth", null); var canModify = await MatchmakingUtils.IsAtLeastRole((long)userId, (long)id!, RoleType.CoOwner); if (!canModify) return Success.Error("Insufficient permissions"); var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == id); if (room == null) return Success.Error("Room not found"); room.DisableMicAutoMute = disable; await db.SaveChangesAsync(); var fullRoom = await RoomUtils.GetRoom((long)id!); await Notify.SendNotify((long)userId, new Notification { Id = "RoomUpdate", Msg = fullRoom }); return new Success(true, null, fullRoom); } [HttpPut("{id}/comments"), Authorize] public async Task SetDisableComments(long? id, bool disable) { var userId = User.GetUserId(); if (userId == null) return new Success(false, "Invalid auth", null); var canModify = await MatchmakingUtils.IsAtLeastRole((long)userId, (long)id!, RoleType.CoOwner); if (!canModify) return Success.Error("Insufficient permissions"); var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == id); if (room == null) return Success.Error("Room not found"); room.DisableRoomComments = disable; await db.SaveChangesAsync(); var fullRoom = await RoomUtils.GetRoom((long)id!); await Notify.SendNotify((long)userId, new Notification { Id = "RoomUpdate", Msg = fullRoom }); return new Success(true, null, fullRoom); } [HttpPut("{id}/voice_chat_encryption"), Authorize] public async Task SetEncryptVoiceChat(long? id, bool encryptVoiceChat) { var userId = User.GetUserId(); if (userId == null) return new Success(false, "Invalid auth", null); var canModify = await MatchmakingUtils.IsAtLeastRole((long)userId, (long)id!, RoleType.CoOwner); if (!canModify) return Success.Error("Insufficient permissions"); var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == id); if (room == null) return Success.Error("Room not found"); room.EncryptVoiceChat = encryptVoiceChat; await db.SaveChangesAsync(); var fullRoom = await RoomUtils.GetRoom((long)id!); await Notify.SendNotify((long)userId, new Notification { Id = "RoomUpdate", Msg = fullRoom }); return new Success(true, null, fullRoom); } [HttpPut("{id}/allow_new_users"), Authorize] public async Task SetAllowNewUsers(long? id, bool allowNewUsers) { var userId = User.GetUserId(); if (userId == null) return new Success(false, "Invalid auth", null); var canModify = await MatchmakingUtils.IsAtLeastRole((long)userId, (long)id!, RoleType.CoOwner); if (!canModify) return Success.Error("Insufficient permissions"); var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == id); if (room == null) return Success.Error("Room not found"); var fullRoom = await RoomUtils.GetRoom((long)id!); await Notify.SendNotify((long)userId, new Notification { Id = "RoomUpdate", Msg = fullRoom }); return new Success(true, null, fullRoom); } [HttpGet("{id}/bans"), Authorize] public async Task> GetRoomBans(long? id) { var userId = User.GetUserId(); if (userId == null || id == null) return new List(); var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == id); if (room == null) return new List(); var canModify = await MatchmakingUtils.CanModerateRoom((long)userId, (long)id!); if (!canModify) return new List(); return await db.RoomBans .Where(x => x.roomId == id && (!x.expiresAt.HasValue || x.expiresAt.Value > DateTime.UtcNow)) .OrderByDescending(x => x.bannedAt) .ToListAsync(); } [HttpGet("{id}/bans/{bannedPlayerId}"), Authorize] public async Task GetRoomBan(long? id, long bannedPlayerId) { var userId = User.GetUserId(); if (userId == null || id == null) return null; var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == id); if (room == null) return null; if (bannedPlayerId != (long)userId) { var canModify = await MatchmakingUtils.CanModerateRoom((long)userId, (long)id!); if (!canModify) return null; } return await db.RoomBans .FirstOrDefaultAsync(x => x.roomId == id && x.bannedPlayerId == bannedPlayerId && (!x.expiresAt.HasValue || x.expiresAt.Value > DateTime.UtcNow)); } [HttpPost("{id}/bans/import"), Authorize] public async Task ImportRoomBans(long? id) { var userId = User.GetUserId(); if (userId == null || id == null) return new Success(false, "Invalid auth", null); var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == id); if (room == null) return Success.Error("Room not found"); var canModify = await MatchmakingUtils.CanModerateRoom((long)userId, (long)id!); if (!canModify) return Success.Error("Insufficient permissions"); var playerIds = new List(); string rawBody = ""; try { if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); foreach (var key in new[] { "playerIds", "PlayerIds", "ids", "Ids", "bannedPlayerIds", "BannedPlayerIds", "players", "Players" }) if (form.TryGetValue(key, out var values)) foreach (var value in values) foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) if (long.TryParse(part, out var parsedId) && parsedId > 0) playerIds.Add(parsedId); } if (Request.ContentLength is > 0) { using var reader = new StreamReader(Request.Body, Encoding.UTF8, leaveOpen: true); rawBody = await reader.ReadToEndAsync(); if (!string.IsNullOrWhiteSpace(rawBody)) { var parsedBody = JsonNode.Parse(rawBody); if (parsedBody is JsonArray array) { foreach (var item in array) { if (item is JsonValue value && value.TryGetValue(out var bareId) && bareId > 0) playerIds.Add(bareId); else if (item is JsonObject obj) playerIds.Add(ParsePlayerId(obj, new[] { "bannedPlayerId", "playerId", "accountId", "id" })); } } else if (parsedBody is JsonObject body) { foreach (var key in new[] { "playerIds", "PlayerIds", "ids", "Ids", "bannedPlayerIds", "BannedPlayerIds", "players", "Players", "bans", "Bans" }) { if (!body.TryGetPropertyValue(key, out var node) || node == null) continue; if (node is JsonArray arr) { foreach (var item in arr) { if (item is JsonValue value && value.TryGetValue(out var bareId) && bareId > 0) playerIds.Add(bareId); else if (item is JsonObject obj) playerIds.Add(ParsePlayerId(obj, new[] { "bannedPlayerId", "playerId", "accountId", "id" })); } } } } } } } catch { } playerIds = playerIds.Distinct().ToList(); if (playerIds.Count == 0) return Success.Error("No valid player ids found in the import request"); var actorProfile = await db.Profiles.AsNoTracking().FirstOrDefaultAsync(x => x.accountId == (long)userId); var imported = new List(); var now = DateTime.UtcNow; foreach (var targetPlayer in playerIds) { if (targetPlayer == (long)userId) continue; if (await MatchmakingUtils.IsAtLeastRole(targetPlayer, (long)id!, RoleType.CoOwner)) continue; if (await db.Profiles.AnyAsync(x => x.accountId == targetPlayer) == false) continue; var existing = await db.RoomBans.FirstOrDefaultAsync(x => x.roomId == id && x.bannedPlayerId == targetPlayer && (!x.expiresAt.HasValue || x.expiresAt.Value > DateTime.UtcNow)); if (existing != null) continue; var ban = new RoomBan { roomId = (long)id!, actorId = (long)userId, bannedPlayerId = targetPlayer, bannedByName = actorProfile?.displayName ?? $"Player {(long)userId}", reason = "", bannedAt = now, expiresAt = null }; db.RoomBans.Add(ban); imported.Add(ban); } await db.SaveChangesAsync(); return new Success(true, null, new Dictionary { ["imported"] = imported.Count, ["importedCount"] = imported.Count, ["requested"] = playerIds.Count, ["bans"] = imported, ["playerIds"] = imported.Select(x => x.bannedPlayerId).ToArray() }); } [HttpPut("{id}/bans"), Authorize] [HttpPost("{id}/bans"), Authorize] public async Task AddRoomBan(long? id) { var userId = User.GetUserId(); if (userId == null || id == null) return new Success(false, "Invalid auth", null); var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == id); if (room == null) return Success.Error("Room not found"); var canModify = await MatchmakingUtils.CanModerateRoom((long)userId, (long)id!); if (!canModify) { var deniedRole = await RoomUtils.GetRoleForPlayer((long)userId, (long)id!); var highRanksVal = await db.ServerConfig.AsNoTracking().FirstOrDefaultAsync(x => x.Key == "HighRanks"); return Success.Error("Insufficient permissions"); } long targetPlayer = 0; string banReason = ""; string[] idKeys = { "bannedPlayerId", "bannedPlayerID", "bannedPlayer", "bannedplayerid", "bannedId", "bannedid", "banned_id", "bannerId", "bannerid", "banner_id", "playerId", "playerid", "player_id", "player", "playerAccountId", "bannedUserId", "bannedAccountId", "accountId", "account_id", "targetPlayerId", "targetPlayer", "targetId", "targetid", "target", "victimId", "victimid", "userId", "userid", "id" }; if (Request.HasFormContentType) { foreach (var key in idKeys) if (targetPlayer <= 0 && long.TryParse(Request.Form[key], out var formPlayer)) targetPlayer = formPlayer; if (string.IsNullOrEmpty(banReason)) foreach (var key in new[] { "reason", "banReason", "ban_reason" }) if (Request.Form.TryGetValue(key, out var formReason)) { banReason = formReason.ToString(); break; } } foreach (var key in idKeys) if (targetPlayer <= 0 && long.TryParse(Request.Query[key], out var queryPlayer)) targetPlayer = queryPlayer; if (string.IsNullOrEmpty(banReason)) foreach (var key in new[] { "reason", "banReason", "ban_reason" }) if (Request.Query.TryGetValue(key, out var queryReason)) { banReason = queryReason.ToString(); break; } string rawBody = ""; try { using var reader = new StreamReader(Request.Body, Encoding.UTF8, leaveOpen: true); rawBody = await reader.ReadToEndAsync(); if (!string.IsNullOrWhiteSpace(rawBody)) { var parsedBody = JsonNode.Parse(rawBody); if (parsedBody is JsonObject body) { if (targetPlayer <= 0) targetPlayer = ParsePlayerId(body, idKeys); if (string.IsNullOrEmpty(banReason)) banReason = ParseJsonString(body, "reason", "banReason", "ban_reason"); } else if (parsedBody is JsonValue bareValue && targetPlayer <= 0) { if (bareValue.TryGetValue(out var bareId)) targetPlayer = bareId; else if (bareValue.TryGetValue(out var bareStr) && long.TryParse(bareStr, out var bareParsed)) targetPlayer = bareParsed; } } } catch { } if (targetPlayer <= 0) { return Success.Error("bannedPlayerId is required"); } if (targetPlayer == (long)userId) { return Success.Error("You cannot ban yourself"); } if (await MatchmakingUtils.IsAtLeastRole(targetPlayer, (long)id!, RoleType.CoOwner)) return Success.Error("You cannot ban a Co-Owner or higher"); var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == targetPlayer); if (profile == null) return Success.Error("Player not found"); var existing = await db.RoomBans.FirstOrDefaultAsync(x => x.roomId == id && x.bannedPlayerId == targetPlayer && (!x.expiresAt.HasValue || x.expiresAt.Value > DateTime.UtcNow)); if (existing != null) return Success.Error("Player is already banned from this room"); var actorProfile = await db.Profiles.AsNoTracking().FirstOrDefaultAsync(x => x.accountId == (long)userId); var ban = new RoomBan { roomId = (long)id!, actorId = (long)userId, bannedPlayerId = targetPlayer, bannedByName = actorProfile?.displayName ?? $"Player {(long)userId}", reason = banReason.Trim(), bannedAt = DateTime.UtcNow, expiresAt = null }; await db.RoomBans.AddAsync(ban); await db.SaveChangesAsync(); await MatchmakingUtils.KickPlayerFromRoom(targetPlayer, (long)id!, string.IsNullOrWhiteSpace(ban.reason) ? "You were banned from this room." : $"You were banned from this room: {ban.reason}"); return new Success(true, null, ban); } [HttpDelete("{id}/bans/{bannedPlayerId}"), HttpDelete("{id}/bans"), HttpPost("{id}/bans/{bannedPlayerId}"), HttpPut("{id}/bans/{bannedPlayerId}"), Authorize] public async Task RemoveRoomBan(long? id, long? bannedPlayerId) { var userId = User.GetUserId(); if (userId == null || id == null) return new Success(false, "Invalid auth", null); var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == id); if (room == null) return Success.Error("Room not found"); var canModify = await MatchmakingUtils.CanModerateRoom((long)userId, (long)id!); if (!canModify) return Success.Error("Insufficient permissions"); long targetPlayer = bannedPlayerId ?? 0; string[] idKeys = { "bannedPlayerId", "bannedPlayerID", "bannedId", "bannedid", "banned_id", "playerId", "playerid", "player_id", "player", "bannerId", "bannerid", "banner_id", "userId", "userid", "id" }; if (targetPlayer <= 0 && Request.HasFormContentType) foreach (var key in idKeys) if (long.TryParse(Request.Form[key], out var formPlayer)) { targetPlayer = formPlayer; break; } if (targetPlayer <= 0) foreach (var key in idKeys) if (long.TryParse(Request.Query[key], out var queryPlayer)) { targetPlayer = queryPlayer; break; } if (targetPlayer <= 0) { try { using var reader = new StreamReader(Request.Body, Encoding.UTF8, leaveOpen: true); var raw = await reader.ReadToEndAsync(); if (!string.IsNullOrWhiteSpace(raw) && JsonNode.Parse(raw) is JsonObject body) targetPlayer = ParsePlayerId(body, idKeys); } catch { } } if (targetPlayer <= 0) { return Success.Error("bannedPlayerId is required"); } var ban = await db.RoomBans.FirstOrDefaultAsync(x => x.roomId == id && x.bannedPlayerId == targetPlayer); if (ban == null) return Success.Error("Player is not banned from this room"); db.RoomBans.Remove(ban); await db.SaveChangesAsync(); return new Success(true, null, ban); } [HttpPut("{id}/loadscreen")] public async Task SetLoadingScreen(long id, string imageName, string title, string subtitle) { var userId = User.GetUserId(); if (userId == null) return new Success(false, "Invalid auth", null); var canModify = await MatchmakingUtils.IsAtLeastRole((long)userId, (long)id!, RoleType.CoOwner); if (!canModify) return Success.Error("Insufficient permissions"); var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == id); if (room == null) return Success.Error("Room not found"); var entry = new RoomLoadScreen { ImageName = imageName, RoomId = room.RoomId, Subtitle = subtitle, Title = title }; List loadScreen = room.loadScreen.FromJson>() ?? new(); if (loadScreen.Count >= 1) loadScreen[0] = entry; else loadScreen.Add(entry); room.loadScreen = loadScreen.ToJson(); await db.SaveChangesAsync(); var fullRoom = await RoomUtils.GetRoom((long)id!); await Notify.SendNotify((long)userId, new Notification { Id = "RoomUpdate", Msg = fullRoom }); return new Success(true, null, fullRoom); } private static long ParsePlayerId(JsonObject body, string[] idKeys) { foreach (var prop in body) { if (prop.Value is not JsonValue value) continue; bool keyMatches = false; foreach (var key in idKeys) if (prop.Key.Equals(key, StringComparison.OrdinalIgnoreCase)) { keyMatches = true; break; } if (!keyMatches) continue; if (value.TryGetValue(out var longId)) return longId; if (value.TryGetValue(out var strId) && long.TryParse(strId, out var parsed)) return parsed; if (value.TryGetValue(out var doubleId) && doubleId > 0 && doubleId == Math.Floor(doubleId)) return (long)doubleId; } return 0; } private static string ParseJsonString(JsonObject body, params string[] keys) { foreach (var prop in body) { if (prop.Value is not JsonValue value) continue; bool keyMatches = false; foreach (var key in keys) if (prop.Key.Equals(key, StringComparison.OrdinalIgnoreCase)) { keyMatches = true; break; } if (keyMatches && value.TryGetValue(out var str)) return str ?? ""; } return ""; } } }