mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 15:08:47 -07:00
feat: cloned rooms get their own copy of the source's save data
Cloning was a shallow copy: the new room's scenes kept pointing at the SOURCE room's blob until someone saved over them. So every clone of a room shared one object, a clone read as "already has content" while owning none of it, and the room a player copied stayed load-bearing for their copy indefinitely. Cloning RecCenter produced rooms whose data blob was literally room_100_v1.dat, which is visible in the join log. Each sub-room's bytes are now copied into the clone's own namespace, keyed by the CLONE's room id so BlobRouter files them under that room's prefix. Sub-room 0 takes the canonical default name, which matters because the details builder falls back to exactly that name when a room has no recorded blob — writing the copy there means both the recorded and the fallback path resolve to real content. Each copy also gets a RoomDataBlobs row so it is tracked like any other save. Two cases deliberately do not clear a reference: - With no object store configured there is nothing to copy, and a blob name is only a reference; clearing it would throw away the player's edits, which is strictly worse than the shared reference. That path keeps the old shallow behaviour, which is also what the existing user-room clone test asserts. - A source object that is missing or unreadable leaves the clone empty rather than failing the request. The room and its sub-rooms are already committed by that point, so a hard failure would strand a half-made room, and an unsaved source clones to an empty room anyway. Everywhere else a reference is either replaced by a real copy or cleared — it is never left pointing into another room.
This commit is contained in:
@@ -70,6 +70,11 @@ public sealed class ChatStartConversationTests : IClassFixture<DorkNetServerFact
|
|||||||
cannot be opened until someone has already sent a message, which is
|
cannot be opened until someone has already sent a message, which is
|
||||||
impossible to do first.
|
impossible to do first.
|
||||||
""");
|
""");
|
||||||
|
Console.WriteLine("=== WITHMEMBERS ===");
|
||||||
|
Console.WriteLine(createBody);
|
||||||
|
Console.WriteLine("=== GET THREAD ===");
|
||||||
|
Console.WriteLine(openBody);
|
||||||
|
|
||||||
Assert.True(openResponse.IsSuccessStatusCode,
|
Assert.True(openResponse.IsSuccessStatusCode,
|
||||||
$"GET /thread/{threadId} -> {(int)openResponse.StatusCode}: {openBody}");
|
$"GET /thread/{threadId} -> {(int)openResponse.StatusCode}: {openBody}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ using System.Net.Http.Headers;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using DorkNet.Server.Data;
|
using DorkNet.Server.Data;
|
||||||
using DorkNet.Server.Data.Entities;
|
using DorkNet.Server.Data.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
|
using DorkNet.Server.Services;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace DorkNet.Server.Tests;
|
namespace DorkNet.Server.Tests;
|
||||||
@@ -99,6 +102,107 @@ public sealed class RoomCloneShapeTests : IClassFixture<DorkNetServerFactory>
|
|||||||
""");
|
""");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>A clone must own its save data. Cloning used to be a shallow
|
||||||
|
/// copy — the new room's scenes kept pointing at the SOURCE's blob until
|
||||||
|
/// someone saved over them — so every copy of a room shared one object and
|
||||||
|
/// cloning RecCenter produced rooms whose data blob was literally
|
||||||
|
/// <c>room_100_v1.dat</c>. A clone that references another room's blob is
|
||||||
|
/// the bug, whatever the name.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Clone_does_not_reference_the_source_rooms_blob()
|
||||||
|
{
|
||||||
|
// The suite runs with S3 unconfigured, where there is nothing to copy
|
||||||
|
// and the clone deliberately keeps the shallow reference. Swap in a
|
||||||
|
// real (in-memory) store so this exercises the copy path itself.
|
||||||
|
var store = new InMemoryObjectStorage();
|
||||||
|
using var factory = _factory.WithWebHostBuilder(builder =>
|
||||||
|
builder.ConfigureServices(services =>
|
||||||
|
{
|
||||||
|
services.RemoveAll<IObjectStorage>();
|
||||||
|
services.AddSingleton<IObjectStorage>(store);
|
||||||
|
}));
|
||||||
|
|
||||||
|
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||||
|
client.BaseAddress = new Uri($"http://rooms.{_factory.ApexDomain}");
|
||||||
|
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
|
||||||
|
client.DefaultRequestHeaders.Authorization =
|
||||||
|
new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||||
|
|
||||||
|
var sourceId = 9_500_000 + Random.Shared.Next(1, 99_999);
|
||||||
|
var sourceBlob = $"room_{sourceId}_v1.dat";
|
||||||
|
var sourceBytes = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 };
|
||||||
|
var (srcBucket, srcKey) = BlobRouter.Route(sourceBlob);
|
||||||
|
await store.PutAsync(srcBucket, srcKey, sourceBytes, "application/octet-stream");
|
||||||
|
|
||||||
|
await using (var scope = factory.Services.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||||
|
db.Rooms.Add(new RoomEntity
|
||||||
|
{
|
||||||
|
Id = sourceId,
|
||||||
|
Name = $"BlobSrc{Guid.NewGuid():N}"[..20],
|
||||||
|
CreatorPlayerId = session.PlayerId,
|
||||||
|
CloningAllowed = true,
|
||||||
|
CurrentDataBlobName = sourceBlob,
|
||||||
|
});
|
||||||
|
db.RoomScenes.Add(new RoomSceneEntity
|
||||||
|
{
|
||||||
|
RoomId = sourceId,
|
||||||
|
Name = "Home",
|
||||||
|
OrderIndex = 0,
|
||||||
|
DataBlobName = sourceBlob,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var clone = await ReadJsonAsync(client, HttpMethod.Post, $"/rooms/{sourceId}/clone",
|
||||||
|
new FormUrlEncodedContent([new KeyValuePair<string, string>("name", $"Copy{Guid.NewGuid():N}"[..20])]));
|
||||||
|
var cloneId = clone.GetProperty("RoomId").GetInt64();
|
||||||
|
|
||||||
|
await using (var scope = factory.Services.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||||
|
var room = await db.Rooms.FirstAsync(r => r.Id == cloneId);
|
||||||
|
var scenes = await db.RoomScenes.Where(s => s.RoomId == cloneId).ToListAsync();
|
||||||
|
|
||||||
|
Assert.False(room.CurrentDataBlobName == sourceBlob,
|
||||||
|
$"the clone's room row still points at the source's blob ({sourceBlob})");
|
||||||
|
foreach (var scene in scenes)
|
||||||
|
Assert.False(scene.DataBlobName == sourceBlob,
|
||||||
|
$"cloned sub-room {scene.OrderIndex} still points at the source's blob ({sourceBlob})");
|
||||||
|
|
||||||
|
// It must be a real copy, not just a cleared reference — the point
|
||||||
|
// is that the player's content comes along.
|
||||||
|
Assert.False(string.IsNullOrEmpty(room.CurrentDataBlobName),
|
||||||
|
"the clone has no blob at all; the source's data should have been copied, not dropped");
|
||||||
|
var (dstBucket, dstKey) = BlobRouter.Route(room.CurrentDataBlobName);
|
||||||
|
Assert.Equal(sourceBytes, await store.GetAsync(dstBucket, dstKey));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Minimal in-memory <see cref="IObjectStorage"/>. The suite runs
|
||||||
|
/// with S3 unconfigured, so without this the clone's copy path short-circuits
|
||||||
|
/// and never gets exercised.</summary>
|
||||||
|
private sealed class InMemoryObjectStorage : IObjectStorage
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, byte[]> _objects = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public bool IsS3Configured => true;
|
||||||
|
|
||||||
|
public Task<long> PutAsync(
|
||||||
|
string bucket, string key, byte[] bytes, string contentType, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
_objects[$"{bucket}/{key}"] = bytes;
|
||||||
|
return Task.FromResult((long)bytes.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<byte[]?> GetAsync(string bucket, string key, CancellationToken ct = default) =>
|
||||||
|
Task.FromResult(_objects.TryGetValue($"{bucket}/{key}", out var bytes) ? bytes : null);
|
||||||
|
|
||||||
|
public Task<bool> ExistsAsync(string bucket, string key, CancellationToken ct = default) =>
|
||||||
|
Task.FromResult(_objects.ContainsKey($"{bucket}/{key}"));
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<JsonElement> ReadJsonAsync(
|
private static async Task<JsonElement> ReadJsonAsync(
|
||||||
HttpClient client, HttpMethod method, string path, HttpContent? content = null)
|
HttpClient client, HttpMethod method, string path, HttpContent? content = null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ namespace DorkNet.Server.Controllers.API.Rooms.V2;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
|
public class RoomsModerationController(
|
||||||
|
DorkNetDbContext db,
|
||||||
|
IObjectStorage storage,
|
||||||
|
ILogger<RoomsModerationController> logger) : ControllerBase
|
||||||
{
|
{
|
||||||
private const int PublicAccessibility = 1;
|
private const int PublicAccessibility = 1;
|
||||||
private const long CanUseShareCamPermission = 1L << 18;
|
private const long CanUseShareCamPermission = 1L << 18;
|
||||||
@@ -1507,6 +1510,8 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await GiveCloneItsOwnBlobsAsync(clone, sourceScenes, clonedScenes, Me);
|
||||||
|
|
||||||
// The 2023 client's clone (RecNet.Runtime NLDBPDCNNCF.GDHIIAHCBMN)
|
// The 2023 client's clone (RecNet.Runtime NLDBPDCNNCF.GDHIIAHCBMN)
|
||||||
// deserializes the response as the FULL room-details object
|
// deserializes the response as the FULL room-details object
|
||||||
// (FGCPNAACHIK — the same type get-by-id / rename return on the
|
// (FGCPNAACHIK — the same type get-by-id / rename return on the
|
||||||
@@ -1518,6 +1523,144 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
|
|||||||
// room".
|
// room".
|
||||||
return Ok(RoomsController.BuildRoomServerDetails(clone, clonedScenes));
|
return Ok(RoomsController.BuildRoomServerDetails(clone, clonedScenes));
|
||||||
}
|
}
|
||||||
|
/// <summary>Give a freshly-cloned room its OWN copy of the source's save
|
||||||
|
/// data instead of pointing its scenes at the source's blob.
|
||||||
|
///
|
||||||
|
/// The shallow copy this replaces relied on copy-on-write: the clone
|
||||||
|
/// referenced <c>room_<source>_*.dat</c> until someone saved over it.
|
||||||
|
/// That left every clone of a room sharing one object, so a clone read as
|
||||||
|
/// "already has content" while owning none of it, and the room a player
|
||||||
|
/// copied stayed load-bearing for their copy forever. Cloning RecCenter
|
||||||
|
/// produced rooms whose data blob was literally <c>room_100_v1.dat</c>.
|
||||||
|
///
|
||||||
|
/// Each sub-room's bytes are copied to a name derived from the CLONE's id,
|
||||||
|
/// so <see cref="BlobRouter"/> files them under that room's own prefix.
|
||||||
|
/// Sub-room 0 takes the canonical default name, which matters because the
|
||||||
|
/// details builder falls back to exactly that name when a room has no
|
||||||
|
/// recorded blob — writing the copy there means both paths resolve to real
|
||||||
|
/// content.
|
||||||
|
///
|
||||||
|
/// A missing source object is not fatal: the clone keeps an empty blob name
|
||||||
|
/// and behaves like a fresh room, which is the same outcome as cloning a
|
||||||
|
/// room that never had a save. Failing the whole clone would be worse — the
|
||||||
|
/// room and its sub-rooms are already committed by this point.</summary>
|
||||||
|
private async Task GiveCloneItsOwnBlobsAsync(
|
||||||
|
RoomEntity clone,
|
||||||
|
List<RoomSceneEntity> sourceScenes,
|
||||||
|
List<RoomSceneEntity> clonedScenes,
|
||||||
|
long callerId)
|
||||||
|
{
|
||||||
|
// With no object store there are no objects to copy, and a blob name is
|
||||||
|
// only a reference. Clearing it would throw away the player's edits —
|
||||||
|
// strictly worse than the shared reference — so leave the shallow copy
|
||||||
|
// alone and let copy-on-write handle it, exactly as before.
|
||||||
|
if (!storage.IsS3Configured)
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"[room-clone] no object store configured; room {Room} keeps the source's blob references",
|
||||||
|
clone.Id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? subRoomZeroBlob = null;
|
||||||
|
|
||||||
|
for (var i = 0; i < clonedScenes.Count; i++)
|
||||||
|
{
|
||||||
|
var target = clonedScenes[i];
|
||||||
|
// The scene was created as a shallow copy, so its DataBlobName is
|
||||||
|
// currently the SOURCE's. Either a copy replaces it or it gets
|
||||||
|
// cleared — it must never survive as-is.
|
||||||
|
var copied = await CopyBlobForCloneAsync(
|
||||||
|
sourceScenes[i].DataBlobName, clone.Id, target.OrderIndex, callerId);
|
||||||
|
|
||||||
|
target.DataBlobName = copied ?? string.Empty;
|
||||||
|
target.DataModifiedAt = DateTime.UtcNow;
|
||||||
|
if (target.OrderIndex == 0) subRoomZeroBlob = copied;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same rule for the room row, which carries its own inherited copy of
|
||||||
|
// the name. Sub-room 0 is the room's current save, so prefer whatever
|
||||||
|
// that produced; with no sub-room 0 at all, copy the room-level blob
|
||||||
|
// directly. Anything that can't be copied is cleared rather than left
|
||||||
|
// pointing into another room.
|
||||||
|
if (clonedScenes.Any(s => s.OrderIndex == 0))
|
||||||
|
{
|
||||||
|
clone.CurrentDataBlobName = subRoomZeroBlob ?? string.Empty;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
clone.CurrentDataBlobName =
|
||||||
|
await CopyBlobForCloneAsync(clone.CurrentDataBlobName, clone.Id, 0, callerId)
|
||||||
|
?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Copy one blob into the clone's own namespace. Returns the new
|
||||||
|
/// name, or null when there was nothing to copy or the source object could
|
||||||
|
/// not be read — in which case the caller clears the reference.
|
||||||
|
///
|
||||||
|
/// A missing source is not fatal: the clone then behaves like a room that
|
||||||
|
/// was never saved, which is also what cloning an unsaved room does. The
|
||||||
|
/// room and its sub-rooms are already committed by this point, so failing
|
||||||
|
/// the request outright would leave a half-made room behind.</summary>
|
||||||
|
private async Task<string?> CopyBlobForCloneAsync(
|
||||||
|
string? sourceBlob, long cloneId, int orderIndex, long callerId)
|
||||||
|
{
|
||||||
|
// A first-party template deliberately starts empty (see
|
||||||
|
// CurrentDataBlobName in BareClone), and a sub-room may simply never
|
||||||
|
// have been saved.
|
||||||
|
if (string.IsNullOrWhiteSpace(sourceBlob)) return null;
|
||||||
|
|
||||||
|
byte[]? bytes;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (srcBucket, srcKey) = BlobRouter.Route(sourceBlob);
|
||||||
|
bytes = await storage.GetAsync(srcBucket, srcKey);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex,
|
||||||
|
"[room-clone] could not read source blob {Blob}; room {Room} sub-room {Sub} starts empty",
|
||||||
|
sourceBlob, cloneId, orderIndex);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bytes is null || bytes.Length == 0)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"[room-clone] source blob {Blob} is missing from storage; room {Room} sub-room {Sub} "
|
||||||
|
+ "starts empty", sourceBlob, cloneId, orderIndex);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var newBlob = CloneBlobName(cloneId, orderIndex);
|
||||||
|
var (dstBucket, dstKey) = BlobRouter.Route(newBlob);
|
||||||
|
await storage.PutAsync(dstBucket, dstKey, bytes, "application/octet-stream");
|
||||||
|
|
||||||
|
db.RoomDataBlobs.Add(new RoomDataBlobEntity
|
||||||
|
{
|
||||||
|
RoomId = cloneId,
|
||||||
|
BlobName = newBlob,
|
||||||
|
SubRoomId = orderIndex,
|
||||||
|
UploadedByPlayerId = callerId,
|
||||||
|
UploadedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"[room-clone] copied {Bytes} bytes {From} → {To} (room {Room} sub-room {Sub})",
|
||||||
|
bytes.Length, sourceBlob, newBlob, cloneId, orderIndex);
|
||||||
|
return newBlob;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Blob name for a cloned sub-room, keyed by the CLONE's room id so
|
||||||
|
/// BlobRouter files it under that room rather than the source's.</summary>
|
||||||
|
private static string CloneBlobName(long roomId, int orderIndex) =>
|
||||||
|
orderIndex == 0
|
||||||
|
? RoomService.SyntheticDefaultRoomDataBlobName(roomId)
|
||||||
|
: $"room_{roomId}_dorknet_v8_sub{orderIndex}.dat";
|
||||||
|
|
||||||
/// <summary>A seeded first-party RRO template (RecCenter, the games):
|
/// <summary>A seeded first-party RRO template (RecCenter, the games):
|
||||||
/// AG-flagged AND owned by the system account. User clones inherit the
|
/// AG-flagged AND owned by the system account. User clones inherit the
|
||||||
/// AG flag but are owned by the player, so they don't match.</summary>
|
/// AG flag but are owned by the player, so they don't match.</summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user