using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using System.Text.Json; using DorkNet.Server.Auth; using DorkNet.Server.Data; using DorkNet.Server.Data.Entities; namespace DorkNet.Server.Controllers.API.Rooms.V2; /// /// api.rec.net/api/rooms/v{1,2}/{modify,modifyPermissions,banfromroom, /// importroombans,roombans,report,modify/scene,modify/sceneParent, /// modify/tags,modifyscene} — owner-side mutation surface for rooms. /// /// Split from to keep that file /// (browse / details / clone) at a manageable size. All endpoints /// are owner-gated (the caller must equal /// ) or admin-bypassed via /// on the admin-only sub-routes. /// [ApiController] [Authorize] public class RoomsModerationController(DorkNetDbContext db) : ControllerBase { private const int PublicAccessibility = 1; private const long CanUseShareCamPermission = 1L << 18; private long Me => this.RequireCurrentPlayerId(); private async Task RequireOwnedRoomAsync(long roomId) { var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); if (room is null) return null; if (room.CreatorPlayerId == Me) return room; var coOwner = await db.RoomRoles.AnyAsync(r => r.RoomId == roomId && r.PlayerId == Me && r.Accepted && r.Role == 0); return coOwner ? room : null; } // ── /modify (general fields) ───────────────────────────────────────── public sealed class ModifyRoomRequest { public long RoomId { get; set; } public string? Name { get; set; } public string? Description { get; set; } public string? ImageName { get; set; } public string? Tags { get; set; } public int? Accessibility { get; set; } public bool? SupportsScreens { get; set; } public bool? SupportsMobile { get; set; } public bool? SupportsVRLow { get; set; } public bool? SupportsWalkVR { get; set; } public bool? SupportsTeleportVR { get; set; } public bool? DisableMicAutoMute { get; set; } } [HttpPost("api/rooms/v2/modify")] public async Task Modify([FromBody] ModifyRoomRequest req) { var room = await RequireOwnedRoomAsync(req.RoomId); if (room is null) return NotFound(); if (!string.IsNullOrWhiteSpace(req.Name)) room.Name = req.Name.Trim(); if (req.Description is not null) room.Description = req.Description; if (req.ImageName is not null) room.ImageName = req.ImageName; if (req.Tags is not null) room.TagsCsv = req.Tags; if (req.Accessibility.HasValue) room.Accessibility = Math.Clamp(req.Accessibility.Value, 0, 2); if (req.SupportsScreens.HasValue) room.SupportsScreens = req.SupportsScreens.Value; if (req.SupportsMobile.HasValue) room.SupportsMobile = req.SupportsMobile.Value; if (req.SupportsVRLow.HasValue) room.SupportsVRLow = req.SupportsVRLow.Value; if (req.SupportsWalkVR.HasValue) room.SupportsWalkVR = req.SupportsWalkVR.Value; if (req.SupportsTeleportVR.HasValue) room.SupportsTeleportVR = req.SupportsTeleportVR.Value; if (req.DisableMicAutoMute.HasValue) room.DisableMicAutoMute = req.DisableMicAutoMute.Value; room.UpdatedAt = DateTime.UtcNow; try { await db.SaveChangesAsync(); } catch (DbUpdateException) { // Likely a unique-name collision. return Conflict(new { Result = 2, Message = "name taken" }); } return Ok(new { Result = 0, Room = Services.RoomService.ToWireRoom(room) }); } public sealed class ModifyPermissionsRequest { public long RoomId { get; set; } public int? Accessibility { get; set; } public bool? SupportsLevelVoting { get; set; } public bool? AllowsJuniors { get; set; } public bool? CloningAllowed { get; set; } public int? RoomWarningMask { get; set; } public string? CustomRoomWarning { get; set; } } [HttpPost("api/rooms/v2/modifyPermissions")] public async Task ModifyPermissions([FromBody] ModifyPermissionsRequest req) { var room = await RequireOwnedRoomAsync(req.RoomId); if (room is null) return NotFound(); if (req.Accessibility.HasValue) room.Accessibility = Math.Clamp(req.Accessibility.Value, 0, 2); if (req.SupportsLevelVoting.HasValue) room.SupportsLevelVoting = req.SupportsLevelVoting.Value; if (req.AllowsJuniors.HasValue) room.AllowsJuniors = req.AllowsJuniors.Value; if (req.CloningAllowed.HasValue) room.CloningAllowed = req.CloningAllowed.Value; if (req.RoomWarningMask.HasValue) room.RoomWarningMask = req.RoomWarningMask.Value; if (req.CustomRoomWarning is not null) room.CustomRoomWarning = req.CustomRoomWarning; room.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return Ok(Services.RoomService.ToWireRoom(room)); } // ── Scene mutations (LocationReplicationId) ────────────────────────── public sealed class ModifySceneRequest { public long RoomId { get; set; } public string? LocationReplicationId { get; set; } } [HttpPost("api/rooms/v1/modify/scene")] [HttpPost("api/rooms/v1/modifyscene")] public async Task ModifyScene([FromBody] ModifySceneRequest req) { var room = await RequireOwnedRoomAsync(req.RoomId); if (room is null) return NotFound(); if (!string.IsNullOrWhiteSpace(req.LocationReplicationId)) room.LocationReplicationId = req.LocationReplicationId; room.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return Ok(Services.RoomService.ToWireRoom(room)); } public sealed class ModifySceneParentRequest { public long RoomId { get; set; } public long? ParentRoomId { get; set; } } /// Sub-room / nested-scene parent override. We don't /// model sub-rooms as a real graph, so we accept-and-ignore but /// still owner-gate to prevent grief. [HttpPost("api/rooms/v1/modify/sceneParent")] public async Task ModifySceneParent([FromBody] ModifySceneParentRequest req) { var room = await RequireOwnedRoomAsync(req.RoomId); if (room is null) return NotFound(); return Ok(new { Result = 0 }); } public sealed class ModifyTagsRequest { public long RoomId { get; set; } public string? Tags { get; set; } } [HttpPost("api/rooms/v1/modify/tags")] public async Task ModifyTags([FromBody] ModifyTagsRequest req) { var room = await RequireOwnedRoomAsync(req.RoomId); if (room is null) return NotFound(); room.TagsCsv = req.Tags ?? string.Empty; room.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return Ok(Services.RoomService.ToWireRoom(room)); } // ── Bans ───────────────────────────────────────────────────────────── public sealed class BanFromRoomRequest { public long RoomId { get; set; } public long PlayerId { get; set; } public int BanType { get; set; } // 0=Soft (kick), 1=Permanent, 2=Temp public DateTime? Until { get; set; } public string? Reason { get; set; } } [HttpPost("api/rooms/v2/banfromroom")] public async Task BanFromRoom([FromBody] BanFromRoomRequest req) { var room = await RequireOwnedRoomAsync(req.RoomId); if (room is null) return NotFound(); db.RoomBans.Add(new RoomBanEntity { RoomId = req.RoomId, BannedPlayerId = req.PlayerId, BannedByPlayerId = Me, BanType = req.BanType, Until = req.Until, Reason = req.Reason ?? string.Empty, }); await db.SaveChangesAsync(); return Ok(new { Result = 0 }); } public sealed class ImportRoomBansRequest { public long RoomId { get; set; } public List? Bans { get; set; } } [HttpPost("api/rooms/v1/importroombans")] public async Task ImportRoomBans([FromBody] ImportRoomBansRequest req) { var room = await RequireOwnedRoomAsync(req.RoomId); if (room is null) return NotFound(); if (req.Bans is null || req.Bans.Count == 0) return Ok(new { Imported = 0 }); foreach (var b in req.Bans) { db.RoomBans.Add(new RoomBanEntity { RoomId = req.RoomId, BannedPlayerId = b.PlayerId, BannedByPlayerId = Me, BanType = b.BanType, Until = b.Until, Reason = b.Reason ?? string.Empty, }); } await db.SaveChangesAsync(); return Ok(new { Imported = req.Bans.Count }); } [HttpGet("api/rooms/v1/roombans/{roomId:long}")] public async Task RoomBansList(long roomId) { var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); if (room is null) return NotFound(); // Only owner sees the ban list (admins also via existing // admin endpoints). if (room.CreatorPlayerId != Me) return Forbid(); var rows = await db.RoomBans .Where(b => b.RoomId == roomId && (b.Until == null || b.Until > DateTime.UtcNow)) .OrderByDescending(b => b.CreatedAt) .ToListAsync(); return Ok(rows.Select(b => new { b.Id, b.RoomId, b.BannedPlayerId, b.BannedByPlayerId, b.BanType, b.Until, b.Reason, b.CreatedAt, })); } // ── Report ─────────────────────────────────────────────────────────── public sealed class RoomReportRequest { public long RoomId { get; set; } public int Category { get; set; } public string? Message { get; set; } } [HttpPost("api/rooms/v2/report")] public async Task Report([FromBody] RoomReportRequest req) { var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == req.RoomId); if (room is null) return NotFound(); var msg = req.Message ?? string.Empty; db.Reports.Add(new ReportEntity { ReporterPlayerId = Me, TargetPlayerId = room.CreatorPlayerId, TargetRoomId = req.RoomId, RoomId = req.RoomId, Category = req.Category, Message = msg[..Math.Min(1000, msg.Length)], }); await db.SaveChangesAsync(); return Ok(new { Reported = true }); } public sealed class RoomRolePermissionRequest { public long RoomId { get; set; } public long PlayerId { get; set; } } /// POST api/rooms/v1/roomRolePermissions — watch /// posts its self-computed role + permission grants for /// server-side validation. Role enum: /// 0=Visitor, 5=Member, 10=Mod, 20=Host, 30=Owner. Room creator /// is always Owner; everyone else defaults to Visitor. [HttpPost("api/rooms/v1/roomRolePermissions")] [AllowAnonymous] public async Task RoomRolePermissions( [FromBody] RoomRolePermissionRequest? body, [FromForm(Name = "RoomId")] long? roomIdForm, [FromForm(Name = "PlayerId")] long? playerIdForm) { var roomId = body?.RoomId ?? roomIdForm ?? 0; var playerId = body?.PlayerId ?? playerIdForm ?? (this.CurrentPlayerId() ?? 0); if (roomId <= 0 || playerId <= 0) return Ok(new { success = true, error = "" }); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); var acceptedRoles = await db.RoomRoles .Where(r => r.RoomId == roomId && r.PlayerId == playerId && r.Accepted) .Select(r => r.Role) .ToListAsync(); int role = (room?.CreatorPlayerId == playerId) ? 30 : acceptedRoles.Select(RoomRoleRank).DefaultIfEmpty(0).Max(); long permissions = role switch { 30 => -1L, 20 => 0x0FFFFFFFL, 10 => 0x0FFFFFFFL, 5 => 0x000000FFL, _ => 0x0000000FL, }; // RoomRole.BJICCBAKLAF.CAN_USE_SHARE_CAM = 18 in the // 2020.12 decompile. Public rooms should allow desktop/share // screens without requiring host/mod/co-owner, while private // rooms keep their explicit role gate. if (room?.Accessibility == PublicAccessibility) { permissions |= CanUseShareCamPermission; } return Ok(new { success = true, error = "", RoomId = roomId, PlayerId = (int)playerId, Role = role, Permissions = permissions, }); } /// POST api/rooms/v1/verifyRole — status-only /// gate used by 2020.12 before privileged room actions. Body is /// form or JSON with roomId, role, and optional context. [HttpPost("api/rooms/v1/verifyRole")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] public async Task VerifyRole() { var req = await ReadVerifyRoleRequestAsync(); if (req.RoomId <= 0) return BadRequest(); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == req.RoomId); if (room is null) return NotFound(); var me = Me; var required = RoleRank(req.Role); if (required <= 0) return Ok(); var isAdmin = await db.Players .Where(p => p.Id == me) .Select(p => p.IsAdmin) .FirstOrDefaultAsync(); if (isAdmin || room.CreatorPlayerId == me) return Ok(); var acceptedRoles = await db.RoomRoles .Where(r => r.RoomId == req.RoomId && r.PlayerId == me && r.Accepted) .Select(r => r.Role) .ToListAsync(); var actual = acceptedRoles.Select(RoomRoleRank).DefaultIfEmpty(0).Max(); return actual >= required ? Ok() : Forbid(); } private sealed class VerifyRoleRequest { public long RoomId { get; set; } public string Role { get; set; } = string.Empty; public string Context { get; set; } = string.Empty; } private async Task ReadVerifyRoleRequestAsync() { if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); return new VerifyRoleRequest { RoomId = long.TryParse(form["roomId"].FirstOrDefault() ?? form["RoomId"].FirstOrDefault(), out var roomId) ? roomId : 0, Role = form["role"].FirstOrDefault() ?? form["Role"].FirstOrDefault() ?? string.Empty, Context = form["context"].FirstOrDefault() ?? form["Context"].FirstOrDefault() ?? string.Empty, }; } try { var req = await JsonSerializer.DeserializeAsync( Request.Body, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); return req ?? new VerifyRoleRequest(); } catch (JsonException) { return new VerifyRoleRequest(); } } private static int RoomRoleRank(int role) => role switch { 0 => 30, // CoOwner 1 => 10, // Moderator 2 => 20, // Host _ => 0, }; private static int RoleRank(string role) { if (int.TryParse(role, out var n)) return n; return role.Trim().ToLowerInvariant() switch { "owner" or "creator" or "coowner" or "co-owner" => 30, "host" => 20, "moderator" or "mod" => 10, "member" => 5, "visitor" or "" => 0, _ => 0, }; } // ── Bare-path per-field mutations (2020.12 watch URLs) ───────────── // // The 2020.12 client emits per-field mutation URLs like // POST rooms/{id}/description rather than POST api/rooms/v2/modify // with one field set. Each handler below shares the same // RequireOwnedRoomAsync gate as /modify and returns the same // ToWireRoom shape so the watch's CreateModifyRoomResponse // deserializer reads cleanly. public sealed class BareDescriptionRequest { public string? Description { get; set; } } public sealed class BareImageRequest { public string? ImageName { get; set; } } public sealed class BareTagsRequest { public string? Tags { get; set; } public List? TagsList { get; set; } } public sealed class BareIntRequest { public int? Value { get; set; } } public sealed class BareBoolRequest { public bool? Value { get; set; } } public sealed class BareWarningRequest { public int? RoomWarningMask { get; set; } public string? CustomRoomWarning { get; set; } } // Each per-field route registers BOTH POST and PUT — the 2020.12 // watch's request-builder wraps the HTTP method inside opaque // BPHGKAEDBPE helpers; the existing /name handler at // RoomsController.cs:1455-1456 set the precedent of "register both // because the ISIL is opaque". Same pattern here so the bind never // 405s. [HttpPost("rooms/{roomId:long}/description")] [HttpPut("rooms/{roomId:long}/description")] public async Task BareDescription(long roomId, [FromBody] BareDescriptionRequest? body, [FromForm(Name = "Description")] string? form) => await ApplyAndReturn(roomId, r => r.Description = body?.Description ?? form ?? r.Description); [HttpPost("rooms/{roomId:long}/image")] [HttpPut("rooms/{roomId:long}/image")] public async Task BareImage(long roomId, [FromBody] BareImageRequest? body, [FromForm(Name = "ImageName")] string? form) => await ApplyAndReturn(roomId, r => r.ImageName = body?.ImageName ?? form ?? r.ImageName); [HttpPost("rooms/{roomId:long}/tags")] [HttpPut("rooms/{roomId:long}/tags")] public async Task BareTags(long roomId, [FromBody] BareTagsRequest? body, [FromForm(Name = "Tags")] string? form) { var csv = body?.Tags ?? (body?.TagsList is { Count: > 0 } list ? string.Join(',', list) : null) ?? form; return await ApplyAndReturn(roomId, r => r.TagsCsv = csv ?? r.TagsCsv); } [HttpPost("rooms/{roomId:long}/accessibility")] [HttpPut("rooms/{roomId:long}/accessibility")] [HttpPost("roomserver/rooms/{roomId:long}/accessibility")] [HttpPut("roomserver/rooms/{roomId:long}/accessibility")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] public async Task BareAccessibility(long roomId) { var value = await ReadBareIntAsync("accessibility", "Accessibility", "value", "Value"); return await ApplyAndReturn(roomId, r => { if (value is int vv) r.Accessibility = Math.Clamp(vv, 0, 2); }); } private async Task ReadBareIntAsync(params string[] keys) { if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); foreach (var key in keys) if (int.TryParse(form[key].FirstOrDefault(), out var value)) return value; return null; } foreach (var key in keys) if (int.TryParse(Request.Query[key].FirstOrDefault(), out var value)) return value; if ((Request.ContentLength ?? 0) <= 0) return null; try { using var doc = await JsonDocument.ParseAsync(Request.Body); if (doc.RootElement.ValueKind != JsonValueKind.Object) return null; foreach (var key in keys) { if (!doc.RootElement.TryGetProperty(key, out var prop)) continue; if (prop.ValueKind == JsonValueKind.Number && prop.TryGetInt32(out var n)) return n; if (prop.ValueKind == JsonValueKind.String && int.TryParse(prop.GetString(), out n)) return n; } } catch (JsonException) { return null; } return null; } private async Task ReadBareLongAsync(params string[] keys) { if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); foreach (var key in keys) if (long.TryParse(form[key].FirstOrDefault(), out var value)) return value; return null; } foreach (var key in keys) if (long.TryParse(Request.Query[key].FirstOrDefault(), out var value)) return value; if ((Request.ContentLength ?? 0) <= 0) return null; try { using var doc = await JsonDocument.ParseAsync(Request.Body); if (doc.RootElement.ValueKind != JsonValueKind.Object) return null; foreach (var key in keys) { if (!doc.RootElement.TryGetProperty(key, out var prop)) continue; if (prop.ValueKind == JsonValueKind.Number && prop.TryGetInt64(out var n)) return n; if (prop.ValueKind == JsonValueKind.String && long.TryParse(prop.GetString(), out n)) return n; } } catch (JsonException) { return null; } return null; } private async Task ReadBareBoolAsync(params string[] keys) { if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); foreach (var key in keys) if (TryParseBool(form[key].FirstOrDefault(), out var value)) return value; return null; } foreach (var key in keys) if (TryParseBool(Request.Query[key].FirstOrDefault(), out var value)) return value; if ((Request.ContentLength ?? 0) <= 0) return null; try { using var doc = await JsonDocument.ParseAsync(Request.Body); if (doc.RootElement.ValueKind != JsonValueKind.Object) return doc.RootElement.ValueKind == JsonValueKind.True ? true : doc.RootElement.ValueKind == JsonValueKind.False ? false : null; foreach (var key in keys) { if (!doc.RootElement.TryGetProperty(key, out var prop)) continue; if (prop.ValueKind == JsonValueKind.True) return true; if (prop.ValueKind == JsonValueKind.False) return false; if (prop.ValueKind == JsonValueKind.Number && prop.TryGetInt32(out var n)) return n != 0; if (prop.ValueKind == JsonValueKind.String && TryParseBool(prop.GetString(), out var parsed)) return parsed; } } catch (JsonException) { return null; } return null; } private async Task ReadStringValueAsync(params string[] keys) { if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); foreach (var key in keys) { var value = form[key].FirstOrDefault(); if (!string.IsNullOrWhiteSpace(value)) return value; } return null; } foreach (var key in keys) { var value = Request.Query[key].FirstOrDefault(); if (!string.IsNullOrWhiteSpace(value)) return value; } if ((Request.ContentLength ?? 0) <= 0) return null; try { using var doc = await JsonDocument.ParseAsync(Request.Body); if (doc.RootElement.ValueKind == JsonValueKind.String) return doc.RootElement.GetString(); if (doc.RootElement.ValueKind != JsonValueKind.Object) return null; foreach (var key in keys) { if (!doc.RootElement.TryGetProperty(key, out var prop)) continue; if (prop.ValueKind == JsonValueKind.String) return prop.GetString(); if (prop.ValueKind == JsonValueKind.Number || prop.ValueKind == JsonValueKind.True || prop.ValueKind == JsonValueKind.False) return prop.ToString(); } } catch (JsonException) { return null; } return null; } private async Task ReadJsonElementAsync(params string[] stringKeys) { if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); var dict = new Dictionary(StringComparer.Ordinal); foreach (var key in form.Keys) { var value = form[key].FirstOrDefault(); if (!string.IsNullOrWhiteSpace(value)) dict[key] = value; } if (dict.Count == 0) return null; return JsonSerializer.SerializeToElement(dict); } if ((Request.ContentLength ?? 0) > 0) { try { using var doc = await JsonDocument.ParseAsync(Request.Body); return doc.RootElement.Clone(); } catch (JsonException) { return null; } } foreach (var key in stringKeys) { var value = Request.Query[key].FirstOrDefault(); if (!string.IsNullOrWhiteSpace(value)) return JsonSerializer.SerializeToElement(value); } return null; } private static bool TryParseBool(string? raw, out bool value) { if (bool.TryParse(raw, out value)) return true; if (int.TryParse(raw, out var n)) { value = n != 0; return true; } value = false; return false; } private static List ReadJsonElementList(string? json) { if (string.IsNullOrWhiteSpace(json)) return new List(); try { return JsonSerializer.Deserialize(json)?.ToList() ?? new List(); } catch (JsonException) { return new List(); } } private static List ReadStringList(string? json) { if (string.IsNullOrWhiteSpace(json)) return new List(); try { return JsonSerializer.Deserialize(json)?.Where(v => !string.IsNullOrWhiteSpace(v)).ToList() ?? new List(); } catch (JsonException) { return new List(); } } private static string SerializeLimited(IEnumerable values, int maxChars = 4096) { var json = JsonSerializer.Serialize(values); return json.Length <= maxChars ? json : "[]"; } [HttpPost("rooms/{roomId:long}/cloning")] [HttpPut("rooms/{roomId:long}/cloning")] public async Task BareCloning(long roomId, [FromBody] BareBoolRequest? body, [FromForm(Name = "CloningAllowed")] bool? form) => await ApplyAndReturn(roomId, r => { var v = body?.Value ?? form; if (v is bool vv) r.CloningAllowed = vv; }); [HttpPost("rooms/{roomId:long}/automute")] [HttpPut("rooms/{roomId:long}/automute")] public async Task BareAutomute(long roomId, [FromBody] BareBoolRequest? body, [FromForm(Name = "DisableMicAutoMute")] bool? form) => await ApplyAndReturn(roomId, r => { var v = body?.Value ?? form; if (v is bool vv) r.DisableMicAutoMute = vv; }); /// rooms/{id}/restrictions — junior / age-related /// restriction toggle. Body bool maps to AllowsJuniors (true = no /// restriction, false = juniors restricted). [HttpPost("rooms/{roomId:long}/restrictions")] [HttpPut("rooms/{roomId:long}/restrictions")] public async Task BareRestrictions(long roomId, [FromBody] BareBoolRequest? body, [FromForm(Name = "AllowsJuniors")] bool? form) => await ApplyAndReturn(roomId, r => { var v = body?.Value ?? form; if (v is bool vv) r.AllowsJuniors = vv; }); [HttpPost("rooms/{roomId:long}/warning")] [HttpPut("rooms/{roomId:long}/warning")] public async Task BareWarning(long roomId, [FromBody] BareWarningRequest? body) => await ApplyAndReturn(roomId, r => { if (body?.RoomWarningMask is int m) r.RoomWarningMask = m; if (body?.CustomRoomWarning is not null) r.CustomRoomWarning = body.CustomRoomWarning; }); [HttpPost("rooms/{roomId:long}/creator")] [HttpPut("rooms/{roomId:long}/creator")] [HttpPost("roomserver/rooms/{roomId:long}/creator")] [HttpPut("roomserver/rooms/{roomId:long}/creator")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] public async Task BareCreator(long roomId) { var creatorId = await ReadBareLongAsync( "creatorAccountId", "CreatorAccountId", "creatorPlayerId", "CreatorPlayerId", "accountId", "AccountId", "value", "Value"); if (creatorId is null or <= 0) return BadRequest(new { error = "missing_creator" }); var exists = await db.Players.AnyAsync(p => p.Id == creatorId.Value); if (!exists) return NotFound(new { error = "creator_not_found", creatorId }); return await ApplyAndReturn(roomId, r => r.CreatorPlayerId = creatorId.Value); } [HttpPost("rooms/{roomId:long}/allow_new_users")] [HttpPut("rooms/{roomId:long}/allow_new_users")] [HttpPost("roomserver/rooms/{roomId:long}/allow_new_users")] [HttpPut("roomserver/rooms/{roomId:long}/allow_new_users")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] public async Task BareAllowNewUsers(long roomId) { var value = await ReadBareBoolAsync("allowNewUsers", "AllowNewUsers", "value", "Value"); return await ApplyAndReturn(roomId, r => { if (value is bool v) r.AllowNewUsers = v; }); } [HttpPost("rooms/{roomId:long}/min_level")] [HttpPut("rooms/{roomId:long}/min_level")] [HttpPost("roomserver/rooms/{roomId:long}/min_level")] [HttpPut("roomserver/rooms/{roomId:long}/min_level")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] public async Task BareMinLevel(long roomId) { var value = await ReadBareIntAsync("minLevel", "MinLevel", "level", "Level", "value", "Value"); return await ApplyAndReturn(roomId, r => { if (value is int v) r.MinLevel = Math.Max(0, v); }); } [HttpPost("rooms/{roomId:long}/max_player_calculation_mode")] [HttpPut("rooms/{roomId:long}/max_player_calculation_mode")] [HttpPost("roomserver/rooms/{roomId:long}/max_player_calculation_mode")] [HttpPut("roomserver/rooms/{roomId:long}/max_player_calculation_mode")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] public async Task BareMaxPlayerCalculationMode(long roomId) { var value = await ReadBareIntAsync( "maxPlayerCalculationMode", "MaxPlayerCalculationMode", "mode", "Mode", "value", "Value"); return await ApplyAndReturn(roomId, r => { if (value is int v) r.MaxPlayerCalculationMode = Math.Max(0, v); }); } [HttpPost("rooms/{roomId:long}/loadscreen")] [HttpPut("rooms/{roomId:long}/loadscreen")] [HttpPost("roomserver/rooms/{roomId:long}/loadscreen")] [HttpPut("roomserver/rooms/{roomId:long}/loadscreen")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] public async Task BareLoadScreen(long roomId) { var payload = await ReadJsonElementAsync("loadScreen", "LoadScreen", "value", "Value"); return await ApplyAndReturn(roomId, r => { if (payload is JsonElement p) r.LoadScreensJson = p.ValueKind == JsonValueKind.Array ? p.GetRawText() : SerializeLimited(new[] { p }); }); } [HttpPost("rooms/{roomId:long}/promo_images")] [HttpPut("rooms/{roomId:long}/promo_images")] [HttpPost("roomserver/rooms/{roomId:long}/promo_images")] [HttpPut("roomserver/rooms/{roomId:long}/promo_images")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] public async Task BarePromoImagesAdd(long roomId) { var image = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value"); if (string.IsNullOrWhiteSpace(image)) return BadRequest(new { error = "missing_image" }); return await ApplyAndReturn(roomId, r => { var images = ReadStringList(r.PromoImagesJson); if (!images.Contains(image, StringComparer.OrdinalIgnoreCase)) images.Add(image); r.PromoImagesJson = SerializeLimited(images); }); } [HttpDelete("rooms/{roomId:long}/promo_images")] [HttpDelete("roomserver/rooms/{roomId:long}/promo_images")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] public async Task BarePromoImagesRemove(long roomId) { var image = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value"); if (string.IsNullOrWhiteSpace(image)) return BadRequest(new { error = "missing_image" }); return await ApplyAndReturn(roomId, r => { var images = ReadStringList(r.PromoImagesJson) .Where(v => !string.Equals(v, image, StringComparison.OrdinalIgnoreCase)) .ToList(); r.PromoImagesJson = SerializeLimited(images); }); } [HttpPost("rooms/{roomId:long}/promo_external")] [HttpPut("rooms/{roomId:long}/promo_external")] [HttpPost("roomserver/rooms/{roomId:long}/promo_external")] [HttpPut("roomserver/rooms/{roomId:long}/promo_external")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] public async Task BarePromoExternalAdd(long roomId) { var payload = await ReadJsonElementAsync("url", "Url", "value", "Value"); if (payload is null) return BadRequest(new { error = "missing_promo_external" }); return await ApplyAndReturn(roomId, r => { var items = ReadJsonElementList(r.PromoExternalContentJson); var raw = payload.Value.GetRawText(); if (!items.Any(i => string.Equals(i.GetRawText(), raw, StringComparison.Ordinal))) items.Add(payload.Value); r.PromoExternalContentJson = SerializeLimited(items); }); } [HttpDelete("rooms/{roomId:long}/promo_external")] [HttpDelete("roomserver/rooms/{roomId:long}/promo_external")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] public async Task BarePromoExternalRemove(long roomId) { var match = await ReadStringValueAsync("id", "Id", "url", "Url", "value", "Value"); if (string.IsNullOrWhiteSpace(match)) return BadRequest(new { error = "missing_promo_external" }); return await ApplyAndReturn(roomId, r => { var items = ReadJsonElementList(r.PromoExternalContentJson) .Where(i => !PromoExternalMatches(i, match)) .ToList(); r.PromoExternalContentJson = SerializeLimited(items); }); } private static bool PromoExternalMatches(JsonElement item, string match) { if (string.Equals(item.GetRawText(), match, StringComparison.OrdinalIgnoreCase)) return true; if (item.ValueKind == JsonValueKind.String) return string.Equals(item.GetString(), match, StringComparison.OrdinalIgnoreCase); if (item.ValueKind != JsonValueKind.Object) return false; foreach (var key in new[] { "Id", "id", "Url", "url", "Value", "value" }) { if (item.TryGetProperty(key, out var prop) && prop.ValueKind == JsonValueKind.String && string.Equals(prop.GetString(), match, StringComparison.OrdinalIgnoreCase)) return true; } return false; } /// rooms/{id}/comments + voice_chat_encryption /// — fields not persisted in the 2020 RoomEntity. Owner-gated /// acknowledgement so the watch's settings panel doesn't surface /// a "save failed" error; populated when the columns land. [HttpPost("rooms/{roomId:long}/comments")] [HttpPut("rooms/{roomId:long}/comments")] [HttpPost("rooms/{roomId:long}/voice_chat_encryption")] [HttpPut("rooms/{roomId:long}/voice_chat_encryption")] public async Task BareAck(long roomId) => await ApplyAndReturn(roomId, _ => { /* no-op: not modelled yet */ }); /// rooms/{id}/modify — bare-path equivalent /// of api/rooms/v2/modify. Reuses by /// forwarding the body with RoomId pre-populated from the URL. [HttpPost("rooms/{roomId:long}/modify")] [HttpPut("rooms/{roomId:long}/modify")] public async Task BareModify(long roomId, [FromBody] ModifyRoomRequest req) { req.RoomId = roomId; return await Modify(req); } /// POST rooms/{id}/clone — bare-path alias of /// api/rooms/v1/clone. The actual clone logic lives on /// ; /// this handler proxies via the service so we don't duplicate the /// substantial clone-rooms logic. [HttpPost("rooms/{roomId:long}/clone")] public async Task BareClone(long roomId, [FromBody] CloneBareRequest? body, [FromForm(Name = "Name")] string? formName) { var newName = body?.Name ?? formName ?? string.Empty; var source = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); if (source is null) return NotFound(); if (!source.CloningAllowed && source.CreatorPlayerId != Me) return Forbid(); if (string.IsNullOrWhiteSpace(newName)) newName = $"{source.Name}Copy"; else newName = newName.Trim(); // Uniqueness — append a numeric suffix until free. var baseName = newName; int suffix = 1; while (await db.Rooms.AnyAsync(r => r.Name == newName)) { suffix += 1; newName = $"{baseName}{suffix}"; } var clone = new RoomEntity { Name = newName, Description = source.Description, CreatorPlayerId = Me, ImageName = source.ImageName, State = 0, Accessibility = source.Accessibility, SupportsLevelVoting = source.SupportsLevelVoting, IsAGRoom = false, IsDormRoom = false, CloningAllowed = source.CloningAllowed, SupportsVRLow = source.SupportsVRLow, SupportsMobile = source.SupportsMobile, SupportsScreens = source.SupportsScreens, SupportsWalkVR = source.SupportsWalkVR, SupportsTeleportVR = source.SupportsTeleportVR, AllowsJuniors = source.AllowsJuniors, AllowNewUsers = source.AllowNewUsers, MinLevel = source.MinLevel, MaxPlayerCalculationMode = source.MaxPlayerCalculationMode, LoadScreensJson = source.LoadScreensJson, PromoImagesJson = source.PromoImagesJson, PromoExternalContentJson = source.PromoExternalContentJson, RoomWarningMask = source.RoomWarningMask, CustomRoomWarning = source.CustomRoomWarning, DisableMicAutoMute = source.DisableMicAutoMute, LocationReplicationId = source.LocationReplicationId, IsStudioRoom = source.IsStudioRoom, IsRoomLinkedToRecRoomStudio = source.IsRoomLinkedToRecRoomStudio, StudioSessionId = source.StudioSessionId, TagsCsv = source.TagsCsv, CurrentDataBlobName = source.CurrentDataBlobName, }; db.Rooms.Add(clone); await db.SaveChangesAsync(); return Ok(Services.RoomService.ToWireRoom(clone)); } public sealed class CloneBareRequest { public string? Name { get; set; } } // ── Bare-path bans aliases ───────────────────────────────────────── public sealed class BareBanRequest { public long PlayerId { get; set; } public int BanType { get; set; } public DateTime? Until { get; set; } public string? Reason { get; set; } } [HttpGet("rooms/{roomId:long}/bans")] public Task BareBansList(long roomId) => RoomBansList(roomId); [HttpPost("rooms/{roomId:long}/bans")] public Task BareBan(long roomId, [FromBody] BareBanRequest req) => BanFromRoom(new BanFromRoomRequest { RoomId = roomId, PlayerId = req.PlayerId, BanType = req.BanType, Until = req.Until, Reason = req.Reason, }); [HttpDelete("rooms/{roomId:long}/bans/{playerId:long}")] public async Task BareUnban(long roomId, long playerId) { var room = await RequireOwnedRoomAsync(roomId); if (room is null) return NotFound(); var rows = await db.RoomBans .Where(b => b.RoomId == roomId && b.BannedPlayerId == playerId) .ToListAsync(); if (rows.Count == 0) return Ok(new { Removed = 0 }); db.RoomBans.RemoveRange(rows); await db.SaveChangesAsync(); return Ok(new { Removed = rows.Count }); } [HttpPost("rooms/{roomId:long}/bans/import")] public Task BareBansImport(long roomId, [FromBody] ImportRoomBansRequest req) { req.RoomId = roomId; return ImportRoomBans(req); } private async Task ApplyAndReturn(long roomId, Action mutator) { var room = await RequireOwnedRoomAsync(roomId); if (room is null) return NotFound(); mutator(room); room.UpdatedAt = DateTime.UtcNow; try { await db.SaveChangesAsync(); } catch (DbUpdateException) { return Conflict(new { Result = 2 }); } return Ok(Services.RoomService.ToWireRoom(room)); } }