From e93bbb14cba2856fb2fc09dccdf3056c689d7f78 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Thu, 16 Jul 2026 23:34:55 +0300 Subject: [PATCH] feat: Implement room blob version clamp toggle; add settings endpoint and UI support for managing version clamping of imported rooms --- .../RoomBlobVersionClampTests.cs | 91 +++++++++++++++++++ .../Controllers/Admin/AdminController.cs | 33 +++++++ .../Controllers/Cdn/CdnController.cs | 38 +++++++- .../Data/Entities/ServerSettingsEntity.cs | 10 ++ .../Services/RoomDataBlobService.cs | 70 +++++++++++++- .../Services/ServerSettingsService.cs | 20 ++++ DorkNet.Server/Startup/DatabaseBootstrap.cs | 14 +++ .../admin-ui/src/pages/Settings.tsx | 57 ++++++++++++ docs/admin.md | 23 ++++- 9 files changed, 353 insertions(+), 3 deletions(-) create mode 100644 DorkNet.Server.Tests/RoomBlobVersionClampTests.cs diff --git a/DorkNet.Server.Tests/RoomBlobVersionClampTests.cs b/DorkNet.Server.Tests/RoomBlobVersionClampTests.cs new file mode 100644 index 0000000..c6ab0ee --- /dev/null +++ b/DorkNet.Server.Tests/RoomBlobVersionClampTests.cs @@ -0,0 +1,91 @@ +using DorkNet.Server.Services; +using Google.Protobuf; +using RecRoom.Protobuf; + +namespace DorkNet.Server.Tests; + +/// +/// A modern RecNet export's subroom save stamps PersistedRoomData +/// versions past what the March-2023 client knows (field 1 +/// DEPRECATED_RoomPersistenceVersion, max 38; field 30 +/// PersistedRoomVersion, max 19 = V19February23BetaRelease). The client +/// rejects the whole room with its "update Rec Room to visit this room" +/// gate — the ShibuyaCrossing import (Sep-2025 save, version=131) is the +/// canonical repro. The CDN serve path clamps both varints down; these +/// tests pin the rewrite to exactly those two fields. +/// +public sealed class RoomBlobVersionClampTests +{ + private static byte[] BuildModernBlob(int deprecatedVersion, int version) + { + // Emit the wire shape a modern save actually has: version fields + // plus payload fields the 2023 schema does and doesn't know about. + using var ms = new MemoryStream(); + var output = new CodedOutputStream(ms); + output.WriteTag(1, WireFormat.WireType.Varint); + output.WriteInt32(deprecatedVersion); + // Field 2: persistence_views payload (opaque here — the clamp + // must copy it verbatim). + output.WriteTag(2, WireFormat.WireType.LengthDelimited); + output.WriteBytes(ByteString.CopyFrom(0x0A, 0x03, 0x01, 0x02, 0x03)); + // Field 6: sub_room_id. + output.WriteTag(6, WireFormat.WireType.Varint); + output.WriteInt64(19589779); + output.WriteTag(30, WireFormat.WireType.Varint); + output.WriteInt32(version); + // Field 37: unknown-to-2023 varint field from newer schemas. + output.WriteTag(37, WireFormat.WireType.Varint); + output.WriteInt32(1); + output.Flush(); + return ms.ToArray(); + } + + [Fact] + public void Clamps_modern_versions_to_2023_maxima() + { + var input = BuildModernBlob(deprecatedVersion: 38, version: 131); + + var (bytes, changed) = RoomDataBlobService.ClampVersionsFor2023(input); + + Assert.True(changed); + var msg = PersistedRoomData.Parser.ParseFrom(bytes); + Assert.Equal(PersistedRoomVersion.LatestVersion, msg.Version); + Assert.Equal(DEPRECATED_RoomPersistenceVersion.LatestRoomPersistenceVersion, msg.DEPRECATEDVersion); + } + + [Fact] + public void Preserves_every_non_version_byte() + { + var input = BuildModernBlob(deprecatedVersion: 38, version: 131); + + var (bytes, _) = RoomDataBlobService.ClampVersionsFor2023(input); + + // version=131 is a two-byte varint, 19 is one byte — the output + // shrinks by exactly that and matches the input everywhere else. + Assert.Equal(input.Length - 1, bytes.Length); + var expected = BuildModernBlob(deprecatedVersion: 38, version: 19); + Assert.Equal(expected, bytes); + } + + [Fact] + public void Blob_already_at_2023_versions_passes_through_unchanged() + { + var input = BuildModernBlob(deprecatedVersion: 38, version: 19); + + var (bytes, changed) = RoomDataBlobService.ClampVersionsFor2023(input); + + Assert.False(changed); + Assert.Same(input, bytes); + } + + [Fact] + public void Malformed_input_is_returned_verbatim() + { + var input = new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x01 }; + + var (bytes, changed) = RoomDataBlobService.ClampVersionsFor2023(input); + + Assert.False(changed); + Assert.Same(input, bytes); + } +} diff --git a/DorkNet.Server/Controllers/Admin/AdminController.cs b/DorkNet.Server/Controllers/Admin/AdminController.cs index f5fc2fa..c0ae192 100644 --- a/DorkNet.Server/Controllers/Admin/AdminController.cs +++ b/DorkNet.Server/Controllers/Admin/AdminController.cs @@ -1499,6 +1499,7 @@ public class AdminController( row.WeeklyChallengesCompletedRequired, row.ProfanityFilterDisabled, row.AllAvatarItemsOwned, + row.RoomBlobVersionClampDisabled, row.UpdatedAt, }); } @@ -1526,6 +1527,7 @@ public class AdminController( row.WeeklyChallengesCompletedRequired, row.ProfanityFilterDisabled, row.AllAvatarItemsOwned, + row.RoomBlobVersionClampDisabled, row.UpdatedAt, }); } @@ -1552,6 +1554,35 @@ public class AdminController( row.WeeklyChallengesCompletedRequired, row.ProfanityFilterDisabled, row.AllAvatarItemsOwned, + row.RoomBlobVersionClampDisabled, + row.UpdatedAt, + }); + } + + public sealed record RoomBlobVersionClampToggleRequest(bool Disabled); + + /// POST api/admin/v1/settings/room-blob-version-clamp — + /// flip the CDN's PersistedRoomData version clamp. While active (the + /// default), room-data blobs are served with their two top-level version + /// varints clamped down to the March-2023 client's maxima so rooms + /// imported from modern RecNet exports don't trip the client's + /// "update Rec Room to visit this room" gate. Disabling serves every + /// blob byte-for-byte as stored. Takes effect on the next blob download + /// (plus up to a minute of edge cache). + [HttpPost("settings/room-blob-version-clamp")] + public async Task SetRoomBlobVersionClampDisabled([FromBody] RoomBlobVersionClampToggleRequest body) + { + var row = await serverSettings.SetRoomBlobVersionClampDisabledAsync(body.Disabled); + await LogAsync(body.Disabled ? "room_blob_version_clamp_disabled" : "room_blob_version_clamp_enabled", "system", 0, ""); + await db.SaveChangesAsync(); + return Ok(new + { + row.SignupsDisabled, + row.GlobalFriendsEnabled, + row.WeeklyChallengesCompletedRequired, + row.ProfanityFilterDisabled, + row.AllAvatarItemsOwned, + row.RoomBlobVersionClampDisabled, row.UpdatedAt, }); } @@ -1667,6 +1698,7 @@ public class AdminController( row.WeeklyChallengesCompletedRequired, row.ProfanityFilterDisabled, row.AllAvatarItemsOwned, + row.RoomBlobVersionClampDisabled, row.UpdatedAt, }); } @@ -1690,6 +1722,7 @@ public class AdminController( row.WeeklyChallengesCompletedRequired, row.ProfanityFilterDisabled, row.AllAvatarItemsOwned, + row.RoomBlobVersionClampDisabled, row.UpdatedAt, }); } diff --git a/DorkNet.Server/Controllers/Cdn/CdnController.cs b/DorkNet.Server/Controllers/Cdn/CdnController.cs index 5709dd7..ff3c5e8 100644 --- a/DorkNet.Server/Controllers/Cdn/CdnController.cs +++ b/DorkNet.Server/Controllers/Cdn/CdnController.cs @@ -42,6 +42,7 @@ public class CdnController( IObjectStorage storage, ImageSignatureService signatures, DorkNetDbContext db, + ServerSettingsService serverSettings, ILogger logger) : ControllerBase { /// 1x1 transparent PNG used for missing image files. @@ -494,12 +495,38 @@ public class CdnController( } } + // Modern-authored saves (RecNet zip exports, 2024+ clients) + // stamp PersistedRoomVersion values past what the March-2023 + // client knows, and it rejects the whole room with the + // "update Rec Room to visit this room" gate before spawning + // anything. Clamp the two top-level version varints down to + // the 2023 maxima on the way out; blobs already at or below + // them pass through byte-identical. Admins can bypass the + // clamp from the SPA's server-settings page. + var clampedRoomVersions = false; + if (IsRoomDataName(fileName) && + !await serverSettings.IsRoomBlobVersionClampDisabledAsync()) + { + var (clamped, clampChanged) = RoomDataBlobService.ClampVersionsFor2023(s3Bytes); + if (clampChanged) + { + logger.LogInformation( + "[cdn] clamped persisted-room versions to 2023 maxima host={Host} file={File} bytes={Bytes}", + Request.Host.Host, fileName, clamped.Length); + s3Bytes = clamped; + clampedRoomVersions = true; + } + } + logger.LogInformation("[cdn] s3 hit host={Host} file={File} bytes={Bytes}", Request.Host.Host, fileName, s3Bytes.Length); // Allow CF edge to cache for an hour. RoomDataBlobs are // content-addressed by hash so the same BlobName never // serves different bytes — safe to cache aggressively. - Response.Headers.CacheControl = overlaidRoomRoleData + // Overlaid/clamped responses depend on runtime settings, so + // keep their edge TTL short enough that admin toggles land + // within a minute. + Response.Headers.CacheControl = overlaidRoomRoleData || clampedRoomVersions ? "public, max-age=60" : "public, max-age=3600"; signatures.AddContentSignature(Response, s3Bytes); @@ -654,6 +681,15 @@ public class CdnController( }; } + /// Blob names that carry PersistedRoomData: subroom scene + /// saves (.room), room-level role/meta blobs (.meta) + /// and our synthetic room_<id>_*.dat defaults. + private static bool IsRoomDataName(string name) + { + var ext = Path.GetExtension(name).ToLowerInvariant(); + return ext is ".room" or ".meta" or ".dat"; + } + private static bool IsImageName(string name) { var ext = Path.GetExtension(name).ToLowerInvariant(); diff --git a/DorkNet.Server/Data/Entities/ServerSettingsEntity.cs b/DorkNet.Server/Data/Entities/ServerSettingsEntity.cs index 173b8b7..1f5a176 100644 --- a/DorkNet.Server/Data/Entities/ServerSettingsEntity.cs +++ b/DorkNet.Server/Data/Entities/ServerSettingsEntity.cs @@ -92,6 +92,16 @@ public class ServerSettingsEntity /// redeploy. public string CharadesSlotBindingsJson { get; set; } = string.Empty; + /// When true, the CDN serve path stops clamping the two + /// top-level PersistedRoomData version varints down to the March-2023 + /// client's maxima (DEPRECATED_RoomPersistenceVersion 38 / + /// PersistedRoomVersion 19). Modern RecNet exports stamp far higher + /// values and the client rejects the room with its "update Rec Room to + /// visit this room" gate — so the clamp is ON by default and this + /// toggle exists as an escape hatch if a particular blob misbehaves. + /// See . + public bool RoomBlobVersionClampDisabled { get; set; } + /// Ordered list of room NAMES shown in the room-creation "base /// room" picker (rooms/base / api/rooms/v*/baserooms), as a /// JSON string array. Empty falls back to the built-in default set. Lets diff --git a/DorkNet.Server/Services/RoomDataBlobService.cs b/DorkNet.Server/Services/RoomDataBlobService.cs index 19ba290..0c00a38 100644 --- a/DorkNet.Server/Services/RoomDataBlobService.cs +++ b/DorkNet.Server/Services/RoomDataBlobService.cs @@ -109,7 +109,12 @@ public class RoomDataBlobService pos += 8; break; case 2: - pos += checked((int)ReadVarint(input, ref pos)); + // NOTE: read the length into a local first. `pos += ReadVarint(ref pos)` + // adds the payload length to the PRE-length-varint position (C# evaluates + // the left operand of += before the right), landing the skip short by + // the size of the length varint and desyncing the whole walk. + var length = checked((int)ReadVarint(input, ref pos)); + pos += length; break; case 5: pos += 4; @@ -121,6 +126,69 @@ public class RoomDataBlobService if (pos > input.Length) throw new EndOfStreamException("Unexpected end of protobuf field."); } + /// Clamp the two top-level version varints of a + /// PersistedRoomData blob to the newest values the March-2023 + /// client knows: field 1 (DEPRECATED_RoomPersistenceVersion, + /// max 38) and field 30 (PersistedRoomVersion, max 19 = + /// V19February23BetaRelease). Modern RecNet exports stamp much higher + /// values (a Sep-2025 save carries version=131) and the client refuses + /// the whole room with its "update Rec Room to visit this room" gate. + /// Pure wire-format rewrite — every other byte is preserved verbatim, + /// so blobs already at or below the 2023 versions round-trip + /// unchanged. On malformed input the original bytes are returned. + public static (byte[] Bytes, bool Changed) ClampVersionsFor2023(byte[] input) + { + try + { + using var output = new MemoryStream(input.Length); + var pos = 0; + var changed = false; + while (pos < input.Length) + { + var fieldStart = pos; + var tag = ReadVarint(input, ref pos); + var fieldNumber = (int)(tag >> 3); + var wireType = (int)(tag & 0x07); + if (wireType == 0 && fieldNumber is 1 or 30) + { + var value = ReadVarint(input, ref pos); + var max = fieldNumber == 1 + ? (ulong)LatestDeprecatedPersistenceVersion + : (ulong)LatestPersistenceVersion; + if (value > max) + { + value = max; + changed = true; + } + WriteVarint(output, tag); + WriteVarint(output, value); + } + else + { + SkipField(input, ref pos, wireType); + output.Write(input, fieldStart, pos - fieldStart); + } + } + return changed ? (output.ToArray(), true) : (input, false); + } + catch + { + // Not parseable as a protobuf message — serve the original + // bytes rather than dropping the download. + return (input, false); + } + } + + private static void WriteVarint(Stream output, ulong value) + { + while (value >= 0x80) + { + output.WriteByte((byte)(value | 0x80)); + value >>= 7; + } + output.WriteByte((byte)value); + } + public byte[] OverlayAllPermsRoleData(byte[] existingBlob) { var msg = PersistedRoomData.Parser.ParseFrom(existingBlob); diff --git a/DorkNet.Server/Services/ServerSettingsService.cs b/DorkNet.Server/Services/ServerSettingsService.cs index cc75c0b..6a80cac 100644 --- a/DorkNet.Server/Services/ServerSettingsService.cs +++ b/DorkNet.Server/Services/ServerSettingsService.cs @@ -247,6 +247,26 @@ public class ServerSettingsService(DorkNetDbContext db) return existing; } + /// Whether the CDN's PersistedRoomData version clamp for the + /// March-2023 client is bypassed. Off by default (clamp active) — see + /// and + /// . + public async Task IsRoomBlobVersionClampDisabledAsync() + { + var row = await db.ServerSettings.AsNoTracking() + .FirstOrDefaultAsync(s => s.Id == RowId); + return row?.RoomBlobVersionClampDisabled ?? false; + } + + public async Task SetRoomBlobVersionClampDisabledAsync(bool disabled) + { + var existing = await GetTrackedRowAsync(); + existing.RoomBlobVersionClampDisabled = disabled; + existing.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return existing; + } + /// Live charades slot→list bindings. Empty/unparseable JSON /// resolves to (every slot /// falls back to its built-in seed list). diff --git a/DorkNet.Server/Startup/DatabaseBootstrap.cs b/DorkNet.Server/Startup/DatabaseBootstrap.cs index 73e473c..ddbcc53 100644 --- a/DorkNet.Server/Startup/DatabaseBootstrap.cs +++ b/DorkNet.Server/Startup/DatabaseBootstrap.cs @@ -533,6 +533,8 @@ public static class DatabaseBootstrap @"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""AllAvatarItemsOwned"" boolean NOT NULL DEFAULT false;"); await db.Database.ExecuteSqlRawAsync( @"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""BaseRoomNamesJson"" text NOT NULL DEFAULT '';"); + await db.Database.ExecuteSqlRawAsync( + @"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""RoomBlobVersionClampDisabled"" boolean NOT NULL DEFAULT false;"); return; } @@ -631,6 +633,18 @@ public static class DatabaseBootstrap // ignore the duplicate-column error; any other schema problem // will surface when EF reads ServerSettings. } + + try + { + await db.Database.ExecuteSqlRawAsync( + @"ALTER TABLE ""ServerSettings"" ADD COLUMN ""RoomBlobVersionClampDisabled"" INTEGER NOT NULL DEFAULT 0;"); + } + catch + { + // SQLite has no ADD COLUMN IF NOT EXISTS. If it already exists, + // ignore the duplicate-column error; any other schema problem + // will surface when EF reads ServerSettings. + } } private static async Task EnsureInventorySchemaAsync(DorkNetDbContext db) diff --git a/DorkNet.Server/admin-ui/src/pages/Settings.tsx b/DorkNet.Server/admin-ui/src/pages/Settings.tsx index 4f3f6ee..9d5d30c 100644 --- a/DorkNet.Server/admin-ui/src/pages/Settings.tsx +++ b/DorkNet.Server/admin-ui/src/pages/Settings.tsx @@ -10,6 +10,7 @@ interface ServerSettings { weeklyChallengesCompletedRequired: boolean; profanityFilterDisabled: boolean; allAvatarItemsOwned: boolean; + roomBlobVersionClampDisabled: boolean; updatedAt: string; } @@ -240,6 +241,29 @@ export function Settings({ embedded }: { embedded?: boolean } = {}) { } }; + const toggleRoomBlobVersionClamp = async () => { + if (!settings) return; + const next = !settings.roomBlobVersionClampDisabled; + if (!confirm( + next + ? 'Turn off the room version clamp? Rooms imported from modern RecNet exports will be served with their original version stamps, and the client will refuse them with "update Rec Room to visit this room". Only turn this off if a specific room misbehaves with the clamp on.' + : 'Turn the room version clamp back on? Imported modern rooms become joinable again on the next blob download.', + )) return; + setBusy(true); + try { + const updated = await api('/settings/room-blob-version-clamp', { + method: 'POST', + body: { Disabled: next }, + }); + setSettings(updated); + toast.push(next ? 'Room version clamp disabled.' : 'Room version clamp enabled.', 'success'); + } catch (e) { + toast.push((e as Error).message, 'error'); + } finally { + setBusy(false); + } + }; + const toggleProfanity = async () => { if (!settings) return; const next = !settings.profanityFilterDisabled; @@ -598,6 +622,39 @@ export function Settings({ embedded }: { embedded?: boolean } = {}) { )} + {settings && ( +
+
+
+

Imported-room version clamp

+

+ Rooms imported from modern RecNet exports carry room-data version stamps far newer + than the 2023 client understands, so it refuses to load them with "update Rec Room + to visit this room". While the clamp is on, the CDN serves those blobs with their + version stamps lowered to the client's maximum — nothing stored is modified, and + already-compatible blobs pass through byte-for-byte. Leave it on unless a specific + room misbehaves with the clamp active. +

+

+ Last changed {new Date(settings.updatedAt).toLocaleString()}. +

+
+ +
+
+ {settings.roomBlobVersionClampDisabled + ? Clamp off — modern imports will demand an update + : Clamp active — modern imports joinable} +
+
+ )} + {gameConfigs && (