mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
feat: Implement room blob version clamp toggle; add settings endpoint and UI support for managing version clamping of imported rooms
This commit is contained in:
@@ -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);
|
||||
|
||||
/// <summary>POST <c>api/admin/v1/settings/room-blob-version-clamp</c> —
|
||||
/// 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).</summary>
|
||||
[HttpPost("settings/room-blob-version-clamp")]
|
||||
public async Task<ActionResult> 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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ public class CdnController(
|
||||
IObjectStorage storage,
|
||||
ImageSignatureService signatures,
|
||||
DorkNetDbContext db,
|
||||
ServerSettingsService serverSettings,
|
||||
ILogger<CdnController> logger) : ControllerBase
|
||||
{
|
||||
/// <summary>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(
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Blob names that carry PersistedRoomData: subroom scene
|
||||
/// saves (<c>.room</c>), room-level role/meta blobs (<c>.meta</c>)
|
||||
/// and our synthetic <c>room_<id>_*.dat</c> defaults.</summary>
|
||||
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();
|
||||
|
||||
@@ -92,6 +92,16 @@ public class ServerSettingsEntity
|
||||
/// redeploy.</summary>
|
||||
public string CharadesSlotBindingsJson { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>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 <see cref="Services.RoomDataBlobService.ClampVersionsFor2023"/>.</summary>
|
||||
public bool RoomBlobVersionClampDisabled { get; set; }
|
||||
|
||||
/// <summary>Ordered list of room NAMES shown in the room-creation "base
|
||||
/// room" picker (<c>rooms/base</c> / <c>api/rooms/v*/baserooms</c>), as a
|
||||
/// JSON string array. Empty falls back to the built-in default set. Lets
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
/// <summary>Clamp the two top-level version varints of a
|
||||
/// <c>PersistedRoomData</c> blob to the newest values the March-2023
|
||||
/// client knows: field 1 (<c>DEPRECATED_RoomPersistenceVersion</c>,
|
||||
/// max 38) and field 30 (<c>PersistedRoomVersion</c>, 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.</summary>
|
||||
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);
|
||||
|
||||
@@ -247,6 +247,26 @@ public class ServerSettingsService(DorkNetDbContext db)
|
||||
return existing;
|
||||
}
|
||||
|
||||
/// <summary>Whether the CDN's PersistedRoomData version clamp for the
|
||||
/// March-2023 client is bypassed. Off by default (clamp active) — see
|
||||
/// <see cref="ServerSettingsEntity.RoomBlobVersionClampDisabled"/> and
|
||||
/// <see cref="RoomDataBlobService.ClampVersionsFor2023"/>.</summary>
|
||||
public async Task<bool> IsRoomBlobVersionClampDisabledAsync()
|
||||
{
|
||||
var row = await db.ServerSettings.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.Id == RowId);
|
||||
return row?.RoomBlobVersionClampDisabled ?? false;
|
||||
}
|
||||
|
||||
public async Task<ServerSettingsEntity> SetRoomBlobVersionClampDisabledAsync(bool disabled)
|
||||
{
|
||||
var existing = await GetTrackedRowAsync();
|
||||
existing.RoomBlobVersionClampDisabled = disabled;
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return existing;
|
||||
}
|
||||
|
||||
/// <summary>Live charades slot→list bindings. Empty/unparseable JSON
|
||||
/// resolves to <see cref="CharadesSlotBindings.Empty"/> (every slot
|
||||
/// falls back to its built-in seed list).</summary>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<ServerSettings>('/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 } = {}) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{settings && (
|
||||
<div className="card !p-5 max-w-2xl mt-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-sm font-semibold text-ink-50">Imported-room version clamp</h2>
|
||||
<p className="mt-1 text-xs text-ink-400">
|
||||
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.
|
||||
</p>
|
||||
<p className="mt-2 text-[11px] text-ink-500">
|
||||
Last changed {new Date(settings.updatedAt).toLocaleString()}.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleRoomBlobVersionClamp}
|
||||
disabled={busy}
|
||||
className={(settings.roomBlobVersionClampDisabled ? 'btn-primary' : 'btn-danger') + ' text-xs shrink-0'}
|
||||
>
|
||||
{busy ? 'Working…' : settings.roomBlobVersionClampDisabled ? 'Enable clamp' : 'Disable clamp'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4 text-xs">
|
||||
{settings.roomBlobVersionClampDisabled
|
||||
? <span className="badge-banned">Clamp off — modern imports will demand an update</span>
|
||||
: <span className="badge-online">Clamp active — modern imports joinable</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{gameConfigs && (
|
||||
<DiscoveredGameConfigsPanel
|
||||
settings={gameConfigs}
|
||||
|
||||
Reference in New Issue
Block a user