using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
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;
///
/// api.rec.net/api/admin/v1/* — privileged moderation endpoints. Every
/// action is gated by , which checks
/// ; every state mutation is logged
/// to for audit replay.
///
/// The first account created on a fresh DB gets IsAdmin = true
/// (see ) so a
/// freshly-bootstrapped private server always has at least one root
/// admin without manual SQL.
///
[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,
SignupCodeService signupCodes,
IObjectStorage storage,
DomainConfig domain,
IdentityServerSigningKeyProvider signingKeys,
ILogger adminLogger) : ControllerBase
{
private long CurrentAdminId => this.RequireCurrentPlayerId();
// ── Player request logs ──────────────────────────────────────────────
/// Most recent HTTP requests made by a single player, newest
/// first. Backed by 's Redis ring buffer
/// (in-process fallback for dev) — capped at
/// 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.
[HttpGet("players/{id:long}/logs")]
public ActionResult PlayerLogs(long id, [FromQuery] int take = 200)
{
var entries = playerLog.GetRecent(id, take);
return Ok(entries);
}
// ── Players ──────────────────────────────────────────────────────────
/// List players with online status, paginated. Used by the
/// admin watch tab. Default page size 50, max 200 to keep responses
/// reasonable.
[HttpGet("players")]
public async Task ListPlayers(
[FromQuery] string? query, [FromQuery] int take = 50, [FromQuery] int skip = 0)
{
take = Math.Clamp(take, 1, 200);
skip = Math.Max(0, skip);
IQueryable 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();
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 = playerPresence.IsRecentlyActive(p.Id),
}));
}
/// Single-player detail — bundles the player record plus
/// their currency balances so the admin UI can render an edit page
/// in one round trip.
[HttpGet("players/{id:long}")]
public async Task 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 = playerPresence.IsRecentlyActive(p.Id),
Balances = balances,
Avatar = avatar,
});
}
public sealed record BanRequest(int DurationDays, string? Reason);
/// Ban a player for the given number of days. The
/// 401s any subsequent request
/// from the player until BannedUntil elapses; we also push
/// a ModerationKick notification so the watch sees the ban
/// immediately.
[HttpPost("players/{id:long}/ban")]
public async Task 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.KickPlayerAsync(id, body.Reason ?? "You have been banned.");
playerPresence.Clear(id);
return Ok(new { player.Id, player.BannedUntil });
}
[HttpPost("players/{id:long}/unban")]
public async Task 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 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 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 });
}
public sealed record DeletePlayerRequest(string ConfirmUsername, string ConfirmPhrase, string? Reason);
/// Hard-remove a non-admin player account. The destructive
/// endpoint requires two exact confirmations: the current username and
/// the phrase DELETE {id}. Personal rows are deleted, authored
/// durable content is reassigned to the system account, and live sessions
/// are kicked so stale bearer tokens stop being useful immediately.
[HttpDelete("players/{id:long}")]
public async Task DeletePlayer(long id, [FromBody] DeletePlayerRequest body)
{
if (id == 1)
return BadRequest(new { Error = "Cannot delete the system account." });
if (id == CurrentAdminId)
return BadRequest(new { Error = "Cannot delete your own admin account." });
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == id);
if (player is null) return NotFound();
if (player.IsAdmin)
return BadRequest(new { Error = "Cannot delete an admin account. Demote it first." });
var expectedPhrase = $"DELETE {player.Id}";
if (!string.Equals(body.ConfirmUsername?.Trim(), player.Username, StringComparison.Ordinal)
|| !string.Equals(body.ConfirmPhrase?.Trim(), expectedPhrase, StringComparison.Ordinal))
return BadRequest(new { Error = $"Confirmation failed. Type @{player.Username} and {expectedPhrase} exactly." });
var reason = string.IsNullOrWhiteSpace(body.Reason)
? $"removed @{player.Username}"
: body.Reason.Trim();
var systemPlayerId = await db.Players.AnyAsync(p => p.Id == 1) ? 1L : CurrentAdminId;
await using var tx = await db.Database.BeginTransactionAsync();
var summary = await DeletePlayerDataAsync(id, systemPlayerId);
db.Players.Remove(player);
await LogAsync("delete_player", "player", id, $"{reason}; {summary}");
await db.SaveChangesAsync();
await tx.CommitAsync();
await notifications.KickPlayerAsync(id, "Your account has been removed by an admin.");
playerPresence.Clear(id);
return Ok(new
{
deleted = id,
username = player.Username,
reassignedToPlayerId = systemPlayerId,
summary,
});
}
// ── Rooms ────────────────────────────────────────────────────────────
public sealed record DeleteRoomRequest(string? Reason);
/// Soft-delete a room by setting State = 1
/// (Archived). The room stops appearing in browse/search results;
/// its data is still retrievable for an undelete if needed.
/// GET api/admin/v1/diagnostics/room-images —
/// emergency diagnostic for "room tile shows smiley" symptoms.
/// Returns one row per seeded room with: the DB ImageName, whether
/// the corresponding file exists in data/images/, 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.
[HttpGet("diagnostics/room-images")]
public async Task 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,
});
}
/// GET api/admin/v1/players/{id}/clothing-debug —
/// 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.
[HttpGet("players/{id:long}/clothing-debug")]
public async Task 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 inventoryGuids;
try
{
inventoryGuids = System.Text.Json.JsonSerializer
.Deserialize>(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,
});
}
/// GET api/admin/v1/rooms — 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.
[HttpGet("rooms")]
public async Task ListAllRooms()
{
var rooms = await db.Rooms
.OrderByDescending(r => r.IsAGRoom)
.ThenBy(r => r.Name)
.Select(r => new
{
r.Id,
r.Name,
r.ImageName,
r.IsAGRoom,
r.IsDormRoom,
r.CreatorPlayerId,
BlobCount = db.RoomDataBlobs.Count(b => b.RoomId == r.Id),
})
.ToListAsync();
return Ok(rooms);
}
/// GET api/admin/v1/rooms/{id}/visitors — most
/// recent unique visitors of a room. Joins
/// against
/// so the admin UI can show "who's been here lately" with
/// usernames + per-player visit counts.
[HttpGet("rooms/{id:long}/visitors")]
public async Task 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 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);
/// POST api/admin/v1/rooms/{id}/purge — 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 IsAGRoom=true from its
/// source — that doesn't make it canonical, it just means the
/// matchmaker uses the AG-room join path. Gate purely on
/// CreatorPlayerId == SystemAccountId, which is the only
/// reliable "is this actually a seeded RR Original?" signal.
///
/// Requires BOTH ConfirmName1 and ConfirmName2 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
/// confirm_name_mismatch if either is missing/wrong so the
/// SPA can surface the error inline without leaking which one
/// failed.
[HttpPost("rooms/{id:long}/purge")]
public async Task 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,
});
}
public sealed record WipeImportTargetsRequest(
long[]? InventionIds,
string[]? RoomNames);
/// POST api/admin/v1/import/wipe-targets — clear the
/// DB rows that would conflict with a zip re-import. Built for the
/// zip importer's "Wipe duplicates first" affordance — when the
/// admin uploads a refreshed zip after a recovery pass added more
/// .inv bytes, the second import would otherwise: (a) reject every
/// room with duplicate_name, and (b) silently auto-id every
/// invention whose original id is already in the DB, leaving the
/// room blob's placement refs pointing at orphans (= empty/missing
/// inventions in-game).
///
/// Wipes:
/// * rows for the listed ids + their
/// associated rows (.inv bytes
/// + image thumbnails)
/// * rows matching any of the listed
/// names exactly + their rows +
/// rows (scene blobs + room
/// image)
///
/// Skips dorms and seed-owned RR-Originals (same safety net as
/// ) — only user-content survives the
/// wipe path.
///
/// Returns a per-room and per-invention rollup so the UI can show
/// what landed before kicking off the actual upload.
[HttpPost("import/wipe-targets")]
public async Task WipeImportTargets([FromBody] WipeImportTargetsRequest body)
{
var inventionIds = body?.InventionIds ?? Array.Empty();
var roomNames = body?.RoomNames ?? Array.Empty();
if (inventionIds.Length == 0 && roomNames.Length == 0)
return BadRequest(new { error = "nothing_to_wipe" });
// ── Inventions ────────────────────────────────────────────────
var inventions = inventionIds.Length == 0
? new List()
: await db.Inventions.Where(i => inventionIds.Contains(i.Id)).ToListAsync();
var invBlobNames = inventions
.SelectMany(i => new[] { i.CurrentBlobName, i.ImageName })
.Where(n => !string.IsNullOrEmpty(n))
.Cast()
.Distinct()
.ToList();
var invBlobs = invBlobNames.Count == 0
? new List()
: await db.RoomDataBlobs
.Where(b => b.RoomId == 0 && invBlobNames.Contains(b.BlobName))
.ToListAsync();
// ── Rooms ─────────────────────────────────────────────────────
// Exact-name match — same key the importer uses for its
// duplicate-name rejection. Skip dorms + seed-owned RR-Originals
// so a fat-finger on the wipe list can't nuke canonical content.
var roomMatches = roomNames.Length == 0
? new List()
: await db.Rooms.Where(r => roomNames.Contains(r.Name)).ToListAsync();
var roomsToDelete = roomMatches
.Where(r => !r.IsDormRoom
&& !(r.IsAGRoom && r.CreatorPlayerId == PlayerService.SystemAccountId))
.ToList();
var roomIds = roomsToDelete.Select(r => r.Id).ToList();
var roomScenes = roomIds.Count == 0
? new List()
: await db.RoomScenes.Where(s => roomIds.Contains(s.RoomId)).ToListAsync();
var roomBlobs = roomIds.Count == 0
? new List()
: await db.RoomDataBlobs.Where(b => roomIds.Contains(b.RoomId)).ToListAsync();
var roomImageNames = roomsToDelete
.Select(r => r.ImageName)
.Where(n => !string.IsNullOrEmpty(n))
.Cast()
.Distinct()
.ToList();
var roomImages = roomImageNames.Count == 0
? new List()
: await db.RoomDataBlobs
.Where(b => b.RoomId == 0 && roomImageNames.Contains(b.BlobName))
.ToListAsync();
db.RoomDataBlobs.RemoveRange(invBlobs);
db.Inventions.RemoveRange(inventions);
db.RoomScenes.RemoveRange(roomScenes);
db.RoomDataBlobs.RemoveRange(roomBlobs);
db.RoomDataBlobs.RemoveRange(roomImages);
db.Rooms.RemoveRange(roomsToDelete);
await LogAsync("wipe_import_targets", "import", 0,
$"inventions={inventions.Count}/{inventionIds.Length} " +
$"rooms={roomsToDelete.Count}/{roomNames.Length} " +
$"roomBlobs={roomBlobs.Count} roomScenes={roomScenes.Count} " +
$"invBlobs={invBlobs.Count} roomImages={roomImages.Count}");
await db.SaveChangesAsync();
var skippedRooms = roomMatches
.Where(r => r.IsDormRoom
|| (r.IsAGRoom && r.CreatorPlayerId == PlayerService.SystemAccountId))
.Select(r => new { id = r.Id, name = r.Name, reason = r.IsDormRoom ? "dorm" : "seeded_rr_original" })
.ToList();
return Ok(new
{
inventionsDeleted = inventions.Count,
inventionsRequested = inventionIds.Length,
inventionsNotFound = inventionIds.Length - inventions.Count,
inventionBlobsRemoved = invBlobs.Count,
roomsDeleted = roomsToDelete.Count,
roomsRequested = roomNames.Length,
roomsNotFound = roomNames.Length - roomMatches.Count,
roomScenesRemoved = roomScenes.Count,
roomBlobsRemoved = roomBlobs.Count + roomImages.Count,
skippedRooms,
});
}
public sealed record PurgeInventionsRequest(
long[]? InventionIds,
// The caller must echo back the count of ids it expects to delete.
// Mismatch = client and server disagree about scope → refuse.
// Same pattern as `git rm` requiring -f when files would be lost.
int? ExpectedCount);
/// POST api/admin/v1/inventions/purge — hard-delete a
/// batch of inventions by id. Used by the zip-recovery workflow to
/// wipe a room's referenced inventions before re-importing a refreshed
/// zip — avoids the duplicate-id problem where the zip importer
/// auto-generates new ids when the original is already in the DB,
/// leaving the room blob's invention references pointing at orphans.
///
/// Deletes:
/// * rows for the given ids
/// * Associated rows for both the
/// invention's CurrentBlobName (the .inv bytes) and
/// ImageName (the thumbnail). RoomId on those rows is 0
/// for invention-owned blobs.
///
/// Does NOT touch S3 — the importer will overwrite on re-import, and
/// orphan S3 keys are harmless (read path lookups them by BlobName
/// from the DB).
///
/// Logs each delete to AdminActions for audit replay.
///
[HttpPost("inventions/purge")]
public async Task PurgeInventions([FromBody] PurgeInventionsRequest body)
{
var ids = body?.InventionIds;
if (ids is null || ids.Length == 0)
return BadRequest(new { error = "missing_ids" });
if (body!.ExpectedCount is not int ec || ec != ids.Length)
return BadRequest(new { error = "expected_count_mismatch", expected = body.ExpectedCount, actual = ids.Length });
var inventions = await db.Inventions
.Where(i => ids.Contains(i.Id))
.ToListAsync();
var blobNames = inventions
.SelectMany(i => new[] { i.CurrentBlobName, i.ImageName })
.Where(n => !string.IsNullOrEmpty(n))
.Cast()
.Distinct()
.ToList();
var blobs = await db.RoomDataBlobs
.Where(b => b.RoomId == 0 && blobNames.Contains(b.BlobName))
.ToListAsync();
db.RoomDataBlobs.RemoveRange(blobs);
db.Inventions.RemoveRange(inventions);
await LogAsync("purge_inventions", "invention", 0,
$"count={inventions.Count} blobs={blobs.Count} ids=[{string.Join(",", inventions.Select(i => i.Id).Take(20))}{(inventions.Count > 20 ? ",..." : "")}]");
await db.SaveChangesAsync();
return Ok(new
{
deleted = inventions.Count,
requested = ids.Length,
notFound = ids.Length - inventions.Count,
blobsRemoved = blobs.Count,
});
}
// ── Leaderboards ─────────────────────────────────────────────────────
/// Verified stat-channel metadata. The 2020 watch's
/// ConditionalSetStat_ReturningNewValue 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
/// StuntRunnerManager.get_TotalTimeStatChannelForLeaderboard
/// (StuntRunnerManager.txt:220-228 returns the literal 7); the
/// stored value is milliseconds, hence ValueFormat = "time-ms".
/// 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.
private static readonly Dictionary KnownStatChannels = new()
{
[7] = new("Stunt Runner total time", true, "time-ms"),
};
private sealed record StatChannelMeta(string Name, bool LowerIsBetter, string ValueFormat);
/// GET api/admin/v1/leaderboards/channels — summary
/// of every stat channel that has at least one row. Used by the
/// Leaderboards SPA tab to populate the channel picker.
[HttpGet("leaderboards/channels")]
public async Task ListChannels()
{
var rows = await db.LeaderboardStats
.GroupBy(s => new { s.RoomId, s.StatChannel })
.Select(g => new
{
g.Key.RoomId,
Channel = g.Key.StatChannel,
EntryCount = g.Count(),
MinValue = g.Min(x => x.Value),
MaxValue = g.Max(x => x.Value),
})
.OrderBy(r => r.RoomId)
.ThenBy(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 => new { c.RoomId, c.Channel });
var labelled = rows.Select(r =>
{
dbMeta.TryGetValue(new { r.RoomId, 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 = r.RoomId,
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,
}),
});
}
/// GET api/admin/v1/leaderboards/{channel} — 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).
[HttpGet("leaderboards/{channel:int}")]
public async Task ChannelDetail(int channel, [FromQuery] long roomId = 0, [FromQuery] int take = 100)
{
take = Math.Clamp(take, 1, 1000);
var liveMeta = await db.LeaderboardChannelMeta.FirstOrDefaultAsync(c => c.RoomId == roomId && 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.RoomId == roomId && 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 = roomId,
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, long RoomId = 0);
/// POST api/admin/v1/leaderboards/{channel}/score
/// — upsert a single player's score in a channel.
[HttpPost("leaderboards/{channel:int}/score")]
public async Task SetScore(int channel, [FromBody] SetLeaderboardScoreRequest body)
{
var row = await db.LeaderboardStats.FirstOrDefaultAsync(s =>
s.RoomId == body.RoomId && s.PlayerId == body.PlayerId && s.StatChannel == channel);
if (row is null)
{
row = new LeaderboardStatEntity
{
PlayerId = body.PlayerId,
RoomId = body.RoomId,
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, $"room={body.RoomId} channel={channel} value={body.Value}");
await db.SaveChangesAsync();
return Ok(new { row.PlayerId, row.RoomId, row.StatChannel, row.Value });
}
[HttpDelete("leaderboards/{channel:int}/score/{playerId:long}")]
public async Task DeleteScore(int channel, long playerId, [FromQuery] long roomId = 0)
{
var row = await db.LeaderboardStats.FirstOrDefaultAsync(s =>
s.RoomId == roomId && s.PlayerId == playerId && s.StatChannel == channel);
if (row is null) return NotFound();
db.LeaderboardStats.Remove(row);
await LogAsync("delete_leaderboard_score", "player", playerId, $"room={roomId} channel={channel}");
await db.SaveChangesAsync();
return Ok(new { deleted = playerId, roomId, channel });
}
/// DELETE api/admin/v1/leaderboards/{channel} —
/// wipe every entry in a stat channel. Used for season resets or
/// to clear test data.
[HttpDelete("leaderboards/{channel:int}")]
public async Task WipeChannel(int channel, [FromQuery] long roomId = 0)
{
var rows = await db.LeaderboardStats
.Where(s => s.RoomId == roomId && s.StatChannel == channel)
.ToListAsync();
if (rows.Count == 0) return Ok(new { wiped = 0, roomId, channel });
db.LeaderboardStats.RemoveRange(rows);
await LogAsync("wipe_leaderboard_channel", "system", channel, $"room={roomId} entries={rows.Count}");
await db.SaveChangesAsync();
return Ok(new { wiped = rows.Count, roomId, channel });
}
// ── Rec Room Originals (RROs) ────────────────────────────────────────
/// GET api/admin/v1/rooms/originals — the canonical
/// Rec Room Originals (rooms whose CreatorPlayerId ==
/// PlayerService.SystemAccountId). Backs the RRO admin tab
/// which lets admins flip per-room properties (cloning, max
/// players, description) without scrolling through the global
/// rooms list.
[HttpGet("rooms/originals")]
public async Task 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,
int? MaxCapacity);
/// POST api/admin/v1/rooms/{id}/props — 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.
[HttpPost("rooms/{id:long}/props")]
public async Task 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();
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 (body.MaxCapacity is int cap)
{
// Clamp to a sane range. Note: advertised cap only on the 2020.12
// client today (no Photon hard-enforce) — see RoomEntity.MaxCapacity.
room.MaxCapacity = Math.Clamp(cap, 1, 80);
changes.Add($"maxCapacity={room.MaxCapacity}");
}
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) ─────────
/// GET api/admin/v1/rooms/{id} — 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 /rooms, /rooms/originals,
/// /playerReputation endpoints separately.
[HttpGet("rooms/{id:long}")]
public async Task 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.MaxCapacity,
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);
/// POST api/admin/v1/rooms/{id}/roles — grant a
/// (PlayerId, Role) pair on the room. Role: 0=CoOwner, 1=Moderator,
/// 2=Host. Idempotent — re-granting the same role flips Accepted
/// only.
[HttpPost("rooms/{id:long}/roles")]
public async Task 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 oldRoles = await db.RoomRoles
.Where(r => r.RoomId == id && r.PlayerId == body.PlayerId && r.Role != body.Role)
.ToListAsync();
if (oldRoles.Count > 0)
db.RoomRoles.RemoveRange(oldRoles);
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();
await NotifyRoomRolesChangedAsync(id, body.PlayerId, "AdminRoleGrant");
return Ok(new { ok = true });
}
/// DELETE api/admin/v1/rooms/{id}/roles/{playerId}/{role}
/// — revoke a specific role grant. No-op if the row doesn't exist.
[HttpDelete("rooms/{id:long}/roles/{playerId:long}/{role:int}")]
public async Task 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();
await NotifyRoomRolesChangedAsync(id, playerId, "AdminRoleRevoke");
return Ok(new { ok = true, deleted = 1 });
}
public sealed record TransferOwnerRequest(long NewCreatorPlayerId);
/// POST api/admin/v1/rooms/{id}/owner — 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.
[HttpPost("rooms/{id:long}/owner")]
public async Task 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();
await NotifyRoomRolesChangedAsync(id, previousOwner, "AdminOwnerTransfer");
await NotifyRoomRolesChangedAsync(id, body.NewCreatorPlayerId, "AdminOwnerTransfer");
return Ok(new { ok = true });
}
/// Push fresh RoomDetails after role/owner mutations. The
/// 2020 watch caches RoomDetails, including CoOwners/Moderators/Hosts,
/// and role-gated UI reads that cache until Rooms.OnSubscriptionUpdateRoom
/// stores a replacement.
private async Task NotifyRoomRolesChangedAsync(long roomId, long changedPlayerId, string reason)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return;
var scenes = await db.RoomScenes
.Where(s => s.RoomId == roomId)
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var roles = await db.RoomRoles
.Where(r => r.RoomId == roomId)
.ToListAsync();
var payload = DorkNet.Server.Controllers.API.Rooms.V2.RoomsController
.BuildRoomDetails(room, scenes, roles: roles);
var recipients = onlinePresence.OnlinePlayerIds()
.Where(pid => playerPresence.GetRoom(pid)?.RoomId == roomId)
.Append(changedPlayerId)
.Distinct()
.ToList();
adminLogger.LogInformation(
"[admin-room-role] pushing room details room={RoomId} reason={Reason} changedPlayer={PlayerId} recipients={RecipientCount}",
roomId, reason, changedPlayerId, recipients.Count);
foreach (var pid in recipients)
await notifications.NotifyAsync(pid, PushNotificationId.SubscriptionUpdateRoom, payload);
}
/// GET api/admin/v1/rooms/{id}/instances — active
/// Photon instances for one specific room. Subset of the global
/// /instances 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 masterPlayerId field is best-effort: Photon's master
/// is whoever has the lowest ActorNumber 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.
[HttpGet("rooms/{id:long}/instances")]
public async Task 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 = playerPresence.RecentlyActivePlayerIds().ToList();
var byInstance = new Dictionary 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());
}
slot.pids.Add(pid);
byInstance[r.RoomInstanceId] = slot;
}
if (byInstance.Count == 0) return Ok(Array.Empty