From 143d3a9a07009991600bfe45e99943a2f5057a03 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:40:53 +0300 Subject: [PATCH] Refactor room role permissions: enhance role checks for saving and overlaying data; introduce editable role data for RRO rooms --- .../API/Rooms/V2/RoomsController.cs | 10 +- .../API/Rooms/V2/RoomsModerationController.cs | 9 +- .../Controllers/Cdn/CdnController.cs | 27 +++++- .../Controllers/Storage/StorageController.cs | 21 +++-- .../Services/RoomDataBlobService.cs | 94 ++++++++++++++++--- 5 files changed, 130 insertions(+), 31 deletions(-) diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs index b3b6085..843ae4f 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs @@ -1079,7 +1079,9 @@ public class RoomsController( // the all-perms default blob until their first save replaces // it via StorageController; the rec center / paintball / etc. // serve "" so the persistence flow short-circuits. - var customisable = room.IsDormRoom || room.CreatorPlayerId != 1; + var roleList = roles ?? Array.Empty(); + var editableRro = room.IsAGRoom && roleList.Any(r => r.Accepted); + var customisable = room.IsDormRoom || room.CreatorPlayerId != 1 || editableRro; var dataBlobName = !string.IsNullOrEmpty(room.CurrentDataBlobName) ? room.CurrentDataBlobName : customisable ? $"room_{room.Id}_v1.dat" : ""; @@ -1153,7 +1155,6 @@ public class RoomsController( // Additional grants come from RoomRoleEntity rows when supplied; // callers without role data (clone/myrooms/etc.) ship just the // implicit creator. - var roleList = roles ?? Array.Empty(); int[] PlayersIn(int role, bool accepted) => roleList .Where(r => r.Role == role && r.Accepted == accepted) .Select(r => (int)r.PlayerId) @@ -1194,7 +1195,9 @@ public class RoomsController( string? overrideDataBlobName = null, IReadOnlyList? roles = null) { - var customisable = room.IsDormRoom || room.CreatorPlayerId != 1; + var roleList = roles ?? Array.Empty(); + var editableRro = room.IsAGRoom && roleList.Any(r => r.Accepted); + var customisable = room.IsDormRoom || room.CreatorPlayerId != 1 || editableRro; var dataBlobName = !string.IsNullOrEmpty(room.CurrentDataBlobName) ? room.CurrentDataBlobName : customisable ? $"room_{room.Id}_v1.dat" : ""; @@ -1233,7 +1236,6 @@ public class RoomsController( }, }; - var roleList = roles ?? Array.Empty(); static int WireRole(int role) => role switch { 0 => 30, // CoOwner diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs index 33791be..5c65de1 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs @@ -299,10 +299,17 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase return Ok(new { success = true, error = "" }); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); - int role = (room?.CreatorPlayerId == playerId) ? 30 : 0; + 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, diff --git a/DorkNet.Server/Controllers/Cdn/CdnController.cs b/DorkNet.Server/Controllers/Cdn/CdnController.cs index 795bb8e..ab8ed63 100644 --- a/DorkNet.Server/Controllers/Cdn/CdnController.cs +++ b/DorkNet.Server/Controllers/Cdn/CdnController.cs @@ -356,7 +356,7 @@ public class CdnController( { try { - s3Bytes = roomDataBlob.OverlayAllPermsRoleData(s3Bytes); + s3Bytes = roomDataBlob.OverlayRroEditableRoleData(s3Bytes); overlaidRoomRoleData = true; logger.LogInformation("[cdn] overlaid RRO room-role permissions host={Host} file={File} bytes={Bytes}", Request.Host.Host, fileName, s3Bytes.Length); @@ -393,6 +393,16 @@ public class CdnController( return new FileContentResult(TransparentPng, "image/png"); } + if (await ShouldOverlayRroRoomRoleDataAsync(fileName)) + { + logger.LogInformation("[cdn] MISS host={Host} file={File} -> RRO editable role blob", + Request.Host.Host, fileName); + var rroBlob = roomDataBlob.GetRroEditableBlob(); + Response.Headers.CacheControl = "public, max-age=60"; + signatures.AddContentSignature(Response, rroBlob); + return new FileContentResult(rroBlob, "application/octet-stream"); + } + logger.LogInformation("[cdn] MISS host={Host} file={File} -> default blob", Request.Host.Host, fileName); var fallbackBlob = roomDataBlob.GetDefaultBlob(); @@ -405,13 +415,24 @@ public class CdnController( if (string.IsNullOrWhiteSpace(fileName)) return false; if (await db.Rooms.AsNoTracking().AnyAsync(r => - r.IsAGRoom && r.CurrentDataBlobName == fileName)) + r.IsAGRoom && + r.CurrentDataBlobName == fileName && + db.RoomRoles.Any(rr => rr.RoomId == r.Id && rr.Accepted))) + return true; + + if (fileName.StartsWith("room_", StringComparison.OrdinalIgnoreCase) && + fileName.EndsWith("_v1.dat", StringComparison.OrdinalIgnoreCase) && + long.TryParse(fileName[5..^7], out var roomId) && + await db.Rooms.AsNoTracking().AnyAsync(r => r.Id == roomId && r.IsAGRoom) && + await db.RoomRoles.AsNoTracking().AnyAsync(r => r.RoomId == roomId && r.Accepted)) return true; return await ( from scene in db.RoomScenes.AsNoTracking() join room in db.Rooms.AsNoTracking() on scene.RoomId equals room.Id - where room.IsAGRoom && scene.DataBlobName == fileName + where room.IsAGRoom && + scene.DataBlobName == fileName && + db.RoomRoles.Any(rr => rr.RoomId == room.Id && rr.Accepted) select scene.Id ).AnyAsync(); } diff --git a/DorkNet.Server/Controllers/Storage/StorageController.cs b/DorkNet.Server/Controllers/Storage/StorageController.cs index 64faadc..9de2e3d 100644 --- a/DorkNet.Server/Controllers/Storage/StorageController.cs +++ b/DorkNet.Server/Controllers/Storage/StorageController.cs @@ -28,10 +28,9 @@ namespace DorkNet.Server.Controllers.Storage; /// the room. /// /// -/// Permission rule: only the room's creator (or the dorm owner for a -/// dorm save) can persist a new blob. Saves from other players in the -/// same room are silently rejected with 403 — they can build with the -/// Maker Pen but their changes won't outlive the session. +/// Permission rule: the room's creator, accepted co-owners, and admins +/// can persist a new blob. Dorm saves are always scoped to the local +/// player's own dorm row. /// [ApiController] [Authorize] @@ -174,12 +173,14 @@ public class StorageController( if (room.IsDormRoom) return await UploadDormSaveAsync(playerId, bytes, references, room.Id); - // Permission: only the room owner can save. (Future Phase 5 - // expansion: co-owners + admins. For now, strict owner-only — - // matches the 2020 official server's "RoomRolePermissions - // .CanUseMakerPen" behaviour, which rejects upload at the wire - // for non-owners.) - if (room.CreatorPlayerId != playerId) + var canSave = room.CreatorPlayerId == playerId + || await db.Players.AnyAsync(p => p.Id == playerId && p.IsAdmin) + || await db.RoomRoles.AnyAsync(r => + r.RoomId == room.Id && + r.PlayerId == playerId && + r.Accepted && + r.Role == 0); + if (!canSave) { logger.LogWarning( "[storage] player {PlayerId} tried to save room {RoomId} (creator={CreatorId})", diff --git a/DorkNet.Server/Services/RoomDataBlobService.cs b/DorkNet.Server/Services/RoomDataBlobService.cs index 9211c3f..2e0d7d3 100644 --- a/DorkNet.Server/Services/RoomDataBlobService.cs +++ b/DorkNet.Server/Services/RoomDataBlobService.cs @@ -31,8 +31,11 @@ public class RoomDataBlobService /// private readonly byte[] _allPermsBlob = BuildAllPermsBlob(); private readonly RoomRoleCollectionData _allPermsRoleData = BuildAllPermsRoleData(); + private readonly byte[] _rroEditableBlob = BuildRroEditableBlob(); + private readonly RoomRoleCollectionData _rroEditableRoleData = BuildRroEditableRoleData(); public byte[] GetDefaultBlob() => _allPermsBlob; + public byte[] GetRroEditableBlob() => _rroEditableBlob; public byte[] OverlayAllPermsRoleData(byte[] existingBlob) { @@ -41,6 +44,13 @@ public class RoomDataBlobService return msg.ToByteArray(); } + public byte[] OverlayRroEditableRoleData(byte[] existingBlob) + { + var msg = PersistedRoomData.Parser.ParseFrom(existingBlob); + msg.RoomRoleData = _rroEditableRoleData.Clone(); + return msg.ToByteArray(); + } + private static byte[] BuildAllPermsBlob() { var msg = new PersistedRoomData @@ -52,6 +62,35 @@ public class RoomDataBlobService } private static RoomRoleCollectionData BuildAllPermsRoleData() + { + var collection = new RoomRoleCollectionData(); + collection.RoomRoles.Add(BuildPermissiveRole(0, 100, "Creator")); + + return collection; + } + + private static byte[] BuildRroEditableBlob() + { + var msg = new PersistedRoomData + { + RoomRoleData = BuildRroEditableRoleData(), + }; + + return msg.ToByteArray(); + } + + private static RoomRoleCollectionData BuildRroEditableRoleData() + { + var collection = new RoomRoleCollectionData(); + collection.RoomRoles.Add(BuildPermissiveRole(2_097_152, 100, "Creator")); // AG_CREATOR + collection.RoomRoles.Add(BuildPermissiveRole(4_194_304, 90, "Co-owner")); // AG_COOWNER + collection.RoomRoles.Add(BuildPermissiveRole(8_388_608, 80, "Host")); // AG_HOST + collection.RoomRoles.Add(BuildPermissiveRole(16_777_216, 70, "Moderator")); // AG_MODERATOR + + return collection; + } + + private static PlayerRoomRoleData BuildPermissiveRole(int roleId, int rank, string name) { // OverridableBoolData(overrides=true, inner_value=true) — used for // every Can* field on the role. @@ -61,27 +100,24 @@ public class RoomDataBlobService InnerValue = true, }; - var role = new PlayerRoomRoleData + return new PlayerRoomRoleData { // Identity fields — these are deprecated in the 2026 schema - // but the 2020 client still reads them. role_id=0 = AG_EVERYONE - // which the client treats as "applies to all players in an AG - // room"; combined with all permissions overridden true, every - // player slot inherits maker pen. - DEPRECATEDRoleId = 0, - DEPRECATEDRoleRank = 100, + // but the 2020 client still reads them. + DEPRECATEDRoleId = roleId, + DEPRECATEDRoleRank = rank, DEPRECATEDIsRoleActive = true, DEPRECATEDIsAgRole = true, - RoleName = "Creator", + RoleName = name, RoleVersion = 1, - RoleGuid = Guid.NewGuid().ToString(), + RoleGuid = StableRoleGuid(roleId), // Display name override — keeps the watch's role-list UI from // showing a blank label. Name = new OverridableStringData { Overrides = true, - InnerValue = "Creator", + InnerValue = name, }, // Permission grants. Each Overridable*Data(overrides=true, @@ -115,10 +151,42 @@ public class RoomDataBlobService InnerValue = 0, }, }; + } - var collection = new RoomRoleCollectionData(); - collection.RoomRoles.Add(role); + private static string StableRoleGuid(int roleId) => + GuidUtility.Create(GuidUtility.UrlNamespace, $"dorknet-room-role:{roleId}").ToString(); +} - return collection; +internal static class GuidUtility +{ + public static readonly Guid UrlNamespace = new("6ba7b811-9dad-11d1-80b4-00c04fd430c8"); + + public static Guid Create(Guid namespaceId, string name) + { + var namespaceBytes = namespaceId.ToByteArray(); + SwapByteOrder(namespaceBytes); + + var nameBytes = System.Text.Encoding.UTF8.GetBytes(name); + var data = new byte[namespaceBytes.Length + nameBytes.Length]; + Buffer.BlockCopy(namespaceBytes, 0, data, 0, namespaceBytes.Length); + Buffer.BlockCopy(nameBytes, 0, data, namespaceBytes.Length, nameBytes.Length); + + var hash = System.Security.Cryptography.SHA1.HashData(data); + var newGuid = new byte[16]; + Array.Copy(hash, 0, newGuid, 0, 16); + + newGuid[6] = (byte)((newGuid[6] & 0x0F) | 0x50); + newGuid[8] = (byte)((newGuid[8] & 0x3F) | 0x80); + + SwapByteOrder(newGuid); + return new Guid(newGuid); + } + + private static void SwapByteOrder(byte[] guid) + { + (guid[0], guid[3]) = (guid[3], guid[0]); + (guid[1], guid[2]) = (guid[2], guid[1]); + (guid[4], guid[5]) = (guid[5], guid[4]); + (guid[6], guid[7]) = (guid[7], guid[6]); } }