Import march-2020-03-10 from private repo (scrubbed 2026-05-27)

Targets Rec Room 2020.03.10 client (also serves 2020.03.06).

Re-import after adding:
- DorkNet.ClientMod/dorknet-clientmod.json.template — config schema
  the launcher's manifest-driven patcher renders into UserData/.
- tools/build-patcher-zip.ps1 — produces dorknet-clientpatch-{key}.zip
  for v1-march-* Release uploads.
- .github/workflows/release.yml — on tag push publishes both
  dorknet-server-march_2020_03_10-win-x64.zip and
  dorknet-clientpatch-march_2020_03_10.zip as Release assets.

Same scrub rules as december; tools/build-patcher-zip.ps1 added to
the maintainer-side allow-list so it survives.
This commit is contained in:
Alexa
2026-05-27 01:06:12 +03:00
commit d471b418b9
389 changed files with 104767 additions and 0 deletions
@@ -0,0 +1,2920 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Hubs;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Admin;
/// <summary>
/// api.rec.net/api/admin/v1/* — privileged moderation endpoints. Every
/// action is gated by <see cref="AdminOnlyAttribute"/>, which checks
/// <see cref="PlayerEntity.IsAdmin"/>; every state mutation is logged
/// to <see cref="AdminActionEntity"/> for audit replay.
///
/// The first account created on a fresh DB gets <c>IsAdmin = true</c>
/// (see <see cref="PlayerService.GetOrCreateByDeviceAsync"/>) so a
/// freshly-bootstrapped private server always has at least one root
/// admin without manual SQL.
/// </summary>
[ApiController]
[Route("api/admin/v1")]
[Authorize]
[AdminOnly]
public class AdminController(
DorkNetDbContext db,
NotificationService notifications,
LevelService level,
CommunityBoardService communityBoard,
RoomBlobNormalizerService roomBlobNormalizer,
HtrAssetMirrorService htrMirror,
OnlinePresenceService onlinePresence,
PlayerPresenceService playerPresence,
PlayerLogService playerLog,
ServerSettingsService serverSettings,
IObjectStorage storage,
DomainConfig domain,
ILogger<AdminController> adminLogger) : ControllerBase
{
private long CurrentAdminId => this.RequireCurrentPlayerId();
// ── Player request logs ──────────────────────────────────────────────
/// <summary>Most recent HTTP requests made by a single player, newest
/// first. Backed by <see cref="PlayerLogService"/>'s Redis ring buffer
/// (in-process fallback for dev) — capped at
/// <see cref="PlayerLogService.MaxEntriesPerPlayer"/> entries per
/// player with a 7-day key TTL. Used by the admin UI's "Player logs"
/// tab to spot what API call a player was making when they hit a bug.</summary>
[HttpGet("players/{id:long}/logs")]
public ActionResult PlayerLogs(long id, [FromQuery] int take = 200)
{
var entries = playerLog.GetRecent(id, take);
return Ok(entries);
}
// ── Players ──────────────────────────────────────────────────────────
/// <summary>List players with online status, paginated. Used by the
/// admin watch tab. Default page size 50, max 200 to keep responses
/// reasonable.</summary>
[HttpGet("players")]
public async Task<ActionResult> ListPlayers(
[FromQuery] string? query, [FromQuery] int take = 50, [FromQuery] int skip = 0)
{
take = Math.Clamp(take, 1, 200);
skip = Math.Max(0, skip);
IQueryable<PlayerEntity> q = db.Players.OrderByDescending(p => p.LastSeenAt);
if (!string.IsNullOrWhiteSpace(query))
q = q.Where(p => p.Username.Contains(query) || p.DisplayName.Contains(query));
var page = await q.Skip(skip).Take(take).ToListAsync();
var onlineSet = onlinePresence.OnlinePlayerIds().ToHashSet();
return Ok(page.Select(p => new
{
p.Id,
p.Username,
p.DisplayName,
p.Email,
p.IsAdmin,
p.IsDeveloper,
p.IsCommunityTeam,
p.IsVerified,
p.IsJunior,
p.BannedUntil,
p.LastIp,
p.LastSeenAt,
p.CreatedAt,
p.Level,
p.XP,
p.ProfileImageName,
Online = onlineSet.Contains(p.Id),
}));
}
/// <summary>Single-player detail — bundles the player record plus
/// their currency balances so the admin UI can render an edit page
/// in one round trip.</summary>
[HttpGet("players/{id:long}")]
public async Task<ActionResult> GetPlayer(long id)
{
var p = await db.Players.FirstOrDefaultAsync(x => x.Id == id);
if (p is null) return NotFound();
var balances = await db.CurrencyBalances
.Where(c => c.PlayerId == id)
.Select(c => new { c.CurrencyType, c.Balance })
.ToListAsync();
var avatar = await db.Avatars
.Where(a => a.PlayerId == id)
.Select(a => new { a.OutfitSelections, a.HairColor, a.SkinColor, a.FaceFeatures })
.FirstOrDefaultAsync();
return Ok(new
{
p.Id,
p.Username,
p.DisplayName,
p.Bio,
p.Email,
p.IsAdmin,
p.IsDeveloper,
p.IsCommunityTeam,
p.IsVerified,
p.IsJunior,
p.BannedUntil,
p.LastIp,
p.LastSeenAt,
p.CreatedAt,
p.Level,
p.XP,
p.ProfileImageName,
Online = onlinePresence.OnlinePlayerIds().Contains(p.Id),
Balances = balances,
Avatar = avatar,
});
}
public sealed record BanRequest(int DurationDays, string? Reason);
/// <summary>Ban a player for the given number of days. The
/// <see cref="BanCheckMiddleware"/> 401s any subsequent request
/// from the player until <c>BannedUntil</c> elapses; we also push
/// a <c>ModerationKick</c> notification so the watch sees the ban
/// immediately.</summary>
[HttpPost("players/{id:long}/ban")]
public async Task<ActionResult> BanPlayer(long id, [FromBody] BanRequest body)
{
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == id);
if (player is null) return NotFound();
if (player.IsAdmin) return BadRequest("Cannot ban an admin. Demote first.");
var days = Math.Clamp(body.DurationDays, 1, 3650);
player.BannedUntil = DateTime.UtcNow.AddDays(days);
await LogAsync("ban_player", "player", id, body.Reason ?? "");
await db.SaveChangesAsync();
await notifications.NotifyAsync(id, PushNotificationId.ModerationKick,
new { Reason = body.Reason ?? "", Until = player.BannedUntil });
return Ok(new { player.Id, player.BannedUntil });
}
[HttpPost("players/{id:long}/unban")]
public async Task<ActionResult> UnbanPlayer(long id, [FromBody] BanRequest? body)
{
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == id);
if (player is null) return NotFound();
player.BannedUntil = null;
await LogAsync("unban_player", "player", id, body?.Reason ?? "");
await db.SaveChangesAsync();
return Ok(new { player.Id, player.BannedUntil });
}
[HttpPost("players/{id:long}/promote")]
public async Task<ActionResult> Promote(long id)
{
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == id);
if (player is null) return NotFound();
player.IsAdmin = true;
await LogAsync("promote_admin", "player", id, "");
await db.SaveChangesAsync();
return Ok(new { player.Id, player.IsAdmin });
}
[HttpPost("players/{id:long}/demote")]
public async Task<ActionResult> Demote(long id)
{
// Refuse to demote yourself — guards against the last admin
// accidentally dropping privileges and being unable to recover.
if (id == CurrentAdminId)
return BadRequest("Cannot demote yourself. Have another admin do it.");
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == id);
if (player is null) return NotFound();
player.IsAdmin = false;
await LogAsync("demote_admin", "player", id, "");
await db.SaveChangesAsync();
return Ok(new { player.Id, player.IsAdmin });
}
// ── Rooms ────────────────────────────────────────────────────────────
public sealed record DeleteRoomRequest(string? Reason);
/// <summary>Soft-delete a room by setting <c>State = 1</c>
/// (Archived). The room stops appearing in browse/search results;
/// its data is still retrievable for an undelete if needed.</summary>
/// <summary>GET <c>api/admin/v1/diagnostics/room-images</c> —
/// emergency diagnostic for "room tile shows smiley" symptoms.
/// Returns one row per seeded room with: the DB ImageName, whether
/// the corresponding file exists in <c>data/images/</c>, and the
/// fully-qualified URL the client would fetch. Lets us tell at a
/// glance whether the bug is (a) DB never got real CDN names from
/// the backfill, (b) PNG missing on disk, or (c) wrong URL host
/// being served by the config endpoint.</summary>
[HttpGet("diagnostics/room-images")]
public async Task<IActionResult> DiagnosticsRoomImages()
{
var imgDir = Path.Combine(AppContext.BaseDirectory, "data", "images");
var rooms = await db.Rooms
.Where(r => r.Id >= 100 && r.Id < 1000)
.OrderBy(r => r.Id)
.Select(r => new { r.Id, r.Name, r.ImageName })
.ToListAsync();
var rows = rooms.Select(r =>
{
var path = Path.Combine(imgDir, r.ImageName ?? string.Empty);
return new
{
r.Id,
r.Name,
r.ImageName,
FileExists = !string.IsNullOrEmpty(r.ImageName) && System.IO.File.Exists(path),
FullPath = path,
FetchUrl = $"https://{domain.Sub("img")}/{r.ImageName}",
};
}).ToList();
return Ok(new
{
ImageDirOnDisk = imgDir,
ImageDirExists = Directory.Exists(imgDir),
ImageFileCount = Directory.Exists(imgDir) ? Directory.GetFiles(imgDir).Length : 0,
RoomImagesJsonExists = System.IO.File.Exists(
Path.Combine(AppContext.BaseDirectory, "data", "room_images.json")),
Rooms = rows,
});
}
/// <summary>GET <c>api/admin/v1/players/{id}/clothing-debug</c> —
/// inspect a single player's wardrobe state. Shows the raw
/// AvatarEntity columns the watch reads (OutfitSelections,
/// InventoryJson, HairColor, SkinColor), the parsed-and-filtered
/// list of items the player will see in their wardrobe drawer
/// (intersection of InventoryJson with the bundled safelist),
/// and any GUIDs in InventoryJson that AREN'T in the safelist
/// (those would crash the watch's parser if shipped — silently
/// dropped by AvatarItemsController.Get(), but worth knowing
/// they're sitting in the inventory).
///
/// Mostly here as a one-call sanity check after the avatar
/// pipeline overhaul: hit it against the player ID, eyeball the
/// drawer-count, confirm the ratio matches what's in the DB.</summary>
[HttpGet("players/{id:long}/clothing-debug")]
public async Task<ActionResult> ClothingDebug(long id)
{
var avatar = await db.Avatars
.Where(a => a.PlayerId == id)
.Select(a => new
{
a.OutfitSelections,
a.InventoryJson,
a.HairColor,
a.SkinColor,
a.FaceFeatures,
a.UpdatedAt,
})
.FirstOrDefaultAsync();
if (avatar is null) return NotFound(new { Message = $"No AvatarEntity for player {id}" });
var safe = DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.SafeGuids;
var catalog = DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.Catalog;
// Parse the player's inventory + intersect against the bundled
// safelist. Items that pass through end up in
// /api/avatar/v4/items as renderable wardrobe entries; items
// that fail are silently dropped at request time.
List<string> inventoryGuids;
try
{
inventoryGuids = System.Text.Json.JsonSerializer
.Deserialize<List<string>>(avatar.InventoryJson ?? "[]") ?? new();
}
catch { inventoryGuids = new(); }
var owned = inventoryGuids.Where(g => safe.Contains(g)).ToList();
var unsafeGuids = inventoryGuids.Where(g => !safe.Contains(g)).ToList();
// Parse OutfitSelections (the equipped loadout) the same way
// the watch does — comma-separated, each segment a GUID. Show
// which slot each one resolves to + whether it's in the safe
// catalog.
var equipped = (avatar.OutfitSelections ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(g => g.Trim())
.Where(g => g.Length > 0)
.Select(g => new
{
Guid = g,
InSafelist = safe.Contains(g),
FriendlyName = catalog.TryGetValue(g, out var c) ? c.FriendlyName : null,
})
.ToList();
return Ok(new
{
PlayerId = id,
avatar.UpdatedAt,
avatar.HairColor,
avatar.SkinColor,
avatar.FaceFeatures,
OutfitSelections = avatar.OutfitSelections,
EquippedItems = equipped,
InventoryRaw = avatar.InventoryJson,
InventoryCount = inventoryGuids.Count,
OwnedAndRenderable = owned.Count,
OwnedAndRenderableGuids = owned,
UnsafeOwnedGuids = unsafeGuids,
CatalogTotal = catalog.Count,
SafelistTotal = safe.Count,
});
}
/// <summary>GET <c>api/admin/v1/rooms</c> — flat list of every
/// room in the DB (id + name + creator + blob count) for the admin
/// UI's room-pickers. Custom (AG) rooms first, then RR Originals;
/// each ordered by name. The blob count helps the .htr mirror
/// dropdown skip rooms that have no blobs to scan.</summary>
[HttpGet("rooms")]
public async Task<ActionResult> ListAllRooms()
{
var rooms = await db.Rooms
.OrderByDescending(r => r.IsAGRoom)
.ThenBy(r => r.Name)
.Select(r => new
{
r.Id,
r.Name,
r.IsAGRoom,
r.IsDormRoom,
r.CreatorPlayerId,
BlobCount = db.RoomDataBlobs.Count(b => b.RoomId == r.Id),
})
.ToListAsync();
return Ok(rooms);
}
/// <summary>GET <c>api/admin/v1/rooms/{id}/visitors</c> — most
/// recent unique visitors of a room. Joins
/// <see cref="RoomVisitEntity"/> against <see cref="PlayerEntity"/>
/// so the admin UI can show "who's been here lately" with
/// usernames + per-player visit counts.</summary>
[HttpGet("rooms/{id:long}/visitors")]
public async Task<ActionResult> RoomVisitors(long id, [FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 200);
var rows = await (from v in db.RoomVisits
join p in db.Players on v.PlayerId equals p.Id
where v.RoomId == id
orderby v.LastVisitAt descending
select new
{
PlayerId = v.PlayerId,
p.Username,
p.DisplayName,
v.VisitCount,
v.FirstVisitAt,
v.LastVisitAt,
})
.Take(take)
.ToListAsync();
return Ok(rows);
}
[HttpDelete("rooms/{id:long}")]
public async Task<ActionResult> DeleteRoom(long id, [FromBody] DeleteRoomRequest? body)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
if (room is null) return NotFound();
room.State = 1; // Archived
await LogAsync("delete_room", "room", id, body?.Reason ?? "");
await db.SaveChangesAsync();
return Ok(new { room.Id, room.State });
}
// The bulk "purge every custom room" endpoint was removed: a single
// wrong click could wipe every imported map in one shot. Per-room
// purge (below) is the only hard-delete path, gated by two
// independent room-name typed confirmations to make accidental
// wipes effectively impossible.
public sealed record HardDeleteRoomRequest(
string? Reason,
// The admin UI prompts for the room name TWICE in separate
// inputs — both have to match the room's actual Name (exactly,
// case-sensitive) before the request will fire. Same pattern
// GitHub uses for repository deletion.
string? ConfirmName1,
string? ConfirmName2);
/// <summary>POST <c>api/admin/v1/rooms/{id}/purge</c> — hard-delete a
/// single custom room. Refuses to act on TRUE RR Originals (owned
/// by the system seed account) or dorms; for those, the soft-delete
/// via DELETE /rooms/{id} is the only supported path so a fat
/// finger doesn't wipe canonical content.
///
/// A user-cloned room often inherits <c>IsAGRoom=true</c> from its
/// source — that doesn't make it canonical, it just means the
/// matchmaker uses the AG-room join path. Gate purely on
/// <c>CreatorPlayerId == SystemAccountId</c>, which is the only
/// reliable "is this actually a seeded RR Original?" signal.
///
/// Requires BOTH <c>ConfirmName1</c> and <c>ConfirmName2</c> in the
/// body to exactly match the room's current Name. Both must match
/// independently — the SPA renders two separate inputs and the
/// admin types the name twice. Returns 400 with
/// <c>confirm_name_mismatch</c> if either is missing/wrong so the
/// SPA can surface the error inline without leaking which one
/// failed.</summary>
[HttpPost("rooms/{id:long}/purge")]
public async Task<ActionResult> HardDeleteRoom(long id, [FromBody] HardDeleteRoomRequest? body)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
if (room is null) return NotFound();
if (room.IsDormRoom)
return BadRequest(new { error = "refuses_to_hard_delete_dorm", note = "Dorms are per-player state; never purge." });
if (room.IsAGRoom && room.CreatorPlayerId == PlayerService.SystemAccountId)
return BadRequest(new { error = "refuses_to_hard_delete_seeded_room", note = "This row is owned by the system seed account — soft-archive via DELETE /rooms/{id} instead." });
var confirm1 = body?.ConfirmName1 ?? string.Empty;
var confirm2 = body?.ConfirmName2 ?? string.Empty;
if (!string.Equals(confirm1, room.Name, StringComparison.Ordinal)
|| !string.Equals(confirm2, room.Name, StringComparison.Ordinal))
{
return BadRequest(new
{
error = "confirm_name_mismatch",
note = "Both ConfirmName1 and ConfirmName2 must exactly equal the room's current Name (case-sensitive).",
});
}
var scenes = await db.RoomScenes.Where(s => s.RoomId == id).ToListAsync();
var roomBlobs = await db.RoomDataBlobs.Where(b => b.RoomId == id).ToListAsync();
RoomDataBlobEntity? imageBlob = null;
if (!string.IsNullOrEmpty(room.ImageName))
{
imageBlob = await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.RoomId == 0 && b.BlobName == room.ImageName);
}
db.RoomScenes.RemoveRange(scenes);
db.RoomDataBlobs.RemoveRange(roomBlobs);
if (imageBlob is not null) db.RoomDataBlobs.Remove(imageBlob);
db.Rooms.Remove(room);
await LogAsync("hard_delete_room", "room", id,
$"name={room.Name} reason={body?.Reason ?? ""} blobs={roomBlobs.Count} scenes={scenes.Count}");
await db.SaveChangesAsync();
return Ok(new
{
deleted = id,
name = room.Name,
blobs = roomBlobs.Count,
scenes = scenes.Count,
thumbnail = imageBlob is not null,
});
}
// ── Leaderboards ─────────────────────────────────────────────────────
/// <summary>Verified stat-channel metadata. The 2020 watch's
/// <c>ConditionalSetStat_ReturningNewValue</c> handles the
/// high-vs-low-is-better decision locally, so the server doesn't
/// enforce direction — this is purely for the admin SPA to render
/// channels sensibly.
///
/// Only entries cross-referenced against decompilation live here.
/// Channel 7 is verified by
/// <c>StuntRunnerManager.get_TotalTimeStatChannelForLeaderboard</c>
/// (StuntRunnerManager.txt:220-228 returns the literal 7); the
/// stored value is milliseconds, hence <c>ValueFormat = "time-ms"</c>.
/// Other channels intentionally stay un-named — they used to ship
/// with best-effort guesses ("Paintball wins", "Dodgeball wins",
/// etc.) that were almost certainly wrong, so the admin UI now
/// renders unknown channels as "Channel N" rather than misleading
/// labels. Add new entries here only when you've verified the
/// channel mapping in the decompiled client.</summary>
private static readonly Dictionary<int, StatChannelMeta> KnownStatChannels = new()
{
[7] = new("Stunt Runner total time", true, "time-ms"),
};
private sealed record StatChannelMeta(string Name, bool LowerIsBetter, string ValueFormat);
/// <summary>GET <c>api/admin/v1/leaderboards/channels</c> — summary
/// of every stat channel that has at least one row. Used by the
/// Leaderboards SPA tab to populate the channel picker.</summary>
[HttpGet("leaderboards/channels")]
public async Task<ActionResult> ListChannels()
{
var rows = await db.LeaderboardStats
.GroupBy(s => s.StatChannel)
.Select(g => new
{
Channel = g.Key,
EntryCount = g.Count(),
MinValue = g.Min(x => x.Value),
MaxValue = g.Max(x => x.Value),
})
.OrderBy(r => r.Channel)
.ToListAsync();
// DB-defined meta beats hardcoded KnownStatChannels so admins
// can rename / reassign channels live without a code change.
var dbMeta = await db.LeaderboardChannelMeta.ToDictionaryAsync(c => c.Channel);
var labelled = rows.Select(r =>
{
dbMeta.TryGetValue(r.Channel, out var live);
KnownStatChannels.TryGetValue(r.Channel, out var fallback);
return new
{
r.Channel,
Name = live?.Name is { Length: > 0 } n ? n : (fallback?.Name ?? $"Channel {r.Channel}"),
RoomId = live?.RoomId ?? 0,
LowerIsBetter = live?.LowerIsBetter ?? fallback?.LowerIsBetter ?? false,
ValueFormat = live?.ValueFormat ?? fallback?.ValueFormat ?? "count",
Verified = live is not null || fallback is not null,
r.EntryCount,
r.MinValue,
r.MaxValue,
};
}).ToList();
return Ok(new
{
Channels = labelled,
// Surface the known set as well so the SPA can offer
// "create a row in channel X" even when the channel is
// currently empty.
Known = KnownStatChannels.Select(kv => new
{
Channel = kv.Key,
kv.Value.Name,
kv.Value.LowerIsBetter,
kv.Value.ValueFormat,
}),
});
}
/// <summary>GET <c>api/admin/v1/leaderboards/{channel}</c> — every
/// stat row in a channel with the player's username, sorted by
/// the channel's natural direction (lower-is-better channels
/// sorted ascending, everything else descending).</summary>
[HttpGet("leaderboards/{channel:int}")]
public async Task<ActionResult> ChannelDetail(int channel, [FromQuery] int take = 100)
{
take = Math.Clamp(take, 1, 1000);
var liveMeta = await db.LeaderboardChannelMeta.FirstOrDefaultAsync(c => c.Channel == channel);
KnownStatChannels.TryGetValue(channel, out var fallback);
var name = liveMeta?.Name is { Length: > 0 } ln ? ln : (fallback?.Name ?? $"Channel {channel}");
var lowerIsBetter = liveMeta?.LowerIsBetter ?? fallback?.LowerIsBetter ?? false;
var valueFormat = liveMeta?.ValueFormat ?? fallback?.ValueFormat ?? "count";
var query = db.LeaderboardStats
.Where(s => s.StatChannel == channel);
query = lowerIsBetter
? query.OrderBy(s => s.Value)
: query.OrderByDescending(s => s.Value);
var rows = await (from s in query.Take(take)
join p in db.Players on s.PlayerId equals p.Id into pj
from p in pj.DefaultIfEmpty()
select new
{
s.Id,
s.PlayerId,
Username = p == null ? null : p.Username,
DisplayName = p == null ? null : p.DisplayName,
s.Value,
s.UpdatedAt,
}).ToListAsync();
return Ok(new
{
Channel = channel,
Name = name,
RoomId = liveMeta?.RoomId ?? 0,
LowerIsBetter = lowerIsBetter,
ValueFormat = valueFormat,
Verified = liveMeta is not null || fallback is not null,
Entries = rows.Select((r, i) => new
{
Rank = i + 1,
r.Id,
r.PlayerId,
r.Username,
r.DisplayName,
r.Value,
r.UpdatedAt,
}),
});
}
public sealed record SetLeaderboardScoreRequest(long PlayerId, int Value);
/// <summary>POST <c>api/admin/v1/leaderboards/{channel}/score</c>
/// — upsert a single player's score in a channel.</summary>
[HttpPost("leaderboards/{channel:int}/score")]
public async Task<ActionResult> SetScore(int channel, [FromBody] SetLeaderboardScoreRequest body)
{
var row = await db.LeaderboardStats.FirstOrDefaultAsync(s =>
s.PlayerId == body.PlayerId && s.StatChannel == channel);
if (row is null)
{
row = new LeaderboardStatEntity
{
PlayerId = body.PlayerId,
StatChannel = channel,
Value = body.Value,
};
db.LeaderboardStats.Add(row);
}
else
{
row.Value = body.Value;
row.UpdatedAt = DateTime.UtcNow;
}
await LogAsync("set_leaderboard_score", "player", body.PlayerId, $"channel={channel} value={body.Value}");
await db.SaveChangesAsync();
return Ok(new { row.PlayerId, row.StatChannel, row.Value });
}
[HttpDelete("leaderboards/{channel:int}/score/{playerId:long}")]
public async Task<ActionResult> DeleteScore(int channel, long playerId)
{
var row = await db.LeaderboardStats.FirstOrDefaultAsync(s =>
s.PlayerId == playerId && s.StatChannel == channel);
if (row is null) return NotFound();
db.LeaderboardStats.Remove(row);
await LogAsync("delete_leaderboard_score", "player", playerId, $"channel={channel}");
await db.SaveChangesAsync();
return Ok(new { deleted = playerId, channel });
}
/// <summary>DELETE <c>api/admin/v1/leaderboards/{channel}</c> —
/// wipe every entry in a stat channel. Used for season resets or
/// to clear test data.</summary>
[HttpDelete("leaderboards/{channel:int}")]
public async Task<ActionResult> WipeChannel(int channel)
{
var rows = await db.LeaderboardStats.Where(s => s.StatChannel == channel).ToListAsync();
if (rows.Count == 0) return Ok(new { wiped = 0, channel });
db.LeaderboardStats.RemoveRange(rows);
await LogAsync("wipe_leaderboard_channel", "system", channel, $"entries={rows.Count}");
await db.SaveChangesAsync();
return Ok(new { wiped = rows.Count, channel });
}
// ── Rec Room Originals (RROs) ────────────────────────────────────────
/// <summary>GET <c>api/admin/v1/rooms/originals</c> — the canonical
/// Rec Room Originals (rooms whose <c>CreatorPlayerId ==
/// PlayerService.SystemAccountId</c>). Backs the RRO admin tab
/// which lets admins flip per-room properties (cloning, max
/// players, description) without scrolling through the global
/// rooms list.</summary>
[HttpGet("rooms/originals")]
public async Task<ActionResult> ListOriginals()
{
var rooms = await db.Rooms
.Where(r => r.CreatorPlayerId == PlayerService.SystemAccountId)
.OrderBy(r => r.Name)
.Select(r => new
{
r.Id,
r.Name,
r.Description,
r.ImageName,
r.Accessibility,
r.CloningAllowed,
r.IsAGRoom,
r.IsDormRoom,
r.State,
r.TagsCsv,
r.HotScore,
r.VisitCount,
r.VisitorCount,
r.CheerCount,
r.CurrentDataBlobName,
SceneCount = db.RoomScenes.Count(s => s.RoomId == r.Id),
BlobCount = db.RoomDataBlobs.Count(b => b.RoomId == r.Id),
})
.ToListAsync();
return Ok(rooms);
}
public sealed record UpdateRoomPropsRequest(
string? Description,
bool? CloningAllowed,
int? Accessibility,
string? TagsCsv,
double? HotScore,
int? State,
string? ImageName);
/// <summary>POST <c>api/admin/v1/rooms/{id}/props</c> — partial
/// update of room properties. Only the non-null fields in the
/// body get written; the rest are left untouched. Backs the
/// RRO edit form and is also usable for regular custom rooms.</summary>
[HttpPost("rooms/{id:long}/props")]
public async Task<ActionResult> UpdateRoomProps(long id, [FromBody] UpdateRoomPropsRequest body)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
if (room is null) return NotFound();
var changes = new List<string>();
if (body.Description is string desc)
{
room.Description = desc;
changes.Add($"description={desc.Length} chars");
}
if (body.CloningAllowed is bool c) { room.CloningAllowed = c; changes.Add($"cloning={c}"); }
if (body.Accessibility is int a) { room.Accessibility = a; changes.Add($"accessibility={a}"); }
if (body.TagsCsv is string tags) { room.TagsCsv = tags; changes.Add($"tags={tags}"); }
if (body.HotScore is double h) { room.HotScore = h; changes.Add($"hot={h}"); }
if (body.State is int st) { room.State = st; changes.Add($"state={st}"); }
if (body.ImageName is string img) { room.ImageName = img; changes.Add($"image={img}"); }
if (changes.Count == 0) return Ok(new { room.Id, unchanged = true });
await LogAsync("update_room_props", "room", id, string.Join(", ", changes));
await db.SaveChangesAsync();
return Ok(new { room.Id, room.Name, applied = changes });
}
// ── Room detail (unified per-room view backing the admin SPA) ─────────
/// <summary>GET <c>api/admin/v1/rooms/{id}</c> — every column on the
/// room plus owner/co-owner/mod/host display info, blob count, scene
/// count, current data blob name, and the latest CheerCount. The
/// admin SPA's per-room detail page consumes this in one shot rather
/// than fanning out to the legacy <c>/rooms</c>, <c>/rooms/originals</c>,
/// <c>/playerReputation</c> endpoints separately.</summary>
[HttpGet("rooms/{id:long}")]
public async Task<ActionResult> GetRoomDetail(long id)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
if (room is null) return NotFound();
var sceneCount = await db.RoomScenes.CountAsync(s => s.RoomId == id);
var blobCount = await db.RoomDataBlobs.CountAsync(b => b.RoomId == id);
var roles = await db.RoomRoles
.Where(r => r.RoomId == id)
.OrderBy(r => r.Role).ThenBy(r => r.GrantedAt)
.ToListAsync();
// Hydrate display names for creator + every role grant in one
// hit so the UI doesn't have to follow up with /players/{id}.
var playerIds = roles.Select(r => r.PlayerId)
.Append(room.CreatorPlayerId)
.Distinct()
.ToList();
var players = await db.Players
.Where(p => playerIds.Contains(p.Id))
.Select(p => new { p.Id, p.Username, p.DisplayName, p.ProfileImageName })
.ToListAsync();
object PlayerInfo(long pid) =>
players.FirstOrDefault(p => p.Id == pid) is { } p
? new { id = p.Id, username = p.Username, displayName = p.DisplayName, profileImageName = p.ProfileImageName }
: new { id = pid, username = $"#{pid}", displayName = "", profileImageName = (string?)null };
return Ok(new
{
room.Id,
room.Name,
room.Description,
room.ImageName,
room.State,
room.Accessibility,
room.IsAGRoom,
room.IsDormRoom,
room.CloningAllowed,
room.SupportsLevelVoting,
room.SupportsVRLow,
room.SupportsMobile,
room.SupportsScreens,
room.SupportsWalkVR,
room.SupportsTeleportVR,
room.AllowsJuniors,
room.DisableMicAutoMute,
room.RoomWarningMask,
room.CustomRoomWarning,
room.TagsCsv,
room.CheerCount,
room.FavoriteCount,
room.VisitCount,
room.VisitorCount,
room.HotScore,
room.LocationReplicationId,
room.CurrentDataBlobName,
room.CreatedAt,
room.UpdatedAt,
sceneCount,
blobCount,
owner = PlayerInfo(room.CreatorPlayerId),
roles = roles.Select(r => new
{
r.Id,
r.PlayerId,
r.Role,
r.Accepted,
r.GrantedByPlayerId,
r.GrantedAt,
player = PlayerInfo(r.PlayerId),
}).ToList(),
});
}
public sealed record AddRoleRequest(long PlayerId, int Role, bool Accepted = true);
/// <summary>POST <c>api/admin/v1/rooms/{id}/roles</c> — grant a
/// (PlayerId, Role) pair on the room. Role: 0=CoOwner, 1=Moderator,
/// 2=Host. Idempotent — re-granting the same role flips Accepted
/// only.</summary>
[HttpPost("rooms/{id:long}/roles")]
public async Task<ActionResult> AddRoomRole(long id, [FromBody] AddRoleRequest body)
{
if (body is null) return BadRequest("missing body");
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
if (room is null) return NotFound();
if (body.PlayerId <= 0) return BadRequest("missing player");
if (body.Role is < 0 or > 2) return BadRequest("invalid role");
var existing = await db.RoomRoles.FirstOrDefaultAsync(r =>
r.RoomId == id && r.PlayerId == body.PlayerId && r.Role == body.Role);
if (existing is null)
{
db.RoomRoles.Add(new RoomRoleEntity
{
RoomId = id,
PlayerId = body.PlayerId,
Role = body.Role,
Accepted = body.Accepted,
GrantedByPlayerId = CurrentAdminId,
});
}
else
{
existing.Accepted = body.Accepted;
}
await LogAsync("room_role_add", "room", id, $"player={body.PlayerId} role={body.Role}");
await db.SaveChangesAsync();
return Ok(new { ok = true });
}
/// <summary>DELETE <c>api/admin/v1/rooms/{id}/roles/{playerId}/{role}</c>
/// — revoke a specific role grant. No-op if the row doesn't exist.</summary>
[HttpDelete("rooms/{id:long}/roles/{playerId:long}/{role:int}")]
public async Task<ActionResult> RemoveRoomRole(long id, long playerId, int role)
{
var row = await db.RoomRoles.FirstOrDefaultAsync(r =>
r.RoomId == id && r.PlayerId == playerId && r.Role == role);
if (row is null) return Ok(new { ok = true, deleted = 0 });
db.RoomRoles.Remove(row);
await LogAsync("room_role_remove", "room", id, $"player={playerId} role={role}");
await db.SaveChangesAsync();
return Ok(new { ok = true, deleted = 1 });
}
public sealed record TransferOwnerRequest(long NewCreatorPlayerId);
/// <summary>POST <c>api/admin/v1/rooms/{id}/owner</c> — reassign the
/// room's CreatorPlayerId. The previous owner is automatically added
/// as a CoOwner so the original creator keeps a foothold on their
/// own room. Reject self-transfers.</summary>
[HttpPost("rooms/{id:long}/owner")]
public async Task<ActionResult> TransferOwner(long id, [FromBody] TransferOwnerRequest body)
{
if (body is null || body.NewCreatorPlayerId <= 0) return BadRequest("missing new owner");
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
if (room is null) return NotFound();
if (room.CreatorPlayerId == body.NewCreatorPlayerId) return Ok(new { ok = true, unchanged = true });
var previousOwner = room.CreatorPlayerId;
room.CreatorPlayerId = body.NewCreatorPlayerId;
room.UpdatedAt = DateTime.UtcNow;
// Auto-grant the former owner CoOwner (id=0) so they don't lose
// access to a room they built. Idempotent — re-running this in
// a fast undo-redo flow won't accumulate dupes.
if (previousOwner > 0 && !await db.RoomRoles.AnyAsync(r =>
r.RoomId == id && r.PlayerId == previousOwner && r.Role == 0))
{
db.RoomRoles.Add(new RoomRoleEntity
{
RoomId = id,
PlayerId = previousOwner,
Role = 0,
Accepted = true,
GrantedByPlayerId = CurrentAdminId,
});
}
await LogAsync("room_transfer_owner", "room", id, $"from={previousOwner} to={body.NewCreatorPlayerId}");
await db.SaveChangesAsync();
return Ok(new { ok = true });
}
/// <summary>GET <c>api/admin/v1/rooms/{id}/instances</c> — active
/// Photon instances for one specific room. Subset of the global
/// <c>/instances</c> endpoint pre-filtered to one room; the admin
/// SPA's room detail page embeds these so admins don't have to
/// scroll a global instance list looking for matches.
///
/// The <c>masterPlayerId</c> field is best-effort: Photon's master
/// is whoever has the lowest <c>ActorNumber</c> in the room (i.e.
/// the first to join). The server doesn't see ActorNumbers, but
/// the player with the lowest DorkNet player id among current
/// participants is a reasonable proxy on dorms (creator joined
/// first) and pretty good on user rooms. For non-dorm rooms with
/// the creator absent, the value just identifies a stable "lead"
/// participant — the room won't fall apart if it's not the actual
/// Photon master, and admins primarily use the badge to know
/// which row to click "Pull in" against to land in the right
/// shard.</summary>
[HttpGet("rooms/{id:long}/instances")]
public async Task<ActionResult> GetRoomInstances(long id)
{
var room = await db.Rooms.Where(r => r.Id == id)
.Select(r => new { r.Id, r.CreatorPlayerId, r.IsDormRoom })
.FirstOrDefaultAsync();
var onlinePids = onlinePresence.OnlinePlayerIds().ToList();
var byInstance = new Dictionary<long, (long roomInstanceId, long roomId, long subRoomId, string roomName, string photonRoomId, string photonRegionId, string location, int maxCapacity, bool isPrivate, List<long> pids)>();
foreach (var pid in onlinePids)
{
var r = playerPresence.GetRoom(pid);
if (r is null || r.RoomId != id) continue;
if (!byInstance.TryGetValue(r.RoomInstanceId, out var slot))
{
slot = (r.RoomInstanceId, r.RoomId, r.SubRoomId, r.Name ?? "", r.PhotonRoomId ?? "", r.PhotonRegionId ?? "", r.Location ?? "", r.MaxCapacity, r.IsPrivate, new List<long>());
}
slot.pids.Add(pid);
byInstance[r.RoomInstanceId] = slot;
}
if (byInstance.Count == 0) return Ok(Array.Empty<object>());
var allPids = byInstance.Values.SelectMany(v => v.pids).Distinct().ToList();
var players = await db.Players
.Where(p => allPids.Contains(p.Id))
.Select(p => new { p.Id, p.Username, p.DisplayName })
.ToDictionaryAsync(p => p.Id);
return Ok(byInstance.Values.Select(i =>
{
// Master heuristic: dorm owner if present, else lowest pid.
long master;
if (room is { IsDormRoom: true } && i.pids.Contains(room.CreatorPlayerId))
master = room.CreatorPlayerId;
else
master = i.pids.OrderBy(p => p).FirstOrDefault();
return new
{
roomInstanceId = i.roomInstanceId,
roomId = i.roomId,
subRoomId = i.subRoomId,
roomName = i.roomName,
photonRoomId = i.photonRoomId,
photonRegionId = i.photonRegionId,
location = i.location,
maxCapacity = i.maxCapacity,
isPrivate = i.isPrivate,
masterPlayerId = master,
participants = i.pids.Select(pid => players.TryGetValue(pid, out var p)
? new { id = p.Id, username = p.Username, displayName = p.DisplayName, isMaster = pid == master }
: new { id = pid, username = $"#{pid}", displayName = "", isMaster = pid == master }).ToList(),
};
}));
}
public sealed record PullIntoInstanceRequest(long PlayerId);
/// <summary>POST <c>api/admin/v1/rooms/{id}/instances/{instanceId}/pull</c>
/// — pull a specific player into one already-active instance of this
/// room. Wraps the existing player force-join flow but pre-fills the
/// instance and validates that the target instance is actually live
/// in this room. Useful for "drop me into the shard the streamer is
/// already in" without having to dig the instance id out by hand.</summary>
[HttpPost("rooms/{id:long}/instances/{instanceId:long}/pull")]
public async Task<ActionResult> PullIntoInstance(long id, long instanceId, [FromBody] PullIntoInstanceRequest body)
{
if (body is null || body.PlayerId <= 0) return BadRequest("missing player");
// Find any participant currently in the target instance — we
// need their RoomInstanceDto to know which Photon shard to
// route the pulled player into. If nobody's online in the
// instance anymore the instance has effectively died; reject.
DorkNet.Server.Controllers.Match.RoomInstanceDto? sample = null;
foreach (var pid in onlinePresence.OnlinePlayerIds())
{
var r = playerPresence.GetRoom(pid);
if (r is not null && r.RoomId == id && r.RoomInstanceId == instanceId)
{
sample = r;
break;
}
}
if (sample is null) return NotFound(new { error = "instance_not_active" });
// Re-stamp presence on the target so their next heartbeat /
// goto sees us pointing at this instance. Push a SignalR
// SubscriptionUpdateGameSession so the watch's matchmaking
// updates LocalRoomInstance immediately.
playerPresence.SetRoom(body.PlayerId, sample);
await notifications.NotifyAsync(body.PlayerId,
PushNotificationId.SubscriptionUpdateGameSession,
sample);
await LogAsync("instance_pull", "instance", instanceId, $"player={body.PlayerId} room={id}");
await db.SaveChangesAsync();
return Ok(new { ok = true, instanceId, roomInstanceId = sample.RoomInstanceId });
}
/// <summary>POST <c>api/admin/v1/rooms/{id}/instances/{instanceId}/close</c>
/// — kick every player currently in the instance via ModerationKick
/// pushes and clear their presence so the server-side view matches.
/// The Photon room itself isn't directly destroyed (we don't have
/// Photon admin credentials); it dies on its own once the last
/// participant disconnects. In practice every kick fires within
/// the same second, so the room is empty seconds later.</summary>
[HttpPost("rooms/{id:long}/instances/{instanceId:long}/close")]
public async Task<ActionResult> CloseInstance(long id, long instanceId, [FromBody] KickRequest? body)
{
var reason = body?.Reason ?? "Instance closed by admin";
var kicked = new List<long>();
foreach (var pid in onlinePresence.OnlinePlayerIds().ToList())
{
var r = playerPresence.GetRoom(pid);
if (r is null || r.RoomId != id || r.RoomInstanceId != instanceId) continue;
await notifications.NotifyAsync(pid, PushNotificationId.ModerationKick, new { Reason = reason });
playerPresence.Clear(pid);
kicked.Add(pid);
}
if (kicked.Count > 0)
{
await LogAsync("instance_close", "instance", instanceId, $"room={id} kicked={kicked.Count}");
await db.SaveChangesAsync();
}
return Ok(new { ok = true, kicked });
}
/// <summary>GET <c>api/admin/v1/rooms/{id}/leaderboards</c> — every
/// stat channel mapped to this room via
/// <see cref="LeaderboardChannelMetaEntity"/>, plus the row count
/// per channel and the top scorer. Channels that report scores but
/// haven't been mapped yet show up in
/// <see cref="LeaderboardOrphans"/> instead.</summary>
[HttpGet("rooms/{id:long}/leaderboards")]
public async Task<ActionResult> GetRoomLeaderboards(long id)
{
var metas = await db.LeaderboardChannelMeta
.Where(c => c.RoomId == id)
.OrderBy(c => c.Channel)
.ToListAsync();
if (metas.Count == 0) return Ok(Array.Empty<object>());
var channels = metas.Select(m => m.Channel).ToList();
var counts = await db.LeaderboardStats
.Where(s => channels.Contains(s.StatChannel))
.GroupBy(s => s.StatChannel)
.Select(g => new { Channel = g.Key, Count = g.Count(), Best = g.Max(x => x.Value), Worst = g.Min(x => x.Value) })
.ToListAsync();
return Ok(metas.Select(m =>
{
var stat = counts.FirstOrDefault(c => c.Channel == m.Channel);
return new
{
m.Channel,
m.Name,
m.LowerIsBetter,
m.ValueFormat,
EntryCount = stat?.Count ?? 0,
BestValue = stat is null ? (long?)null : (m.LowerIsBetter ? stat.Worst : stat.Best),
};
}));
}
public sealed record UpsertChannelMetaRequest(int Channel, long RoomId, string Name, bool LowerIsBetter, string? ValueFormat);
/// <summary>POST <c>api/admin/v1/leaderboards/meta</c> — register or
/// rename a stat channel + assign it to a room. Idempotent; updates
/// the existing row when the channel already has metadata. Does NOT
/// touch the value rows themselves — admins can register a channel
/// before any scores are reported.</summary>
[HttpPost("leaderboards/meta")]
public async Task<ActionResult> UpsertChannelMeta([FromBody] UpsertChannelMetaRequest body)
{
if (body is null) return BadRequest("missing body");
var row = await db.LeaderboardChannelMeta.FirstOrDefaultAsync(c => c.Channel == body.Channel);
if (row is null)
{
row = new LeaderboardChannelMetaEntity { Channel = body.Channel };
db.LeaderboardChannelMeta.Add(row);
}
row.RoomId = body.RoomId;
row.Name = body.Name ?? string.Empty;
row.LowerIsBetter = body.LowerIsBetter;
row.ValueFormat = string.IsNullOrWhiteSpace(body.ValueFormat) ? "count" : body.ValueFormat;
row.UpdatedAt = DateTime.UtcNow;
await LogAsync("leaderboard_meta_upsert", "channel", body.Channel, $"room={body.RoomId} name={row.Name}");
await db.SaveChangesAsync();
return Ok(new { row.Channel, row.RoomId, row.Name, row.LowerIsBetter, row.ValueFormat });
}
/// <summary>DELETE <c>api/admin/v1/leaderboards/meta/{channel}</c> —
/// remove the metadata row. The channel goes back to showing as
/// "Channel N" / unscoped. Existing score rows are kept.</summary>
[HttpDelete("leaderboards/meta/{channel:int}")]
public async Task<ActionResult> DeleteChannelMeta(int channel)
{
var row = await db.LeaderboardChannelMeta.FirstOrDefaultAsync(c => c.Channel == channel);
if (row is null) return Ok(new { ok = true, deleted = 0 });
db.LeaderboardChannelMeta.Remove(row);
await LogAsync("leaderboard_meta_remove", "channel", channel, "");
await db.SaveChangesAsync();
return Ok(new { ok = true, deleted = 1 });
}
/// <summary>GET <c>api/admin/v1/leaderboards/orphans</c> — every
/// stat channel id that has at least one score row but no meta row.
/// Used by the admin Leaderboards UI to surface "you have data for
/// channel 11 — want to give it a name and assign it to a room?".
/// Critical for figuring out which channels Stunt Runner courses
/// actually use: load a course, drive it, then orphans surfaces
/// the channel id the game just reported.</summary>
[HttpGet("leaderboards/orphans")]
public async Task<ActionResult> LeaderboardOrphans()
{
var mapped = await db.LeaderboardChannelMeta.Select(c => c.Channel).ToListAsync();
var rows = await db.LeaderboardStats
.Where(s => !mapped.Contains(s.StatChannel))
.GroupBy(s => s.StatChannel)
.Select(g => new { Channel = g.Key, EntryCount = g.Count(), LastSeen = g.Max(s => s.UpdatedAt) })
.OrderByDescending(r => r.LastSeen)
.ToListAsync();
return Ok(rows);
}
// ── Server settings (runtime toggles) ────────────────────────────────
/// <summary>GET <c>api/admin/v1/settings</c> — current server-wide
/// toggle state. Single row, single round trip.</summary>
[HttpGet("settings")]
public async Task<ActionResult> GetServerSettings()
{
var row = await serverSettings.GetAsync();
return Ok(new
{
row.SignupsDisabled,
row.UpdatedAt,
});
}
public sealed record SignupsToggleRequest(bool Disabled);
/// <summary>POST <c>api/admin/v1/settings/signups</c> — flip the
/// server-wide account-creation kill switch. While disabled, both
/// <c>POST /account/create</c> and <c>POST /api/account/v1/create</c>
/// reply with <c>Success=false</c> + a user-facing error string and
/// the watch surfaces that to whoever tried to sign up. Existing
/// logins are untouched.</summary>
[HttpPost("settings/signups")]
public async Task<ActionResult> SetSignupsDisabled([FromBody] SignupsToggleRequest body)
{
var row = await serverSettings.SetSignupsDisabledAsync(body.Disabled);
await LogAsync(body.Disabled ? "signups_disabled" : "signups_enabled", "system", 0, "");
await db.SaveChangesAsync();
return Ok(new
{
row.SignupsDisabled,
row.UpdatedAt,
});
}
// ── Audit log ────────────────────────────────────────────────────────
[HttpGet("audit")]
public async Task<ActionResult> GetAuditLog([FromQuery] int take = 100)
{
take = Math.Clamp(take, 1, 500);
var rows = await db.AdminActions
.OrderByDescending(a => a.Timestamp)
.Take(take)
.ToListAsync();
return Ok(rows);
}
// ── Reports moderation ───────────────────────────────────────────────
/// <summary>List unresolved player reports, oldest-first.
/// Admins triage from this queue.</summary>
[HttpGet("reports")]
public async Task<ActionResult> ListReports([FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 200);
var rows = await db.Reports
.Where(r => r.ResolvedAt == null)
.OrderBy(r => r.CreatedAt)
.Take(take)
.ToListAsync();
return Ok(rows);
}
public sealed record ResolveReportRequest(string? Note);
/// <summary>Mark a report as resolved with an optional note.
/// The actual moderation action (ban, warn, dismiss) is the
/// admin's call; this endpoint only closes the report row.</summary>
[HttpPost("reports/{id:long}/resolve")]
public async Task<ActionResult> ResolveReport(long id, [FromBody] ResolveReportRequest? body)
{
var rep = await db.Reports.FirstOrDefaultAsync(r => r.Id == id);
if (rep is null) return NotFound();
if (rep.ResolvedAt is not null) return Ok(new { already_resolved = true });
rep.ResolvedAt = DateTime.UtcNow;
rep.ResolverAdminId = CurrentAdminId;
rep.ResolutionNote = (body?.Note ?? string.Empty).Trim();
await LogAsync("resolve_report", "report", id, rep.ResolutionNote);
await db.SaveChangesAsync();
return Ok(new { rep.Id, rep.ResolvedAt });
}
// ── Kick / IP ban ────────────────────────────────────────────────────
public sealed record KickRequest(string? Reason);
/// <summary>Force-disconnect a player from the SignalR hub and
/// signal them to reload via a ModerationKick push. Doesn't ban
/// — the player can immediately reconnect — but boots them out
/// of whatever room/session they're in. For temporary
/// moderation actions where a full ban is too heavy.</summary>
[HttpPost("players/{id:long}/kick")]
public async Task<ActionResult> KickPlayer(long id, [FromBody] KickRequest? body)
{
await notifications.NotifyAsync(id, PushNotificationId.ModerationKick,
new { Reason = body?.Reason ?? "" });
await LogAsync("kick_player", "player", id, body?.Reason ?? "");
await db.SaveChangesAsync();
return Ok(new { kicked = id });
}
public sealed record IpBanRequest(string Cidr, string? Reason, int? DurationDays);
/// <summary>Add an IP-level ban. Optional duration in days
/// (null = permanent until removed). Format: dotted IPv4 or
/// CIDR like <c>1.2.3.0/24</c>.</summary>
[HttpPost("ipbans")]
public async Task<ActionResult> AddIpBan([FromBody] IpBanRequest body)
{
if (string.IsNullOrWhiteSpace(body.Cidr))
return BadRequest("missing cidr");
var until = body.DurationDays is int days
? DateTime.UtcNow.AddDays(Math.Clamp(days, 1, 3650))
: (DateTime?)null;
var entry = new IpBanEntity
{
Cidr = body.Cidr.Trim(),
Reason = (body.Reason ?? string.Empty).Trim(),
BannedByAdminId = CurrentAdminId,
Until = until,
};
db.IpBans.Add(entry);
await LogAsync("ipban_add", "ip", 0, $"{body.Cidr} until {until:o}");
await db.SaveChangesAsync();
return Ok(entry);
}
[HttpDelete("ipbans/{id:long}")]
public async Task<ActionResult> RemoveIpBan(long id)
{
var ban = await db.IpBans.FirstOrDefaultAsync(b => b.Id == id);
if (ban is null) return NotFound();
db.IpBans.Remove(ban);
await LogAsync("ipban_remove", "ip", id, ban.Cidr);
await db.SaveChangesAsync();
return Ok();
}
[HttpGet("ipbans")]
public async Task<ActionResult> ListIpBans()
{
var rows = await db.IpBans
.OrderByDescending(b => b.BannedAt)
.ToListAsync();
return Ok(rows);
}
// ── Stats ────────────────────────────────────────────────────────────
/// <summary>Real-time server snapshot for the admin dashboard.
/// Every aggregate is one cheap query — designed to be hit by
/// the admin watch tab on a 5-second timer without melting the
/// SQLite write path.</summary>
[HttpGet("stats")]
public async Task<ActionResult> Stats()
{
var totalPlayers = await db.Players.CountAsync();
var totalRooms = await db.Rooms.CountAsync();
var totalInventions = await db.Inventions.CountAsync();
var openReports = await db.Reports.CountAsync(r => r.ResolvedAt == null);
var bannedNow = await db.Players.CountAsync(p =>
p.BannedUntil != null && p.BannedUntil > DateTime.UtcNow);
var activeIpBans = await db.IpBans.CountAsync(b =>
b.Until == null || b.Until > DateTime.UtcNow);
var online = onlinePresence.OnlinePlayerIds().ToArray();
var topRooms = await db.Rooms
.OrderByDescending(r => r.VisitCount)
.Take(10)
.Select(r => new { r.Id, r.Name, r.VisitCount, r.VisitorCount, r.CheerCount })
.ToListAsync();
var recentJoins = await db.Players
.OrderByDescending(p => p.CreatedAt)
.Take(10)
.Select(p => new { p.Id, p.Username, p.CreatedAt })
.ToListAsync();
return Ok(new
{
Players = new
{
Total = totalPlayers,
OnlineNow = online.Length,
BannedNow = bannedNow,
},
Rooms = new { Total = totalRooms, TopByVisits = topRooms },
Inventions = totalInventions,
Moderation = new
{
OpenReports = openReports,
ActiveIpBans = activeIpBans,
},
RecentJoins = recentJoins,
ServerTime = DateTime.UtcNow,
});
}
// ── Avatar / outfit / inventory grants ──────────────────────────────
public sealed record OutfitRequest(string? OutfitSelections, string? FaceFeatures, string? HairColor, string? SkinColor);
/// <summary>Get a player's current avatar so the admin UI can
/// pre-fill its edit form. Returns the same DTO shape as
/// <c>GET api/avatar/v2/equipped/{id}</c>.</summary>
[HttpGet("players/{id:long}/avatar")]
public async Task<ActionResult> GetAvatar(long id)
{
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == id);
if (avatar is null) return NotFound();
return Ok(new
{
avatar.PlayerId,
avatar.OutfitSelections,
avatar.FaceFeatures,
avatar.HairColor,
avatar.SkinColor,
avatar.UpdatedAt,
});
}
/// <summary>POST <c>api/admin/v1/players/{id}/avatar/reset</c> —
/// wipe a player's equipped outfit + inventory back to the canonical
/// 2020 starter values. Recovery path for the
/// "watch crashes after ~10 minutes" symptom where a player has an
/// item GUID (e.g. <c>Eye_MirrorShades_PinkClear</c>) whose
/// outfits_assets bundle isn't shipped with this build, so
/// <see cref="OutfitManager"/>.SpawnOutfitImposter throws a
/// Dependency Exception from Addressables and the engine eventually
/// tears down the local player. Resetting clears those entries
/// from <see cref="AvatarEntity.OutfitSelections"/> and
/// <see cref="AvatarEntity.InventoryJson"/> so the next avatar
/// load only references items the local build has on disk.</summary>
[HttpPost("players/{id:long}/avatar/reset")]
public async Task<ActionResult> ResetAvatar(long id)
{
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == id);
if (avatar is null)
{
avatar = new AvatarEntity { PlayerId = id };
db.Avatars.Add(avatar);
}
avatar.OutfitSelections = PlayerService.StarterOutfitSelections;
avatar.InventoryJson = PlayerService.StarterInventoryJson;
avatar.HairColor = PlayerService.StarterHairColor;
avatar.SkinColor = PlayerService.StarterSkinColor;
avatar.UpdatedAt = DateTime.UtcNow;
await LogAsync("reset_avatar", "player", id, "");
await db.SaveChangesAsync();
// Push a profile update so the affected watch re-fetches its
// avatar without waiting for the next scene load.
await notifications.NotifyAsync(id,
PushNotificationId.SubscriptionUpdateProfile,
new { Reason = "AdminAvatarReset" });
return Ok(new
{
avatar.PlayerId,
avatar.OutfitSelections,
avatar.HairColor,
avatar.SkinColor,
});
}
/// <summary>Replace another player's outfit / hair / skin colour.
/// Pushes <c>SubscriptionUpdateProfile</c> so the affected watch
/// re-fetches its avatar and the new clothes propagate to other
/// players in the same room.</summary>
[HttpPost("players/{id:long}/avatar")]
public async Task<ActionResult> SetAvatar(long id, [FromBody] OutfitRequest body)
{
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == id);
if (avatar is null)
{
avatar = new AvatarEntity { PlayerId = id };
db.Avatars.Add(avatar);
}
if (body.OutfitSelections is not null) avatar.OutfitSelections = body.OutfitSelections;
if (body.FaceFeatures is not null) avatar.FaceFeatures = body.FaceFeatures;
if (body.HairColor is not null) avatar.HairColor = body.HairColor;
if (body.SkinColor is not null) avatar.SkinColor = body.SkinColor;
avatar.UpdatedAt = DateTime.UtcNow;
await LogAsync("set_avatar", "player", id, body.OutfitSelections ?? "");
await db.SaveChangesAsync();
await notifications.NotifyAsync(id,
PushNotificationId.SubscriptionUpdateProfile,
new { Reason = "AdminAvatarUpdate" });
return Ok(new { avatar.PlayerId, avatar.OutfitSelections, avatar.HairColor, avatar.SkinColor });
}
public sealed record GrantItemRequest(string ItemId, int Quantity);
/// <summary>Append an item GUID to the player's inventory JSON list.
/// The client treats <c>InventoryJson</c> as the truth source for
/// what they own; once granted, the next avatar fetch shows the
/// item available in the customisation menu.</summary>
[HttpPost("players/{id:long}/inventory/grant")]
public async Task<ActionResult> GrantItem(long id, [FromBody] GrantItemRequest body)
{
if (string.IsNullOrWhiteSpace(body.ItemId)) return BadRequest("missing itemId");
var qty = Math.Clamp(body.Quantity, 1, 1000);
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == id);
if (avatar is null)
{
avatar = new AvatarEntity { PlayerId = id };
db.Avatars.Add(avatar);
}
// Inventory wire format: JSON array of { itemId, quantity }. Merge
// by itemId so repeat grants don't duplicate rows.
var list = ParseInventory(avatar.InventoryJson);
var existing = list.FirstOrDefault(e => e.ItemId == body.ItemId);
if (existing is null)
list.Add(new InventoryEntry { ItemId = body.ItemId, Quantity = qty });
else
existing.Quantity += qty;
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(list);
avatar.UpdatedAt = DateTime.UtcNow;
await LogAsync("grant_item", "player", id, $"{body.ItemId} x{qty}");
await db.SaveChangesAsync();
await notifications.NotifyAsync(id,
PushNotificationId.SubscriptionUpdateProfile,
new { Reason = "InventoryGrant", body.ItemId, Quantity = qty });
return Ok(new { granted = body.ItemId, quantity = qty });
}
public sealed record CurrencyRequest(int CurrencyType, long Amount, string? Reason);
/// <summary>Adjust a player's currency wallet. Positive amounts
/// grant; negative deduct (clamped to zero by
/// <see cref="LevelService.GrantCurrencyAsync"/>). The client's
/// next storefronts/balance fetch returns the new value.</summary>
[HttpPost("players/{id:long}/currency")]
public async Task<ActionResult> GrantCurrency(long id, [FromBody] CurrencyRequest body)
{
var newBalance = await level.GrantCurrencyAsync(
id, body.CurrencyType, body.Amount, body.Reason ?? "admin_grant");
await LogAsync("grant_currency", "player", id,
$"type={body.CurrencyType} amount={body.Amount}");
await db.SaveChangesAsync();
return Ok(new { id, body.CurrencyType, balance = newBalance });
}
public sealed record XpRequest(int Amount, string? Reason);
/// <summary>Grant XP to a player. Triggers level-up rewards via
/// <see cref="LevelService.AwardXpAsync"/> if the new XP crosses a
/// level threshold.</summary>
[HttpPost("players/{id:long}/xp")]
public async Task<ActionResult> GrantXp(long id, [FromBody] XpRequest body)
{
var (lvl, xp) = await level.AwardXpAsync(id, body.Amount, body.Reason ?? "admin_grant");
await LogAsync("grant_xp", "player", id, $"amount={body.Amount}");
await db.SaveChangesAsync();
return Ok(new { id, level = lvl, xp });
}
public sealed record ProfileFlagsRequest(
bool? IsVerified,
bool? IsDeveloper,
bool? IsJunior,
bool? IsCommunityTeam);
/// <summary>Toggle the verified / developer / community-team /
/// junior flags on a player profile. Verified shows up as the
/// blue-checkmark badge on the watch's profile card; IsDeveloper
/// and IsCommunityTeam both unlock the watch's overhead-badge
/// slider (see <see cref="DorkNet.Server.Controllers.API.Role.RoleController.IsDeveloper"/>)
/// — once unlocked, the player picks "Community Team" or
/// "Developer" themselves from the in-watch settings menu and the
/// label renders above their head for every other player in the
/// room. We expose both as separate admin flags so the audit log
/// preserves the distinction even though the 2020 watch can't
/// enforce one badge over the other.</summary>
[HttpPost("players/{id:long}/flags")]
public async Task<ActionResult> SetFlags(long id, [FromBody] ProfileFlagsRequest body)
{
var p = await db.Players.FirstOrDefaultAsync(x => x.Id == id);
if (p is null) return NotFound();
if (body.IsVerified is bool v) p.IsVerified = v;
if (body.IsDeveloper is bool d) p.IsDeveloper = d;
if (body.IsJunior is bool j) p.IsJunior = j;
if (body.IsCommunityTeam is bool ct) p.IsCommunityTeam = ct;
await LogAsync("set_flags", "player", id,
$"verified={p.IsVerified} dev={p.IsDeveloper} communityteam={p.IsCommunityTeam} junior={p.IsJunior}");
await db.SaveChangesAsync();
return Ok(new { p.Id, p.IsVerified, p.IsDeveloper, p.IsCommunityTeam, p.IsJunior });
}
public sealed record DisplayNameRequest(string DisplayName);
/// <summary>Force-set a player's display name (admin override of
/// the normal username-uniqueness flow).</summary>
[HttpPost("players/{id:long}/displayName")]
public async Task<ActionResult> SetDisplayName(long id, [FromBody] DisplayNameRequest body)
{
var p = await db.Players.FirstOrDefaultAsync(x => x.Id == id);
if (p is null) return NotFound();
if (string.IsNullOrWhiteSpace(body.DisplayName)) return BadRequest("empty");
p.DisplayName = body.DisplayName.Trim();
await LogAsync("set_display_name", "player", id, p.DisplayName);
await db.SaveChangesAsync();
return Ok(new { p.Id, p.DisplayName });
}
private sealed class InventoryEntry
{
[System.Text.Json.Serialization.JsonPropertyName("itemId")]
public string ItemId { get; set; } = string.Empty;
[System.Text.Json.Serialization.JsonPropertyName("quantity")]
public int Quantity { get; set; }
}
private static List<InventoryEntry> ParseInventory(string? json)
{
if (string.IsNullOrWhiteSpace(json) || json == "[]") return new();
try
{
return System.Text.Json.JsonSerializer.Deserialize<List<InventoryEntry>>(json) ?? new();
}
catch
{
return new();
}
}
// ── Avatar catalog (gifting picker source) ───────────────────────────
/// <summary>GET <c>api/admin/v1/avatar-items</c> — flat list of every
/// renderable avatar item, keyed by the GUID the inventory grant
/// endpoint expects. Backs the Gift page's item picker so admins can
/// browse by friendly name instead of pasting GUIDs by hand. Read-only.
/// Catalog source is <c>data/avatar_item_lookup.json</c>, parsed once
/// at startup by <see cref="DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController"/>.</summary>
[HttpGet("avatar-items")]
public ActionResult ListAvatarItems()
{
var catalog = DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.Catalog;
var safe = DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.SafeGuids;
var rows = catalog
.Select(kv => new
{
Guid = kv.Key,
Slot = kv.Value.AvatarItemType,
FriendlyName = kv.Value.FriendlyName,
Tooltip = kv.Value.Tooltip,
Rarity = kv.Value.Rarity,
Safe = safe.Contains(kv.Key),
})
.Where(r => r.Safe) // hide GUIDs that would crash the watch's parser
.OrderBy(r => r.Slot)
.ThenBy(r => r.FriendlyName)
.ToList();
return Ok(rows);
}
// ── Send gift package (in-game gift box popup) ────────────────────────
public sealed record SendGiftRequest(
string? AvatarItemGuid,
int? AvatarItemType, // 0 = Outfit, 1 = HairDye (per RecNet.Avatars+AvatarItemType)
int? CurrencyType, // 1 = Tokens, 2 = Coins (0 = none)
int? Currency,
int? Xp,
int? Level,
string? Message,
int? Rarity); // 0 Common, 10 Uncommon, 20 Rare, 30 Epic, 50 Legendary
/// <summary>POST <c>api/admin/v1/players/{id}/gift</c> — drop a
/// <see cref="GiftPackageEntity"/> into the recipient's gift inbox.
/// The watch's <c>RecNet.Avatars.DowloadGiftPackages</c> loop picks
/// it up on the next poll and shows the standard gift-box popup;
/// the player taps "open" to fire <c>LocalConsumeGiftPackage</c>,
/// which hits <see cref="DorkNet.Server.Controllers.API.Avatar.V2.AvatarGiftsController.ConsumeViaBody"/>
/// and finally writes the avatar item into <c>InventoryJson</c>.
/// We DO NOT touch InventoryJson here — that bypasses the gift
/// flow and the player never sees the popup.
///
/// Also pushes a <see cref="PushNotificationId.GiftPackageReceived"/>
/// over SignalR so an online player sees the toast immediately
/// without waiting for the next /gifts poll.</summary>
[HttpPost("players/{id:long}/gift")]
public async Task<ActionResult> SendGift(long id, [FromBody] SendGiftRequest body)
{
var recipient = await db.Players.FirstOrDefaultAsync(p => p.Id == id);
if (recipient is null) return NotFound();
var hasItem = !string.IsNullOrWhiteSpace(body.AvatarItemGuid);
var hasCurrency = body.CurrencyType is int ct && ct > 0 && body.Currency is int c && c > 0;
var hasXp = body.Xp is int xp && xp > 0;
var hasLevel = body.Level is int lvl && lvl > 0;
if (!hasItem && !hasCurrency && !hasXp && !hasLevel)
return BadRequest(new { error = "empty_gift", message = "Provide at least one of: avatar item, currency, XP, level." });
// Wire shape MUST match the working /api/avatar/v2/gifts/generate
// flow exactly. The watch's ParseGiftPackageReceived
// (Avatars.txt:616) gates on IsValid + SupportsCurrentPlatform,
// then runs the gift through GiftManager. Two deviations from
// /generate were breaking us:
// 1. FromPlayerId set to the admin's id — the watch's P2P-gift
// rendering path looks up sender info; if that lookup
// doesn't return what the popup renderer expects, the toast
// still fires from the SignalR push but the box never
// materializes. /generate uses null; we match.
// 2. IsGifted = true tagged it as a player-to-player gift,
// which routes through a different code path entirely
// (separate inbox / different popup). /generate leaves it
// at the default false; we match.
// CRITICAL invariants for AvatarItemDescOrHairDyeDesc, both
// verified against the in-game crash trace:
//
// 1. AvatarItemType MUST be null when no avatar item is in the
// gift. Setting it to 0 (Outfit) with an empty desc routes
// GiftManager.DequeueGift past the HasAvatarItemOrHairDye
// gate into OutfitManager.MarkGiftPackageItemAvailableOnCurrentPlatform("")
// → AvatarItem.FromRecNetString("") → "empty RecNet string"
// AvatarParseException.
//
// 2. When there IS an item, AvatarItemDescOrHairDyeDesc must
// have at least 4 comma-separated parts. The client's
// FromRecNetString(string) splits on `,` then constructs
// ArraySegment(parts, 1, 3) — parts.Length < 4 triggers
// ArgumentException at ArraySegmentEnumerator.get_Current
// (output_log.txt:1567). Bare GUIDs crash; we suffix `,,,`
// for three empty default customisations. See
// AvatarItemsController.cs:31-40 for the canonical comment.
var gift = new GiftPackageEntity
{
RecipientPlayerId = id,
FromPlayerId = null,
AvatarItemType = hasItem ? (body.AvatarItemType ?? 0) : (int?)null,
AvatarItemDescOrHairDyeDesc = hasItem
? NormalizeAvatarItemDesc(body.AvatarItemGuid!.Trim())
: string.Empty,
CurrencyType = hasCurrency ? body.CurrencyType!.Value : 0,
Currency = hasCurrency ? body.Currency!.Value : 0,
Xp = hasXp ? body.Xp!.Value : 0,
Level = hasLevel ? body.Level!.Value : 0,
GiftContext = 0,
GiftRarity = body.Rarity ?? 0,
Message = string.IsNullOrWhiteSpace(body.Message)
? "A gift from the admins."
: body.Message.Trim(),
Platform = -1,
PackageVariant = "Standard",
PackageMaterial = string.Empty,
IsValid = true,
SupportsCurrentPlatform = true,
};
db.GiftPackages.Add(gift);
await LogAsync("send_gift", "player", id,
$"item={(hasItem ? gift.AvatarItemDescOrHairDyeDesc : "-")} " +
$"currency={(hasCurrency ? $"{gift.CurrencyType}×{gift.Currency}" : "-")} " +
$"xp={gift.Xp} level={gift.Level}");
await db.SaveChangesAsync();
// GiftPackageReceivedImmediate (31), NOT GiftPackageReceived (30).
// GiftManager.OnGiftPackageReceivedEvent (GiftManager.txt:501) takes
// a `showImmediately` bool — true for id 31, false for id 30. When
// false the handler returns *without* calling ReceiveGifts, so no
// box pops. Admin gifts want the box now, not "next time you open
// the gift inbox", so push 31. The watch's RecNet.Avatars handler
// registers BOTH ids (Avatars.txt:177 + 192), and 31 is the only
// one that ends up invoking the GiftManager popup path.
await notifications.NotifyAsync(id,
PushNotificationId.GiftPackageReceivedImmediate,
new
{
gift.Id,
gift.FromPlayerId,
gift.AvatarItemType,
AvatarItemDesc = gift.AvatarItemDescOrHairDyeDesc,
gift.AvatarItemDescOrHairDyeDesc,
gift.EquipmentPrefabName,
gift.EquipmentModificationGuid,
gift.CurrencyType,
gift.Currency,
gift.Xp,
gift.Level,
gift.GiftContext,
gift.GiftRarity,
gift.Message,
gift.Platform,
gift.PackageMaterial,
gift.PackageVariant,
gift.Consumed,
gift.IsValid,
gift.ErrorMessage,
gift.SupportsCurrentPlatform,
gift.IsGifted,
});
return Ok(new { gift.Id, gift.RecipientPlayerId });
}
/// <summary>Pad a bare avatar item GUID with three empty default
/// customisations so the watch's <c>AvatarItem.FromRecNetString</c>
/// doesn't crash on <c>ArraySegment(parts, 1, 3)</c> when the
/// string only contains the GUID. Idempotent — descs that already
/// have at least 4 comma-separated parts are returned untouched.</summary>
private static string NormalizeAvatarItemDesc(string descOrGuid)
{
if (string.IsNullOrWhiteSpace(descOrGuid)) return string.Empty;
var commaCount = 0;
foreach (var ch in descOrGuid) if (ch == ',') commaCount++;
return commaCount >= 3 ? descOrGuid : descOrGuid + new string(',', 3 - commaCount);
}
// ── Pending gift inbox (clean-up) ────────────────────────────────────
/// <summary>GET <c>api/admin/v1/players/{id}/gifts</c> — list the
/// recipient's pending (unconsumed) gift packages. Used by the admin
/// Gift page to surface broken / abandoned gifts that the watch
/// can't render so we can delete them. Includes consumed ones too
/// in case we want to audit what's been opened.</summary>
[HttpGet("players/{id:long}/gifts")]
public async Task<ActionResult> ListPlayerGifts(long id, [FromQuery] bool includeConsumed = false)
{
IQueryable<GiftPackageEntity> q = db.GiftPackages.Where(g => g.RecipientPlayerId == id);
if (!includeConsumed) q = q.Where(g => !g.Consumed);
var rows = await q
.OrderByDescending(g => g.CreatedAt)
.Take(200)
.Select(g => new
{
g.Id,
g.RecipientPlayerId,
g.FromPlayerId,
g.AvatarItemType,
g.AvatarItemDescOrHairDyeDesc,
g.CurrencyType,
g.Currency,
g.Xp,
g.Level,
g.GiftContext,
g.GiftRarity,
g.Message,
g.Consumed,
g.ConsumedAt,
g.IsValid,
g.IsGifted,
g.CreatedAt,
})
.ToListAsync();
return Ok(rows);
}
/// <summary>DELETE <c>api/admin/v1/gifts/{id}</c> — hard-delete a
/// gift package row. Use when a gift is broken (wrong shape, won't
/// render its box in the watch) or was a mis-fire from a test send.
/// Does not affect the player's actual inventory if they'd already
/// consumed it — that write happened during consume and is final.</summary>
[HttpDelete("gifts/{id:long}")]
public async Task<ActionResult> DeleteGift(long id)
{
var gift = await db.GiftPackages.FirstOrDefaultAsync(g => g.Id == id);
if (gift is null) return NotFound();
var recipient = gift.RecipientPlayerId;
db.GiftPackages.Remove(gift);
await LogAsync("delete_gift", "gift", id, $"recipient={recipient} consumed={gift.Consumed}");
await db.SaveChangesAsync();
return Ok(new { deleted = id });
}
/// <summary>POST <c>api/admin/v1/players/{id}/gifts/clear</c> —
/// nuke every unconsumed gift for a player. Useful after a burst of
/// broken admin-test gifts to reset the inbox to a clean state.
/// Consumed gifts are left alone (they're history, not state).</summary>
[HttpPost("players/{id:long}/gifts/clear")]
public async Task<ActionResult> ClearPlayerGifts(long id)
{
var pending = await db.GiftPackages
.Where(g => g.RecipientPlayerId == id && !g.Consumed)
.ToListAsync();
db.GiftPackages.RemoveRange(pending);
await LogAsync("clear_gifts", "player", id, $"removed={pending.Count}");
await db.SaveChangesAsync();
return Ok(new { removed = pending.Count });
}
// ── Password reset (admin override) ──────────────────────────────────
public sealed record SetPasswordRequest(string NewPassword);
/// <summary>Admin-set a new password for any account. Hashes with
/// BCrypt and persists onto <see cref="PlayerEntity.PasswordHash"/>.
/// The audit row records the action but never the plaintext.
/// Refuses passwords shorter than 8 chars — same floor as the
/// self-service flow.</summary>
[HttpPost("players/{id:long}/password")]
public async Task<ActionResult> SetPassword(long id, [FromBody] SetPasswordRequest body)
{
if (string.IsNullOrWhiteSpace(body.NewPassword) || body.NewPassword.Length < 8)
return BadRequest(new { error = "password_too_short", min = 8 });
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == id);
if (player is null) return NotFound();
player.PasswordHash = BCrypt.Net.BCrypt.HashPassword(body.NewPassword);
await LogAsync("set_password", "player", id, "");
await db.SaveChangesAsync();
// Push a logout so any active sessions on the old password get
// dropped — the watch re-authenticates with the new password.
await notifications.NotifyAsync(id, PushNotificationId.Logout,
new { Reason = "AdminPasswordReset" });
return Ok(new { id });
}
// ── Live instance viewer ─────────────────────────────────────────────
/// <summary>GET <c>api/admin/v1/instances</c> — list every active room
/// instance an online player is currently sitting in. Joins the live
/// online-player set from <see cref="OnlinePresenceService"/> against
/// <see cref="PlayerPresenceService"/> (which holds the last-known
/// <see cref="RoomInstanceDto"/> per player, TTL'd to 45 seconds) and
/// groups by <c>RoomInstanceId</c>. Empty list when no one is online.</summary>
[HttpGet("instances")]
public async Task<ActionResult> ListInstances()
{
var onlineIds = onlinePresence.OnlinePlayerIds().ToHashSet();
if (onlineIds.Count == 0) return Ok(Array.Empty<object>());
// Bulk-pull names so we can render usernames per participant.
var nameMap = await db.Players
.Where(p => onlineIds.Contains(p.Id))
.Select(p => new { p.Id, p.Username, p.DisplayName })
.ToDictionaryAsync(p => p.Id, p => new { p.Username, p.DisplayName });
var roomNameMap = await db.Rooms
.Select(r => new { r.Id, r.Name })
.ToDictionaryAsync(r => r.Id, r => r.Name);
var grouped = new Dictionary<long, InstanceGroup>();
foreach (var pid in onlineIds)
{
var room = playerPresence.GetRoom(pid);
if (room is null) continue;
if (!grouped.TryGetValue(room.RoomInstanceId, out var g))
{
g = new InstanceGroup
{
RoomInstanceId = room.RoomInstanceId,
RoomId = room.RoomId,
SubRoomId = room.SubRoomId,
RoomName = roomNameMap.GetValueOrDefault(room.RoomId, $"#{room.RoomId}"),
PhotonRoomId = room.PhotonRoomId,
PhotonRegionId = room.PhotonRegionId,
Location = room.Location,
MaxCapacity = room.MaxCapacity,
IsPrivate = room.IsPrivate,
Participants = new(),
};
grouped[room.RoomInstanceId] = g;
}
nameMap.TryGetValue(pid, out var nm);
g.Participants.Add(new InstanceParticipant
{
Id = pid,
Username = nm?.Username ?? $"#{pid}",
DisplayName = nm?.DisplayName ?? $"#{pid}",
});
}
return Ok(grouped.Values
.OrderByDescending(g => g.Participants.Count)
.ThenBy(g => g.RoomName));
}
private sealed class InstanceGroup
{
public long RoomInstanceId { get; set; }
public long RoomId { get; set; }
public long SubRoomId { get; set; }
public string RoomName { get; set; } = "";
public string PhotonRoomId { get; set; } = "";
public string PhotonRegionId { get; set; } = "";
public string Location { get; set; } = "";
public int MaxCapacity { get; set; }
public bool IsPrivate { get; set; }
public List<InstanceParticipant> Participants { get; set; } = new();
}
private sealed class InstanceParticipant
{
public long Id { get; set; }
public string Username { get; set; } = "";
public string DisplayName { get; set; } = "";
}
// ── Force-join (experimental) ────────────────────────────────────────
/// <summary>
/// Force-join body. <c>RoomInstanceId</c> + <c>PhotonRoomId</c> are
/// optional — when omitted, the watch lands in a fresh matchmade
/// instance of the room. When provided (from the Instances browser
/// → "send into this match" flow), we target that exact Photon room
/// so the admin can drop a player into a specific public or private
/// instance other players are already in.
/// </summary>
public sealed record ForceJoinRequest(
long RoomId,
long? SubRoomId,
long? RoomInstanceId,
string? PhotonRoomId,
string? PhotonRegionId);
/// <summary>POST <c>api/admin/v1/players/{id}/forcejoin</c> — rewrite
/// the target player's <see cref="PlayerPresenceService"/> entry to
/// the destination room (and optionally a specific Photon instance),
/// then push <c>SubscriptionUpdateGameSession</c> + <c>SubscriptionUpdateRoom</c>
/// so the watch's cached RoomInstance + RoomDetails match the new
/// server-side state. The watch's
/// <c>OnPresenceHeartbeatResponse</c> detects the change on its
/// next heartbeat and refreshes its cache; pair with
/// <c>POST players/{id}/kick</c> to also drop the player out of
/// their current Photon match so they re-matchmake into the new
/// target on reconnect.
///
/// When <c>RoomInstanceId</c> + <c>PhotonRoomId</c> are supplied,
/// the player lands in that EXACT instance (public or private). Use
/// the Instances browser to enumerate available Photon rooms and
/// pick one. With both fields omitted, a fresh deterministic
/// instance id is used so the player matchmakes normally.</summary>
[HttpPost("players/{id:long}/forcejoin")]
public async Task<ActionResult> ForceJoin(long id, [FromBody] ForceJoinRequest body)
{
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == id);
if (player is null) return NotFound();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == body.RoomId);
if (room is null) return NotFound(new { error = "room_not_found" });
// Build the destination RoomInstance. When the admin picked a
// specific instance from the Instances browser, RoomInstanceId
// + PhotonRoomId arrive together — use them verbatim so the
// player lands in THAT match, not a fresh one. Otherwise mint
// deterministic defaults that match what /goto/room would
// produce for a first join (roomId*100k + subId base; bare
// ^roomname_id photon room name).
var subRoomId = body.SubRoomId ?? 0;
var photonRoomId = !string.IsNullOrEmpty(body.PhotonRoomId)
? body.PhotonRoomId
: $"^{room.Name.ToLowerInvariant()}_{room.Id}";
var roomInstanceId = body.RoomInstanceId
?? (room.Id * 100_000L + subRoomId);
var photonRegionId = !string.IsNullOrEmpty(body.PhotonRegionId)
? body.PhotonRegionId
: "us";
var roomInstance = new DorkNet.Server.Controllers.Match.RoomInstanceDto
{
RoomInstanceId = roomInstanceId,
RoomId = room.Id,
SubRoomId = subRoomId,
Location = room.LocationReplicationId,
PhotonRegionId = photonRegionId,
PhotonRoomId = photonRoomId,
Name = room.Name,
MaxCapacity = 8,
IsFull = false,
IsPrivate = false,
IsInProgress = true,
};
playerPresence.SetRoom(id, roomInstance);
// Two pushes back-to-back: GameSession update changes the
// watch's cached RoomInstance (OnSubscriptionUpdateRoomInstance
// → UpdateCachedRoomInstance), and SubscriptionUpdateRoom
// refreshes the watch's RoomDetails cache so the room-info
// panel doesn't show stale data after the warp.
await notifications.NotifyAsync(id,
PushNotificationId.SubscriptionUpdateGameSession, roomInstance);
await notifications.NotifyAsync(id,
PushNotificationId.SubscriptionUpdateRoom,
new
{
Room = new { Id = room.Id, Name = room.Name },
RoomId = room.Id,
SubRoomId = subRoomId,
Reason = "AdminForceJoin",
});
await LogAsync("force_join", "player", id,
$"room={body.RoomId} sub={subRoomId} instance={roomInstanceId} photon={photonRoomId}");
await db.SaveChangesAsync();
return Ok(new
{
id,
roomId = body.RoomId,
subRoomId,
roomInstanceId,
photonRoomId,
});
}
// ── System ───────────────────────────────────────────────────────────
public sealed record SetMotdRequest(string Message);
/// <summary>Push a server-maintenance broadcast to every connected
/// player. The MOTD config value is changed via appsettings.json
/// (server restart required); this is for ad-hoc announcements.</summary>
[HttpPost("broadcast")]
public async Task<ActionResult> Broadcast([FromBody] SetMotdRequest body)
{
if (string.IsNullOrWhiteSpace(body.Message)) return BadRequest("empty message");
await notifications.BroadcastAsync(PushNotificationId.ServerMaintenance,
new { Message = body.Message });
await LogAsync("broadcast", "system", 0, body.Message);
await db.SaveChangesAsync();
return Ok();
}
// ── Store catalog management ─────────────────────────────────────────
/// <summary>Canonical storefront keys the admin UI can assign to
/// store items. Exact <c>giftdrop:N</c> keys target a single
/// in-room shelf; <c>rro</c> and <c>all</c> are shared groups.</summary>
[HttpGet("storefronts")]
public ActionResult ListStorefronts() =>
Ok(StoreService.GetStorefrontDefinitions().Select(s => new
{
s.Key,
s.StorefrontType,
s.DisplayName,
s.Scope,
}));
/// <summary>List every item in the catalog (including inactive
/// rows) so the admin UI can render the full catalog and let an
/// admin flip flags / edit prices.</summary>
[HttpGet("storeitems")]
public async Task<ActionResult> ListStoreItems(
[FromQuery] string? storefront, [FromQuery] string? category,
[FromQuery] int take = 200, [FromQuery] int skip = 0)
{
take = Math.Clamp(take, 1, 500);
skip = Math.Max(0, skip);
IQueryable<StoreItemEntity> q = db.StoreItems;
if (!string.IsNullOrWhiteSpace(storefront)) q = q.Where(i => i.Storefront == storefront);
if (!string.IsNullOrWhiteSpace(category)) q = q.Where(i => i.Category == category);
var rows = await q.OrderBy(i => i.Storefront).ThenBy(i => i.Category).ThenBy(i => i.Price)
.Skip(skip).Take(take).ToListAsync();
return Ok(rows);
}
public sealed record StoreItemUpsertRequest(
string? Slug, string? DisplayName, string? Description,
string? Category, string? ImageName, int? CurrencyType,
long? Price, bool? IsActive, bool? IsLimitedTime,
DateTime? AvailableUntil, string? Storefront);
/// <summary>Create a new catalog item.</summary>
[HttpPost("storeitems")]
public async Task<ActionResult> CreateStoreItem([FromBody] StoreItemUpsertRequest body)
{
if (string.IsNullOrWhiteSpace(body.Slug)) return BadRequest("missing_slug");
if (string.IsNullOrWhiteSpace(body.DisplayName)) return BadRequest("missing_display_name");
var existing = await db.StoreItems.AnyAsync(i => i.Slug == body.Slug);
if (existing) return Conflict(new { error = "slug_taken" });
var item = new StoreItemEntity
{
Slug = body.Slug.Trim(),
DisplayName = body.DisplayName.Trim(),
Description = (body.Description ?? string.Empty).Trim(),
Category = (body.Category ?? "accessory").Trim().ToLowerInvariant(),
ImageName = body.ImageName ?? string.Empty,
CurrencyType = body.CurrencyType ?? 2,
Price = Math.Max(0, body.Price ?? 0),
IsActive = body.IsActive ?? true,
IsLimitedTime = body.IsLimitedTime ?? false,
AvailableUntil = body.AvailableUntil,
Storefront = (body.Storefront ?? "main").Trim(),
};
db.StoreItems.Add(item);
await LogAsync("create_store_item", "storeitem", 0, item.Slug);
await db.SaveChangesAsync();
return Ok(item);
}
/// <summary>Patch an existing catalog item — any null field is
/// left untouched so the admin UI can do partial edits.</summary>
[HttpPost("storeitems/{id:long}")]
[HttpPut("storeitems/{id:long}")]
public async Task<ActionResult> UpdateStoreItem(long id, [FromBody] StoreItemUpsertRequest body)
{
var item = await db.StoreItems.FirstOrDefaultAsync(i => i.Id == id);
if (item is null) return NotFound();
if (body.DisplayName is not null) item.DisplayName = body.DisplayName.Trim();
if (body.Description is not null) item.Description = body.Description.Trim();
if (body.Category is not null) item.Category = body.Category.Trim().ToLowerInvariant();
if (body.ImageName is not null) item.ImageName = body.ImageName;
if (body.CurrencyType is int ct) item.CurrencyType = ct;
if (body.Price is long p) item.Price = Math.Max(0, p);
if (body.IsActive is bool a) item.IsActive = a;
if (body.IsLimitedTime is bool lt) item.IsLimitedTime = lt;
if (body.AvailableUntil.HasValue) item.AvailableUntil = body.AvailableUntil;
if (body.Storefront is not null) item.Storefront = body.Storefront.Trim();
item.UpdatedAt = DateTime.UtcNow;
await LogAsync("update_store_item", "storeitem", id, item.Slug);
await db.SaveChangesAsync();
return Ok(item);
}
/// <summary>Delete a catalog item. Soft-flag is recommended via
/// IsActive=false; hard delete is here for cleanup of test
/// items.</summary>
[HttpDelete("storeitems/{id:long}")]
public async Task<ActionResult> DeleteStoreItem(long id)
{
var item = await db.StoreItems.FirstOrDefaultAsync(i => i.Id == id);
if (item is null) return NotFound();
db.StoreItems.Remove(item);
await LogAsync("delete_store_item", "storeitem", id, item.Slug);
await db.SaveChangesAsync();
return Ok(new { deleted = id });
}
// ── Helpers ──────────────────────────────────────────────────────────
private Task LogAsync(string action, string targetType, long targetId, string reason)
{
db.AdminActions.Add(new AdminActionEntity
{
AdminPlayerId = CurrentAdminId,
Action = action,
TargetType = targetType,
TargetId = targetId,
Reason = reason,
});
return Task.CompletedTask;
}
// ── Community Board ──────────────────────────────────────────────
// Backs the dorm community board panel. The watch hits
// /api/communityboard/v1/current; admins edit here.
/// <summary>GET the current board state. Same shape the watch
/// receives — handy for the admin UI to populate its form.</summary>
[HttpGet("communityboard")]
public async Task<ActionResult<CommunityBoardState>> GetCommunityBoard()
=> Ok(await communityBoard.GetAsync());
/// <summary>POST a full replacement state. Any null nested objects
/// (FeaturedPlayer / FeaturedRoomGroup / CurrentAnnouncement)
/// clear that section. Lists default to empty if omitted.</summary>
[HttpPost("communityboard")]
public async Task<ActionResult<CommunityBoardState>> SetCommunityBoard(
[FromBody] CommunityBoardState body)
{
body.InstagramImages ??= new();
body.Videos ??= new();
var saved = await communityBoard.UpdateAsync(body);
await LogAsync("set_community_board", "global", 0,
$"announcement=\"{saved.CurrentAnnouncement?.Message ?? ""}\" " +
$"featuredPlayer={saved.FeaturedPlayer?.Id} " +
$"videos={saved.Videos.Count} instagram={saved.InstagramImages.Count}");
return Ok(saved);
}
/// <summary>POST <c>api/admin/v1/communityboard/instagram/upload</c>
/// — accept a single image file, persist it as a global
/// <see cref="RoomDataBlobEntity"/> at <c>RoomId=0</c> under a
/// generated hash filename, return the filename + a ready-to-paste
/// img.* URL. The Community Board SPA section pipes a file picker
/// into this so admins don't have to know what an
/// <c>ImageName</c> is — they just drop a JPG/PNG and we wire it up.
///
/// Cap: 25 MB. Anything larger is almost certainly a mistake for an
/// Instagram-strip tile and would also push us close to the CF edge
/// limit on a non-chunked upload.</summary>
[HttpPost("communityboard/instagram/upload")]
[DisableRequestSizeLimit]
[RequestFormLimits(MultipartBodyLengthLimit = 25_000_000)]
public async Task<ActionResult> UploadInstagramImage([FromForm(Name = "file")] IFormFile? file)
{
if (file is null || file.Length == 0)
return BadRequest(new { error = "missing_file" });
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (ext is not (".jpg" or ".jpeg" or ".png" or ".webp"))
return BadRequest(new { error = "unsupported_extension", allowed = new[] { ".jpg", ".jpeg", ".png", ".webp" } });
using var ms = new MemoryStream();
await file.CopyToAsync(ms);
var bytes = ms.ToArray();
// Hashed filename so re-uploads of the same content don't
// proliferate and so the URL is content-addressed. Prefix
// `cb_insta_` to keep these distinguishable from room-image
// blobs at a glance when grepping the table.
using var sha = System.Security.Cryptography.SHA1.Create();
var hash = Convert.ToHexString(sha.ComputeHash(bytes)).ToLowerInvariant();
var imageName = $"cb_insta_{hash[..24]}{ext}";
var existing = await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.BlobName == imageName);
if (existing is null)
{
var (bucket, key) = BlobRouter.Route(imageName);
await storage.PutAsync(bucket, key, bytes,
ext == ".png" ? "image/png" : "image/jpeg");
db.RoomDataBlobs.Add(new RoomDataBlobEntity
{
RoomId = 0,
BlobName = imageName,
UploadedByPlayerId = CurrentAdminId,
UploadedAt = DateTime.UtcNow,
});
await LogAsync("cb_insta_upload", "community_board", 0, $"image={imageName} bytes={bytes.Length}");
await db.SaveChangesAsync();
}
// Use the configured apex (DomainConfig) so the returned URL
// works whether the admin is on localhost, rec.net, or a future
// deployment apex — driven solely by the DORKNET_DOMAIN env var.
return Ok(new
{
imageName,
imageUrl = $"https://{domain.Sub("img")}/{imageName}",
bytes = bytes.Length,
reused = existing is not null,
});
}
/// <summary>POST <c>api/admin/v1/communityboard/video/upload</c> —
/// accept a single video file and persist it as a global
/// <see cref="RoomDataBlobEntity"/> at <c>RoomId=0</c> under a hashed
/// <c>cb_video_*</c> name. The board's Videos section binds the
/// returned <c>blobName</c> straight into <c>VideoState.BlobName</c>
/// so the watch's <c>CommunityBoardVideoData</c> resolves it to
/// <c>cdn.{apex}/&lt;blobName&gt;</c>.
///
/// Cap: 100 MB — picks the same ceiling Cloudflare enforces at the
/// edge so a non-chunked POST never sees a 413. Anything bigger
/// should be hosted externally and pasted into <c>SourceUrl</c>.</summary>
// [RequestSizeLimit] alone is enough — pairing it with
// [DisableRequestSizeLimit] was undefined-behaviour territory
// (Microsoft docs explicitly say "don't combine them"). Setting
// 100 MB at the action keeps Kestrel from clipping the body at its
// 30 MB default while still capping uploads under Cloudflare's
// free-tier edge limit.
[HttpPost("communityboard/video/upload")]
[RequestFormLimits(MultipartBodyLengthLimit = 100_000_000, ValueLengthLimit = 100_000_000)]
[RequestSizeLimit(100_000_000)]
public async Task<ActionResult> UploadCommunityBoardVideo([FromForm(Name = "file")] IFormFile? file)
{
if (file is null || file.Length == 0)
return BadRequest(new { error = "missing_file" });
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (ext is not (".mp4" or ".webm" or ".mov" or ".m4v"))
return BadRequest(new {
error = "unsupported_extension",
allowed = new[] { ".mp4", ".webm", ".mov", ".m4v" },
});
var declaredSize = file.Length;
using var ms = new MemoryStream();
await file.CopyToAsync(ms);
var bytes = ms.ToArray();
// Catch the "Kestrel/Cloudflare clipped the body" case BEFORE
// we hash + write — IFormFile.Length reports what the multipart
// parser saw in the part header, so a mismatch with how many
// bytes the stream actually produced is a transport-level
// truncation and the resulting blob would be the corrupted file
// the user saw on download.
if (bytes.LongLength != declaredSize)
{
adminLogger.LogError(
"[cb-video-upload] size mismatch — multipart says {Declared:N0} bytes, stream produced {Read:N0} bytes (file={Name})",
declaredSize, bytes.LongLength, file.FileName);
return BadRequest(new {
error = "upload_truncated",
declaredBytes = declaredSize,
receivedBytes = bytes.LongLength,
});
}
// Content-addressed name keeps re-uploads from proliferating —
// identical bytes resolve to the same row + S3 object regardless
// of original filename. `cb_video_` prefix keeps it greppable.
using var sha = System.Security.Cryptography.SHA1.Create();
var hash = Convert.ToHexString(sha.ComputeHash(bytes)).ToLowerInvariant();
var blobName = $"cb_video_{hash[..24]}{ext}";
var (bucket, key) = BlobRouter.Route(blobName);
// We were previously skipping S3 PUT when the DB row already
// existed. That created a zombie state: if the first upload's
// PUT silently failed (Garage outage, bad creds) the row was
// committed but the bytes weren't, and every subsequent
// upload of the same content returned `reused: true` without
// ever fixing the missing object. Verify S3 has the object;
// re-PUT if it doesn't.
var existing = await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.BlobName == blobName);
var alreadyOnS3 = existing is not null && await storage.ExistsAsync(bucket, key);
if (!alreadyOnS3)
{
await storage.PutAsync(bucket, key, bytes, MimeFromExt(ext));
// Post-PUT HEAD: Garage occasionally accepts a PutObject
// and returns 200 without persisting (auth misconfig,
// bucket policy reject). HeadObject is the cheapest way to
// confirm the bytes are actually queryable before we
// pretend the upload succeeded.
if (!await storage.ExistsAsync(bucket, key))
{
adminLogger.LogError(
"[cb-video-upload] PUT returned OK but HEAD says {Bucket}/{Key} missing — Garage may be silently dropping",
bucket, key);
return StatusCode(500, new { error = "s3_put_lost", bucket, key });
}
adminLogger.LogInformation(
"[cb-video-upload] stored {Bucket}/{Key} ({Bytes:N0} bytes, ext={Ext})",
bucket, key, bytes.LongLength, ext);
}
if (existing is null)
{
db.RoomDataBlobs.Add(new RoomDataBlobEntity
{
RoomId = 0,
BlobName = blobName,
UploadedByPlayerId = CurrentAdminId,
UploadedAt = DateTime.UtcNow,
});
await LogAsync("cb_video_upload", "community_board", 0, $"video={blobName} bytes={bytes.Length}");
await db.SaveChangesAsync();
}
return Ok(new
{
blobName,
videoUrl = $"https://{domain.Sub("cdn")}/video/{blobName}",
bytes = bytes.Length,
reused = existing is not null && alreadyOnS3,
repaired = existing is not null && !alreadyOnS3,
});
}
/// <summary>POST <c>api/admin/v1/communityboard/videothumb/upload</c>
/// — accept a single image and persist it under a hashed
/// <c>cb_thumb_*</c> name for use as <see cref="VideoState"/>'s
/// <c>ThumbnailBlobName</c>. Mechanically identical to the Instagram
/// upload endpoint, just with a different prefix so thumbnail vs.
/// gallery images are distinguishable at a glance in the blob table.
///
/// Cap: 25 MB. The watch renders thumbnails at small sizes; bigger
/// is wasted bandwidth.</summary>
[HttpPost("communityboard/videothumb/upload")]
[DisableRequestSizeLimit]
[RequestFormLimits(MultipartBodyLengthLimit = 25_000_000)]
public async Task<ActionResult> UploadCommunityBoardVideoThumbnail(
[FromForm(Name = "file")] IFormFile? file)
{
if (file is null || file.Length == 0)
return BadRequest(new { error = "missing_file" });
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (ext is not (".jpg" or ".jpeg" or ".png" or ".webp"))
return BadRequest(new {
error = "unsupported_extension",
allowed = new[] { ".jpg", ".jpeg", ".png", ".webp" },
});
using var ms = new MemoryStream();
await file.CopyToAsync(ms);
var bytes = ms.ToArray();
using var sha = System.Security.Cryptography.SHA1.Create();
var hash = Convert.ToHexString(sha.ComputeHash(bytes)).ToLowerInvariant();
var blobName = $"cb_thumb_{hash[..24]}{ext}";
var existing = await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.BlobName == blobName);
if (existing is null)
{
var (bucket, key) = BlobRouter.Route(blobName);
await storage.PutAsync(bucket, key, bytes,
ext == ".png" ? "image/png" : ext == ".webp" ? "image/webp" : "image/jpeg");
db.RoomDataBlobs.Add(new RoomDataBlobEntity
{
RoomId = 0,
BlobName = blobName,
UploadedByPlayerId = CurrentAdminId,
UploadedAt = DateTime.UtcNow,
});
await LogAsync("cb_videothumb_upload", "community_board", 0, $"thumb={blobName} bytes={bytes.Length}");
await db.SaveChangesAsync();
}
return Ok(new
{
blobName,
imageUrl = $"https://{domain.Sub("img")}/{blobName}",
bytes = bytes.Length,
reused = existing is not null,
});
}
// MIME mapping for the video upload — the CDN serves whatever
// Content-Type we wrote to S3, so getting this right makes the
// watch's HTTPVideoStreamPlayer accept the response without
// sniffing. Default to mp4 since that's what most modern exporters
// produce; unknown extensions fall back to octet-stream which the
// watch usually still handles but is worth flagging.
private static string MimeFromExt(string ext) => ext switch
{
".mp4" => "video/mp4",
".m4v" => "video/mp4",
".webm" => "video/webm",
".mov" => "video/quicktime",
_ => "application/octet-stream",
};
// ── Loading screen tips ──────────────────────────────────────────────
public sealed class LoadingScreenTipDto
{
public long? Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
public string ImageName { get; set; } = string.Empty;
public int Context { get; set; }
public int PlatformMask { get; set; } = -1;
public string RoomNamesCsv { get; set; } = string.Empty;
public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
}
/// <summary>GET <c>api/admin/v1/loadingscreentips</c> — list every
/// tip (including inactive ones) for the admin SPA.</summary>
[HttpGet("loadingscreentips")]
public async Task<IActionResult> ListLoadingScreenTips()
{
var rows = await db.LoadingScreenTips
.OrderBy(t => t.SortOrder).ThenBy(t => t.Id)
.AsNoTracking()
.ToListAsync();
return Ok(rows.Select(ToLoadingScreenTipDto));
}
/// <summary>POST <c>api/admin/v1/loadingscreentips</c> — create
/// a new tip. Returns the persisted row including its assigned
/// Id so the SPA can update its local state.</summary>
[HttpPost("loadingscreentips")]
public async Task<IActionResult> CreateLoadingScreenTip([FromBody] LoadingScreenTipDto body)
{
var entity = new Data.Entities.LoadingScreenTipEntity
{
Title = (body.Title ?? string.Empty).Trim(),
Message = (body.Message ?? string.Empty).Trim(),
ImageName = (body.ImageName ?? string.Empty).Trim(),
Context = body.Context,
PlatformMask = body.PlatformMask,
RoomNamesCsv = NormaliseCsv(body.RoomNamesCsv),
SortOrder = body.SortOrder,
IsActive = body.IsActive,
};
db.LoadingScreenTips.Add(entity);
await db.SaveChangesAsync();
await LogAsync("create_loading_tip", "global", entity.Id, entity.Title);
return Ok(ToLoadingScreenTipDto(entity));
}
/// <summary>PUT <c>api/admin/v1/loadingscreentips/{id}</c> — update
/// an existing tip.</summary>
[HttpPut("loadingscreentips/{id:long}")]
public async Task<IActionResult> UpdateLoadingScreenTip(long id, [FromBody] LoadingScreenTipDto body)
{
var row = await db.LoadingScreenTips.FirstOrDefaultAsync(t => t.Id == id);
if (row is null) return NotFound();
row.Title = (body.Title ?? string.Empty).Trim();
row.Message = (body.Message ?? string.Empty).Trim();
row.ImageName = (body.ImageName ?? string.Empty).Trim();
row.Context = body.Context;
row.PlatformMask = body.PlatformMask;
row.RoomNamesCsv = NormaliseCsv(body.RoomNamesCsv);
row.SortOrder = body.SortOrder;
row.IsActive = body.IsActive;
row.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
await LogAsync("update_loading_tip", "global", id, row.Title);
return Ok(ToLoadingScreenTipDto(row));
}
/// <summary>DELETE <c>api/admin/v1/loadingscreentips/{id}</c> — hard
/// delete a tip. (Toggling <c>IsActive=false</c> via the update
/// endpoint is the soft-delete path.)</summary>
[HttpDelete("loadingscreentips/{id:long}")]
public async Task<IActionResult> DeleteLoadingScreenTip(long id)
{
var row = await db.LoadingScreenTips.FirstOrDefaultAsync(t => t.Id == id);
if (row is null) return NotFound();
db.LoadingScreenTips.Remove(row);
await db.SaveChangesAsync();
await LogAsync("delete_loading_tip", "global", id, row.Title);
return Ok(new { deleted = id });
}
/// <summary>POST <c>api/admin/v1/loadingscreentips/upload</c> —
/// accept an image file (jpg/png/webp), persist it via the same
/// content-addressed flow as the Community Board uploader, and
/// return the BlobName + img.* URL. The SPA pipes this into the
/// tip's <c>ImageName</c> field.</summary>
[HttpPost("loadingscreentips/upload")]
[DisableRequestSizeLimit]
[RequestFormLimits(MultipartBodyLengthLimit = 25_000_000)]
public async Task<IActionResult> UploadLoadingScreenTipImage([FromForm(Name = "file")] IFormFile? file)
{
if (file is null || file.Length == 0)
return BadRequest(new { error = "missing_file" });
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (ext is not (".jpg" or ".jpeg" or ".png" or ".webp"))
return BadRequest(new { error = "unsupported_extension", allowed = new[] { ".jpg", ".jpeg", ".png", ".webp" } });
using var ms = new MemoryStream();
await file.CopyToAsync(ms);
var bytes = ms.ToArray();
using var sha = System.Security.Cryptography.SHA1.Create();
var hash = Convert.ToHexString(sha.ComputeHash(bytes)).ToLowerInvariant();
var imageName = $"loadingtip_{hash[..24]}{ext}";
var existing = await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.BlobName == imageName);
if (existing is null)
{
var (bucket, key) = BlobRouter.Route(imageName);
await storage.PutAsync(bucket, key, bytes,
ext == ".png" ? "image/png" : "image/jpeg");
db.RoomDataBlobs.Add(new RoomDataBlobEntity
{
RoomId = 0,
BlobName = imageName,
UploadedByPlayerId = CurrentAdminId,
UploadedAt = DateTime.UtcNow,
});
await LogAsync("loading_tip_upload", "global", 0, $"image={imageName} bytes={bytes.Length}");
await db.SaveChangesAsync();
}
return Ok(new
{
imageName,
imageUrl = $"https://{domain.Sub("img")}/{imageName}",
bytes = bytes.Length,
reused = existing is not null,
});
}
private static LoadingScreenTipDto ToLoadingScreenTipDto(Data.Entities.LoadingScreenTipEntity t) => new()
{
Id = t.Id,
Title = t.Title,
Message = t.Message,
ImageName = t.ImageName,
Context = t.Context,
PlatformMask = t.PlatformMask,
RoomNamesCsv = t.RoomNamesCsv,
SortOrder = t.SortOrder,
IsActive = t.IsActive,
};
private static string NormaliseCsv(string? csv)
{
if (string.IsNullOrWhiteSpace(csv)) return string.Empty;
var parts = csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return string.Join(",", parts);
}
// ── Room import ──────────────────────────────────────────────────────
/// <summary>POST <c>api/admin/v1/rooms/import</c> — multipart upload
/// of an archived multi-scene room. Each .room file (one per scene)
/// is uploaded with its filename including the scene folder, e.g.
/// <c>Lobby/abc.room</c> or <c>Ch1_Headfirst/def.room</c>. The form
/// can either send the per-file folder via <c>webkitRelativePath</c>
/// (browser folder picker preserves it) or via the filename itself.
///
/// Server inserts:
/// 1. A <see cref="RoomEntity"/> row for the new room (id 1000+).
/// 2. One <see cref="RoomDataBlobEntity"/> per uploaded file,
/// named <c>{roomname}_{scenefolder}.room</c> (lowercased).
/// 3. One <see cref="RoomSceneEntity"/> per scene with the entry
/// scene at OrderIndex=0 and the rest in folder order.
///
/// Default <c>RoomSceneLocationId</c> for every scene is the
/// MakerRoom (Basement) GUID — the canonical 2020 custom-room
/// canvas. If the import was a 2020-era export of an apartment
/// scene, edit <see cref="RoomSceneEntity.RoomSceneLocationId"/>
/// per-row after import.
///
/// Returns 400 if a room with this name already exists or no
/// files were uploaded.</summary>
[HttpPost("rooms/import")]
[DisableRequestSizeLimit]
[RequestFormLimits(MultipartBodyLengthLimit = 500_000_000)]
public async Task<IActionResult> ImportRoom(
[FromForm(Name = "name")] string? name,
[FromForm(Name = "description")] string? description,
[FromForm(Name = "entryScene")] string? entryScene,
[FromForm(Name = "creatorPlayerId")] long? creatorPlayerId,
[FromForm(Name = "scenePaths")] List<string>? scenePaths,
[FromForm(Name = "files")] List<IFormFile>? files,
[FromForm(Name = "normalizeBlobs")] bool? normalizeBlobs)
{
// Per-import "run blob bytes through the 2020 protobuf normaliser"
// toggle, mirroring RoomZipImportController. Default OFF — see
// that file's notes for why the round-trip currently crashes the
// watch on load. Diagnostic Normalize() call still runs so its
// parse-OK / parse-FAIL log line remains in the import telemetry.
var normalize = normalizeBlobs ?? false;
if (string.IsNullOrWhiteSpace(name))
return BadRequest(new { error = "name_required" });
if (files is null || files.Count == 0)
return BadRequest(new { error = "no_files" });
var roomName = name.Trim();
if (await db.Rooms.AnyAsync(r => r.Name == roomName))
return BadRequest(new { error = "duplicate_name", message = $"A room named '{roomName}' already exists." });
// Pair each uploaded file with its scene folder name. Browsers
// submit folder uploads as files with FileName = "Lobby/blob.room"
// when webkitdirectory is used; fall back to a parallel
// scenePaths[] form field when the client supplies it explicitly
// (e.g. uploading individual files via JS FormData with the
// scene name set per entry).
//
// Each blob is also passed through RoomBlobNormalizerService
// (round-trip via the full 2020 PersistedRoomData schema). That
// strips redundant default-value encodings the modern client
// emits — without this, the watch's stricter 2020 parser logs
// "Error attempting to parse room data stream" and boots the
// player to dorm. Normalize() falls back to the original bytes
// if parse fails, so a corrupted upload doesn't drop content.
var perScene = new List<(string SceneName, string OriginalName, byte[] Bytes, RoomBlobNormalizerService.Result Norm)>();
for (int i = 0; i < files.Count; i++)
{
var f = files[i];
// FormFile.FileName is "scenefolder/blob.room" when uploaded
// via webkitdirectory. ScenePaths (if provided) overrides.
var sceneName = scenePaths is { } sp && i < sp.Count && !string.IsNullOrWhiteSpace(sp[i])
? sp[i].Trim()
: ExtractSceneFolder(f.FileName);
if (string.IsNullOrWhiteSpace(sceneName))
{
return BadRequest(new {
error = "missing_scene_folder",
message = $"File '{f.FileName}' has no scene folder. Either upload via folder picker, or supply scenePaths[] alongside files[]."
});
}
using var ms = new MemoryStream();
await f.CopyToAsync(ms);
var raw = ms.ToArray();
var norm = roomBlobNormalizer.Normalize(raw);
var persistedBytes = normalize ? norm.Bytes : raw;
adminLogger.LogInformation(
"[import] scene={Scene} file={File} raw={Raw:N0} → normalized={Out:N0} ({Status}) persistedFrom={Source}",
sceneName, f.FileName, raw.Length, norm.Bytes.Length,
norm.Normalized ? "ok" : $"fail: {norm.Error}",
normalize ? "normaliser" : "raw");
perScene.Add((sceneName, f.FileName, persistedBytes, norm));
}
// Pick entry scene: explicit override → "Lobby" if present →
// alphabetically-first folder.
var sceneNames = perScene.Select(s => s.SceneName).ToList();
var entry = !string.IsNullOrWhiteSpace(entryScene)
? perScene.FirstOrDefault(s => string.Equals(s.SceneName, entryScene, StringComparison.OrdinalIgnoreCase))
: default;
if (entry == default)
{
entry = perScene.FirstOrDefault(s => string.Equals(s.SceneName, "Lobby", StringComparison.OrdinalIgnoreCase));
}
if (entry == default) entry = perScene.OrderBy(s => s.SceneName).First();
// Allocate a new id in the user-room range (>=1000).
var newRoomId = (await db.Rooms
.Where(r => r.Id >= 1000)
.Select(r => (long?)r.Id)
.MaxAsync() ?? 999) + 1;
var creator = creatorPlayerId ?? CurrentAdminId;
const string makerRoomLocation = "a75f7547-79eb-47c6-8986-6767abcb4f92";
// Build BlobName per scene.
var slug = roomName.ToLowerInvariant().Replace(' ', '_');
string MakeBlobName(string sceneName) => $"{slug}_{sceneName.ToLowerInvariant()}.room";
// Reorder so entry scene is OrderIndex=0.
var ordered = new List<(string SceneName, string OriginalName, byte[] Bytes, RoomBlobNormalizerService.Result Norm)> { entry };
ordered.AddRange(perScene.Where(s => s.SceneName != entry.SceneName));
using var tx = await db.Database.BeginTransactionAsync();
try
{
// Insert blobs — bytes go to S3, DB only stores metadata.
foreach (var s in ordered)
{
var blobName = MakeBlobName(s.SceneName);
var (bucket, key) = BlobRouter.Route(blobName);
await storage.PutAsync(bucket, key, s.Bytes, "application/octet-stream");
db.RoomDataBlobs.Add(new RoomDataBlobEntity
{
RoomId = newRoomId,
BlobName = blobName,
UploadedByPlayerId = creator,
UploadedAt = DateTime.UtcNow,
});
}
// Insert Room row pointing at entry scene's blob.
var entryBlob = MakeBlobName(entry.SceneName);
db.Rooms.Add(new RoomEntity
{
Id = newRoomId,
Name = roomName,
Description = string.IsNullOrWhiteSpace(description)
? $"Imported via admin upload ({ordered.Count} scenes)"
: description.Trim(),
CreatorPlayerId = creator,
ImageName = string.Empty,
State = 0,
Accessibility = 1,
IsAGRoom = false,
IsDormRoom = false,
CloningAllowed = true,
LocationReplicationId = makerRoomLocation,
TagsCsv = "community",
CheerCount = 0,
FavoriteCount = 0,
VisitCount = 0,
VisitorCount = 0,
HotScore = 5.0,
CurrentDataBlobName = entryBlob,
});
// Insert RoomScenes rows.
int idx = 0;
foreach (var s in ordered)
{
db.RoomScenes.Add(new RoomSceneEntity
{
RoomId = newRoomId,
OrderIndex = idx++,
Name = s.SceneName,
RoomSceneLocationId = makerRoomLocation,
DataBlobName = MakeBlobName(s.SceneName),
MaxPlayers = 8,
IsSandbox = false,
CanMatchmakeInto = true,
DataModifiedAt = DateTime.UtcNow,
});
}
await db.SaveChangesAsync();
await tx.CommitAsync();
}
catch
{
await tx.RollbackAsync();
throw;
}
await LogAsync("import_room", "room", newRoomId,
$"name={roomName} scenes={ordered.Count} entry={entry.SceneName}");
// Fire-and-forget: scan the just-imported scene blobs for
// Holotar/AudioSampler .htr references and download each from
// cdn.rec.net into RoomDataBlobs (BlobName="{hash}.htr",
// RoomId=0). The watch's holotar projectors and audio
// samplers can then pull their content from cdn.localhost
// instead of failing on the public CDN. Doing this in the
// background keeps the upload response snappy (the .htr
// download set for a YarrHarrHeist-sized room is ~25 MB).
htrMirror.EnqueueAsync(
ordered.Select(s => s.Bytes).ToList(),
contextLabel: $"room={roomName}");
return Ok(new
{
roomId = newRoomId,
roomName,
sceneCount = ordered.Count,
entryScene = entry.SceneName,
normalizedSceneCount = ordered.Count(s => s.Norm.Normalized),
normalizationFailures = ordered.Where(s => !s.Norm.Normalized)
.Select(s => new { scene = s.SceneName, error = s.Norm.Error }),
htrMirrorStarted = true,
scenes = ordered.Select((s, i) => new
{
orderIndex = i,
name = s.SceneName,
blobName = MakeBlobName(s.SceneName),
rawBytes = s.Norm.InputBytes,
bytes = s.Bytes.Length,
normalized = s.Norm.Normalized,
}),
});
}
/// <summary>POST <c>api/admin/v1/rooms/{id}/mirror-htr</c> —
/// re-run the HoloTar / AudioSampler .htr mirror for an existing
/// room's blobs. Useful to (a) backfill assets for rooms imported
/// before the auto-mirror existed, (b) debug whether
/// HtrAssetMirrorService can actually extract refs from the C#
/// generated 2020 PersistedRoomData schema. Synchronous (awaits the
/// download stream) so the response carries the result counts —
/// the import-time path uses fire-and-forget instead.</summary>
[HttpPost("rooms/{roomId:long}/mirror-htr")]
public async Task<IActionResult> MirrorHtrForRoom(long roomId)
{
// Stream bytes back from S3 — DB carries only metadata now.
var blobNames = await db.RoomDataBlobs
.Where(b => b.RoomId == roomId)
.Select(b => b.BlobName)
.ToListAsync();
if (blobNames.Count == 0) return NotFound(new { error = "no_blobs", roomId });
var blobs = new List<byte[]>(blobNames.Count);
foreach (var blobName in blobNames)
{
var (bucket, key) = BlobRouter.Route(blobName);
var bytes = await storage.GetAsync(bucket, key);
if (bytes is { Length: > 0 }) blobs.Add(bytes);
}
if (blobs.Count == 0) return NotFound(new { error = "blobs_missing_in_s3", roomId, expected = blobNames.Count });
var result = await htrMirror.MirrorAsync(blobs, contextLabel: $"manual room={roomId}");
await LogAsync("mirror_htr", "room", roomId,
$"refs={result.TotalRefs} skipped={result.AlreadyMirrored} inserted={result.Inserted} parseFails={result.RoomBlobParseFailures}");
return Ok(new
{
roomId,
scannedBlobs = blobs.Count,
uniqueRefs = result.TotalRefs,
alreadyMirrored = result.AlreadyMirrored,
downloaded = result.Inserted,
roomParseFailures = result.RoomBlobParseFailures,
assetDownloadFailures = result.AssetDownloadFailures,
});
}
/// <summary>Pull the immediate-parent folder out of a multi-segment
/// filename like "Lobby/abc.room" or "YarrHarrHeist/Lobby/abc.room".
/// Returns the second-to-last segment (the file's parent folder).
/// Returns null if the filename has no folder component.</summary>
private static string? ExtractSceneFolder(string fileName)
{
if (string.IsNullOrWhiteSpace(fileName)) return null;
// Browsers may use either separator depending on platform.
var parts = fileName.Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 2) return null;
// The .room file's parent folder is the scene name.
return parts[^2];
}
}
@@ -0,0 +1,54 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Data;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Admin;
/// <summary>
/// admin.rec.net/api/admin/v1/login — same-origin login endpoint for
/// the browser-based admin UI. Wraps a username+password verify
/// against PlayerEntity.PasswordHash and returns the JWT pair the UI
/// can stash in localStorage. Refuses non-admin accounts up front so a
/// regular player password can't be used to scope into the admin tools
/// even if the JWT is valid against api/admin/v1/* (the AdminOnly
/// filter would catch it anyway, but the early reject saves a
/// confused-looking 403).
///
/// Lives on admin.rec.net so the static admin page (also served from
/// admin.rec.net) can fetch with a same-origin request and skip CORS
/// entirely.
/// </summary>
[ApiController]
[Route("api/admin/v1")]
public class AdminLoginController(
DorkNetDbContext db,
AuthService authService) : ControllerBase
{
public sealed record AdminLoginRequest(string Username, string Password);
[HttpPost("login")]
public async Task<ActionResult> Login([FromBody] AdminLoginRequest body)
{
if (string.IsNullOrWhiteSpace(body.Username) || string.IsNullOrWhiteSpace(body.Password))
return BadRequest(new { error = "missing_credentials" });
var player = await db.Players.FirstOrDefaultAsync(p => p.Username == body.Username);
if (player is null || player.PasswordHash is null)
return Unauthorized(new { error = "invalid_credentials" });
if (!BCrypt.Net.BCrypt.Verify(body.Password, player.PasswordHash))
return Unauthorized(new { error = "invalid_credentials" });
if (!player.IsAdmin)
return StatusCode(403, new { error = "not_admin" });
var (access, refresh) = authService.GenerateTokenPair(player.Id);
return Ok(new
{
access_token = access,
refresh_token = refresh,
account_id = player.Id,
username = player.Username,
display_name = player.DisplayName,
});
}
}
@@ -0,0 +1,1209 @@
using System.IO.Compression;
using System.Text.Json;
using System.Text.Json.Serialization;
using Google.Protobuf;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Admin;
/// <summary>
/// admin.rec.net/api/admin/v1/rooms/zip-bulk-import — preferred room
/// import path going forward. The legacy multi-file endpoint
/// (<c>POST api/admin/v1/rooms/import</c>) stays alongside this for
/// tooling that already targets it.
///
/// The archive format mirrors RecNet's own room export shape so a
/// user can download a room from rec.net, drop the zip into the admin
/// page, and we re-create it with full fidelity — including the
/// <c>.htr</c> AudioSampler / Holotar assets already extracted into
/// each subroom folder so we never have to hit Rec Room's CDN:
///
/// root.zip/
/// Rooms/Room_&lt;id&gt;_&lt;name&gt;_&lt;timestamp&gt;/
/// RoomDetails.json (full RecNet v4 details schema)
/// RoomImage.jpg | &lt;hash&gt;.jpg
/// Promo/Image/PromoImage_*.jpg
/// SubRooms/&lt;SceneName&gt;/
/// Subroom.json (per-subroom manifest)
/// &lt;hash&gt;.room (referenced by Subroom.CurrentSave.DataBlob)
/// AudioSampler/&lt;hash&gt;.htr
/// Holotar/&lt;hash&gt;.htr
/// Image/PVImage_&lt;hash&gt;.jpg
/// Inventions/Invention_&lt;id&gt;_&lt;name&gt;_&lt;timestamp&gt;/
/// Invention.json
/// InventionDetails.json
/// InventionVersion.json
/// InventionImage.jpg
/// &lt;hash&gt;.inv
///
/// Entry-scene resolution: the order in <c>RoomDetails.SubRooms[]</c>
/// is the entry order (RecNet API convention). The first entry is the
/// entry scene. The admin can override with an explicit
/// <c>RoomDetails.EntryScene</c> field.
/// </summary>
[ApiController]
[Route("api/admin/v1/rooms")]
[Authorize]
[AdminOnly]
public class RoomZipImportController(
DorkNetDbContext db,
RoomBlobNormalizerService roomBlobNormalizer,
IObjectStorage storage,
ILogger<RoomZipImportController> logger) : ControllerBase
{
private long CurrentAdminId => this.RequireCurrentPlayerId();
// ── Room-blob normaliser toggle ─────────────────────────────────────
// The normaliser re-encodes .room bytes through the 2020 schema to
// strip non-canonical encodings. It's been tripping the watch into
// "Error attempting to parse room data stream" — the generated 2020
// protobuf bindings appear to be missing fields or to emit them in
// a wire-order the watch's parser disagrees with.
//
// Per-import flag (`normalizeBlobs` in the form / finalize body)
// defaults OFF. The SPA's importer surfaces it as a checkbox so an
// admin can opt back in once we're confident a particular zip's
// contents survive the re-encode. The diagnostic parse still runs
// either way (its parse-OK / parse-FAIL log line is useful), but
// when the flag is false the original bytes are what reach S3.
/// <summary>Write a blob to S3 at its canonical (bucket, key) AND
/// record the metadata row in <see cref="RoomDataBlobEntity"/>.
/// Bytes are NOT persisted on the DB row — S3 is the only place
/// they live. Previously the importer wrote bytes to the
/// <c>Bytes</c> column and skipped S3 entirely, so freshly
/// imported rooms were only reachable through the read-path's
/// DB-fallback branch. Centralised here so every importer call
/// site (scene saves, .htr/PV/polaroid assets, .meta sentinels,
/// invention saves, room thumbnails, promo images) ends up in
/// S3 the same way.</summary>
private async Task<RoomDataBlobEntity> WriteBlobAsync(
long roomId, string blobName, byte[] bytes, long uploaderPlayerId,
string? referencesCsv = null, string? contentTypeOverride = null)
{
var (bucket, key) = BlobRouter.Route(blobName);
var contentType = contentTypeOverride ?? MimeFromName(blobName);
try
{
await storage.PutAsync(bucket, key, bytes, contentType);
}
catch (Exception ex)
{
logger.LogError(ex,
"[zip-import] S3 PUT failed bucket={Bucket} key={Key} blob={Blob} bytes={Bytes} — row will still be recorded so the import isn't lost",
bucket, key, blobName, bytes.Length);
}
var entity = new RoomDataBlobEntity
{
RoomId = roomId,
BlobName = blobName,
UploadedByPlayerId = uploaderPlayerId,
UploadedAt = DateTime.UtcNow,
ReferencedFilenamesCsv = referencesCsv ?? string.Empty,
};
db.RoomDataBlobs.Add(entity);
return entity;
}
private static string MimeFromName(string name) => Path.GetExtension(name).ToLowerInvariant() switch
{
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".gif" => "image/gif",
".webp" => "image/webp",
".mp4" => "video/mp4",
".htr" => "application/octet-stream",
".room" => "application/octet-stream",
".meta" => "application/octet-stream",
".inv" => "application/octet-stream",
".dat" => "application/octet-stream",
_ => "application/octet-stream",
};
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
// ── RoomDetails.json DTOs ──────────────────────────────────────────
public sealed class RoomDetailsDto
{
public long? RoomId { get; set; }
public string? Name { get; set; }
public string? FriendlyName { get; set; }
public string? Description { get; set; }
public string? ImageName { get; set; }
public int? WarningMask { get; set; }
public string? CustomWarning { get; set; }
public long? CreatorAccountId { get; set; }
public int? State { get; set; }
public int? Accessibility { get; set; }
public bool? SupportsLevelVoting { get; set; }
public bool? IsRRO { get; set; }
public bool? SupportsScreens { get; set; }
public bool? SupportsWalkVR { get; set; }
public bool? SupportsTeleportVR { get; set; }
public bool? SupportsVRLow { get; set; }
public bool? SupportsMobile { get; set; }
public bool? SupportsJuniors { get; set; }
public DateTime? CreatedAt { get; set; }
public DateTime? PublishedAt { get; set; }
public bool? IsDorm { get; set; }
public int? MaxPlayers { get; set; }
public bool? CloningAllowed { get; set; }
public bool? DisableMicAutoMute { get; set; }
public StatsDto? Stats { get; set; }
public List<TagDto>? Tags { get; set; }
public List<SubRoomDto>? SubRooms { get; set; }
public string? EntryScene { get; set; } // admin override; not in real exports
}
public sealed class StatsDto
{
public int? CheerCount { get; set; }
public int? FavoriteCount { get; set; }
public int? VisitorCount { get; set; }
public int? VisitCount { get; set; }
}
public sealed class TagDto
{
public string? Tag { get; set; }
public int? Type { get; set; }
public bool? IsPrimaryGenre { get; set; }
}
public sealed class SubRoomDto
{
public long? SubRoomId { get; set; }
public string? UnitySceneId { get; set; }
public string? Name { get; set; }
public bool? IsSandbox { get; set; }
public int? MaxPlayers { get; set; }
public int? Accessibility { get; set; }
public CurrentSaveDto? CurrentSave { get; set; }
}
public sealed class CurrentSaveDto
{
public long? SubRoomDataSaveId { get; set; }
public string? DataBlob { get; set; }
}
// ── Invention DTOs ────────────────────────────────────────────────
public sealed class InventionJsonDto
{
public long? InventionId { get; set; }
public string? ReplicationId { get; set; }
public long? CreatorPlayerId { get; set; }
public string? Name { get; set; }
public string? Description { get; set; }
public string? LongDescription { get; set; }
public string? ImageName { get; set; }
public int? UgcVersion { get; set; }
public int? CurrentVersionNumber { get; set; }
public int? LatestVersionNumber { get; set; }
public int? Accessibility { get; set; }
public DateTime? CreatedAt { get; set; }
public DateTime? ModifiedAt { get; set; }
public DateTime? FirstPublishedAt { get; set; }
public long? CreationRoomId { get; set; }
public int? NumPlayersHaveUsedInRoom { get; set; }
public int? CheerCount { get; set; }
public int? CreatorPermission { get; set; }
public int? GeneralPermission { get; set; }
public bool? IsAgInvention { get; set; }
public bool? IsRecRoomApproved { get; set; }
}
public sealed class InventionVersionDto
{
public int? VersionNumber { get; set; }
public string? BlobName { get; set; }
}
public sealed class InventionDetailsDto
{
public List<TagDto>? Tags { get; set; }
}
// ── Endpoint ──────────────────────────────────────────────────────
[HttpPost("zip-bulk-import")]
[DisableRequestSizeLimit]
[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)] // 2 GB
public async Task<IActionResult> ZipImport(
[FromForm(Name = "archive")] IFormFile? archive,
[FromForm(Name = "creatorPlayerId")] long? creatorPlayerId,
[FromForm(Name = "normalizeBlobs")] bool? normalizeBlobs)
{
if (archive is null || archive.Length == 0)
return BadRequest(new { error = "missing_archive" });
await using var stream = archive.OpenReadStream();
return await RunImportAsync(
stream, archive.Length, creatorPlayerId,
normalizeBlobs: normalizeBlobs ?? false);
}
// ── Chunked upload (Cloudflare 100MB workaround) ─────────────────
//
// The whole multipart-zip path 413s through Cloudflare's edge for
// anything over 100 MB on free/pro plans. Real RecNet exports
// routinely run 150-300 MB. Splitting the upload into ≤90 MB
// chunks keeps each request safely under the cap; the server
// appends chunks to a temp file and only invokes the import logic
// once the finalize call comes in.
//
// Sessions are in-memory and short-lived (admins use the importer
// synchronously). If the process restarts before finalize, the
// upload is lost — acceptable for an admin tool used by 1-2 people.
private static readonly System.Collections.Concurrent.ConcurrentDictionary<Guid, ZipUploadSession> Sessions = new();
public sealed class ZipUploadSession
{
public Guid Id { get; init; }
public string TempPath { get; init; } = string.Empty;
public long TotalBytes { get; set; }
public long ReceivedBytes { get; set; }
public string FileName { get; init; } = string.Empty;
public long AdminPlayerId { get; init; }
public DateTime CreatedAt { get; init; } = DateTime.UtcNow;
}
public sealed record ChunkInitRequest(string FileName, long TotalBytes);
/// <summary>POST <c>zip-upload-init</c> — allocates a temp file and
/// returns an <c>uploadId</c> the SPA threads through subsequent
/// chunk POSTs. Capped at 2 GB total; opens a sparse temp file so
/// disk usage tracks actual bytes received.</summary>
[HttpPost("zip-upload-init")]
public ActionResult ChunkInit([FromBody] ChunkInitRequest body)
{
if (body.TotalBytes <= 0 || body.TotalBytes > 2_000_000_000)
return BadRequest(new { error = "invalid_size" });
// Prune stale sessions before allocating a new one. Anything
// older than 2 hours with no activity is abandoned.
foreach (var (sid, sess) in Sessions)
{
if (DateTime.UtcNow - sess.CreatedAt > TimeSpan.FromHours(2))
{
Sessions.TryRemove(sid, out _);
try { if (System.IO.File.Exists(sess.TempPath)) System.IO.File.Delete(sess.TempPath); } catch { /* best effort */ }
}
}
var id = Guid.NewGuid();
var safeName = Path.GetFileName(body.FileName);
var tempPath = Path.Combine(Path.GetTempPath(), $"dorknet-ziplupload-{id:N}.zip");
// Pre-allocate the file at full length so chunk writes at
// arbitrary offsets work without the OS having to grow it
// page-by-page during the random writes.
using (var fs = new FileStream(tempPath, FileMode.Create, FileAccess.Write))
{
fs.SetLength(body.TotalBytes);
}
Sessions[id] = new ZipUploadSession
{
Id = id,
TempPath = tempPath,
TotalBytes = body.TotalBytes,
FileName = safeName,
AdminPlayerId = CurrentAdminId,
};
return Ok(new { uploadId = id });
}
/// <summary>POST <c>zip-upload-chunk/{id}?offset=N</c> — writes the
/// posted body at <c>offset</c> in the session's temp file.
/// Per-call size capped at 100 MB (Kestrel-side, well below the
/// Cloudflare 100 MB edge cap so we never see a 413). Body is the
/// raw chunk bytes; the SPA's <c>uploadZipInChunks</c> helper sends
/// the right slice.</summary>
[HttpPost("zip-upload-chunk/{id:guid}")]
[DisableRequestSizeLimit]
[RequestSizeLimit(100_000_000)]
public async Task<IActionResult> ChunkAppend(Guid id, [FromQuery] long offset)
{
if (!Sessions.TryGetValue(id, out var session)) return NotFound(new { error = "unknown_session" });
if (session.AdminPlayerId != CurrentAdminId) return Forbid();
if (offset < 0 || offset >= session.TotalBytes)
return BadRequest(new { error = "bad_offset", offset, total = session.TotalBytes });
await using var fs = new FileStream(session.TempPath, FileMode.Open, FileAccess.Write, FileShare.Read);
fs.Position = offset;
var written = 0L;
await Request.Body.CopyToAsync(fs);
written = fs.Position - offset;
session.ReceivedBytes = Math.Max(session.ReceivedBytes, fs.Position);
return Ok(new
{
uploadId = id,
offset,
written,
received = session.ReceivedBytes,
total = session.TotalBytes,
});
}
/// <summary>Body for <c>zip-upload-finalize</c>. <see cref="CreatorPlayerId"/>
/// stamps every imported room/invention with that player as the
/// owner. <see cref="SelectedRoomFolders"/> and
/// <see cref="SelectedInventionFolders"/>, when non-null and
/// non-empty, restrict the import to only those top-level folders
/// inside the archive — the SPA's preview offers a checkbox per
/// room/invention so admins can skip a bad export's broken entry
/// without rebuilding the zip. Null/empty means "import
/// everything" (back-compat with older SPA builds).</summary>
public sealed record ChunkFinalizeRequest(
long? CreatorPlayerId,
List<string>? SelectedRoomFolders,
List<string>? SelectedInventionFolders,
bool? NormalizeBlobs);
/// <summary>POST <c>zip-upload-finalize/{id}</c> — runs the existing
/// zip import on the assembled temp file then deletes it. Same
/// response shape as <c>zip-bulk-import</c>.</summary>
[HttpPost("zip-upload-finalize/{id:guid}")]
public async Task<IActionResult> ChunkFinalize(Guid id, [FromBody] ChunkFinalizeRequest? body)
{
if (!Sessions.TryGetValue(id, out var session)) return NotFound(new { error = "unknown_session" });
if (session.AdminPlayerId != CurrentAdminId) return Forbid();
try
{
await using var stream = System.IO.File.OpenRead(session.TempPath);
return await RunImportAsync(
stream,
session.TotalBytes,
body?.CreatorPlayerId,
body?.SelectedRoomFolders,
body?.SelectedInventionFolders,
normalizeBlobs: body?.NormalizeBlobs ?? false);
}
finally
{
Sessions.TryRemove(id, out _);
try { if (System.IO.File.Exists(session.TempPath)) System.IO.File.Delete(session.TempPath); }
catch (Exception ex) { logger.LogWarning(ex, "[zip-import] failed to clean up {Path}", session.TempPath); }
}
}
/// <summary>DELETE <c>zip-upload-abort/{id}</c> — drop an in-flight
/// upload session (e.g. user navigated away). No-op if the id is
/// already gone.</summary>
[HttpDelete("zip-upload-abort/{id:guid}")]
public IActionResult ChunkAbort(Guid id)
{
if (Sessions.TryRemove(id, out var session))
{
try { if (System.IO.File.Exists(session.TempPath)) System.IO.File.Delete(session.TempPath); }
catch (Exception ex) { logger.LogWarning(ex, "[zip-import] failed to clean up {Path}", session.TempPath); }
}
return Ok();
}
// ── Shared import core (called by both single-shot and chunked) ──
private async Task<IActionResult> RunImportAsync(
Stream archiveStream,
long archiveBytes,
long? creatorPlayerId,
List<string>? selectedRoomFolders = null,
List<string>? selectedInventionFolders = null,
bool normalizeBlobs = false)
{
var creator = creatorPlayerId ?? CurrentAdminId;
if (!await db.Players.AnyAsync(p => p.Id == creator))
return BadRequest(new { error = "creator_not_found", creator });
using var zip = new ZipArchive(archiveStream, ZipArchiveMode.Read, leaveOpen: false);
// Index entries by normalized path. We do path-based lookups
// throughout (per-room folder, per-subroom folder, per-invention
// folder), so a flat dictionary keyed by full path is simplest.
var entryByPath = new Dictionary<string, ZipArchiveEntry>(StringComparer.OrdinalIgnoreCase);
foreach (var e in zip.Entries)
{
if (e.FullName.EndsWith("/", StringComparison.Ordinal)) continue;
entryByPath[Normalize(e.FullName)] = e;
}
// Find every room manifest (`Rooms/<folder>/RoomDetails.json`).
// Folder names are like "Room_3163127_YarrHarrHeist_20260509_180319"
// but we don't parse anything out of them — RoomDetails.json is
// the source of truth.
var roomFolders = entryByPath.Keys
.Where(k => k.StartsWith("Rooms/", StringComparison.OrdinalIgnoreCase)
&& k.EndsWith("/RoomDetails.json", StringComparison.OrdinalIgnoreCase))
.Select(k => k.Substring(0, k.Length - "/RoomDetails.json".Length))
.ToList();
// Inventions can live either at the zip root (Inventions/Invention_<id>_<name>_<ts>/)
// OR inside a room folder (Rooms/Room_<id>_<name>_<ts>/Inventions/Invention_…).
// Both layouts show up in the wild — handle either.
var inventionFolders = entryByPath.Keys
.Where(k => k.EndsWith("/Invention.json", StringComparison.OrdinalIgnoreCase)
&& (k.StartsWith("Inventions/", StringComparison.OrdinalIgnoreCase)
|| k.Contains("/Inventions/", StringComparison.OrdinalIgnoreCase)))
.Select(k => k[..^"/Invention.json".Length])
.ToList();
if (roomFolders.Count == 0 && inventionFolders.Count == 0)
return BadRequest(new { error = "empty_archive_no_manifests" });
// If the admin supplied an explicit selection (non-null,
// non-empty), filter to just those folders. Comparison is
// case-insensitive and tolerates leading/trailing slashes that
// the client might have included. Anything in the selection
// that we can't match against the archive is just ignored —
// the per-room report will simply not include it.
if (selectedRoomFolders is { Count: > 0 })
{
var allowed = selectedRoomFolders
.Select(s => Normalize(s).TrimEnd('/'))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
roomFolders = roomFolders.Where(f => allowed.Contains(f)).ToList();
}
if (selectedInventionFolders is { Count: > 0 })
{
var allowed = selectedInventionFolders
.Select(s => Normalize(s).TrimEnd('/'))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
inventionFolders = inventionFolders.Where(f => allowed.Contains(f)).ToList();
}
var roomReports = new List<object>();
long nextRoomId = (await db.Rooms
.Where(r => r.Id >= 1000)
.Select(r => (long?)r.Id)
.MaxAsync() ?? 999) + 1;
foreach (var folder in roomFolders)
{
try
{
var result = await ImportRoomAsync(folder, nextRoomId, creator, entryByPath, normalizeBlobs);
roomReports.Add(result);
if (result.GetType().GetProperty("ok")!.GetValue(result) as bool? == true)
nextRoomId++;
}
catch (Exception ex)
{
logger.LogError(ex, "[zip-import] room folder='{Folder}' failed", folder);
roomReports.Add(new { folder, ok = false, error = ex.Message });
}
}
var inventionReports = new List<object>();
foreach (var folder in inventionFolders)
{
try { inventionReports.Add(await ImportInventionAsync(folder, creator, entryByPath)); }
catch (Exception ex)
{
logger.LogError(ex, "[zip-import] invention folder='{Folder}' failed", folder);
inventionReports.Add(new { folder, ok = false, error = ex.Message });
}
}
return Ok(new
{
archiveBytes,
roomCount = roomFolders.Count,
inventionCount = inventionFolders.Count,
rooms = roomReports,
inventions = inventionReports,
});
}
// ── Per-room import ───────────────────────────────────────────────
private async Task<object> ImportRoomAsync(
string roomFolder,
long roomId,
long creator,
Dictionary<string, ZipArchiveEntry> entries,
bool normalizeBlobs)
{
// RoomDetails.json
var detailsKey = $"{roomFolder}/RoomDetails.json";
var details = await ReadJsonAsync<RoomDetailsDto>(entries[detailsKey]) ?? new RoomDetailsDto();
var name = !string.IsNullOrWhiteSpace(details.Name) ? details.Name!.Trim() : Path.GetFileName(roomFolder);
if (await db.Rooms.AnyAsync(r => r.Name == name))
return new { name, ok = false, error = "duplicate_name", folder = roomFolder };
// Subroom manifests + blobs. Walk each folder under
// <roomFolder>/SubRooms/<scene>/ and pair its Subroom.json with
// the .room blob it references via CurrentSave.DataBlob, plus
// any AudioSampler/Holotar .htr files alongside.
var subroomRoot = $"{roomFolder}/SubRooms/";
var scenes = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var path in entries.Keys.Where(k => k.StartsWith(subroomRoot, StringComparison.OrdinalIgnoreCase)))
{
var rel = path.Substring(subroomRoot.Length);
var slash = rel.IndexOf('/');
if (slash <= 0) continue;
var sceneName = rel.Substring(0, slash);
if (!scenes.ContainsKey(sceneName)) scenes[sceneName] = sceneName;
}
var resolvedScenes = new List<ResolvedSubroom>();
var missingScenes = new List<string>();
foreach (var sceneName in scenes.Keys)
{
var sceneFolder = $"{subroomRoot}{sceneName}";
var subroomJsonKey = $"{sceneFolder}/Subroom.json";
if (!entries.TryGetValue(subroomJsonKey, out var subroomJsonEntry))
{
missingScenes.Add($"{sceneName} (missing Subroom.json)");
continue;
}
var manifest = await ReadJsonAsync<SubRoomDto>(subroomJsonEntry) ?? new SubRoomDto();
var blobFile = manifest.CurrentSave?.DataBlob;
if (string.IsNullOrWhiteSpace(blobFile))
{
missingScenes.Add($"{sceneName} (no CurrentSave.DataBlob)");
continue;
}
var blobKey = $"{sceneFolder}/{blobFile}";
if (!entries.TryGetValue(blobKey, out var blobEntry))
{
missingScenes.Add($"{sceneName} → {blobFile}");
continue;
}
var blobBytes = await ReadAllAsync(blobEntry);
// The normaliser still runs so we keep the parse-OK / parse-FAIL
// log line — useful telemetry even when we're not persisting
// its output. Per-import `normalizeBlobs` flag (defaulting
// off because the re-encode currently crashes the watch on
// load) decides which bytes actually reach S3.
var norm = roomBlobNormalizer.Normalize(blobBytes);
var persistedBytes = normalizeBlobs ? norm.Bytes : blobBytes;
// Collect .htr asset blobs that live alongside this subroom.
var htrAssets = new List<(string Name, ZipArchiveEntry Entry)>();
foreach (var sub in new[] { "AudioSampler", "Holotar" })
{
var prefix = $"{sceneFolder}/{sub}/";
foreach (var (k, e) in entries.Where(kv => kv.Key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)))
{
if (k.EndsWith(".htr", StringComparison.OrdinalIgnoreCase))
htrAssets.Add((Path.GetFileName(k), e));
}
}
// Image/ folder: persistence-view previews (PVImage_<hash>.ext)
// and — since the exporter consolidated layouts — also the
// placed-in-world polaroids that used to live in Polaroids/.
//
// The watch fetches BOTH URLs for the same content:
// * `img.localhost/PVImage_<hash>.png` — persistence-view preview
// * `img.localhost/<hash>.png` — placed polaroid (room blob
// references this bare-hash form, decoded directly from
// the .room protobuf in our sample)
// So for any `PVImage_`-prefixed file we save the bytes under
// BOTH BlobNames. Same file in S3/DB twice is cheap and saves
// us from missing one of the lookup paths.
const string pvPrefix = "PVImage_";
var pvImages = new List<(string Name, ZipArchiveEntry Entry)>();
var polaroids = new List<(string Name, ZipArchiveEntry Entry)>();
var imageFolderPrefix = $"{sceneFolder}/Image/";
foreach (var (k, e) in entries.Where(kv => kv.Key.StartsWith(imageFolderPrefix, StringComparison.OrdinalIgnoreCase)))
{
var fileName = Path.GetFileName(k);
pvImages.Add((fileName, e));
if (fileName.StartsWith(pvPrefix, StringComparison.OrdinalIgnoreCase))
{
var bareHashName = fileName[pvPrefix.Length..];
polaroids.Add((bareHashName, e));
}
}
// Legacy fallback: older archives shipped polaroids as a
// separate Polaroids/ subfolder. Still support those so we
// don't regress on older test zips that haven't been re-
// exported. The persistence loop's de-dupe will collapse
// overlaps from the Image/-derived path.
var polaroidFolderPrefix = $"{sceneFolder}/Polaroids/";
foreach (var (k, e) in entries.Where(kv => kv.Key.StartsWith(polaroidFolderPrefix, StringComparison.OrdinalIgnoreCase)))
{
polaroids.Add((Path.GetFileName(k), e));
}
resolvedScenes.Add(new ResolvedSubroom(
Manifest: manifest,
SceneName: sceneName,
Bytes: persistedBytes,
RawBytes: blobBytes.Length,
// When the toggle is off we still report `norm.Normalized` —
// it reflects whether the diagnostic parse succeeded, not
// whether we actually applied the normalised output.
NormalizedOk: norm.Normalized,
HtrAssets: htrAssets,
PvImages: pvImages,
Polaroids: polaroids));
logger.LogInformation(
"[zip-import] room='{Room}' scene='{Scene}' blob={Blob} raw={Raw:N0} normOutput={Norm:N0} persistedFrom={Source} htr={Htr} pv={Pv} polaroids={Pol}",
name, sceneName, blobFile, blobBytes.Length, norm.Bytes.Length,
normalizeBlobs ? "normaliser" : "raw",
htrAssets.Count, pvImages.Count, polaroids.Count);
}
if (resolvedScenes.Count == 0)
return new { name, ok = false, error = "no_resolvable_subrooms", missingScenes, folder = roomFolder };
// Entry resolution: order subrooms by manifest order if
// RoomDetails.SubRooms[] is present; otherwise by sceneName.
// EntryScene field overrides everything.
var manifestOrder = (details.SubRooms ?? new())
.Where(s => !string.IsNullOrWhiteSpace(s.Name))
.Select(s => s.Name!)
.ToList();
if (manifestOrder.Count > 0)
{
resolvedScenes = resolvedScenes
.OrderBy(r => manifestOrder.IndexOf(r.SceneName) is int i && i >= 0 ? i : int.MaxValue)
.ThenBy(r => r.SceneName)
.ToList();
}
else
{
resolvedScenes = resolvedScenes.OrderBy(r => r.SceneName).ToList();
}
// Entry resolution cascade:
// 1. Explicit `RoomDetails.EntryScene` override (admin-set,
// not in real exports).
// 2. <hash>.meta protobuf at the room root — RecNet's
// published-snapshot manifest. Field 2 is the entry
// subroom INDEX into RoomDetails.SubRooms[] (0-based).
// Decoded directly without a generated schema because
// the message is tiny (6 bytes total in the known sample).
// 3. First entry in manifest order.
// 4. Alphabetical fallback.
ResolvedSubroom? metaEntry = null;
int? metaIndex = null;
string? metaFileName = null;
var metaCandidate = entries
.Where(kv => kv.Key.StartsWith($"{roomFolder}/", StringComparison.OrdinalIgnoreCase)
&& !kv.Key[$"{roomFolder}/".Length..].Contains('/')
&& kv.Key.EndsWith(".meta", StringComparison.OrdinalIgnoreCase))
.Select(kv => kv.Value)
.FirstOrDefault();
if (metaCandidate is not null)
{
metaFileName = Path.GetFileName(metaCandidate.FullName);
var metaBytes = await ReadAllAsync(metaCandidate);
metaIndex = TryReadEntrySubroomIndexFromMeta(metaBytes);
logger.LogInformation("[zip-import] room='{Room}' .meta={Meta} bytes={Bytes} entryIndex={Idx}",
name, metaFileName, metaBytes.Length, metaIndex);
if (metaIndex is int idx && idx >= 0 && idx < manifestOrder.Count)
{
var sceneAtIndex = manifestOrder[idx];
metaEntry = resolvedScenes.FirstOrDefault(r =>
string.Equals(r.SceneName, sceneAtIndex, StringComparison.OrdinalIgnoreCase));
}
}
ResolvedSubroom entry;
string entrySource;
if (!string.IsNullOrWhiteSpace(details.EntryScene)
&& resolvedScenes.FirstOrDefault(r => string.Equals(r.SceneName, details.EntryScene, StringComparison.OrdinalIgnoreCase)) is { } overrideEntry)
{
entry = overrideEntry; entrySource = "RoomDetails.EntryScene";
}
else if (metaEntry is not null)
{
entry = metaEntry; entrySource = $"meta_index={metaIndex}";
}
else
{
entry = resolvedScenes[0]; entrySource = manifestOrder.Count > 0 ? "manifest_first" : "alphabetical_first";
}
// Image: try ImageName lookup first (RecNet-style hash filename),
// then RoomImage.* fallback (convenience format).
ZipArchiveEntry? imageEntry = null;
if (!string.IsNullOrWhiteSpace(details.ImageName)
&& entries.TryGetValue($"{roomFolder}/{details.ImageName}", out var hashed))
{
imageEntry = hashed;
}
else
{
imageEntry = entries.FirstOrDefault(kv =>
kv.Key.StartsWith($"{roomFolder}/RoomImage.", StringComparison.OrdinalIgnoreCase)).Value;
}
// Reorder so entry is first. Manifest order preserved for the rest.
var ordered = new List<ResolvedSubroom> { entry };
ordered.AddRange(resolvedScenes.Where(r => !ReferenceEquals(r, entry)));
var slug = name.ToLowerInvariant().Replace(' ', '_');
// Prefix with room_<id>_ so BlobRouter parks every .room scene
// save alongside its room_<id>_v*.dat versioned snapshots in
// saves/room/<id>/. Same routing parser handles both shapes;
// no per-extension special case.
string MakeBlobName(string sceneName) => $"room_{roomId}_{slug}_{sceneName.ToLowerInvariant()}.room";
// Use the entry subroom's UnitySceneId as the room's location.
var entryLocation = entry.Manifest.UnitySceneId ?? "a75f7547-79eb-47c6-8986-6767abcb4f92";
// Persist .htr assets and PV images at the GLOBAL level
// (RoomId=0) under their original hash filenames. The CDN
// / img.* controllers look up by exact blob name, so this
// matches what HtrAssetMirrorService would have produced if it
// had to fetch them from rec.net.
//
// We track three counters per asset type:
// * referenced — total unique filenames the archive points at
// * imported — newly inserted into RoomDataBlobs this run
// * alreadyInDb — already present from a prior import / mirror
// The third is important to surface: bulk-purge-custom keeps
// shared assets intentionally (other rooms might use them),
// so a re-import after purge will legitimately report 0 newly
// imported with non-zero already-in-DB. Reporting only "0
// imported" reads as "no HTR uploaded" which is wrong.
var htrAssetCount = 0;
var pvImageCount = 0;
var polaroidCount = 0;
var allHtrAssets = ordered.SelectMany(s => s.HtrAssets).ToList();
var allPvImages = ordered.SelectMany(s => s.PvImages).ToList();
var allPolaroids = ordered.SelectMany(s => s.Polaroids).ToList();
// De-dupe within the archive first (same .htr can appear under
// multiple scenes), then de-dupe against the DB. Both layers
// matter for accurate reporting.
var uniqueHtrNames = allHtrAssets.Select(a => a.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var uniquePvNames = allPvImages.Select(a => a.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var uniquePolaroidNames = allPolaroids.Select(a => a.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var existingHtrNames = new HashSet<string>(
await db.RoomDataBlobs
.Where(b => uniqueHtrNames.Contains(b.BlobName))
.Select(b => b.BlobName).ToListAsync(),
StringComparer.OrdinalIgnoreCase);
var existingPvNames = new HashSet<string>(
await db.RoomDataBlobs
.Where(b => uniquePvNames.Contains(b.BlobName))
.Select(b => b.BlobName).ToListAsync(),
StringComparer.OrdinalIgnoreCase);
var existingPolaroidNames = new HashSet<string>(
await db.RoomDataBlobs
.Where(b => uniquePolaroidNames.Contains(b.BlobName))
.Select(b => b.BlobName).ToListAsync(),
StringComparer.OrdinalIgnoreCase);
var htrAlreadyInDbAtStart = existingHtrNames.Count;
var pvAlreadyInDbAtStart = existingPvNames.Count;
var polaroidAlreadyInDbAtStart = existingPolaroidNames.Count;
var imageBlobName = string.Empty;
await using (var tx = await db.Database.BeginTransactionAsync())
{
// Subroom blobs
foreach (var s in ordered)
{
await WriteBlobAsync(roomId, MakeBlobName(s.SceneName), s.Bytes, creator);
}
// HTR assets (shared content — keyed by hash, RoomId=0)
foreach (var (hname, hentry) in allHtrAssets)
{
if (existingHtrNames.Contains(hname)) continue;
existingHtrNames.Add(hname);
var bytes = await ReadAllAsync(hentry);
await WriteBlobAsync(0, hname, bytes, creator);
htrAssetCount++;
}
// PV images (persistence-view previews; shared)
foreach (var (pname, pentry) in allPvImages)
{
if (existingPvNames.Contains(pname)) continue;
existingPvNames.Add(pname);
var bytes = await ReadAllAsync(pentry);
await WriteBlobAsync(0, pname, bytes, creator);
pvImageCount++;
}
// Polaroids (placed in-world; watch fetches by bare hash).
foreach (var (polName, polEntry) in allPolaroids)
{
if (existingPolaroidNames.Contains(polName)) continue;
existingPolaroidNames.Add(polName);
var bytes = await ReadAllAsync(polEntry);
await WriteBlobAsync(0, polName, bytes, creator);
polaroidCount++;
}
// Room-root opaque marker files (e.g. <hash>.meta — 6-byte
// sentinel the exporter drops, purpose unclear but watching
// for fetches just in case). Rename to room_<id>_<hash>.meta
// so BlobRouter parks them in saves/room/<id>/ alongside the
// versioned save snapshots — keeps every byte that belongs
// to a specific room in one S3 folder.
var rootMetaPrefix = $"{roomFolder}/";
foreach (var (k, mentry) in entries.Where(kv =>
kv.Key.StartsWith(rootMetaPrefix, StringComparison.OrdinalIgnoreCase)
&& !kv.Key[rootMetaPrefix.Length..].Contains('/')
&& kv.Key.EndsWith(".meta", StringComparison.OrdinalIgnoreCase)))
{
var originalName = Path.GetFileName(k);
var fname = $"room_{roomId}_{originalName}";
if (await db.RoomDataBlobs.AnyAsync(b => b.BlobName == fname)) continue;
var bytes = await ReadAllAsync(mentry);
await WriteBlobAsync(roomId, fname, bytes, creator);
}
// Room image
if (imageEntry is not null)
{
var bytes = await ReadAllAsync(imageEntry);
var ext = Path.GetExtension(imageEntry.Name).ToLowerInvariant();
if (string.IsNullOrEmpty(ext)) ext = ".jpg";
imageBlobName = $"image_room_{roomId}{ext}";
await WriteBlobAsync(0, imageBlobName, bytes, creator);
}
// Promo images (kept under their original hashed names so
// the room's promo strip resolves them via img.* CDN).
var promoPrefix = $"{roomFolder}/Promo/Image/";
foreach (var (k, pentry) in entries.Where(kv => kv.Key.StartsWith(promoPrefix, StringComparison.OrdinalIgnoreCase)))
{
var fname = Path.GetFileName(k);
if (await db.RoomDataBlobs.AnyAsync(b => b.BlobName == fname)) continue;
var bytes = await ReadAllAsync(pentry);
await WriteBlobAsync(0, fname, bytes, creator);
}
db.Rooms.Add(new RoomEntity
{
Id = roomId,
Name = name,
Description = details.Description?.Trim() ?? $"Imported from zip ({ordered.Count} scenes)",
CreatorPlayerId = creator,
ImageName = imageBlobName,
State = details.State ?? 0,
Accessibility = details.Accessibility ?? 1,
IsAGRoom = false,
IsDormRoom = details.IsDorm ?? false,
CloningAllowed = details.CloningAllowed ?? true,
SupportsLevelVoting = details.SupportsLevelVoting ?? false,
SupportsVRLow = details.SupportsVRLow ?? true,
SupportsMobile = details.SupportsMobile ?? false,
SupportsScreens = details.SupportsScreens ?? true,
SupportsWalkVR = details.SupportsWalkVR ?? true,
SupportsTeleportVR = details.SupportsTeleportVR ?? true,
AllowsJuniors = details.SupportsJuniors ?? true,
RoomWarningMask = details.WarningMask ?? 0,
CustomRoomWarning = details.CustomWarning ?? string.Empty,
DisableMicAutoMute = details.DisableMicAutoMute ?? false,
LocationReplicationId = entryLocation,
TagsCsv = JoinTags(details.Tags),
CheerCount = details.Stats?.CheerCount ?? 0,
FavoriteCount = details.Stats?.FavoriteCount ?? 0,
VisitCount = details.Stats?.VisitCount ?? 0,
VisitorCount = details.Stats?.VisitorCount ?? 0,
HotScore = 5.0,
CurrentDataBlobName = MakeBlobName(entry.SceneName),
CreatedAt = details.CreatedAt ?? DateTime.UtcNow,
});
int idx = 0;
foreach (var s in ordered)
{
db.RoomScenes.Add(new RoomSceneEntity
{
RoomId = roomId,
OrderIndex = idx++,
Name = s.SceneName,
RoomSceneLocationId = s.Manifest.UnitySceneId ?? entryLocation,
DataBlobName = MakeBlobName(s.SceneName),
MaxPlayers = s.Manifest.MaxPlayers ?? 8,
IsSandbox = s.Manifest.IsSandbox ?? false,
CanMatchmakeInto = true,
DataModifiedAt = DateTime.UtcNow,
});
}
db.AdminActions.Add(new AdminActionEntity
{
AdminPlayerId = CurrentAdminId,
Action = "zip_import_room",
TargetType = "room",
TargetId = roomId,
Reason = $"name={name} scenes={ordered.Count} entry={entry.SceneName} src={entrySource} " +
$"htr={htrAssetCount}new+{htrAlreadyInDbAtStart}existed " +
$"pv={pvImageCount}new+{pvAlreadyInDbAtStart}existed " +
$"polaroids={polaroidCount}new+{polaroidAlreadyInDbAtStart}existed",
});
await db.SaveChangesAsync();
await tx.CommitAsync();
}
return new
{
name,
ok = true,
roomId,
folder = roomFolder,
sceneCount = ordered.Count,
entryScene = entry.SceneName,
entryDetectionSource = entrySource,
missingScenes = missingScenes.Count > 0 ? missingScenes : null,
tags = details.Tags?.Select(t => t.Tag).Where(t => !string.IsNullOrWhiteSpace(t)).ToList(),
imageBlob = string.IsNullOrEmpty(imageBlobName) ? null : imageBlobName,
htrAssets = new
{
referenced = uniqueHtrNames.Count,
newlyImported = htrAssetCount,
alreadyInDb = htrAlreadyInDbAtStart,
},
pvImages = new
{
referenced = uniquePvNames.Count,
newlyImported = pvImageCount,
alreadyInDb = pvAlreadyInDbAtStart,
},
polaroids = new
{
referenced = uniquePolaroidNames.Count,
newlyImported = polaroidCount,
alreadyInDb = polaroidAlreadyInDbAtStart,
},
// Legacy fields kept for back-compat with anything that
// already reads them; new code should prefer htrAssets /
// pvImages / polaroids above.
htrAssetsImported = htrAssetCount,
pvImagesImported = pvImageCount,
stats = new
{
cheers = details.Stats?.CheerCount ?? 0,
favorites = details.Stats?.FavoriteCount ?? 0,
visitors = details.Stats?.VisitorCount ?? 0,
visits = details.Stats?.VisitCount ?? 0,
},
scenes = ordered.Select((s, i) => new
{
orderIndex = i,
name = s.SceneName,
blobName = MakeBlobName(s.SceneName),
bytes = s.Bytes.Length,
rawBytes = s.RawBytes,
normalized = s.NormalizedOk,
unitySceneId = s.Manifest.UnitySceneId,
isSandbox = s.Manifest.IsSandbox,
maxPlayers = s.Manifest.MaxPlayers,
htrAssets = s.HtrAssets.Count,
pvImages = s.PvImages.Count,
}),
};
}
// ── Per-invention import ─────────────────────────────────────────
private async Task<object> ImportInventionAsync(
string folder,
long creator,
Dictionary<string, ZipArchiveEntry> entries)
{
// Invention.json
var meta = await ReadJsonAsync<InventionJsonDto>(entries[$"{folder}/Invention.json"])
?? new InventionJsonDto();
InventionVersionDto version = entries.TryGetValue($"{folder}/InventionVersion.json", out var ve)
? await ReadJsonAsync<InventionVersionDto>(ve) ?? new InventionVersionDto()
: new InventionVersionDto();
InventionDetailsDto extras = entries.TryGetValue($"{folder}/InventionDetails.json", out var de)
? await ReadJsonAsync<InventionDetailsDto>(de) ?? new InventionDetailsDto()
: new InventionDetailsDto();
var name = !string.IsNullOrWhiteSpace(meta.Name) ? meta.Name!.Trim() : Path.GetFileName(folder);
// Find the .inv blob — by manifest reference if present, else
// first .inv file in the invention folder.
ZipArchiveEntry? blobEntry = null;
if (!string.IsNullOrWhiteSpace(version.BlobName)
&& entries.TryGetValue($"{folder}/{version.BlobName}", out var byManifest))
{
blobEntry = byManifest;
}
else
{
blobEntry = entries.FirstOrDefault(kv =>
kv.Key.StartsWith($"{folder}/", StringComparison.OrdinalIgnoreCase)
&& kv.Key.EndsWith(".inv", StringComparison.OrdinalIgnoreCase)).Value;
}
if (blobEntry is null)
return new { name, ok = false, error = "no_inv_blob", folder };
// Image (optional)
ZipArchiveEntry? imageEntry = null;
if (!string.IsNullOrWhiteSpace(meta.ImageName)
&& entries.TryGetValue($"{folder}/{meta.ImageName}", out var byHash))
{
imageEntry = byHash;
}
else
{
imageEntry = entries.FirstOrDefault(kv =>
kv.Key.StartsWith($"{folder}/InventionImage.", StringComparison.OrdinalIgnoreCase)).Value;
}
var blobBytes = await ReadAllAsync(blobEntry);
var entity = new InventionEntity
{
CreatorPlayerId = creator,
ReplicationId = !string.IsNullOrWhiteSpace(meta.ReplicationId)
? meta.ReplicationId!.Trim()
: Guid.NewGuid().ToString("N"),
Name = name,
Description = meta.Description?.Trim() ?? string.Empty,
ImageName = string.Empty, // set below after we know the blob name
IsAgInvention = meta.IsAgInvention ?? true,
IsPublished = false,
Permission = meta.Accessibility ?? 0,
CreatorPermission = meta.CreatorPermission ?? 100,
GeneralPermission = meta.GeneralPermission ?? 0,
CurrentVersionNumber = meta.CurrentVersionNumber ?? version.VersionNumber ?? 1,
NumPlayersHaveUsedInRoom = meta.NumPlayersHaveUsedInRoom ?? 0,
CheerCount = meta.CheerCount ?? 0,
CreationRoomId = meta.CreationRoomId,
TagsCsv = JoinTags(extras.Tags),
CreatedAt = meta.CreatedAt ?? DateTime.UtcNow,
UpdatedAt = meta.ModifiedAt ?? DateTime.UtcNow,
FirstPublishedAt = meta.FirstPublishedAt,
};
db.Inventions.Add(entity);
await db.SaveChangesAsync();
var blobName = $"invention_{entity.Id}_v{entity.CurrentVersionNumber}.dat";
entity.CurrentBlobName = blobName;
await WriteBlobAsync(0, blobName, blobBytes, creator);
string? imageBlobName = null;
if (imageEntry is not null)
{
var bytes = await ReadAllAsync(imageEntry);
var ext = Path.GetExtension(imageEntry.Name).ToLowerInvariant();
if (string.IsNullOrEmpty(ext)) ext = ".jpg";
imageBlobName = $"image_invention_{entity.Id}{ext}";
entity.ImageName = imageBlobName;
await WriteBlobAsync(0, imageBlobName, bytes, creator);
}
db.AdminActions.Add(new AdminActionEntity
{
AdminPlayerId = CurrentAdminId,
Action = "zip_import_invention",
TargetType = "invention",
TargetId = entity.Id,
Reason = $"name={name} blob={blobName} bytes={blobBytes.Length}",
});
await db.SaveChangesAsync();
logger.LogInformation(
"[zip-import] invention id={Id} name={Name} bytes={Bytes:N0} image={Image}",
entity.Id, name, blobBytes.Length, imageBlobName ?? "(none)");
return new
{
name,
ok = true,
inventionId = entity.Id,
folder,
blobName,
imageBlob = imageBlobName,
bytes = blobBytes.Length,
tags = extras.Tags?.Select(t => t.Tag).Where(t => !string.IsNullOrWhiteSpace(t)).ToList(),
};
}
// ── Helpers ──────────────────────────────────────────────────────
private static string Normalize(string path) => path.Replace('\\', '/').TrimStart('/');
private static async Task<byte[]> ReadAllAsync(ZipArchiveEntry entry)
{
await using var s = entry.Open();
using var ms = new MemoryStream();
await s.CopyToAsync(ms);
return ms.ToArray();
}
private static async Task<T?> ReadJsonAsync<T>(ZipArchiveEntry entry)
{
await using var s = entry.Open();
return await JsonSerializer.DeserializeAsync<T>(s, JsonOpts);
}
private static string JoinTags(List<TagDto>? tags)
{
if (tags is null || tags.Count == 0) return "community";
var values = tags
.Select(t => t.Tag?.Trim())
.Where(t => !string.IsNullOrEmpty(t))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
return values.Count > 0 ? string.Join(',', values!) : "community";
}
private sealed record ResolvedSubroom(
SubRoomDto Manifest,
string SceneName,
byte[] Bytes,
int RawBytes,
bool NormalizedOk,
List<(string Name, ZipArchiveEntry Entry)> HtrAssets,
List<(string Name, ZipArchiveEntry Entry)> PvImages,
List<(string Name, ZipArchiveEntry Entry)> Polaroids);
/// <summary>Decode RecNet's room-snapshot manifest <c>.meta</c> and
/// return the entry-subroom index it encodes, or null if the file
/// isn't shaped like one. The full <c>.proto</c> schema isn't part
/// of the generated C# bindings, but the known-sample bytes are
/// trivially small and parseable by hand:
///
/// 10 01 1A 02 08 01
/// ┬──── ┬──────────
/// │ │
/// │ └─ field 3 (length-delimited), inner = { field 1 (varint) = 1 }
/// └─ field 2 (varint) = 1
///
/// Empirically field 2 is the entry subroom INDEX into
/// <c>RoomDetails.SubRooms[]</c> (0-based), and field 3's nested
/// field 1 echoes the same value (probably "published version =
/// 1"). We only need field 2 for the entry-selection decision.</summary>
private static int? TryReadEntrySubroomIndexFromMeta(byte[] bytes)
{
if (bytes is null || bytes.Length == 0) return null;
try
{
using var input = new CodedInputStream(bytes);
while (!input.IsAtEnd)
{
var tag = input.ReadTag();
var field = WireFormat.GetTagFieldNumber(tag);
var wire = WireFormat.GetTagWireType(tag);
if (field == 2 && wire == WireFormat.WireType.Varint)
{
return (int)input.ReadInt64();
}
input.SkipLastField();
}
}
catch
{
return null;
}
return null;
}
}
@@ -0,0 +1,230 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Admin;
/// <summary>
/// admin.*/api/admin/v1/storage/backfill — one-shot migrator that
/// moves the legacy <c>RoomDataBlobs.Bytes</c> column out of the
/// database and into S3 at the canonical key from
/// <see cref="BlobRouter.Route"/>. After every row's bytes have been
/// uploaded and verified, a follow-up EF migration drops the
/// <c>Bytes</c> column entirely.
///
/// Why this exists: every controller that writes new content already
/// targets S3 directly (the importer, photo upload, profile image,
/// dorm save, room save, …). The <c>Bytes</c> column only holds
/// pre-cutover data — rooms imported before the S3-only refactor
/// landed. This endpoint drains that legacy column into S3 once and
/// then we never look at it again.
///
/// Runs as a background task so the HTTP request returns immediately
/// even for a multi-GB drain. Progress streams to the server log and
/// to a small in-memory <see cref="BackfillStatus"/> snapshot the UI
/// can poll. Idempotent — each row is checked against
/// <see cref="IObjectStorage.ExistsAsync"/> first, and rows whose
/// bytes are already in S3 get their DB <c>Bytes</c> cleared without
/// re-upload.
/// </summary>
[ApiController]
[Route("api/admin/v1/storage")]
[Authorize]
[AdminOnly]
public class StorageBackfillController(
ILogger<StorageBackfillController> log,
IServiceScopeFactory scopeFactory) : ControllerBase
{
private static readonly object _gate = new();
private static BackfillStatus _status = BackfillStatus.Idle;
/// <summary>POST <c>/storage/backfill?dryRun=true</c> — start a
/// drain pass. Returns 200 immediately; the actual work happens
/// in a background task. Re-POSTing while running returns 409.
/// Set <c>?dryRun=true</c> on the first pass to log every
/// (row → bucket/key) decision without writing to S3 or clearing
/// the DB <c>Bytes</c> column.</summary>
[HttpPost("backfill")]
public IActionResult Start([FromQuery] bool dryRun = false)
{
lock (_gate)
{
if (_status.Running)
return Conflict(new { error = "already_running", since = _status.StartedAt });
_status = BackfillStatus.Starting(dryRun);
}
_ = Task.Run(() => RunAsync(dryRun));
return Ok(new { started = true, dryRun });
}
/// <summary>GET <c>/storage/backfill/status</c> — current
/// progress. Updated row-by-row during a run.</summary>
[HttpGet("backfill/status")]
public IActionResult Status() => Ok(_status);
private async Task RunAsync(bool dryRun)
{
var startedAt = DateTime.UtcNow;
try
{
// Phase 1: pull the list of row ids that still have bytes.
// Materialise just the ids (not the bytes) up-front so we
// don't hold a reader open across per-row S3 PUTs — that
// would conflict with the per-row ExecuteUpdateAsync on
// Npgsql, which doesn't allow a second command while a
// reader is mid-iteration. Bytes get fetched one row at a
// time below so RAM only holds one blob at a time.
// Filter on Bytes != null only — Npgsql can't translate
// .Length on a byte[] property (it sees it as Enumerable.Any
// and bails). Empty-byte-array rows don't exist in our
// schema; rows either hold real bytes or NULL after backfill.
// We still re-check Length on the materialised row before
// uploading, defensive against any future client-evaluated
// edge case.
List<long> rowIds;
long total;
await using (var scope = scopeFactory.CreateAsyncScope())
{
var dbScoped = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
rowIds = await dbScoped.RoomDataBlobs
.AsNoTracking()
.Where(b => b.Bytes != null)
.OrderBy(b => b.Id)
.Select(b => b.Id)
.ToListAsync();
total = rowIds.Count;
}
log.LogInformation(
"[backfill] starting — rowsWithBytes={Total} dryRun={DryRun}",
total, dryRun);
lock (_gate) _status = _status with { Total = total };
// Phase 2: per-row upload + clear. Each row gets its own
// short-lived DbContext so the reader/writer conflict is
// structurally impossible. Memory-bound at one row's
// bytes plus query overhead.
foreach (var rowId in rowIds)
{
await using var rowScope = scopeFactory.CreateAsyncScope();
var rowDb = rowScope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var storageScoped = rowScope.ServiceProvider.GetRequiredService<IObjectStorage>();
var row = await rowDb.RoomDataBlobs
.AsNoTracking()
.Where(b => b.Id == rowId)
.Select(b => new { b.Id, b.BlobName, b.Bytes })
.FirstOrDefaultAsync();
if (row is null || row.Bytes is null || row.Bytes.Length == 0)
{
Bump(s => s with { Skipped = s.Skipped + 1 });
continue;
}
var (bucket, key) = BlobRouter.Route(row.BlobName);
try
{
if (dryRun)
{
log.LogInformation(
"[backfill:dry] {Blob} → {Bucket}/{Key} ({Bytes} B)",
row.BlobName, bucket, key, row.Bytes.Length);
Bump(s => s with { Uploaded = s.Uploaded + 1, LastBlobName = row.BlobName });
continue;
}
// Upload unconditionally (S3 PUT is idempotent /
// overwriting). Verifying with ExistsAsync first
// would let us skip already-mirrored rows, but a
// mismatch between S3 and DB bytes (corrupted
// earlier upload, partial backfill, etc.) would
// leave the divergence in place. Re-uploading
// guarantees S3 == DB before we clear DB.
var contentType = MimeFromName(row.BlobName);
// Per-row timeout: 4 minutes generous enough for a
// 100 MB blob over a 5 Mbps origin link, well
// inside the 5-minute S3 SDK ceiling. Without a
// CT, a hung S3 connection could freeze the whole
// drain indefinitely.
using var putCt = new CancellationTokenSource(TimeSpan.FromMinutes(4));
await storageScoped.PutAsync(bucket, key, row.Bytes, contentType, putCt.Token);
// SAFETY GATE: confirm S3 actually persisted the
// object before dropping the DB copy. PutAsync
// returning success isn't enough — some S3 impls
// can ack a write that disappears (eventual
// consistency, replication lag, write buffer
// dropped on shutdown). HEAD costs nothing.
using var headCt = new CancellationTokenSource(TimeSpan.FromSeconds(15));
if (!await storageScoped.ExistsAsync(bucket, key, headCt.Token))
throw new InvalidOperationException(
$"S3 PUT {bucket}/{key} returned success but ExistsAsync says no — refusing to clear DB bytes");
await rowDb.RoomDataBlobs
.Where(b => b.Id == row.Id)
.ExecuteUpdateAsync(s => s.SetProperty(b => b.Bytes, (byte[]?)null));
Bump(s => s with { Uploaded = s.Uploaded + 1, LastBlobName = row.BlobName });
}
catch (Exception ex)
{
log.LogWarning(ex, "[backfill] failed {Blob} → {Bucket}/{Key} — DB bytes left intact",
row.BlobName, bucket, key);
Bump(s => s with { Failed = s.Failed + 1, LastBlobName = row.BlobName });
}
}
var elapsed = (int)(DateTime.UtcNow - startedAt).TotalMilliseconds;
lock (_gate) _status = _status with
{
Running = false,
FinishedAt = DateTime.UtcNow,
ElapsedMs = elapsed,
};
log.LogInformation(
"[backfill] finished — total={Total} uploaded={Up} alreadyMirrored={Already} skipped={Skip} failed={Fail} dryRun={Dry} elapsed={Ms}ms",
_status.Total, _status.Uploaded, _status.AlreadyMirrored, _status.Skipped, _status.Failed, dryRun, elapsed);
}
catch (Exception ex)
{
log.LogError(ex, "[backfill] crashed");
lock (_gate) _status = _status with { Running = false, FinishedAt = DateTime.UtcNow, Error = ex.Message };
}
}
private static void Bump(Func<BackfillStatus, BackfillStatus> mutate)
{
lock (_gate) _status = mutate(_status);
}
private static string MimeFromName(string name) => Path.GetExtension(name).ToLowerInvariant() switch
{
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".gif" => "image/gif",
".webp" => "image/webp",
".mp4" => "video/mp4",
_ => "application/octet-stream",
};
public sealed record BackfillStatus(
bool Running,
bool DryRun,
DateTime? StartedAt,
DateTime? FinishedAt,
long Total,
long Uploaded,
long AlreadyMirrored,
long Skipped,
long Failed,
string? LastBlobName,
int? ElapsedMs,
string? Error)
{
public static BackfillStatus Idle => new(false, false, null, null, 0, 0, 0, 0, 0, null, null, null);
public static BackfillStatus Starting(bool dryRun) =>
new(true, dryRun, DateTime.UtcNow, null, 0, 0, 0, 0, 0, null, null, null);
}
}