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;
///
/// 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,
IObjectStorage storage,
DomainConfig domain,
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();
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),
}));
}
/// 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 = onlinePresence.OnlinePlayerIds().Contains(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.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 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 });
}
// ── 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.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 => 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,
}),
});
}
/// 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] 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);
/// 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.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 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 });
}
/// 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)
{
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) ────────────────────────────────────────
/// 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);
/// 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 (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.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 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 });
}
/// 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();
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();
return Ok(new { ok = true });
}
/// 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 = onlinePresence.OnlinePlayerIds().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