using System.Security.Principal; using System.Text.RegularExpressions; using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json; using Campus_live_rewrite.api; using Campus_live_rewrite.api.dbs; using Campus_live_rewrite.api.server; using Campus_live_rewrite.api.server.Classes; using Campus_live_rewrite.api.server.Classes.db_class; using System.IdentityModel.Tokens.Jwt; using static Campus_live_rewrite.api.server.Moderation_Detail; using Microsoft.AspNetCore.Http.HttpResults; using static Campus_live_rewrite.api.server.Classes.db_class.RoomDBClasses; using System.ComponentModel.DataAnnotations; using System.Text.Json.Nodes; namespace Campus_live_rewrite.Controllers { [Route("Room_server")] public class RoomController : ControllerBase { [HttpGet("rooms")] public ContentResult Room_server_rooms([FromQuery] ulong? roomid = null, [FromQuery] string? name = null, [FromQuery] ulong? include = null, [FromQuery] int? unityAssetTarget = null, [FromQuery] int? unityAssetVersion = null) { try { // DELETE by roomid if (HttpContext.Request.Method == HttpMethods.Delete && roomid.HasValue) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; RoomDBClasses.RoomRoot room = RoomDB.GetRoom(roomid.Value); if (room == null) { return new ContentResult() { Content = JsonConvert.SerializeObject(new { Success = false, Value = (string?)null, ErrorId = "Rooms.DoesntExist", Error = "This room does not exist!" }), ContentType = "application/json", StatusCode = 200 }; } if (room.CreatorAccountId != account.Id) { return new ContentResult() { Content = JsonConvert.SerializeObject(new { Success = false, Value = (string?)null, ErrorId = "Rooms.PermissionDenied", Error = "You are not the owner of this room!" }), ContentType = "application/json", StatusCode = 200 }; } RoomDB.DeleteRoom(roomid.Value); return new ContentResult() { Content = "[]", ContentType = "application/json", StatusCode = 200 }; } // Lookup by roomid if (roomid.HasValue) { RoomDBClasses.RoomRoot existingRoom = RoomDB.GetRoom(roomid.Value); if (existingRoom == null) { return new ContentResult() { Content = "[]", ContentType = "application/json", StatusCode = 200 }; } return new ContentResult() { Content = JsonConvert.SerializeObject(StripUnservableSaves(DirectLookup(existingRoom))), ContentType = "application/json", StatusCode = 200 }; } // Lookup by name if (!string.IsNullOrEmpty(name)) { RoomDBClasses.RoomRoot room = RoomDB.GetRoomByName(name); if (room == null) { return new ContentResult() { Content = "", ContentType = "application/json", StatusCode = 404 }; } return new ContentResult() { Content = JsonConvert.SerializeObject(StripUnservableSaves(room)), ContentType = "application/json", StatusCode = 200 }; } // Neither roomid nor name provided return new ContentResult() { Content = "", ContentType = "application/json", StatusCode = 404 }; } catch (Exception ex) { Console.WriteLine(ex.ToString()); return new ContentResult() { Content = "", ContentType = "application/json", StatusCode = 404 }; } } // Strip a subroom's CurrentSave when its .room blob cannot be served. // // The client loads a room from the CurrentSave embedded in the ROOM OBJECT, not // from the /saves endpoint - so filtering /saves alone was not enough, and // "Error downloading subroom data! (Code 8715)" persisted. It requests // /cdn/room/, which 404s when the file is absent from // Data/CDN/RoomBlobs (the cdn.rec.net fallback is dead - those blobs are no // longer public), and the load aborts. // // Joining an existing instance never fetches the blob (state replicates over // Photon from whoever is already inside), which is exactly why Rec Center worked // via a friend but failed alone. // // Nulling CurrentSave makes the room load its default Unity scene - the same // path rooms with no save (Park, dorms) already take successfully. // // Safe to mutate: GetRoom returns a freshly deserialized LiteDB object and no // caller writes it back (verified), so this never persists. Self-correcting - // put the real .room files in Data/CDN/RoomBlobs and saves return untouched. // List overload. Room LISTINGS matter as much as the single-room GETs: the client // caches these and can join straight from a cached entry (rooms/visitedby/me is // requested moments before every join), so an unservable blob left in a listing // resurfaces the same failure by another route. // // Only ever call this on GET/serialize paths. The PUT/POST mutation endpoints // (SetRoomName, SetRoomData, ...) load-modify-save the same objects, so stripping // there would PERSIST the null into the DB and destroy the reference for good. private static List StripUnservableSaves(List? rooms) => WithCCU(Campus_live_rewrite.api.server.RoomBlobFilter.Strip(rooms)); /// /// The viewer's account id, or 0 when the request is unauthenticated. /// /// Some room endpoints (bulk, hot, search) never called ValidateToken, so there is /// no `account` in scope there. Visibility still has to be decided per viewer, so /// read it from the token directly. 0 means "no one" - which sees only published /// rooms, the correct default for an anonymous caller. /// private ulong ViewerId() { try { var (acc, err) = AuthHelper.ValidateToken(HttpContext); return err == null && acc != null ? acc.Id : 0UL; } catch { return 0UL; } } // ---- Visibility ------------------------------------------------------- // // Applied at every point a room is SERVED, not just in listings. Fetching a room // straight by id used to bypass every check: any player could read another // player's unpublished private room - name, subrooms, save data - simply by // asking for /Room_server/rooms/78. // // A room is visible when it is published, or when the viewer holds a role on it // (owner / co-owner / moderator / host). See RoomDB.CanPlayerSeeRoom. /// Returns the room, or null when this viewer is not allowed to see it. private static RoomDBClasses.RoomRoot? VisibleTo(RoomDBClasses.RoomRoot? room, ulong viewerId) => RoomDB.CanPlayerSeeRoom(room, viewerId) ? room : null; // A DIRECT lookup (rooms/{id}, rooms?id=, rooms/bulk) answers the record whatever its // accessibility, exactly like the reference (and rec.net): Private / Unlisted only keep a // room off the browse lists and search - CanPlayerEnterRoom still gates joining, and the // saves are stripped separately. Hiding it here (answering null / []) broke every player // who was INSIDE a private room or another player's dorm: their friend card, search // result and profile all resolve the room they're in through these routes, the client // reads the empty answer as NoSuchRoom, and the card / profile never rendered - "some // players' profiles won't load for anyone". Only excludePrivateRooms=True filters, and // it filters on listing, not on who is asking. private static RoomDBClasses.RoomRoot? DirectLookup(RoomDBClasses.RoomRoot? room, bool excludePrivate = false) => room == null ? null : (excludePrivate && !RoomDB.IsRoomListed(room)) ? null : room; /// Drops every room this viewer is not allowed to see. private static List VisibleTo(List? rooms, ulong viewerId) => (rooms ?? new List()) .Where(r => RoomDB.CanPlayerSeeRoom(r, viewerId)) .ToList(); private static RoomDBClasses.RoomRoot? StripUnservableSaves(RoomDBClasses.RoomRoot? room) => WithCCU(Campus_live_rewrite.api.server.RoomBlobFilter.Strip(room)); // Fill the client's CCU key (live player count) on whatever this response is about to // return. One presence pass per response, not per room. private static RoomDBClasses.RoomRoot? WithCCU(RoomDBClasses.RoomRoot? room) { if (room == null) return null; try { room.CCU = HeartbeatDB.LivePlayerCountsByRoom().TryGetValue((long)room.RoomId, out var n) ? n : 0; } catch { } return room; } private static List WithCCU(List? rooms) { rooms ??= new List(); try { var counts = HeartbeatDB.LivePlayerCountsByRoom(); foreach (var r in rooms) if (r != null) r.CCU = counts.TryGetValue((long)r.RoomId, out var n) ? n : 0; } catch { } return rooms; } [HttpGet("rooms/{roomid}")] public ContentResult GetRoomById(ulong roomid) { var room = RoomDB.GetRoom(roomid); if (room == null) { return new ContentResult { Content = "[]", ContentType = "application/json", StatusCode = 200 }; } return new ContentResult { Content = JsonConvert.SerializeObject(StripUnservableSaves(DirectLookup(room))), ContentType = "application/json", StatusCode = 200 }; } [HttpDelete("rooms/{roomid}")] public ContentResult DeleteRoomById(ulong roomid) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var room = RoomDB.GetRoom(roomid); if (room == null) { return new ContentResult() { Content = JsonConvert.SerializeObject(new { Success = false, Value = (string?)null, ErrorId = "Rooms.DoesntExist", Error = "This room does not exist!" }), ContentType = "application/json", StatusCode = 200 }; } if (room.CreatorAccountId != account.Id) { return new ContentResult() { Content = JsonConvert.SerializeObject(new { Success = false, Value = (string?)null, ErrorId = "Rooms.PermissionDenied", Error = "You are not the owner of this room!" }), ContentType = "application/json", StatusCode = 200 }; } RoomDB.DeleteRoom(roomid); // This used to BAN the caller - Set_moderation_block(account.Id, ..., isBan: true, // duration: 1). account.Id is the room OWNER, i.e. whoever just deleted their own // room, so deleting a room banned you from the game. isBan sends websocket event 22, // which blacks the client out, logs it out and closes it. Duration is in SECONDS, so // the block cleared after one second and you could log straight back in - which is // exactly why it looked like "something/someone keeps closing my game" rather than // a ban. // // The intent was presumably to get the owner out of the room they just deleted. That // is a host kick, not a ban: isHostKick drops them to their dorm with no ban screen, // no logout and no moderation record. Same shape ChatCommands.HandleKick uses. PlayerDB.Set_moderation_block( account.Id, ReportCategory.Moderator, duration: 1, gameSessionId: -1, isHostKick: true, message: "The room you were in was deleted.", playerIdReporter: null, isBan: false, bannedByPlayerId: 0 ); return new ContentResult() { Content = JsonConvert.SerializeObject(new { success = true, error = "", value = (string?)null }), ContentType = "application/json", StatusCode = 200 }; } [HttpPost("rooms/bulk")] public IActionResult Room_server_rooms_bulk_post() { var f = RoomModeration.Fields(Request); var ids = RoomModeration.ULongs(f, "id", "ids", "roomIds", "Ids"); string? name = RoomModeration.Str(f, "name"); bool excl = (RoomModeration.Str(f, "excludePrivateRooms") ?? "").Equals("true", StringComparison.OrdinalIgnoreCase); if (ids.Count <= 1) return Room_server_rooms_bulk(ids.Count == 1 ? ids[0] : 0, name, null, excl); ulong viewer = ViewerId(); var rooms = new List(); foreach (var id in ids.Take(100)) { var room = RoomDB.GetRoom(id); if (room == null) continue; if (DirectLookup(room, excl) == null) continue; rooms.Add(StripUnservableSaves(room)); } return Content(JsonConvert.SerializeObject(rooms), "application/json"); } [HttpGet("rooms/bulk")] public IActionResult Room_server_rooms_bulk([FromQuery(Name = "id")] ulong roomId, [FromQuery] string? name, [FromQuery] ulong? include, [FromQuery] bool? excludePrivateRooms) { RoomDBClasses.RoomRoot room = RoomDB.GetRoomByName(name); if (room == null) { RoomDBClasses.RoomRoot room2 = RoomDB.GetRoom(roomId); if (room2 == null) { return Ok(ConfigCampus.Bracket); } // by-ID branch - this is the one the client uses (rooms/bulk?id=2) and it // was handing back CurrentSave unfiltered, which is how the DataBlob // kept reaching the client even after the room-load endpoints were fixed. return Content(DirectLookup(room2, excludePrivateRooms == true) == null ? "[]" : $"[{JsonConvert.SerializeObject(StripUnservableSaves(room2))}]", "application/json"); } return Content(DirectLookup(room, excludePrivateRooms == true) == null ? "[]" : $"[{JsonConvert.SerializeObject(StripUnservableSaves(room))}]", "application/json"); } // These three back the Create -> "My Rooms" list. createdby/me used to return a // hardcoded "[]" stub, and ownedby/me fell through to the ulong route below where // "me" bound to 0 - so the tab was always empty even with rooms in the DB. // contributedby/me did not exist at all and 404'd. [HttpGet("rooms/createdby/me")] public ContentResult roomscreatedbyme() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; return new ContentResult { Content = JsonConvert.SerializeObject(StripUnservableSaves(RoomDB.GetRoomsByCreatorPaged(account.Id, 0, ConfigCampus.ProfileRoomsCap))), ContentType = "application/json", StatusCode = 200 }; } // The profile's Rooms tab asks by PLAYER ID (createdby/{id}, contributedby/{id}), not "me"; // only the /me spellings existed, so another player's rooms - and your own after a restart, // once the client stopped using the locally cached list - came back 404 and the tab was // empty while the showcase (which does take an id) still filled. [HttpGet("rooms/createdby/{playerId:long}")] public ContentResult RoomsCreatedBy(ulong playerId, int skip = 0, int take = 0) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; if (take <= 0) take = ConfigCampus.ProfileRoomsCap; var rooms = RoomDB.GetRoomsByCreatorPaged(playerId, skip, take); return new ContentResult { Content = JsonConvert.SerializeObject(StripUnservableSaves(VisibleTo(rooms, account.Id).Where(r => !r.IsDorm).ToList())), ContentType = "application/json", StatusCode = 200 }; } [HttpGet("rooms/contributedby/{playerId:long}")] public ContentResult RoomsContributedBy(ulong playerId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; return new ContentResult { // GetRoomsContributedTo now leads with the player's OWN rooms, so this is the // profile's whole room list. VisibleTo is what keeps another player's unpublished // rooms off it - a viewer with no role on a room sees it only once it is public. Content = JsonConvert.SerializeObject(StripUnservableSaves( VisibleTo(RoomDB.GetRoomsContributedTo(playerId), account.Id) .Where(r => !r.IsDorm).ToList())), ContentType = "application/json", StatusCode = 200 }; } // MUST be declared before rooms/ownedby/{PlayerId}: a literal segment beats the // parameter route, otherwise "me" is parsed as a ulong and silently becomes 0. [HttpGet("rooms/ownedby/me")] public ContentResult RoomsOwnedByMe() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; return new ContentResult { Content = JsonConvert.SerializeObject(StripUnservableSaves(RoomDB.GetRoomsByCreatorPaged(account.Id, 0, ConfigCampus.ProfileRoomsCap))), ContentType = "application/json", StatusCode = 200 }; } [HttpGet("rooms/contributedby/me")] public ContentResult RoomsContributedByMe() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; return new ContentResult { Content = JsonConvert.SerializeObject(StripUnservableSaves( RoomDB.GetRoomsContributedTo(account.Id).Where(r => !r.IsDorm).ToList())), ContentType = "application/json", StatusCode = 200 }; } // What each role may do in ONE subroom - the Permissions tab of Room Settings. JSON array // body, each entry addressed by its (Permission, Role) pair: Override:true stores the // value, Override:false is the unchecked box and clears the pair back to the default. // The stored table is what GET /photon_access_token merges over its defaults for every // player standing in that subroom - so this IS "edit what the perms do", and it lives on // the subroom record, which is what a room save carries. (Reference: creator-only. Here // co-owners may too: "high enough permissions" - a co-owner already builds in the room.) [HttpPut("rooms/{roomId}/subrooms/{subRoomId}/permissions")] [HttpPost("rooms/{roomId}/subrooms/{subRoomId}/permissions")] public async Task SetSubRoomPermissions(ulong roomId, ulong subRoomId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var room = RoomDB.GetRoom(roomId); if (room == null || RoomDB.GetSubRoom(roomId, subRoomId) == null) return StatusCode(404, ""); if (!RoomDB.PlayerCanManageRoles(roomId, account.Id)) { Console.WriteLine($"[room-perms] {account.Id} may not edit permissions on room {roomId} - refused"); return StatusCode(403, ""); } string raw; using (var reader = new StreamReader(Request.Body)) raw = await reader.ReadToEndAsync(); var changes = Campus_live_rewrite.api.server.RoomPermissions.ParseBody(raw); if (changes == null) { Console.WriteLine($"[room-perms] {account.Id}: unreadable body on room {roomId}/{subRoomId}: {raw.Substring(0, Math.Min(200, raw.Length))}"); return StatusCode(400, ""); } var current = RoomDB.GetSubRoom(roomId, subRoomId)?.Permissions; var table = Campus_live_rewrite.api.server.RoomPermissions.Apply(current, changes); RoomDB.SetSubRoomPermissions(roomId, subRoomId, table); Console.WriteLine($"[room-perms] {account.Id} set {changes.Count} permission(s) on room {roomId} subroom {subRoomId}: " + string.Join(", ", changes.Select(c => $"{c.Permission}@{c.Role}={(c.Override ? c.Value : "default")}"))); // Instant: everyone standing in the room re-reads it (and re-pulls photon_access_token // with the new table) instead of waiting for their next spawn. foreach (var pid in Campus_live_rewrite.api.server.RoomPermissions.PlayersInRoom(roomId)) Campus_live_rewrite.api.server.RoomPermissions.PushRoomUpdate(pid, room); // The 2025 client's ChangeAndRefresh reads the answer through the enveloped room // helper - an empty body is "Response was empty" / "Failed to change subroom // permissions" in Player.log, even though the change was stored. (Reference: empty.) return RoomWriteEnvelope(roomId); } // The ROOM-level spelling (PUT rooms/{id}/permissions, 404'd): the same body, applied to // every subroom of the room, so a room with one subroom behaves exactly like the subroom // route and a multi-subroom room gets the change everywhere. [HttpPut("rooms/{roomId}/permissions")] [HttpPost("rooms/{roomId}/permissions")] public async Task SetRoomPermissions(ulong roomId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var room = RoomDB.GetRoom(roomId); if (room == null) return StatusCode(404, ""); if (!RoomDB.PlayerCanManageRoles(roomId, account.Id)) return StatusCode(403, ""); string raw; using (var reader = new StreamReader(Request.Body)) raw = await reader.ReadToEndAsync(); var changes = Campus_live_rewrite.api.server.RoomPermissions.ParseBody(raw); if (changes == null) return StatusCode(400, ""); RoomDB.AttachSubRooms(new[] { room }); foreach (var sub in room.SubRooms ?? new List()) { var table = Campus_live_rewrite.api.server.RoomPermissions.Apply(sub.Permissions, changes); RoomDB.SetSubRoomPermissions(roomId, (ulong)sub.SubRoomId, table); } Console.WriteLine($"[room-perms] {account.Id} set {changes.Count} permission(s) on every subroom of room {roomId}"); foreach (var pid in Campus_live_rewrite.api.server.RoomPermissions.PlayersInRoom(roomId)) Campus_live_rewrite.api.server.RoomPermissions.PushRoomUpdate(pid, room); return RoomWriteEnvelope(roomId); } [HttpGet("rooms/{roomId}/subrooms/{subRoomId}/permissions")] public IActionResult GetSubRoomPermissions(ulong roomId, ulong subRoomId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var sub = RoomDB.GetSubRoom(roomId, subRoomId); if (sub == null) return StatusCode(404, ""); return Ok(sub.Permissions ?? new List()); } [HttpGet("photon_access_token")] public IActionResult photon_access_token() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var heartbeat = HeartbeatDB.GetPlayerHeartbeat(account.Id); // The table the client applies when it spawns: RoomPermissions.Defaults (reset and // delete-all now start at Moderator, not at everyone) with the overrides the room's // managers saved on the SUBROOM the caller is standing in merged over it, by // (Permission, Role) - see PUT rooms/{id}/subrooms/{sub}/permissions. var permissions = Campus_live_rewrite.api.server.RoomPermissions.Defaults(account.Id); var inst = heartbeat?.roomInstance; if (inst != null && inst.subRoomId > 0) { var sub = RoomDB.GetSubRoom((ulong)inst.roomId, (ulong)inst.subRoomId); permissions = Campus_live_rewrite.api.server.RoomPermissions.Merge(permissions, sub?.Permissions); } return Ok(new { RoomInstanceId = inst?.roomInstanceId, PhotonAccessToken = ClientSecurity.Createphotonacesstoken(account.Id), Permissions = permissions.ToArray() }); } [HttpGet("rooms/hot")] public ContentResult HotRooms(string tag, int skip = 0, int take = 30) { var rooms = RoomDB.GetHotRooms(); if (rooms == null || rooms.Count == 0) { return new ContentResult { Content = JsonConvert.SerializeObject(new Resultslist { Results = new List(), TotalResults = 0 }), ContentType = "application/json", StatusCode = 200 }; } IEnumerable query = rooms; bool isRRO = false; if (!string.IsNullOrEmpty(tag)) { string t = tag.ToLower(); // --- RRO Filter --- if (t == "rro" || t == "recroomoriginal") { isRRO = true; query = query.Where(r => r.Tags != null && r.Tags.Any(x => { string tt = x.Tag?.ToLower(); return tt == "rro" || tt == "recroomoriginal"; }) ); } else if (t == "new") { query = query.OrderByDescending(r => r.CreatedAt) .Take(30); } } else { // Default mode: EXCLUDE rro + base query = query.Where(r => r.Tags == null || !r.Tags.Any(x => { string tt = x.Tag?.ToLower(); return tt == "base" || tt == "rro" || tt == "recroomoriginal"; }) ); } // Only public rooms when NOT requesting RRO if (!isRRO) { query = query.Where(r => r.Accessibility == RoomDBClasses.RoomAccessibility.Public); } // Order by popularity query = query.OrderByDescending(r => r.Stats?.VisitCount ?? 0) .ThenByDescending(r => r.Stats?.CheerCount ?? 0); // Pagination int totalCount = query.Count(); var paged = query.Skip(skip).Take(take).ToList(); // Subrooms only for the page being returned (see RoomDB.GetHotRooms). RoomDB.AttachSubRooms(paged); int countTotal = rooms.Count; int countRRO = rooms.Count(r => r?.Tags != null && r.Tags.Any(t => { var x = t.Tag?.ToLower(); return x == "rro" || x == "recroomoriginal"; }) ); Console.WriteLine($"HOT TOTAL ROOMS: {countTotal}"); Console.WriteLine($"HOT RRO ROOMS: {countRRO}"); return new ContentResult { Content = JsonConvert.SerializeObject(new Resultslist { Results = StripUnservableSaves(VisibleTo(paged, ViewerId())), TotalResults = totalCount }), ContentType = "application/json", StatusCode = 200 }; } [HttpGet("rooms/{roomid}/interactionby/me")] public IActionResult Room_server_room_interactionby_me(ulong roomid) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var interaction = PlayerDB.GetInteraction(account.Id, roomid); // ContentResult, not Ok(string): a raw string goes out as text/plain (see CommerceController). return Content(JsonConvert.SerializeObject(new { Cheered = interaction.Cheered, Favorited = interaction.Favorited, LastVisitedAt = DateTime.Now // could persist if you want }), "application/json"); } [Route("rooms/{roomid}/interactionby/me/cheer")] public IActionResult Room_server_room_interactionby_me_cheer(ulong roomid) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; bool cheered = PlayerDB.ToggleCheer(account.Id, roomid); var favorited = PlayerDB.GetInteraction(account.Id, roomid).Favorited; return Content(JsonConvert.SerializeObject(new { Cheered = cheered, Favorited = favorited, LastVisitedAt = DateTime.Now }), "application/json"); } [Route("rooms/{roomid}/interactionby/me/favorite")] public IActionResult Room_server_room_interactionby_me_favorite(ulong roomid) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; bool favorited = PlayerDB.ToggleFavorite(account.Id, roomid); var cheered = PlayerDB.GetInteraction(account.Id, roomid).Cheered; return Content(JsonConvert.SerializeObject(new { Cheered = cheered, Favorited = favorited, LastVisitedAt = DateTime.Now }), "application/json"); } [HttpGet("rooms/ownedby/{PlayerId}")] public ContentResult RoomsOwnedBy(ulong PlayerId, int skip = 0, int take = 0) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; // Page at the DB level - only this page's rooms (and their subrooms) are loaded. An // account that owns thousands (e.g. @Importer) used to load EVERY room + a subroom query // each on every profile view, overloading the server. if (take <= 0) take = ConfigCampus.ProfileRoomsCap; var owned = RoomDB.GetRoomsByCreatorPaged(PlayerId, skip, take); // The dorm is personal space, served by /dormroom/me - never part of a rooms list. return new ContentResult { Content = JsonConvert.SerializeObject(StripUnservableSaves(VisibleTo(owned, account.Id).Where(r => !r.IsDorm).ToList())), ContentType = "application/json", StatusCode = 200 }; } // Search suggestions for the search box - type-ahead. Ported from RecFlare. // // Returns a BARE ARRAY OF STRINGS (not rooms, not an envelope): room names and tags // drawn from the public, non-dorm rooms that /rooms/search searches, so every // suggestion finds something when submitted. A tag suggestion carries its '#' so it // searches by tag; a query starting with '#' suggests tags ONLY. Matches that START // with the query come first, then ones that merely contain it, alphabetical within // each. `take` caps the list (the client asks for 4). searchSessionId is ignored. // // This did not exist, so search-as-you-type got a 404 and no suggestions ever showed. [HttpGet("rooms/autocomplete_search")] public ContentResult RoomAutocompleteSearch(string? query = "", int take = 10, string? searchSessionId = null) { query = (query ?? "").Trim(); bool tagsOnly = query.StartsWith("#"); string q = query.TrimStart('#').ToLowerInvariant(); var suggestions = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var room in RoomDB.GetAllRooms()) { if (room == null || room.IsDorm) continue; if (!RoomDB.IsRoomListed(room)) continue; // only rooms search can actually find if (!tagsOnly && !string.IsNullOrWhiteSpace(room.Name)) suggestions.Add(room.Name); if (room.Tags != null) foreach (var t in room.Tags) if (!string.IsNullOrWhiteSpace(t?.Tag)) suggestions.Add("#" + t.Tag); } // Rank: starts-with the query before merely-contains, alphabetical within each. // A tag's '#' is ignored when matching, so "#rr" and "rr" both hit the "rro" tag. static string Bare(string s) => s.TrimStart('#').ToLowerInvariant(); var ordered = suggestions .Where(s => tagsOnly ? s.StartsWith("#") : true) .Where(s => q.Length == 0 || Bare(s).Contains(q)) .OrderByDescending(s => q.Length > 0 && Bare(s).StartsWith(q)) .ThenBy(s => s, StringComparer.OrdinalIgnoreCase) .Take(take <= 0 ? 10 : take) .ToList(); return new ContentResult { Content = JsonConvert.SerializeObject(ordered), ContentType = "application/json", StatusCode = 200 }; } [HttpGet("rooms/search")] public ContentResult RoomSearch(string query, int skip = 0, int take = 30) { var rooms = RoomDB.Search(query); if (rooms == null || rooms.Count == 0) { return new ContentResult { Content = JsonConvert.SerializeObject( new RoomDBClasses.Resultslist { Results = new List(), TotalResults = 0 }), ContentType = "application/json", StatusCode = 200 }; } int total = rooms.Count; var paged = rooms.Skip(skip).Take(take).ToList(); return new ContentResult { Content = JsonConvert.SerializeObject( new RoomDBClasses.Resultslist { Results = StripUnservableSaves(VisibleTo(paged, ViewerId())), TotalResults = total }), ContentType = "application/json", StatusCode = 200 }; } [HttpGet("rooms/visitedby/me")] public ContentResult RoomsVisitedByMe(int skip = 0, int take = 20) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; return new ContentResult() { Content = PlayerDB.GetRecentRooms(account.Id, skip, take), ContentType = "application/json", StatusCode = 200 }; } // The "recent rooms" section on ANOTHER player's profile. Only /me existed, so opening // someone else's profile 404'd this section. Respects the target's recent-room-history // privacy setting exactly as Rec Room does: when they've hidden it, the viewer gets []. // Your own id always returns your history (privacy is about what OTHERS see). [HttpGet("rooms/visitedby/{playerid}")] public ContentResult RoomsVisitedByPlayer(ulong playerid, int skip = 0, int take = 20) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; if (playerid != account.Id) { var target = PlayerDB.GetFullAccount(playerid); bool visible = target?.player_Extra?.IsRecentRoomHistoryVisible ?? false; if (!visible) return new ContentResult { Content = "[]", ContentType = "application/json", StatusCode = 200 }; } return new ContentResult() { Content = PlayerDB.GetRecentRooms(playerid, skip, take), ContentType = "application/json", StatusCode = 200 }; } // The room showcase on a player's profile. This used to be a hardcoded "[]" // stub, which is why the Profile tab rendered as one big empty panel even for // accounts that own rooms. The client wants a plain list of room ids: [1, 2, 3]. // // Dorms and private rooms are deliberately excluded - a showcase is public, and // listing someone's private rooms to any viewer would leak them. // The showcase is the handful of rooms a player PICKS for the top of their profile // (PUT showcase, room ids). It used to list every room they own, which is what made // everything land in "Room Showcase" instead of the Rooms tab. No pick = empty, exactly // like the real service; the Rooms tab (createdby/{id}) is where all of them live. [HttpPut("showcase")] [HttpPost("showcase")] [HttpPut("showcase/me")] [HttpPost("showcase/me")] public ContentResult SetRoomShowcase() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var ids = new List(); void Add(string? v) { if (!string.IsNullOrWhiteSpace(v)) foreach (var p in v.Split(',')) if (ulong.TryParse(p.Trim(), out var id) && id != 0 && !ids.Contains(id)) ids.Add(id); } try { if (Request.HasFormContentType) foreach (var k in new[] { "roomIds", "roomId", "ids", "id", "RoomIds" }) foreach (var v in Request.Form[k]) Add(v); foreach (var k in new[] { "roomIds", "roomId", "ids", "id" }) foreach (var v in Request.Query[k]) Add(v); if (ids.Count == 0 && !Request.HasFormContentType) { Request.EnableBuffering(); Request.Body.Position = 0; using var reader = new StreamReader(Request.Body, leaveOpen: true); string raw = reader.ReadToEndAsync().GetAwaiter().GetResult(); if (!string.IsNullOrWhiteSpace(raw)) { var node = System.Text.Json.Nodes.JsonNode.Parse(raw); if (node is System.Text.Json.Nodes.JsonArray ja) foreach (var n in ja) Add(n?.ToString()); else if (node is System.Text.Json.Nodes.JsonObject jo) foreach (var kv in jo) { if (kv.Value is System.Text.Json.Nodes.JsonArray a2) foreach (var n in a2) Add(n?.ToString()); else Add(kv.Value?.ToString()); } } } } catch (Exception ex) { Console.WriteLine($"[showcase] {account.Id}: could not read body - {ex.Message}"); } // Only rooms the player actually created may be showcased. ids = ids.Where(id => RoomDB.GetRoom(id)?.CreatorAccountId == account.Id).Take(ConfigCampus.ShowcaseCap).ToList(); SettingsDB.SetPlayerSetting("Campus.RoomShowcase", string.Join(",", ids), account.Id); Console.WriteLine($"[showcase] {account.Id} set showcase to [{string.Join(",", ids)}]"); return new ContentResult { Content = JsonConvert.SerializeObject(ids), ContentType = "application/json", StatusCode = 200 }; } [HttpGet("showcase/{playerid}")] public ContentResult RoomShowcaseForPlayer(ulong playerid) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var picked = (SettingsDB.GetPlayerSettingValue("Campus.RoomShowcase", playerid) ?? "") .Split(',', StringSplitOptions.RemoveEmptyEntries) .Select(p => ulong.TryParse(p.Trim(), out var id) ? id : 0) .Where(id => id != 0) .ToList(); var rooms = picked.Select(id => RoomDB.GetRoom(id)).Where(r => r != null && r!.CreatorAccountId == playerid).Select(r => r!).ToList(); // NO FALLBACK. A showcase with nothing picked is EMPTY. // // This briefly fell back to "every room the player created", which did put the rooms // on the profile - but under the Room Showcase heading, all 33 of them, instead of in // the rooms list where the co-owned rooms (contributedby) correctly land. The rooms // list is fed by rooms/createdby/{id}, which is the route that was actually missing // (only the /me spelling existed, so a profile by id 404'd and the grid was empty). // That route is what makes an owner see their own rooms; this one stays a pick list. // Show what THIS VIEWER is allowed to see, not just public rooms. // // This used to be "Accessibility == Public", which hid almost everything: a // freshly created room defaults to PRIVATE (RoomAccessibility.Private = 0), so // players saw an empty portfolio even on their own profile. Now: // - your own profile shows all your rooms, private included // - someone else's shows their public/unlisted rooms, plus any private room // you personally hold a role on (owner / co-owner / moderator / host) // Dorms are never listed - they are personal space, not portfolio pieces. var ids = rooms .Where(r => r != null && !r.IsDorm && RoomDB.CanPlayerSeeRoom(r, account.Id)) .Select(r => r.RoomId) .Take(ConfigCampus.ShowcaseCap) .ToList(); return new ContentResult() { Content = JsonConvert.SerializeObject(ids), ContentType = "application/json", StatusCode = 200 }; } // Room role management (co-owner / moderator / host). These were ack/empty-array stubs, so // "invite as co-owner" did nothing and the roles list always showed empty. Now they persist. // Ban history for one account in one room. ENVELOPED {Value:{ActiveBan, PreviousBans}} // (wire-shapes: rooms-bans-history); "never banned" is a null ActiveBan and an empty list, // and nothing throws on either. Was a 404. [HttpGet("rooms/{roomId}/bans/history")] public IActionResult RoomBanHistory(ulong roomId, [FromQuery] long id) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; // A room ban is the Banned ROLE on the room plus a RoomBans record (RoomDB.BanFromRoom); // the record carries who / when / why / until, and closed ones are the history. var room = RoomDB.GetRoom(roomId); object? BanRow(RoomDBClasses.RoomBanDetails d) => new { Status = d.Status, // Active 0 / Elapsed 1 / Lifted 2 UnbannedByAccountId = d.UnbannedByAccountId.HasValue ? (int?)d.UnbannedByAccountId.Value : null, BanEndTime = d.BanEndTime, Reason = d.Reason ?? "", AccountId = (int)d.AccountId, BannedByAccountId = d.BannedByAccountId != 0 ? (int?)d.BannedByAccountId : null, BanStartTime = d.BanStartTime }; var current = room != null ? RoomDB.ActiveRoomBan(room, (ulong)id) : null; object? active = current != null ? BanRow(current) : null; var previous = (room?.RoomBans ?? new()) .Where(b => b.AccountId == id && b.Status != 0) .OrderByDescending(b => b.BanStartTime) .Select(b => BanRow(b)!) .ToArray(); return Ok(new { Value = new { ActiveBan = active, PreviousBans = previous }, Success = true, Error = (string?)null, error_id = (string?)null }); } // What every role / permission WRITE answers: the room-write envelope with the updated // room as its value, {success, error, value}. The 2025 client sends all of these through // the room facade's enveloped helper (OALMDGJKBJH.PJPLCMBOAFB) and parses the answer // as an object - Player.log, inviting a co-owner: "Malformed Response: '[{"AccountId":1, // ...}]' ... expected:'{', actual:'['", then "Failed to invite account to role"; and on // the permissions PUT: "Response was empty" / "Failed to change subroom permissions". // The bare roles ARRAY is what GET /roles answers (rooms-roles-wire-shape) and only that. // The wire keys accept any casing, so this is the same lowercase envelope PUT /name uses. private ContentResult RoomWriteEnvelope(ulong roomId) => new ContentResult { Content = JsonConvert.SerializeObject(new { success = true, error = "", value = RoomDB.GetRoom(roomId) }), ContentType = "application/json", StatusCode = 200 }; [HttpGet("rooms/{roomId}/roles")] public IActionResult GetRoomRolesEndpoint(ulong roomId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; return Ok(RoomDB.GetRoomRoles(roomId)); // BARE array - the one roles route that is } // Remove a player's role outright (the roles panel's "remove"). Same gate as invite. [HttpDelete("rooms/{roomId}/roles/{playerId}")] public IActionResult RemoveRoomRole(ulong roomId, long playerId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; // Removing YOURSELF: decline a pending invite, or step down from a role you hold. if ((ulong)playerId == account.Id) { if (RoomDB.PendingRoleInvite(roomId, playerId) != RoomDBClasses.Role_data.None) return ResolveRoleInvite(roomId, account.Id, accept: false); var mine = RoomDB.GetRoom(roomId); if (mine != null && mine.CreatorAccountId == account.Id) return StatusCode(403, ""); RoomDB.SetRoomRole(roomId, playerId, RoomDBClasses.Role_data.None, (long)account.Id); Console.WriteLine($"[room-role] {account.Id} left their role on room {roomId}"); return RoomWriteEnvelope(roomId); } if (!RoomDB.PlayerCanManageRoles(roomId, account.Id)) return StatusCode(403, ""); bool ok = RoomDB.SetRoomRole(roomId, playerId, RoomDBClasses.Role_data.None, (long)account.Id); Console.WriteLine($"[room-role] {account.Id} removed player {playerId}'s role on room {roomId} -> {ok}"); return RoomWriteEnvelope(roomId); } [HttpPut("rooms/{roomId}/roles/{playerId}")] [HttpPost("rooms/{roomId}/roles/{playerId}")] [HttpPut("rooms/{roomId}/roles/{playerId}/invite")] [HttpPost("rooms/{roomId}/roles/{playerId}/invite")] public IActionResult InviteRoomRole(ulong roomId, long playerId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; // The INVITEE answering their own pending invite comes through the same route with // their own id: that is an accept, never a self-grant (nothing here lets a caller give // themselves a role - the grant is exactly the role a manager offered). if ((ulong)playerId == account.Id) { var pending = RoomDB.PendingRoleInvite(roomId, playerId); if (pending == RoomDBClasses.Role_data.None) { // Already accepted (the notification tapped twice, or a retry after the first // answer failed to parse): a no-op success, with the room. A player with no // role and no invite is still refused - nothing here grants. var held = RoomDB.PlayerRoleOn(RoomDB.GetRoom(roomId), account.Id) ?? RoomDBClasses.Role_data.None; if (held != RoomDBClasses.Role_data.None && held != RoomDBClasses.Role_data.Banned) return RoomWriteEnvelope(roomId); Console.WriteLine($"[room-role] {account.Id} tried to set their OWN role on room {roomId} with no pending invite - refused"); return StatusCode(403, ""); } return ResolveRoleInvite(roomId, account.Id, accept: true); } // Only the room's creator, a co-owner, or a dev/admin may offer roles. if (!RoomDB.PlayerCanManageRoles(roomId, account.Id)) return StatusCode(403, ""); // The client PUTs a small form body: role= (30 CoOwner, 20 Moderator, // 10 Host, 0 None=remove). Fall back to query / invitedRole; default CoOwner if absent. string roleStr = Request.HasFormContentType ? Request.Form["role"].ToString() : ""; if (string.IsNullOrWhiteSpace(roleStr)) roleStr = Request.Query["role"].ToString(); if (string.IsNullOrWhiteSpace(roleStr) && Request.HasFormContentType) roleStr = Request.Form["invitedRole"].ToString(); int roleVal = int.TryParse(roleStr, out var rv) ? rv : (int)RoomDBClasses.Role_data.CoOwner; var role = Enum.IsDefined(typeof(RoomDBClasses.Role_data), (byte)roleVal) ? (RoomDBClasses.Role_data)(byte)roleVal : RoomDBClasses.Role_data.CoOwner; // A creator can't be demoted through here, can't be invited to their own room, and // nobody can hand out Creator. var target_room = RoomDB.GetRoom(roomId); if (target_room != null && (ulong)playerId == target_room.CreatorAccountId) { Console.WriteLine($"[room-role] {account.Id} tried to change the creator's role on room {roomId} - refused"); return StatusCode(403, ""); } if (role == RoomDBClasses.Role_data.Creator) role = RoomDBClasses.Role_data.CoOwner; if (role == RoomDBClasses.Role_data.Banned) role = RoomDBClasses.Role_data.None; bool ok; if (role == RoomDBClasses.Role_data.None) { ok = RoomDB.SetRoomRole(roomId, playerId, role, (long)account.Id); Console.WriteLine($"[room-role] {account.Id} removed player {playerId}'s role on room {roomId} -> {ok}"); if (ok) Campus_live_rewrite.api.server.RoomPermissions.PushRoomUpdate((ulong)playerId, RoomDB.GetRoom(roomId)); return RoomWriteEnvelope(roomId); } // Co-owner and contributor are OFFERED and wait for the invitee's Accept - they hand // over the room's contents. Moderator and host are GRANTED on the spot: they are // staffing roles, and an invite for them is what the owner reported as "adding a mod // makes them get an invite". The target's client is told immediately either way. if (role == RoomDBClasses.Role_data.Moderator || role == RoomDBClasses.Role_data.Host) { ok = RoomDB.SetRoomRole(roomId, playerId, role, (long)account.Id); Console.WriteLine($"[room-role] {account.Id} made player {playerId} {role} ({roleVal}) on room {roomId} -> {ok} (granted directly)"); if (ok) Campus_live_rewrite.api.server.RoomPermissions.PushRoomUpdate((ulong)playerId, RoomDB.GetRoom(roomId)); return RoomWriteEnvelope(roomId); } ok = RoomDB.InviteRoomRole(roomId, playerId, role, (long)account.Id); Console.WriteLine($"[room-role] {account.Id} invited player {playerId} as {role} ({roleVal}) on room {roomId} -> {ok} (pending acceptance)"); if (ok) { try { var msg = PlayerDB.AddMessage((ulong)playerId, account.Id, WebsocketEvents.MessageType.RoomCoOwnerInvited, ((int)role).ToString(), roomId, null); if (msg != null) Notifications.SendToPlayer((ulong)playerId, JsonConvert.SerializeObject(WebsocketEvents.CreateMessageResponse(msg))); } catch (Exception ex) { Console.WriteLine($"[room-role] invite notification failed: {ex.Message}"); } } // The client re-GETs /roles right after; return the updated list so it reflects immediately. return RoomWriteEnvelope(roomId); } private IActionResult ResolveRoleInvite(ulong roomId, ulong accountId, bool accept) { var pending = RoomDB.PendingRoleInvite(roomId, (long)accountId); if (pending == RoomDBClasses.Role_data.None) return StatusCode(404, ""); bool ok = RoomDB.ResolveRoleInvite(roomId, (long)accountId, accept); Console.WriteLine($"[room-role] {accountId} {(accept ? "ACCEPTED" : "declined")} the {pending} invite on room {roomId} -> {ok}"); if (ok && accept) Campus_live_rewrite.api.server.RoomPermissions.PushRoomUpdate(accountId, RoomDB.GetRoom(roomId)); if (ok && accept) { // RoomCoOwnerAdded (60) reads "You are now a Co-Owner of X" - it is addressed to the // player who GAINED the role, not to the room's owner. Sending it to the creator is // what made the owner receive "You are now a Co-Owner" of their own room. var room = RoomDB.GetRoom(roomId); if (room != null && room.CreatorAccountId != accountId) { try { var msg = PlayerDB.AddMessage(accountId, room.CreatorAccountId, WebsocketEvents.MessageType.RoomCoOwnerAdded, ((int)pending).ToString(), roomId, null); if (msg != null) Notifications.SendToPlayer(accountId, JsonConvert.SerializeObject(WebsocketEvents.CreateMessageResponse(msg))); } catch { } } } return RoomWriteEnvelope(roomId); } // The explicit accept / decline spellings, plus "myrole" (the reference lists GET // rooms/{id}/roles/myrole; a PUT there is the same accept). [HttpPut("rooms/{roomId}/roles/{playerId}/accept")] [HttpPost("rooms/{roomId}/roles/{playerId}/accept")] public IActionResult AcceptRoomRole(ulong roomId, long playerId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; if ((ulong)playerId != account.Id) return StatusCode(403, ""); return ResolveRoleInvite(roomId, account.Id, accept: true); } [HttpPut("rooms/{roomId}/roles/{playerId}/decline")] [HttpPost("rooms/{roomId}/roles/{playerId}/decline")] [HttpDelete("rooms/{roomId}/roles/{playerId}/invite")] public IActionResult DeclineRoomRole(ulong roomId, long playerId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; if ((ulong)playerId == account.Id) return ResolveRoleInvite(roomId, account.Id, accept: false); // A manager withdrawing an offer they made. if (!RoomDB.PlayerCanManageRoles(roomId, account.Id)) return StatusCode(403, ""); RoomDB.ResolveRoleInvite(roomId, playerId, accept: false); return RoomWriteEnvelope(roomId); } [HttpGet("rooms/{roomId}/roles/myrole")] public IActionResult MyRoomRole(ulong roomId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var room = RoomDB.GetRoom(roomId); return Ok((int)(RoomDB.PlayerRoleOn(room, account.Id) ?? RoomDBClasses.Role_data.None)); } [HttpPut("rooms/{roomId}/roles/myrole")] [HttpPost("rooms/{roomId}/roles/myrole")] public IActionResult SetMyRoomRole(ulong roomId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; return ResolveRoleInvite(roomId, account.Id, accept: true); } // Anything else under /roles: log the shape so an unknown client spelling names itself. [Route("rooms/{roomId}/roles/{**rest}")] public IActionResult UnhandledRoleRoute(ulong roomId, string rest) { string body = ""; try { if (Request.HasFormContentType) body = string.Join("&", Request.Form.Select(kv => kv.Key + "=" + kv.Value)); } catch { } Console.WriteLine($"[room-role] UNHANDLED {Request.Method} rooms/{roomId}/roles/{rest}{Request.QueryString} body='{body}'"); return StatusCode(404, ""); } [HttpGet("rooms/base")] public ContentResult BaseRooms() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; List room = RoomDB.Search("#base", isBase: true); return new ContentResult() { Content = JsonConvert.SerializeObject(StripUnservableSaves(VisibleTo(room, ViewerId()))), ContentType = "application/json", StatusCode = 200 }; } [HttpPost("rooms/{roomId}/clone")] public ContentResult CloneRoom(ulong roomId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; string RoomName = HttpContext.Request.Form["name"]; if (string.IsNullOrEmpty(RoomName)) { return Utils.Error("You must enter a name for your room."); } var checkifroomexists = RoomDB.GetRoomByName(RoomName); if (checkifroomexists != null) { return Utils.Error("A room with that name already exists!"); } bool CheckSwears = !string.IsNullOrEmpty(RoomName) && Sanitize.ContainsSwears(RoomName); if (CheckSwears) { return Utils.Error("Room name contains swear words!"); } // Anyone could clone ANY room (unpublished drafts included) and end up owning a copy. // Only the room's own creator/co-owners, or anyone when the owner allowed cloning on a // room others can see. var source = RoomDB.GetRoom(roomId); if (source == null) return Utils.Error("This room does not exist!"); bool mineToClone = source.CreatorAccountId == account.Id || RoomDB.IsCoOwner(source, account.Id) || PlayerDB.CheckDevFlag(account.Id); bool template = RoomDB.RoomHasTag(source, "base") || source.IsRRO; // the "create a room" templates if (!mineToClone && !template && !(source.CloningAllowed && RoomDB.CanPlayerSeeRoom(source, account.Id))) { Console.WriteLine($"[rooms] {account.Id} tried to clone room {roomId} ({source.Name}) without permission - refused"); return Utils.Error("This room can't be cloned."); } ulong? temp = RoomDB.CloneRoom(roomId, RoomName, account.Id); if (temp == null) { return Utils.Error("You can't clone this room!"); } var NewRoom = RoomDB.GetRoom(temp.Value); if (NewRoom == null) { Console.WriteLine($"[Room_server_room_clone] get_room({temp.Value}) returned null"); } Console.WriteLine($"[Room_server_room_clone] clone_room returned id: {temp}"); return new ContentResult() { Content = JsonConvert.SerializeObject(new { success = true, error = "", value = NewRoom }), ContentType = "application/json", StatusCode = 200 }; } // set room stuff [HttpPut("rooms/{roomId}/name")] public ContentResult SetRoomName(ulong roomId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; string NewRoomName = HttpContext.Request.Form["name"].ToString(); RoomDBClasses.RoomRoot? room_data = RoomDB.GetRoom(roomId); Uri.UnescapeDataString(NewRoomName); if (room_data == null) { return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!"); } if (room_data.CreatorAccountId != account?.Id && !RoomDB.IsCoOwner(room_data, account?.Id ?? 0)) { return Utils.ErrorRoom("Rooms.PermissionDenied", "You are not the owner of this room!"); } if (room_data.IsDorm == true) { return Utils.ErrorRoom("Rooms.Dorm", "You cannot edit your dorm room's name!"); } if (NewRoomName.Contains(" ")) { return Utils.ErrorRoom("Rooms.NoSpaces", "Your room name must not contain spaces!"); } if (Regex.IsMatch(NewRoomName, @"[^\u0000-\u007F]")) { return Utils.ErrorRoom("Rooms.NoUnicodeCharacters", "Your room name contains Unicode characters!"); } bool CheckSwears = !string.IsNullOrEmpty(NewRoomName) && Sanitize.ContainsSwears(NewRoomName); if (CheckSwears) { return Utils.ErrorRoom("Rooms.NoSwearWords", "Your room name contains swear words!"); } RoomDBClasses.RoomRoot? temp = RoomDB.SetRoomName(roomId, NewRoomName); RoomDBClasses.RoomRoot? UpdatedRoom = RoomDB.GetRoom(roomId); if (UpdatedRoom != null) { return new ContentResult() { Content = JsonConvert.SerializeObject(new { success = true, error = "", value = UpdatedRoom }), ContentType = "application/json", StatusCode = 200 }; } else { return Utils.ErrorRoom("Rooms.DuplicateName", "A room with the same name already exists!"); } } [HttpPut("rooms/{roomId}/description")] public ContentResult SetRoomDescriptionAPI(ulong roomId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; string NewDescription = HttpContext.Request.Form["description"].ToString(); Uri.UnescapeDataString(NewDescription); RoomDBClasses.RoomRoot room_data = RoomDB.GetRoom(roomId); if (room_data == null) { return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!"); } if (room_data.CreatorAccountId != account?.Id && !RoomDB.IsCoOwner(room_data, account?.Id ?? 0)) { return Utils.ErrorRoom("Rooms.PermissionDenied", "You are not the owner of this room!"); } bool CheckSwears = !string.IsNullOrEmpty(NewDescription) && Sanitize.ContainsSwears(NewDescription); if (CheckSwears) { return Utils.ErrorRoom("Rooms.NoSwearWords", "Your room description contains swear words!"); } RoomDBClasses.RoomRoot temp = RoomDB.SetRoomDescription(roomId, NewDescription); RoomDBClasses.RoomRoot UpdatedRoom = RoomDB.GetRoom(roomId); if (UpdatedRoom != null) { return new ContentResult() { Content = JsonConvert.SerializeObject(new { success = true, error = "", value = UpdatedRoom }), ContentType = "application/json", StatusCode = 200 }; } else { return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!"); } } [HttpPut("rooms/{roomId}/accessibility")] public ContentResult SetRoomAccessibility(ulong roomId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; string NewAccessibilityString = HttpContext.Request.Form["accessibility"].ToString(); RoomDBClasses.RoomAccessibility accessibility = RoomDBClasses.RoomAccessibility.Private; if (int.TryParse(NewAccessibilityString, out int accessibilityValue) && Enum.IsDefined(typeof(RoomDBClasses.RoomAccessibility), accessibilityValue)) { accessibility = (RoomDBClasses.RoomAccessibility)accessibilityValue; } else { return new ContentResult() { Content = JsonConvert.SerializeObject(new { success = false, error = "", value = (object?)null }), ContentType = "application/json", StatusCode = 400 }; } RoomDBClasses.RoomRoot room_data = RoomDB.GetRoom(roomId); if (room_data == null) { return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!"); } if (room_data.CreatorAccountId != account?.Id && !RoomDB.IsCoOwner(room_data, account?.Id ?? 0)) { return Utils.ErrorRoom("Rooms.PermissionDenied", "You are not the owner of this room!"); } RoomDBClasses.RoomRoot temp = RoomDB.SetRoomAccessibility(roomId, accessibility); RoomDBClasses.RoomRoot UpdatedRoom = RoomDB.GetRoom(roomId); if (UpdatedRoom != null) { return new ContentResult() { Content = JsonConvert.SerializeObject(new { success = true, error = "", value = UpdatedRoom }), ContentType = "application/json", StatusCode = 200 }; } else { return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!"); } } // Transfer a room to a new owner (reference rooms PUT /rooms/{id}/creator): the new owner's // role entry becomes Creator (255), the old owner's becomes CoOwner (30), and // CreatorAccountId - the field every gate and "rooms I made" list reads - moves with it. // Owner only (a co-owner cannot give a room away), and a dorm can never be transferred. [HttpPut("rooms/{roomId}/creator")] [HttpPost("rooms/{roomId}/creator")] public ContentResult TransferRoom(ulong roomId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var f = RoomModeration.Fields(Request); ulong target = RoomModeration.ULong(f, "accountId", "id", "playerId", "newOwnerId"); var room = RoomDB.GetRoom(roomId); if (room == null) return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!"); bool isOwner = room.CreatorAccountId == account.Id || PlayerDB.CheckDevFlag(account.Id); if (!isOwner) return Utils.ErrorRoom("Rooms.PermissionDenied", "Only the owner can transfer this room!"); if (room.IsDorm) return Utils.ErrorRoom("Rooms.PermissionDenied", "A dorm cannot be transferred!"); if (target == 0 || PlayerDB.GetAccountById(target) == null) return Utils.ErrorRoom("Rooms.InvalidAccount", "You must name a valid player!"); if (target == room.CreatorAccountId) return Utils.ErrorRoom("Rooms.InvalidAccount", "That player already owns this room!"); var stored = RoomDB.col.FindOne(x => x.RoomId == roomId); if (stored == null) return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!"); ulong previous = stored.CreatorAccountId; stored.Roles ??= new List(); stored.Roles.RemoveAll(x => (ulong)x.AccountId == target); stored.Roles.Add(new RoomDBClasses.RoleClass { AccountId = (long)target, Role = RoomDBClasses.Role_data.Creator, InvitedRole = 0, LastChangedByAccountId = (long)account.Id }); var old = stored.Roles.FirstOrDefault(x => (ulong)x.AccountId == previous); if (old != null) { old.Role = RoomDBClasses.Role_data.CoOwner; old.InvitedRole = 0; old.LastChangedByAccountId = (long)account.Id; } else if (previous != 0) stored.Roles.Add(new RoomDBClasses.RoleClass { AccountId = (long)previous, Role = RoomDBClasses.Role_data.CoOwner, InvitedRole = 0, LastChangedByAccountId = (long)account.Id }); stored.CreatorAccountId = target; RoomDB.col.Update(stored); Console.WriteLine($"[rooms] room {roomId} '{stored.Name}': ownership {previous} -> {target} (by {account.Id})"); var updated = RoomDB.GetRoom(roomId); foreach (var pid in RoomPermissions.PlayersInRoom(roomId).Concat(new[] { previous, target }).Distinct()) RoomPermissions.PushRoomUpdate(pid, updated); return new ContentResult { Content = JsonConvert.SerializeObject(new { success = true, error = "", value = updated }), ContentType = "application/json", StatusCode = 200 }; } // A subroom's own visibility (reference rooms PUT /rooms/{id}/subrooms/{sub}/accessibility): // the client sends the RoomAccessibility NAME (accessibility=Private); the ordinal works too. // Owner only. Answers the updated ROOM so the client re-renders its subroom list. [HttpPut("rooms/{roomId}/subrooms/{subRoomId}/accessibility")] [HttpPost("rooms/{roomId}/subrooms/{subRoomId}/accessibility")] public ContentResult SetSubroomAccessibility(ulong roomId, ulong subRoomId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var f = RoomModeration.Fields(Request); string raw = RoomModeration.Str(f, "accessibility", "Accessibility") ?? ""; RoomDBClasses.RoomAccessibility acc; if (int.TryParse(raw, out var n) && Enum.IsDefined(typeof(RoomDBClasses.RoomAccessibility), n)) acc = (RoomDBClasses.RoomAccessibility)n; else if (!Enum.TryParse(raw, true, out acc)) return Utils.ErrorRoom("Rooms.InvalidArgument", "Unknown accessibility!"); var room = RoomDB.GetRoom(roomId); if (room == null) return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!"); if (room.CreatorAccountId != account.Id && !RoomDB.IsCoOwner(room, account.Id) && !PlayerDB.CheckDevFlag(account.Id)) return Utils.ErrorRoom("Rooms.PermissionDenied", "You are not the owner of this room!"); var sub = room.SubRooms?.FirstOrDefault(s => s.SubRoomId == subRoomId); if (sub == null) return Utils.ErrorRoom("Rooms.DoesntExist", "This subroom does not exist!"); RoomDB.ModifySubroom(roomId, subRoomId, sub.Name ?? "", acc, sub.MaxPlayers); var updated = RoomDB.GetRoom(roomId); RoomPermissions.PushRoomUpdate(account.Id, updated); return new ContentResult { Content = JsonConvert.SerializeObject(new { success = true, error = "", value = updated }), ContentType = "application/json", StatusCode = 200 }; } [HttpPut("rooms/{roomId}/image")] public ContentResult SetRoomImageName(ulong roomId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; string NewImageName = HttpContext.Request.Form["imageName"].ToString(); RoomDBClasses.RoomRoot room_data = RoomDB.GetRoom(roomId); if (room_data == null) { return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!"); } if (room_data.CreatorAccountId != account?.Id && !RoomDB.IsCoOwner(room_data, account?.Id ?? 0)) { return Utils.ErrorRoom("Rooms.PermissionDenied", "You are not the owner of this room!"); } RoomDBClasses.RoomRoot temp = RoomDB.SetRoomImageName(roomId, NewImageName); RoomDBClasses.RoomRoot UpdatedRoom = RoomDB.GetRoom(roomId); if (UpdatedRoom != null) { return new ContentResult() { Content = JsonConvert.SerializeObject(new { success = true, error = "", value = UpdatedRoom }), ContentType = "application/json", StatusCode = 200 }; } else { return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!"); } } [HttpPost("rooms/{roomId}/subrooms/{subroomId}/data")] public async Task SetRoomData(ulong roomId, ulong subroomId, [FromBody] RoomFileData request) { string authtoken = HttpContext.Request.Headers["Authorization"].ToString(); if (string.IsNullOrEmpty(authtoken)) { return new ContentResult() { Content = "", ContentType = "application/json", StatusCode = 401 }; } string playerdata = ClientSecurity.DecodeToken(authtoken); if (playerdata == null) return new ContentResult() { Content = "", ContentType = "application/json", StatusCode = 401 }; if (playerdata.Contains("error_token")) return new ContentResult() { Content = "", ContentType = "application/json", StatusCode = 401 }; player_info account = JsonConvert.DeserializeObject(playerdata); string token = authtoken.ToString().Replace("Bearer ", "", StringComparison.OrdinalIgnoreCase); var handler = new JwtSecurityTokenHandler(); var jwt = handler.ReadJwtToken(token); // "platform" is normally numeric, but int.Parse here threw an UNHANDLED // FormatException -> HTTP 500 on any token carrying a name ("Standalone"). // A room save is the one request you never want to lose, so accept either // shape and fall back to All rather than dropping the save on the floor. string currentplatformstr = jwt.Payload.TryGetValue("platform", out var platObj) ? platObj?.ToString() ?? "" : ""; Platforms platformparsed; if (int.TryParse(currentplatformstr, out int currentplatform)) platformparsed = Enum.IsDefined(typeof(Platforms), currentplatform) ? (Platforms)currentplatform : Platforms.All; else if (!Enum.TryParse(currentplatformstr, ignoreCase: true, out platformparsed)) platformparsed = Platforms.All; // The subroom blob (the actual build) and the ROOM-level blob are two // different uploads. Both lines used to read SubRoomData, so RoomData was // silently discarded and subroomFilename was dead code. string subroomFilename = request.SubRoomData?.Filename ?? ""; string? roomLevelBlob = request.RoomData?.Filename; string RoomDesc = request.Description ?? ""; RoomDBClasses.RoomRoot room_data = RoomDB.GetRoom(roomId); if (room_data == null) { return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!"); } if (!RoomDB.CanEditContent(room_data, account?.Id ?? 0)) // creator, co-owner, or an ACCEPTED contributor { return Utils.ErrorRoom("Rooms.PermissionDenied", "You are not the owner of this room!"); } // Pass the versions the client reported with this save. Without them every // save was recorded as 0/0/0 and the client treated the room as dirty // immediately, prompting to save on exit even when nothing had changed. // A save never changes the room's VISIBILITY (accessibility is the owner's setting). // What AutoPublish decides is whether this save goes LIVE now or is staged as an // unpublished save until the owner publishes it (publish_save) - published and // unpublished saves are kept apart, so visitors keep loading the published build. RoomDBClasses.RoomRoot room = RoomDB.SetRoomSubroomRoomFile( roomId, subroomId, subroomFilename, RoomDesc, account.Id, platformparsed, request.PersistenceVersion, request.OMVersion, request.UgcSubVersion, roomLevelBlob, request.AutoPublish, out var createdSave); RoomDBClasses.RoomRoot UpdatedRoom = RoomDB.GetRoom(roomId); if (UpdatedRoom != null) { // The client parses value as { room, subRoomDataSave } (see wire-shapes // subroom-data-save-endpoint). We used to hand back the bare room, so the // deserializer NRE'd on the missing pair, the promise rejected with the generic // "Failed to save room", and the room stayed dirty - every save after the first // one in an instance "failed" even though the blob had been stored. // subRoomDataSave is a camelCase projection with its OWN field set, not the // PascalCase CurrentSave embedded in the room. // The save just made - staged or published - not necessarily the live CurrentSave. var save = createdSave ?? RoomDB.GetSubRoom(roomId, subroomId)?.CurrentSave; object? subRoomDataSave = save == null ? null : new { subRoomDataSaveId = (long)save.SubRoomDataSaveId, subRoomId = (long)save.SubRoomId, unityAssetId = save.UnityAssetId, unityAsset = (string?)null, unityAssetHash = (string?)null, dataBlob = save.DataBlob ?? "", dataBlobHash = request.SubRoomData?.Hash ?? "", savedByAccountId = (long?)save.SavedByAccountId, savedOnPlatform = (int)(save.SavedOnPlatform ?? Platforms.All), savedOnDeviceClass = (int)(save.SavedOnDeviceClass ?? DeviceClasses.Screen), description = save.Description, createdAt = save.CreatedAt }; Campus_live_rewrite.api.server.RoomPermissions.PushRoomUpdate(account.Id, UpdatedRoom); return new ContentResult() { Content = JsonConvert.SerializeObject(new { success = true, error = (string?)null, value = new { room = UpdatedRoom, subRoomDataSave } }), ContentType = "application/json", StatusCode = 200 }; } else { return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!"); } } [HttpPut("rooms/{roomId}/cloning")] public ContentResult SetRoomCloning(ulong roomId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; string? cloningAllowedStr = HttpContext.Request.Form["cloningAllowed"]; if (!bool.TryParse(cloningAllowedStr, out bool cloningAllowed)) { return new ContentResult() { Content = "", ContentType = "application/json", StatusCode = 400 }; } RoomDBClasses.RoomRoot room_data = RoomDB.GetRoom(roomId); if (room_data.IsDorm == true) { return Utils.ErrorRoom("Rooms.DormNotAllowed", "You cannot edit that in this dorm."); } if (room_data == null) { return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!"); } if (room_data.CreatorAccountId != account?.Id && !RoomDB.IsCoOwner(room_data, account?.Id ?? 0)) { return Utils.ErrorRoom("Rooms.PermissionDenied", "You are not the owner of this room!"); } RoomDBClasses.RoomRoot temp = RoomDB.SetRoomCloning(roomId, cloningAllowed); RoomDBClasses.RoomRoot UpdatedRoom = RoomDB.GetRoom(roomId); if (UpdatedRoom != null) { return new ContentResult() { Content = JsonConvert.SerializeObject(new { success = true, error = "", value = UpdatedRoom }), ContentType = "application/json", StatusCode = 200 }; } else { return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!"); } } [HttpPut("rooms/{roomId}/restrictions")] [HttpPut("rooms/{roomId}/v2/restrictions")] // the 2025 client's spelling (404'd) [HttpPost("rooms/{roomId}/v2/restrictions")] public ContentResult SetRoomRestrictions(ulong roomId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; bool ParseBool(string key, bool defaultValue = false) { string? str = HttpContext.Request.Form[key]; return bool.TryParse(str, out bool result) ? result : defaultValue; } bool supportsScreens = ParseBool("supportsScreens"); bool supportsWalkVR = ParseBool("supportsWalkVR"); bool supportsTeleportVR = ParseBool("supportsTeleportVR"); bool supportsJuniors = ParseBool("supportsJuniors"); RoomDBClasses.RoomRoot? room_data = RoomDB.GetRoom(roomId); if (room_data?.IsDorm == true) { return Utils.ErrorRoom("Rooms.DormNotAllowed", "You cannot edit that in dorms."); } if (room_data == null) { return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!"); } if (room_data.CreatorAccountId != account?.Id && !RoomDB.IsCoOwner(room_data, account?.Id ?? 0)) { return Utils.ErrorRoom("Rooms.PermissionDenied", "You are not the owner of this room!"); } RoomDBClasses.RoomRoot? temp = RoomDB.SetRoomRestrictions(roomId, supportsScreens, supportsWalkVR, supportsTeleportVR, supportsJuniors); RoomDBClasses.RoomRoot? UpdatedRoom = RoomDB.GetRoom(roomId); if (UpdatedRoom != null) { return new ContentResult() { Content = JsonConvert.SerializeObject(new { success = true, error = "", value = UpdatedRoom }), ContentType = "application/json", StatusCode = 200 }; } else { return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!"); } } [HttpPut("rooms/{roomId}/loadscreen")] public ContentResult SetRoomLoadingScreen(ulong roomId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; string imageName = HttpContext.Request.Form["imageName"].ToString(); string title = HttpContext.Request.Form["title"].ToString(); string subtitle = HttpContext.Request.Form["subtitle"].ToString(); RoomDBClasses.RoomRoot? room_data = RoomDB.GetRoom(roomId); if (room_data == null) { return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!"); } if (room_data.CreatorAccountId != account?.Id && !RoomDB.IsCoOwner(room_data, account?.Id ?? 0)) { return Utils.ErrorRoom("Rooms.PermissionDenied", "You are not the owner of this room!"); } if (!string.IsNullOrEmpty(title) && title.Length > 24) { return Utils.ErrorRoom("Room.LoadScreen.Title", "Title cannot exceed 24 characters!"); } if (!string.IsNullOrEmpty(subtitle) && subtitle.Length > 150) { return Utils.ErrorRoom("Room.LoadScreen.Subtitle", "Subtitle cannot exceed 150 characters!"); } RoomDBClasses.RoomRoot? temp = RoomDB.SetRoomLoadscreen(roomId, imageName, title, subtitle); RoomDBClasses.RoomRoot? UpdatedRoom = RoomDB.GetRoom(roomId); if (UpdatedRoom != null) { return new ContentResult() { Content = JsonConvert.SerializeObject(new { success = true, error = "", value = UpdatedRoom }), ContentType = "application/json", StatusCode = 200 }; } else { return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!"); } } [HttpPut("rooms/{roomId}/warning")] public ContentResult SetRoomWarning(ulong roomId, [FromForm] WarningMaskType warningMask) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; RoomDBClasses.RoomRoot room_data = RoomDB.GetRoom(roomId); if (room_data == null) { return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!"); } if (room_data.CreatorAccountId != account?.Id && !RoomDB.IsCoOwner(room_data, account?.Id ?? 0)) { return Utils.ErrorRoom("Rooms.PermissionDenied", "You are not the owner of this room!"); } RoomDBClasses.RoomRoot temp = RoomDB.SetRoomWarningMask(roomId, warningMask); RoomDBClasses.RoomRoot UpdatedRoom = RoomDB.GetRoom(roomId); if (UpdatedRoom != null) { return new ContentResult() { Content = JsonConvert.SerializeObject(new { success = true, error = "", value = UpdatedRoom }), ContentType = "application/json", StatusCode = 200 }; } else { return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!"); } } [HttpPut("rooms/{roomId}/tags")] public IActionResult SetRoomTags(ulong roomId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var room = RoomDB.GetRoom(roomId); if (room == null) return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!"); if (!RoomDB.UserCanEditRoom(roomId, account.Id)) return Utils.ErrorRoom("Rooms.PermissionDenied", "You are not the owner of this room!"); var restricted = new HashSet { "rro", "recroomoriginal", "featured", "base" }; List Values(string name) => Request.HasFormContentType ? Request.Form[name].Select(t => Uri.UnescapeDataString(t ?? "").Trim().ToLowerInvariant()).Where(t => t.Length > 0).Distinct().ToList() : new List(); var tags = Values("tag"); var autoTags = Values("autoTag"); string? primaryGenre = Values("primaryGenreTag").FirstOrDefault(); if (tags.Count == 0 && autoTags.Count == 0 && primaryGenre == null) return Utils.ErrorRoom("Rooms.InvalidTag", "No valid tags provided."); if (tags.Any(t => restricted.Contains(t)) || (primaryGenre != null && restricted.Contains(primaryGenre))) return Utils.ErrorRoom("Rooms.TagNotAllowed", "One or more tags are not allowed!"); // Derived tags are a closed set: what the client's own toggles produce. var allowedAuto = new HashSet { "limitsv2", "beta", "rooms2", "r2" }; if (autoTags.Any(t => !allowedAuto.Contains(t))) return Utils.ErrorRoom("Rooms.TagNotAllowed", "One or more tags are not allowed!"); // A lone `tag` toggles; anything more is the whole-state save (see RoomDB.ApplyRoomTagEdit). bool isToggle = tags.Count == 1 && autoTags.Count == 0 && primaryGenre == null; Console.WriteLine($"[rooms] {account.Id} tags on room {roomId}: {(isToggle ? "toggle " + tags[0] : $"set [{string.Join(",", tags)}] auto [{string.Join(",", autoTags)}] genre {primaryGenre ?? "-"}")}"); var updatedRoom = RoomDB.ApplyRoomTagEdit(roomId, isToggle ? tags[0] : null, isToggle ? null : (tags.Count > 0 ? tags : null), autoTags, primaryGenre); if (updatedRoom == null) return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!"); return Ok(new { success = true, error = "", value = updatedRoom }); } [HttpGet("rooms/favoritedby/me")] public ContentResult MyFavoritedRooms() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; return new ContentResult() { // Was: JsonConvert.SerializeObject(...) wrapped around a method that // ALREADY returns serialized JSON, so the client received the string // "[]" (quoted) instead of the array []. Verified live - this endpoint // was returning "[]" and it is the source of the client's // `Malformed Response: '[]'` errors. Content = PlayerDB.GetFavoritedRooms(account.Id), ContentType = "application/json", StatusCode = 200 }; } // Don't advertise a save whose .room blob we cannot actually serve. // // Symptom this fixes: joining a room ALONE failed with no subroom data, but // joining a friend already inside worked. Creating an instance makes the client // load the room from its save - it requests /cdn/room/, which 404s // when the file is absent from Data/CDN/RoomBlobs (the cdn.rec.net fallback in // CDNController is dead - those blobs are no longer public). Joining an existing // instance never fetches the blob; state replicates over Photon from whoever is // already there. That is exactly why the same room worked via a friend. // // Rooms with no save at all (Park, dorms) were unaffected - they just load their // default Unity scene. So make an unservable save look like no save and let the // room fall back to that same scene. // // Scope: only changes rooms that are ALREADY broken. If the blob is present the // save is returned exactly as before. Self-correcting - drop the real .room files // into Data/CDN/RoomBlobs and the saves come back with no code change. // // Trade-off: an affected room loads its base scene instead of its saved build. // Joinable-but-plain beats unjoinable, and it is reversible. private static List FilterUnservableSaves(dynamic? results, ulong roomId, ulong subroomId) { var servable = new List(); int dropped = 0; if (results != null) { foreach (var s in results) { string? blob = (string?)s?.DataBlob; if (BlobHasContent(blob)) servable.Add(s!); else dropped++; } } if (dropped > 0) { Console.WriteLine($"[rooms/{roomId}/subrooms/{subroomId}/saves] dropped {dropped} save(s) " + "whose .room blob is missing or empty in Data/CDN/RoomBlobs - room will load its " + "default scene instead of failing to load at all"); } return servable; } [HttpGet("rooms/{roomId}/subrooms/{subroomId}/saves")] // SCUFFED CODE fix later public ContentResult GetRoomSaves(ulong roomId, ulong subroomId, int unityAssetTarget = 0, int unityAssetVersion = 1, int skip = 0, int take = 20) { var save = RoomDB.GetRoomSaves(roomId, subroomId, skip, take); if (save == null) { return new ContentResult() { Content = "", ContentType = "application/json", StatusCode = 404 }; } var servable = FilterUnservableSaves(save.Results, roomId, subroomId); return new ContentResult() { Content = JsonConvert.SerializeObject(new { Results = servable, TotalResults = servable.Count }), ContentType = "application/json", StatusCode = 200 }; } [HttpGet("rooms/{roomId}/subrooms/{subroomId}/saves/no_unity_assets")] // SCUFFED CODE fix later public ContentResult GetRoomSaves2(ulong roomId, ulong subroomId, int unityAssetTarget = 0, int unityAssetVersion = 1, int skip = 0, int take = 20) { var save = RoomDB.GetRoomSaves(roomId, subroomId, skip, take); if (save == null) { return new ContentResult() { Content = "", ContentType = "application/json", StatusCode = 404 }; } var servable = FilterUnservableSaves(save.Results, roomId, subroomId); return new ContentResult() { Content = JsonConvert.SerializeObject(new { Results = servable, TotalResults = servable.Count }), ContentType = "application/json", StatusCode = 200 }; } // GetRoomSaveById returns a boxed object whose concrete type varies, so read // DataBlob off its JSON form rather than guessing at properties vs fields - // an earlier reflection-based version silently matched nothing and let the // blob through. // Single source of truth for "can the client actually download this blob?". // NOTE: existence alone is not enough - see BlobHasContent for why. private static bool BlobHasContent(string? blob) => Campus_live_rewrite.api.server.RoomBlobFilter.BlobHasContent(blob); private static bool SaveBlobIsServable(object save) { string? blob; try { blob = Newtonsoft.Json.Linq.JObject .FromObject(save)["DataBlob"]?.ToString(); } catch { return true; // unknown shape - never block on it } return BlobHasContent(blob); } [HttpGet("rooms/{roomId}/subrooms/{subroomId}/saves/{subRoomDataSaveId}")] public IActionResult GetRoomSave(ulong roomId, ulong subroomId, ulong subRoomDataSaveId, [FromQuery] int unityAssetTarget, [FromQuery] int unityAssetVersion) { var save = RoomDB.GetRoomSaveById(roomId, subroomId, subRoomDataSaveId, unityAssetTarget, unityAssetVersion); // Same rule as everywhere else: a save whose .room blob is not in // Data/CDN/RoomBlobs cannot be loaded, so report it as absent rather than // handing back a DataBlob that will 404 at /cdn/room/. if (save != null && !SaveBlobIsServable(save)) { Console.WriteLine($"[rooms/{roomId}/subrooms/{subroomId}/saves/{subRoomDataSaveId}] " + "blob missing or empty in Data/CDN/RoomBlobs - reporting save as absent"); save = null; } if (save == null) { return new ContentResult() { Content = "", ContentType = "application/json", StatusCode = 404 }; } return Ok(save); } [HttpGet("rooms/carousel/rising")] public ContentResult RisingRooms() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; return new ContentResult() { Content = "[]", // todo db ContentType = "application/json", StatusCode = 200 }; } [HttpGet("featuredrooms/current")] public ContentResult CurrentFeaturedRooms() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; /* Response: { "FeaturedRoomGroupId": 1, "name": "Featured Rooms", "StartAt": "2025-12-01T11:01:00Z", "EndAt": "9999-12-08T11:00:00Z", "Rooms": [ { "RoomId": 7243703403195289000, "RoomName": "WinterAlmonds", "ImageName": "96w7irfsiucisrei9fsmhipqn.jpg", "IsRecRoomApproved": false, "ExcludeFromLists": false, "ExcludeFromSearch": false } ] } */ // Build the ONE shared featured group from FeaturedRoomsStore (admin-curated, same // list the website reads). Skips ids whose room no longer exists. Returned as an ARRAY // of groups (what the client expects - the old placeholder was an empty array too). var featuredRooms = new List(); foreach (var rid in Campus_live_rewrite.api.server.FeaturedRoomsStore.GetIds()) { var r = RoomDB.GetRoom(rid); if (r == null || r.IsDorm) continue; featuredRooms.Add(new { RoomId = r.RoomId, RoomName = r.Name ?? "", ImageName = r.ImageName ?? "", // client's DTO wants a string, never null IsRecRoomApproved = r.IsRecRoomApproved, ExcludeFromLists = false, ExcludeFromSearch = false, }); } // A SINGLE group object (FeaturedRoomGroupDto), NOT an array - that's what the client's // deserializer for this call expects (confirmed against the reference server). Wrapping // it in an array made the client fail to parse it, so the in-game Featured Room was blank. var group = new { FeaturedRoomGroupId = 1, name = "Featured Rooms", StartAt = "2025-01-01T00:00:00Z", EndAt = "9999-12-31T00:00:00Z", Rooms = featuredRooms, }; return new ContentResult() { Content = JsonConvert.SerializeObject(group), ContentType = "application/json", StatusCode = 200 }; } [HttpPost("rooms/{roomid}/subrooms")] public ContentResult CreateSubroomForRoom(ulong roomid) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; string SubroomName = HttpContext.Request.Form["name"].ToString(); Uri.UnescapeDataString(SubroomName); RoomDBClasses.RoomRoot room_data = RoomDB.GetRoom(roomid); if (room_data == null) { return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!"); } if (room_data.CreatorAccountId != account?.Id && !RoomDB.IsCoOwner(room_data, account?.Id ?? 0)) { return Utils.ErrorRoom("Rooms.PermissionDenied", "You are not the owner of this room!"); } if (room_data.IsDorm == true) { return Utils.ErrorRoom("Rooms.PermissionDenied", "You cannot create a subroom for your dorm room!"); } bool CheckSwears = !string.IsNullOrEmpty(SubroomName) && Sanitize.ContainsSwears(SubroomName); if (CheckSwears) { return Utils.ErrorRoom("Rooms.NoSwearWords", "Your subroom name contains swear words!"); } RoomDBClasses.RoomRoot temp = RoomDB.CreateSubroomForRoom(roomid, SubroomName); RoomDBClasses.RoomRoot UpdatedRoom = RoomDB.GetRoom(roomid); if (UpdatedRoom != null) { return new ContentResult() { Content = JsonConvert.SerializeObject(new { success = true, error = "", value = UpdatedRoom }), ContentType = "application/json", StatusCode = 200 }; } else { return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!"); } } [HttpDelete("rooms/{roomid}/subrooms/{subroomid}")] public ContentResult DeleteSubroom(ulong roomid, ulong subroomid) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; RoomDBClasses.RoomRoot room_data = RoomDB.GetRoom(roomid); if (room_data == null) { return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!"); } if (room_data.CreatorAccountId != account?.Id && !RoomDB.IsCoOwner(room_data, account?.Id ?? 0)) { return Utils.ErrorRoom("Rooms.PermissionDenied", "You are not the owner of this room!"); } bool success = RoomDB.DeleteSubroom(roomid, subroomid); RoomDBClasses.RoomRoot UpdatedRoom = RoomDB.GetRoom(roomid); if (UpdatedRoom != null) { return new ContentResult() { Content = JsonConvert.SerializeObject(new { success = true, error = "", value = UpdatedRoom }), ContentType = "application/json", StatusCode = 200 }; } else { return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!"); } } // POST rooms/{id}/subrooms/{sid}/clone - a copy of the subroom (fresh SubRoomId, same // scene / settings / saves) in the same room. Owner or co-owner. Answers the updated ROOM // in the envelope, like create/delete - the client re-renders its subroom list from value. [HttpPost("rooms/{roomid}/subrooms/{subroomid}/clone")] public ContentResult CloneSubroom(ulong roomid, ulong subroomid) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var room_data = RoomDB.GetRoom(roomid); if (room_data == null) return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!"); if (room_data.CreatorAccountId != account?.Id && !RoomDB.IsCoOwner(room_data, account?.Id ?? 0)) return Utils.ErrorRoom("Rooms.PermissionDenied", "You are not the owner of this room!"); if (room_data.IsDorm == true) return Utils.ErrorRoom("Rooms.PermissionDenied", "You cannot clone a dorm subroom!"); var cloned = RoomDB.CloneSubroom(roomid, subroomid); if (cloned == null) return Utils.ErrorRoom("Rooms.DoesntExist", "This subroom does not exist!"); Console.WriteLine($"[rooms] {account!.Id} cloned subroom {subroomid} of room {roomid} -> {cloned.SubRoomId}"); var updated = RoomDB.GetRoom(roomid); Campus_live_rewrite.api.server.RoomPermissions.PushRoomUpdate(account.Id, updated); return new ContentResult { Content = JsonConvert.SerializeObject(new { success = true, error = "", value = updated }), ContentType = "application/json", StatusCode = 200 }; } // POST rooms/{id}/subrooms/{sid}/move - reorder a subroom in the room's list. The // position comes as index / newIndex / position (0-based) or direction (up/down). Shape // unpinned; every field is logged so it can be. [HttpPost("rooms/{roomid}/subrooms/{subroomid}/move")] [HttpPut("rooms/{roomid}/subrooms/{subroomid}/move")] public ContentResult MoveSubroom(ulong roomid, ulong subroomid) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var room_data = RoomDB.GetRoom(roomid); if (room_data == null) return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!"); if (room_data.CreatorAccountId != account?.Id && !RoomDB.IsCoOwner(room_data, account?.Id ?? 0)) return Utils.ErrorRoom("Rooms.PermissionDenied", "You are not the owner of this room!"); var f = Campus_live_rewrite.api.server.RoomModeration.Fields(Request); Console.WriteLine($"[rooms] {account!.Id} move subroom {subroomid} of {roomid}: {Campus_live_rewrite.api.server.RoomModeration.Dump(f)}"); string[] idxKeys = { "index", "newIndex", "position", "toIndex", "targetIndex" }; long idx = idxKeys.Any(k => f.ContainsKey(k)) ? Campus_live_rewrite.api.server.RoomModeration.Long(f, idxKeys) : -1; string dir = (Campus_live_rewrite.api.server.RoomModeration.Str(f, "direction", "dir") ?? "").ToLowerInvariant(); var subs = room_data.SubRooms ?? new List(); int cur = subs.FindIndex(x => x.SubRoomId == subroomid); if (cur < 0) return Utils.ErrorRoom("Rooms.DoesntExist", "This subroom does not exist!"); int to = idx >= 0 ? (int)Math.Min(idx, subs.Count - 1) : dir == "up" ? Math.Max(0, cur - 1) : dir == "down" ? Math.Min(subs.Count - 1, cur + 1) : cur; if (to != cur) { var item = subs[cur]; subs.RemoveAt(cur); subs.Insert(to, item); RoomDB.ReorderSubrooms(roomid, subs.Select(x => x.SubRoomId).ToList()); } var updated = RoomDB.GetRoom(roomid); return new ContentResult { Content = JsonConvert.SerializeObject(new { success = true, error = "", value = updated }), ContentType = "application/json", StatusCode = 200 }; } // POST rooms/{id}/subrooms/{sid}/publish_save - make the save named by the // subRoomDataSaveId form field the one players load (its CurrentSave). Saves here are // never staged, so this is the "restore an older save" half of the reference's route. // Owner-only, like the reference. Answers the updated ROOM in the envelope. [HttpPost("rooms/{roomid}/subrooms/{subroomid}/publish_save")] public ContentResult PublishSubroomSave(ulong roomid, ulong subroomid) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var room_data = RoomDB.GetRoom(roomid); if (room_data == null) return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!"); if (room_data.CreatorAccountId != account?.Id && !RoomDB.IsCoOwner(room_data, account?.Id ?? 0)) return Utils.ErrorRoom("Rooms.PermissionDenied", "You are not the owner of this room!"); var f = Campus_live_rewrite.api.server.RoomModeration.Fields(Request); long saveId = Campus_live_rewrite.api.server.RoomModeration.Long(f, "subRoomDataSaveId", "SubRoomDataSaveId", "saveId", "id"); if (saveId <= 0) return Utils.ErrorRoom("Rooms.InvalidSave", "You must provide a valid save!"); if (!RoomDB.PublishSubroomSave(roomid, subroomid, (ulong)saveId)) return Utils.ErrorRoom("Rooms.DoesntExist", "That save does not exist!"); Console.WriteLine($"[rooms] {account!.Id} published save {saveId} on subroom {subroomid} of room {roomid}"); var updated = RoomDB.GetRoom(roomid); Campus_live_rewrite.api.server.RoomPermissions.PushRoomUpdate(account.Id, updated); return new ContentResult { Content = JsonConvert.SerializeObject(new { success = true, error = "", value = updated }), ContentType = "application/json", StatusCode = 200 }; } [HttpPut("rooms/{roomid}/subrooms/{subroomid}/modify")] public ContentResult ModifySubroom(ulong roomid, ulong subroomid, [FromForm] string name, RoomDBClasses.RoomAccessibility accessibility, int maxPlayers = 32) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; RoomDBClasses.RoomRoot room_data = RoomDB.GetRoom(roomid); if (room_data == null) { return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!"); } if (room_data.CreatorAccountId != account?.Id && !RoomDB.IsCoOwner(room_data, account?.Id ?? 0)) { return Utils.ErrorRoom("Rooms.PermissionDenied", "You are not the owner of this room!"); } bool success = RoomDB.ModifySubroom(roomid, subroomid, name, accessibility, maxPlayers); RoomDBClasses.RoomRoot UpdatedRoom = RoomDB.GetRoom(roomid); if (UpdatedRoom != null) { return new ContentResult() { Content = JsonConvert.SerializeObject(new { success = true, error = "", value = UpdatedRoom }), ContentType = "application/json", StatusCode = 200 }; } else { return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!"); } } [Route("unity_assets/{guid}/{target}/{version}")] public IActionResult GetUnityAssets(string guid, int target, int version) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; return Ok(new { UnityAssetId = guid, Target = target, Version = version, Filename = $"{guid}.assetbundle", Hash = (string?)null }); } // Rooms 2.0 rooms resolve their pre-baked mesh assetbundles here. Rec Room generated those // bundles at publish time on its own backend, so we DON'T have them for imported (or most) // rooms - and returning 404 hard-fails the load with "Error downloading room assets (Code // 8715)". The client can BAKE geometry at runtime from the raw shape data in the .room blob // (GatherBakedPrimitiveDataJob / GatherBakedSplineDataJob), so the right answer for an id we // have no baked bundles for is a VALID, EMPTY result - never a 404. When we DO have a cached // BakedBulk/_assets.json (base rooms), we return it. [Route("/Room_server/unity_assets/baked/bulk")] public async Task GetUnityAssetsBakedBulk() { // The request carries one or more asset ids. Accept every shape the client might use: // repeated/comma form field(s), query, or a JSON body (array, {ids:[...]}, or {id:...}). var ids = new List(); void AddCsv(string? s) { if (!string.IsNullOrWhiteSpace(s)) ids.AddRange(s.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(x => x.Trim())); } try { if (Request.HasFormContentType) { foreach (var k in new[] { "id", "ids", "Id", "Ids", "assetId", "assetIds" }) foreach (var v in Request.Form[k]) AddCsv(v); } } catch { } foreach (var k in new[] { "id", "ids" }) foreach (var v in Request.Query[k]) AddCsv(v); string rawBody = ""; if (ids.Count == 0) { try { Request.EnableBuffering(); Request.Body.Position = 0; using var reader = new StreamReader(Request.Body, leaveOpen: true); rawBody = await reader.ReadToEndAsync(); Request.Body.Position = 0; if (!string.IsNullOrWhiteSpace(rawBody)) { var node = JsonNode.Parse(rawBody); if (node is JsonArray ja) foreach (var n in ja) { if (n != null) AddCsv(n.ToString()); } else if (node is JsonObject jo) { foreach (var key in new[] { "ids", "id", "Ids", "Id", "assetIds", "assetId" }) if (jo[key] is JsonArray a2) foreach (var n in a2) { if (n != null) AddCsv(n.ToString()); } else if (jo[key] != null) AddCsv(jo[key]!.ToString()); } else if (node != null) AddCsv(node.ToString()); } } catch { } } ids = ids.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct().ToList(); Console.WriteLine($"[baked/bulk] method={Request.Method} ct={Request.ContentType} ids=[{string.Join(",", ids)}] rawBody={(rawBody.Length > 300 ? rawBody.Substring(0, 300) : rawBody)}"); // Build one group per requested id: its cached baked bundles if we have them, else empty // (client runtime-bakes). If we couldn't parse any id, still return an empty array (200). string dir = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "BakedBulk"); var groups = new List(); foreach (var id in ids) { JsonArray? baked = null; try { string path = Path.Combine(dir, $"{id}_assets.json"); if (System.IO.File.Exists(path)) baked = JsonNode.Parse(System.IO.File.ReadAllText(path)) as JsonArray; } catch { } groups.Add(new { UnityAssetId = id, CreatedByAccountId = 1, BakedUnityAssets = (object?)baked ?? new JsonArray(), SubAssets = new List(), SourceAssetId = (string?)null }); } return Ok(groups); } [HttpGet("/Room_server/publishState/configs")] public IActionResult GetPublishStateConfigs() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; /* { "value": { "UpdateMaxCount": 3, "UpdateRollingWindowInDays": 365, "UpdateExpirationInDays": 30, "UpdateCooldownInDays": 45 }, "success": true, "error_id": null, "error": null } */ return Ok(new { value = new { UpdateMaxCount = 3, UpdateRollingWindowInDays = 365, UpdateExpirationInDays = 30, UpdateCooldownInDays = 45 }, success = true, error_id = (string?)null, error = (string?)null }); } [HttpGet("/Room_server/dormroom/me")] public IActionResult GetMyDormRoom() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var FullAccount = PlayerDB.GetFullAccount(account.Id); return Ok(FullAccount.player_Extra.DormRoomID); } // Rooms like this one: the paged {Results, TotalResults} shape the room lists use, from // shared tags (then the hot list). Answered "[]" before - the client's "Malformed // Response: '[]'" and "Error refreshing rooms from source RoomSimilarity" every room load. [HttpGet("/Room_server/rooms/{roomId}/similar")] public ContentResult SimilarRooms(ulong roomId, int skip = 0, int take = 30) { var seed = RoomDB.GetRoom(roomId); var tags = new HashSet((seed?.Tags ?? new()).Select(t => (t.Tag ?? "").ToLowerInvariant()).Where(t => t.Length > 0 && t != "rro" && t != "base" && t != "recroomoriginal")); var pool = RoomDB.GetHotRooms().Where(r => r.RoomId != roomId && !r.IsDorm && RoomDB.IsRoomListed(r) && !RoomDB.IsRecRoomOriginal(r)).ToList(); var ranked = pool .Select(r => (room: r, shared: (r.Tags ?? new()).Count(t => tags.Contains((t.Tag ?? "").ToLowerInvariant())))) .OrderByDescending(x => x.shared) .ThenByDescending(x => x.room.Stats?.CheerCount ?? 0) .ThenByDescending(x => x.room.Stats?.VisitCount ?? 0) .Select(x => x.room) .ToList(); var page = ranked.Skip(Math.Max(0, skip)).Take(Math.Clamp(take, 1, 100)).ToList(); RoomDB.AttachSubRooms(page); return new ContentResult { Content = JsonConvert.SerializeObject(new RoomDBClasses.Resultslist { Results = StripUnservableSaves(VisibleTo(page, ViewerId())), TotalResults = ranked.Count }), ContentType = "application/json", StatusCode = 200 }; } // Room leaderboard slots (Room Settings -> Leaderboards). A configure / clear is // acknowledged; nothing stores slots yet. DELETE 405'd before. [HttpGet("/Room_server/rooms/{roomId}/leaderboards/{leaderboardId}")] public ContentResult RoomLeaderboard(ulong roomId, int leaderboardId) => new ContentResult { Content = "[]", ContentType = "application/json", StatusCode = 200 }; [HttpPost("/Room_server/rooms/{roomId}/leaderboards/{leaderboardId}")] [HttpPut("/Room_server/rooms/{roomId}/leaderboards/{leaderboardId}")] [HttpDelete("/Room_server/rooms/{roomId}/leaderboards/{leaderboardId}")] public IActionResult RoomLeaderboardWrite(ulong roomId, int leaderboardId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; if (!RoomDB.UserCanEditRoom(roomId, account.Id)) return StatusCode(403, ""); Console.WriteLine($"[leaderboard] {Request.Method} room {roomId} slot {leaderboardId} by {account.Id}"); return Ok(new { success = true, error = "", value = (object?)null }); } // The room's XP settings write (Room Settings -> Experience) - echoes what GET answers. [HttpPost("/Room_server/rooms/{roomId}/experience")] [HttpPut("/Room_server/rooms/{roomId}/experience")] public IActionResult SetExperienceForRoom(ulong roomId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; return Ok(new { DailyLimit = 1000000, Enabled = false, RoomExperienceId = 0 }); } [HttpGet("/Room_server/rooms/{roomId}/experience")] public IActionResult GetExperienceForRoom(ulong roomId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; return Ok(new { DailyLimit = 1000000, Enabled = false, RoomExperienceId = 0 }); } [HttpGet("/Room_server/rooms/{roomId}/experience/player")] public IActionResult GetPlayerExperienceInRoom(ulong roomId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; return Ok(new { Enabled = false, Experience = (object?)null, ConcurrencyCode = (string?)null }); } // Whether a player is banned from a room - read from the same bans the join gates refuse // on, so it answers what would actually happen. Was a constant false, which is why the // client offered "ban" on someone already banned and never showed "unban". [HttpGet("/Room_server/rooms/{roomId}/bans/{playerId}/isBanned")] public IActionResult GetIsBanned(ulong roomId, ulong playerId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var room = RoomDB.GetRoom(roomId); return Ok(new { success = true, error = (string?)null, error_id = (string?)null, value = room != null && RoomDB.PlayerIsBannedFrom(room, playerId) }); } private ContentResult BanEnvelope(RoomDBClasses.RoomBanDetails? ban, ulong roomId, string error = "") => new ContentResult { Content = JsonConvert.SerializeObject(new { success = ban != null, error, value = ban == null ? null : new { RoomId = (long)roomId, BannedPlayerId = ban.AccountId, BanMask = 0, BannedByAccountId = ban.BannedByAccountId, CreatedAt = ban.BanStartTime } }), ContentType = "application/json", StatusCode = 200 }; // The room's active bans (Room Settings -> Moderation), newest first. camelCase entries // (reference: RoomBanEntryDto). Was a constant empty list. [HttpGet("/Room_server/rooms/{roomId}/bans")] public IActionResult GetRoomBans(ulong roomId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var room = RoomDB.GetRoom(roomId); if (room == null) return Ok(Array.Empty()); return Ok(RoomDB.ActiveRoomBans(room).Select(b => new { accountId = b.AccountId, bannedByAccountId = b.BannedByAccountId, banStartTime = b.BanStartTime })); } // Ban a player from the room (form: id, banMask, and optionally reason / duration in // seconds). The room's owners, its moderators, or staff. The banned player gets the // ModerationKick frame their client leaves on, is moved out of the instance, and the // matchmaker refuses them until the ban is lifted. Answers {success,error,value:ban}. [HttpPost("/Room_server/rooms/{roomId}/bans")] [HttpPut("/Room_server/rooms/{roomId}/bans")] public IActionResult BanFromRoom(ulong roomId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var room = RoomDB.GetRoom(roomId); if (room == null) return BanEnvelope(null, roomId, "This room does not exist!"); if (!RoomDB.CanModerateRoom(room, account.Id)) return StatusCode(403, ""); var f = RoomModeration.Fields(Request); ulong target = RoomModeration.ULong(f, "id", "accountId", "playerId", "bannedPlayerId"); if (target == 0) return BanEnvelope(null, roomId, "You must provide a valid player to ban!"); if (target == account.Id) return BanEnvelope(null, roomId, "You cannot ban yourself!"); if (room.CreatorAccountId == target || RoomDB.IsCoOwner(room, target)) return BanEnvelope(null, roomId, "You cannot ban an owner of this room!"); if (PlayerDB.CheckDevFlag(target) && !PlayerDB.CheckDevFlag(account.Id)) return BanEnvelope(null, roomId, "You cannot ban a staff member!"); long seconds = RoomModeration.Long(f, "duration", "durationSeconds", "banDuration"); string reason = RoomModeration.Str(f, "reason", "message", "displayReason") ?? ""; var ban = RoomDB.BanFromRoom(roomId, target, account.Id, reason, seconds > 0 ? TimeSpan.FromSeconds(seconds) : null); if (ban == null) return BanEnvelope(null, roomId, "Could not ban that player."); bool isHostKick = room.CreatorAccountId == account.Id || RoomDB.IsCoOwner(room, account.Id); // isBan stays FALSE on the kick frame: `IsBan: true` is what the client renders as a ban // from the whole GAME (reference rooms POST /bans). The room ban itself is the stored row // the matchmaker refuses on (BannedFromRoom, 55); the frame only makes them leave. RoomModeration.RemoveFromRoom(target, roomId, 0, $"You have been banned from {room.Name}.", account.Id, isBan: false, isHostKick: isHostKick); ModerationLogDB.Record("roomban", target, account.Id, roomId, 0, seconds, ReportCategory.Moderator, reason); return BanEnvelope(ban, roomId); } // Lift a room ban. Answers the closed ban in the same envelope. Lifting also clears any // kick block the player has on the room's instances, so "unban" always means "let them // back in". [HttpDelete("/Room_server/rooms/{roomId}/bans/{playerId:long}")] [HttpPost("/Room_server/rooms/{roomId}/bans/{playerId:long}/unban")] public IActionResult UnbanFromRoom(ulong roomId, ulong playerId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var room = RoomDB.GetRoom(roomId); if (room == null) return BanEnvelope(null, roomId, "This room does not exist!"); if (!RoomDB.CanModerateRoom(room, account.Id)) return StatusCode(403, ""); int lifted = InstanceBlocks.Clear(playerId, (long)roomId); var ban = RoomDB.UnbanFromRoom(roomId, playerId, account.Id); if (ban == null && lifted == 0) return BanEnvelope(null, roomId, "This player is not banned from this room!"); if (ban == null) ban = new RoomDBClasses.RoomBanDetails { AccountId = (long)playerId, BannedByAccountId = 0, BanStartTime = DateTime.UtcNow, Status = 2, UnbannedByAccountId = (long)account.Id, Reason = "" }; ModerationLogDB.Record("roomunban", playerId, account.Id, roomId, 0, 0, ReportCategory.Moderator, ""); return BanEnvelope(ban, roomId); } // The 2025 client's bulk forms of the two calls above: DELETE rooms/{id}/bans/bulk lifts // the bans on every id given (form / JSON / query: ids, accountIds, playerIds, id); // POST / PUT bans/bulk issues them. DELETE used to fall into the single-player route with // playerId = 0 and answer "This player is not banned from this room!" (Player.log: // LLCLDPPAGIJ) whatever was asked. Answers the ban envelope with a list as its value. [HttpDelete("/Room_server/rooms/{roomId}/bans/bulk")] [HttpPost("/Room_server/rooms/{roomId}/bans/bulk/unban")] public IActionResult UnbanBulk(ulong roomId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var room = RoomDB.GetRoom(roomId); if (room == null) return BanEnvelope(null, roomId, "This room does not exist!"); if (!RoomDB.CanModerateRoom(room, account.Id)) return StatusCode(403, ""); var f = RoomModeration.Fields(Request); var ids = RoomModeration.ULongs(f, "ids", "accountIds", "playerIds", "bannedPlayerIds", "id", "accountId", "playerId"); Console.WriteLine($"[roomban] bulk unban on room {roomId} by {account.Id}: {RoomModeration.Dump(f)}"); if (ids.Count == 0) return BanEnvelope(null, roomId, "You must provide a valid player to unban!"); var rows = new List(); foreach (var pid in ids) { int lifted = InstanceBlocks.Clear(pid, (long)roomId); var ban = RoomDB.UnbanFromRoom(roomId, pid, account.Id); if (ban == null && lifted == 0) continue; ModerationLogDB.Record("roomunban", pid, account.Id, roomId, 0, 0, ReportCategory.Moderator, ""); rows.Add(new { RoomId = (long)roomId, BannedPlayerId = (long)pid, BanMask = 0, BannedByAccountId = ban?.BannedByAccountId ?? 0, CreatedAt = ban?.BanStartTime ?? DateTime.UtcNow }); } if (rows.Count == 0) return BanEnvelope(null, roomId, "This player is not banned from this room!"); return new ContentResult { Content = JsonConvert.SerializeObject(new { success = true, error = "", value = rows }), ContentType = "application/json", StatusCode = 200 }; } [HttpPost("/Room_server/rooms/{roomId}/bans/bulk")] [HttpPut("/Room_server/rooms/{roomId}/bans/bulk")] public IActionResult BanBulk(ulong roomId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var room = RoomDB.GetRoom(roomId); if (room == null) return BanEnvelope(null, roomId, "This room does not exist!"); if (!RoomDB.CanModerateRoom(room, account.Id)) return StatusCode(403, ""); var f = RoomModeration.Fields(Request); var ids = RoomModeration.ULongs(f, "ids", "accountIds", "playerIds", "bannedPlayerIds", "id", "accountId", "playerId"); Console.WriteLine($"[roomban] bulk ban on room {roomId} by {account.Id}: {RoomModeration.Dump(f)}"); if (ids.Count == 0) return BanEnvelope(null, roomId, "You must provide a valid player to ban!"); long seconds = RoomModeration.Long(f, "duration", "durationSeconds", "banDuration"); string reason = RoomModeration.Str(f, "reason", "message", "displayReason") ?? ""; bool isHostKick = room.CreatorAccountId == account.Id || RoomDB.IsCoOwner(room, account.Id); var rows = new List(); foreach (var pid in ids) { if (pid == account.Id || room.CreatorAccountId == pid || RoomDB.IsCoOwner(room, pid)) continue; if (PlayerDB.CheckDevFlag(pid) && !PlayerDB.CheckDevFlag(account.Id)) continue; var ban = RoomDB.BanFromRoom(roomId, pid, account.Id, reason, seconds > 0 ? TimeSpan.FromSeconds(seconds) : null); if (ban == null) continue; RoomModeration.RemoveFromRoom(pid, roomId, 0, $"You have been banned from {room.Name}.", account.Id, isBan: false, isHostKick: isHostKick); ModerationLogDB.Record("roomban", pid, account.Id, roomId, 0, seconds, ReportCategory.Moderator, reason); rows.Add(new { RoomId = (long)roomId, BannedPlayerId = ban.AccountId, BanMask = 0, BannedByAccountId = ban.BannedByAccountId, CreatedAt = ban.BanStartTime }); } if (rows.Count == 0) return BanEnvelope(null, roomId, "Could not ban that player."); return new ContentResult { Content = JsonConvert.SerializeObject(new { success = true, error = "", value = rows }), ContentType = "application/json", StatusCode = 200 }; } [HttpGet("/Room_server/rooms/{roomId}/playerdata/me")] public IActionResult GetMyPlayerData(ulong roomId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var data = PlayerDB.GetPlayerData(account.Id, roomId); // The client expects a JSON body from this endpoint — 404 makes it treat the whole // room-join as "failed to fetch player state" and bounce. Return an empty payload // when there's no stored state yet (fresh player, never visited this room). return Ok(new { Data = data ?? "" }); } [HttpPut("/Room_server/rooms/{roomId}/playerdata/me")] public IActionResult SetMyPlayerData(ulong roomId, [FromForm] string data) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; bool success = PlayerDB.SetPlayerData(account.Id, roomId, data); if (!success) { return BadRequest(); } Console.WriteLine("Was updating playerdata successful: " + success); return NoContent(); } [HttpGet("/Room_server/rooms/{roomId}/boosts/{playerId}/count")] public IActionResult GetBoostCountForRoom(ulong roomId, ulong playerId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; return Ok("0"); } [HttpGet("/Room_server/rooms/{roomId}/assets")] public IActionResult GetRoomAssets(ulong roomId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; return Ok(new { success = true, error = (string?)null, error_id = (string?)null, value = Array.Empty() }); } // https://rooms.rec.net/rooms/1089513655118942017/snapshots/ade3aee2-e469-4445-728b-08de39979f5f/loadinfo?subRoomId=1384652020053214301 [HttpGet("/Room_server/rooms/{roomId}/snapshots/{snapshotId}/loadinfo")] public IActionResult GetRoomAssets(ulong roomId, string snapshotId, [FromQuery] ulong subRoomId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var room = RoomDB.GetRoom(roomId); if (room == null) return NotFound(); var subRoom = room.SubRooms?.FirstOrDefault(sr => sr.SubRoomId == subRoomId); return Ok(new { success = true, error = (string?)null, error_id = (string?)null, value = new { Assets = Array.Empty(), RoomId = roomId, SnapshotId = snapshotId, SubRoom = new { SubRoomDataSaveId = subRoom?.CurrentSave?.SubRoomDataSaveId }, SubRoomId = subRoomId, UgcSubVersion = room.UgcSubVersion, UgcVersion = room.UgcVersion } }); } } }