3352 lines
169 KiB
C#
3352 lines
169 KiB
C#
using Microsoft.AspNetCore.Mvc;
|
|
using Newtonsoft.Json;
|
|
using System.IO.Compression;
|
|
using Campus_live_rewrite.api;
|
|
using Campus_live_rewrite.api.dbs;
|
|
using Campus_live_rewrite.api.server;
|
|
using Campus_live_rewrite.api.server.Classes.db_class;
|
|
using CampusLiveSource.Controllers;
|
|
using static Campus_live_rewrite.api.server.Moderation_Detail;
|
|
|
|
namespace Campus_live_rewrite.Controllers
|
|
{
|
|
public class AdminController : Controller
|
|
{
|
|
private const string AuthCookieName = "Authorization";
|
|
|
|
/// <summary>
|
|
/// Finds an admin HTML file whether the process runs from the deploy dir or not.
|
|
///
|
|
/// The real routes are /admin/v4/login and /admin/home/v4; the file is served with
|
|
/// Environment.CurrentDirectory, which is the systemd WorkingDirectory (/opt/campus).
|
|
/// After a reinstall that directory can differ, so also try the assembly's own base
|
|
/// directory - otherwise every admin page 404s even though the files shipped.
|
|
/// </summary>
|
|
private static string? FindAdminFile(params string[] relative)
|
|
{
|
|
foreach (var root in new[] { Environment.CurrentDirectory, AppContext.BaseDirectory })
|
|
{
|
|
var p = Path.Combine(new[] { root }.Concat(relative).ToArray());
|
|
if (System.IO.File.Exists(p)) return p;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Bare /admin and its common variants. There was no route for any of these, so the
|
|
// most natural URL a person types - gameauth.ddns.net/admin - 404'd, which reads as
|
|
// "the admin panel is down". Send them to the panel: to the home page when they are
|
|
// already an authenticated dev, otherwise to the login page.
|
|
// "/admin" already matches "/admin/" (the matcher ignores the trailing slash), so a
|
|
// separate "/admin/" template made both endpoints match "/admin/" -> AmbiguousMatchException
|
|
// -> 500 on the bare entry point. One template covers both.
|
|
[HttpGet("/admin")]
|
|
[HttpGet("/admin/home")]
|
|
[HttpGet("/admin/login")]
|
|
public IActionResult AdminRoot()
|
|
{
|
|
var (_, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
return Redirect(error == null ? "/admin/home/v4" : "/admin/v4/login");
|
|
}
|
|
|
|
// NOTE: there is no separate visible gate page. The web token is just an extra field on the
|
|
// admin login form (LoginV*.html) and is checked inside AdminLoginAPI below, so a person
|
|
// only ever sees the normal login - with a token box on it. The login PAGE is reachable
|
|
// without the gate cookie (that's where you enter the token); everything else on the admin
|
|
// surface requires the gate cookie that a correct-token login stamps.
|
|
[HttpGet("/admin/v{version}/login")]
|
|
public IActionResult Login(int version)
|
|
{
|
|
var filePath = FindAdminFile("Web", "Admin", $"LoginV{version}.html");
|
|
if (filePath == null)
|
|
return NotFound();
|
|
|
|
return PhysicalFile(filePath, "text/html");
|
|
}
|
|
|
|
[HttpPost("/api/web/admin/login")]
|
|
public ContentResult AdminLoginAPI([FromForm] string username, [FromForm] string password)
|
|
{
|
|
try
|
|
{
|
|
var loginResult = PlayerDB.GetAccountPassword(username, password, isAdminPanel: true);
|
|
|
|
if (!loginResult.success)
|
|
{
|
|
return new ContentResult
|
|
{
|
|
Content = JsonConvert.SerializeObject(new
|
|
{
|
|
success = loginResult.success,
|
|
error = loginResult.error
|
|
}),
|
|
ContentType = "text/html"
|
|
};
|
|
}
|
|
|
|
// Admin web session is SHORT-LIVED: a 1-hour dev token (not the game client's
|
|
// 30-day one) so the whole admin session resets every hour and must be re-logged.
|
|
string jwt = ClientSecurity.RefreshToken_Web(loginResult.accid, TimeSpan.FromHours(1), "admin");
|
|
|
|
Response.Cookies.Append(AuthCookieName, jwt, new Microsoft.AspNetCore.Http.CookieOptions
|
|
{
|
|
Expires = DateTime.UtcNow.AddHours(1),
|
|
HttpOnly = true,
|
|
Secure = true,
|
|
SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Strict
|
|
});
|
|
|
|
// Mint a short-lived admin session token (bearer-style, not typed by the user).
|
|
// It self-expires; when it lapses the middleware bounces the browser back to login.
|
|
Response.Cookies.Append(AdminGate.CookieName, AdminGate.Issue(), new Microsoft.AspNetCore.Http.CookieOptions
|
|
{
|
|
Expires = DateTime.UtcNow.Add(AdminGate.Ttl),
|
|
HttpOnly = true,
|
|
Secure = true,
|
|
SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Strict
|
|
});
|
|
|
|
Response.Redirect($"/admin/home/v4");
|
|
|
|
return new ContentResult
|
|
{
|
|
Content = JsonConvert.SerializeObject(loginResult),
|
|
ContentType = "text/html"
|
|
};
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine($"[AdminLoginAPI] Exception: {ex}");
|
|
return new ContentResult
|
|
{
|
|
Content = "Invalid username or password. Please try again.",
|
|
ContentType = "text/html"
|
|
};
|
|
}
|
|
}
|
|
|
|
[HttpGet("/admin/home/v{version}")]
|
|
public IActionResult AdminHomeVVersion(int version)
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null)
|
|
{
|
|
return Redirect("/admin/v4/login");
|
|
}
|
|
|
|
var filePath = FindAdminFile("Web", "Admin", "Home", $"HomeV{version}.html");
|
|
if (filePath == null)
|
|
return NotFound();
|
|
|
|
return PhysicalFile(filePath, "text/html");
|
|
}
|
|
|
|
[Route("/admin/api/dashboard_data")]
|
|
public ContentResult DashboardData()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null)
|
|
return error;
|
|
|
|
// Each count is guarded on its own. A single DB hiccup (a checkpoint mid-read, a
|
|
// locked collection) used to throw here and 500 the WHOLE dashboard, leaving every
|
|
// card stuck - one bad count should cost one number, not the page. -1 marks a count
|
|
// that could not be read.
|
|
int Safe(Func<int> f) { try { return f(); } catch (Exception ex) { Console.WriteLine($"[dashboard] count failed: {ex.Message}"); return -1; } }
|
|
|
|
int OnlinePlayers = Safe(() => NotificationsController.WebSockets.Count);
|
|
string AdminMOTD = "Go wild like Campus!";
|
|
long bytesUsed = GC.GetTotalMemory(false);
|
|
return new ContentResult
|
|
{
|
|
Content = JsonConvert.SerializeObject(new
|
|
{
|
|
total_players = Safe(() => (int)PlayerDB.GetPlayerCount()),
|
|
online_players = OnlinePlayers,
|
|
banned_players = 0,
|
|
total_rooms = Safe(() => (int)RoomDB.GetAllRoomsCount()),
|
|
total_inventions = 0,
|
|
mem_usage = FormatBytes(bytesUsed),
|
|
total_clubs = 0,
|
|
MOTD = AdminMOTD,
|
|
diskspace = 0,
|
|
quick_link_sidebar = new List<Object>
|
|
{
|
|
new
|
|
{
|
|
Title = "Reports",
|
|
Button_Title = "View Reports",
|
|
Button_url = "/admin/reports/all",
|
|
Button_icon = "",
|
|
}
|
|
},
|
|
quick_link = new List<Object>
|
|
{
|
|
new
|
|
{
|
|
Title = "I just don't know!",
|
|
disc = "So I'll put the index page.",
|
|
Button_Title = "Go",
|
|
Button_url = "/",
|
|
Button_Color = "#ff0",
|
|
},
|
|
new
|
|
{
|
|
Title = "Reports",
|
|
disc = "vew players Reports",
|
|
Button_Title = "View Reports",
|
|
Button_url = "/admin/reports/all",
|
|
Button_Color = "#aaa",
|
|
},
|
|
new
|
|
{
|
|
Title = "campus net home page!",
|
|
disc = "go to campus net home page",
|
|
Button_Title = "Go",
|
|
Button_url = "/campus_net/home",
|
|
Button_Color = "#e9a",
|
|
}
|
|
},
|
|
}),
|
|
ContentType = "application/json"
|
|
};
|
|
}
|
|
|
|
[HttpGet("/admin/api/players")]
|
|
public ContentResult AdminGetPlayersAPI()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null)
|
|
return error;
|
|
var query = HttpContext.Request.Query;
|
|
|
|
string? id = query["id"];
|
|
string? displayName = query["displayName"];
|
|
string? minLevel = query["minLevel"];
|
|
string? jrState = query["jrState"];
|
|
string? platformId = query["platformId"];
|
|
string? deviceId = query["deviceId"].FirstOrDefault();
|
|
bool onlyBannedPlayers = bool.TryParse(query["banned"], out var b) && b;
|
|
|
|
var players = PlayerDB.Find_all_player_filtered(id, displayName, minLevel, jrState, platformId, deviceId, onlyBannedPlayers);
|
|
|
|
// Enrich each row with live presence so the panel can show WHERE a player is - the room
|
|
// name, its instance id and the room id - and whether they're online. Read-only peek so
|
|
// enumerating everyone doesn't mutate heartbeats. A player who is nowhere gets nulls.
|
|
var arr = Newtonsoft.Json.Linq.JArray.FromObject(players);
|
|
foreach (var row in arr.OfType<Newtonsoft.Json.Linq.JObject>())
|
|
{
|
|
ulong pid = (ulong?)row["AccountId"] ?? 0;
|
|
var (online, room) = HeartbeatDB.PeekPresence(pid);
|
|
row["IsOnline"] = online;
|
|
row["RoomName"] = room?.name;
|
|
row["RoomInstanceId"] = room != null ? room.roomInstanceId : (long?)null;
|
|
row["RoomId"] = room != null ? room.roomId : (long?)null;
|
|
row["SubRoomId"] = room != null ? room.subRoomId : (long?)null;
|
|
row["RoomIsPrivate"] = room?.isPrivate ?? false;
|
|
}
|
|
|
|
return new ContentResult
|
|
{
|
|
Content = arr.ToString(Newtonsoft.Json.Formatting.None),
|
|
ContentType = "application/json"
|
|
};
|
|
}
|
|
|
|
// Force join: drop the JOINER into the TARGET's current room instance. Same mechanism as the
|
|
// !goto teleport - the joiner's heartbeat is set to the target's exact instance, so on its
|
|
// next heartbeat the joiner's client walks into that room (same instance = same Photon room =
|
|
// they end up together).
|
|
// joinerId = the person being moved (the one who "joins")
|
|
// targetId = the person whose room they land in (the one "being joined")
|
|
[HttpPost("/admin/api/force_join")]
|
|
public ContentResult AdminForceJoin([FromForm] ulong joinerId, [FromForm] ulong targetId)
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null)
|
|
return new ContentResult { Content = JsonConvert.SerializeObject(new { success = false, error = "unauthorized" }), ContentType = "application/json", StatusCode = 401 };
|
|
|
|
ContentResult Fail(string msg) => new ContentResult
|
|
{
|
|
Content = JsonConvert.SerializeObject(new { success = false, error = msg }),
|
|
ContentType = "application/json",
|
|
StatusCode = 200
|
|
};
|
|
|
|
if (joinerId == 0 || targetId == 0) return Fail("Pick both players.");
|
|
if (joinerId == targetId) return Fail("Joiner and target are the same player.");
|
|
|
|
var (targetOnline, room) = HeartbeatDB.PeekPresence(targetId);
|
|
if (room == null) return Fail("Target isn't in a room right now - nowhere to send the joiner.");
|
|
|
|
// Land the joiner in the target's EXACT instance only when it's a joinable PUBLIC one -
|
|
// then they end up together on the same Photon room. A PRIVATE instance (a Dorm) is
|
|
// single-occupant: the client would try, fail to load, and bounce home (the "takes ages
|
|
// then fails" symptom). Say so up front instead. For a public room we also accept a
|
|
// sibling joinable instance if the exact one somehow isn't usable.
|
|
// Admin override: the joiner is dropped into the target's EXACT instance, public or
|
|
// private (a dorm, a private-instance game, an invite-only room). Two players meet when
|
|
// their presence carries the same Photon room name, and a private instance has one just
|
|
// like a public one - the old refusal for private rooms had no technical reason behind
|
|
// it. Room bans and private-room roles are not consulted: this is the admin's call.
|
|
HeartbeatDB.RoomInstance dest;
|
|
if (!string.IsNullOrEmpty(room.photonRoomIdYeee))
|
|
{
|
|
dest = room;
|
|
}
|
|
else
|
|
{
|
|
// An instance with no Photon room name can't be shared (nothing to meet in) - fall
|
|
// back to any joinable public instance of the same room.
|
|
var joinable = HeartbeatDB.FindJoinableInstance((ulong)room.roomId, joinerId);
|
|
if (joinable == null)
|
|
return Fail("Target's instance has no shareable session yet - try again in a few seconds.");
|
|
dest = joinable;
|
|
}
|
|
if (dest.maxCapacity > 0)
|
|
{
|
|
int occupants = HeartbeatDB.CountInInstance(dest.roomInstanceId);
|
|
if (occupants >= dest.maxCapacity)
|
|
Console.WriteLine($"[force_join] instance {dest.roomInstanceId} is at capacity ({occupants}/{dest.maxCapacity}) - admin override, joining anyway");
|
|
}
|
|
|
|
HeartbeatDB.UpdatePlayerHeartbeat(joinerId, dest, online: true);
|
|
Console.WriteLine($"[force_join] admin moving {joinerId} -> {targetId}'s instance {dest.roomInstanceId} ({dest.name})");
|
|
|
|
return new ContentResult
|
|
{
|
|
Content = JsonConvert.SerializeObject(new
|
|
{
|
|
success = true,
|
|
error = "",
|
|
joinerId,
|
|
targetId,
|
|
roomName = dest.name,
|
|
roomInstanceId = dest.roomInstanceId,
|
|
roomId = dest.roomId,
|
|
note = "The joiner moves on their next heartbeat (up to ~20s) - that delay is the client's poll cadence."
|
|
}),
|
|
ContentType = "application/json",
|
|
StatusCode = 200
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Imports rooms from a JSON file placed in Data/Imports.
|
|
///
|
|
/// The file is the shape Rec Room's own room API returns - { "Results": [ ...rooms... ] }
|
|
/// - which is what RoomDB.ImportRooms already expects. Capture a room (with its
|
|
/// subrooms) from the real service, save the response, drop it in Data/Imports, and
|
|
/// import it here. This used to be possible only by uncommenting a line in Program.cs
|
|
/// and rebuilding.
|
|
///
|
|
/// Importing brings the room and its subrooms in, INCLUDING each save's DataBlob
|
|
/// filename - but not the blob itself. The response therefore lists every .room file
|
|
/// the imported rooms reference and whether it is present in Data/CDN/RoomBlobs, so
|
|
/// you know exactly which ones still need downloading. A save whose blob is missing is
|
|
/// hidden by FilterUnservableSaves until the file appears; drop it in and the room
|
|
/// starts loading its build with no code change.
|
|
///
|
|
/// Only reads from Data/Imports - a path from the request is never trusted.
|
|
/// </summary>
|
|
[HttpPost("/admin/api/import_rooms")]
|
|
public async Task<ContentResult> AdminImportRooms()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null) return error;
|
|
|
|
string file = (Request.Form["file"].ToString() ?? "").Trim();
|
|
if (string.IsNullOrWhiteSpace(file))
|
|
return Json(new { success = false, message = "No file given." });
|
|
|
|
// Filename only - no directory traversal.
|
|
file = Path.GetFileName(file);
|
|
string dir = Path.Combine(Environment.CurrentDirectory, "Data", "Imports");
|
|
string path = Path.Combine(dir, file);
|
|
|
|
if (!System.IO.File.Exists(path))
|
|
return Json(new { success = false, message = $"Data/Imports/{file} not found." });
|
|
|
|
ulong before = RoomDB.GetAllRoomsCount();
|
|
|
|
try
|
|
{
|
|
await RoomDB.ImportRooms(path);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine($"[import] {file} failed: {ex.Message}");
|
|
return Json(new { success = false, message = $"Import failed: {ex.Message}" });
|
|
}
|
|
|
|
ulong after = RoomDB.GetAllRoomsCount();
|
|
|
|
// Which .room blobs do the imported rooms now reference, and do we have them?
|
|
string blobDir = Path.Combine(Environment.CurrentDirectory, "Data", "CDN", "RoomBlobs");
|
|
var missing = new List<string>();
|
|
var present = new List<string>();
|
|
|
|
foreach (var room in RoomDB.GetAllRooms())
|
|
{
|
|
foreach (var sub in room.SubRooms ?? new List<RoomDBClasses.SubRoom>())
|
|
{
|
|
foreach (var save in sub.AllSaves ?? new List<RoomDBClasses.CurrentSave>())
|
|
{
|
|
string? blob = save.DataBlob;
|
|
if (string.IsNullOrWhiteSpace(blob)) continue;
|
|
|
|
var fi = new FileInfo(Path.Combine(blobDir, blob));
|
|
if (fi.Exists && fi.Length > 0) { if (!present.Contains(blob)) present.Add(blob); }
|
|
else if (!missing.Contains(blob)) missing.Add(blob);
|
|
}
|
|
}
|
|
}
|
|
|
|
Console.WriteLine($"[import] {file}: rooms {before} -> {after}; " +
|
|
$"{present.Count} blob(s) present, {missing.Count} missing");
|
|
|
|
return Json(new
|
|
{
|
|
success = true,
|
|
message = $"Imported {after - before} room(s) from {file}. " +
|
|
$"{missing.Count} .room blob(s) still needed.",
|
|
roomsBefore = before,
|
|
roomsAfter = after,
|
|
blobsPresent = present.Count,
|
|
blobsMissing = missing
|
|
});
|
|
}
|
|
|
|
// Drag-and-drop upload (admin "Rooms" tab). Accepts any mix of dropped files:
|
|
// • a .json room export -> imported as rooms OWNED BY @Importer (ids reassigned)
|
|
// • a .room / .META blob -> dropped into Data/CDN/RoomBlobs (makes saved builds load)
|
|
// Does the whole thing in one request and reports what was imported + what blobs are still
|
|
// missing. This is the one-shot version of drop-into-Imports + POST import_rooms.
|
|
[HttpPost("/admin/api/import_upload")]
|
|
[DisableRequestSizeLimit]
|
|
[RequestFormLimits(MultipartBodyLengthLimit = long.MaxValue)]
|
|
public async Task<ContentResult> AdminImportUpload()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null) return error;
|
|
|
|
if (!Request.HasFormContentType || Request.Form.Files.Count == 0)
|
|
return Json(new { success = false, message = "No files uploaded." });
|
|
|
|
ulong importerId = PlayerDB.GetAccountIdByUsernameCI("Importer");
|
|
if (importerId == 0)
|
|
return Json(new { success = false, message = "The @Importer account does not exist yet (restart the server to seed it)." });
|
|
|
|
string importsDir = Path.Combine(Environment.CurrentDirectory, "Data", "Imports");
|
|
string blobDir = Path.Combine(Environment.CurrentDirectory, "Data", "CDN", "RoomBlobs");
|
|
Directory.CreateDirectory(importsDir);
|
|
Directory.CreateDirectory(blobDir);
|
|
|
|
ulong before = RoomDB.GetAllRoomsCount();
|
|
var importedFiles = new List<string>();
|
|
var blobFiles = new List<string>();
|
|
var errors = new List<string>();
|
|
int avatarItemsImported = 0;
|
|
int exportRooms = 0, exportBlobs = 0;
|
|
var roomNames = new List<string>();
|
|
|
|
string imagesDir = Path.Combine(Environment.CurrentDirectory, "Data", "Images");
|
|
Directory.CreateDirectory(imagesDir);
|
|
|
|
// Copy one extracted file to the right place by kind. Images -> Data/Images,
|
|
// logs/readme/descriptor -> skipped, everything else (.room/.META/.binpb/subroom data)
|
|
// -> RoomBlobs so whatever a save's DataBlob names is present.
|
|
void RouteFile(string file)
|
|
{
|
|
string bn = Path.GetFileName(file);
|
|
string ext = Path.GetExtension(file).ToLowerInvariant();
|
|
if (ext is ".jpg" or ".jpeg" or ".png")
|
|
{ System.IO.File.Copy(file, Path.Combine(imagesDir, bn), true); return; }
|
|
if (ext is ".log" || bn.Equals("README.txt", StringComparison.OrdinalIgnoreCase)) return;
|
|
if (ext == ".json") return; // handled explicitly (RoomDetails.json)
|
|
System.IO.File.Copy(file, Path.Combine(blobDir, bn), true);
|
|
if (!blobFiles.Contains(bn)) blobFiles.Add(bn);
|
|
}
|
|
|
|
// Host a single loose .room blob and, if nothing already references it and it's old enough
|
|
// for the client, wrap it in a joinable @Importer room. Shared by the single-file drop and
|
|
// the "zip full of .room files" path. `onDiskPath` is where the file already sits on disk.
|
|
void WrapLooseRoomFile(string onDiskPath)
|
|
{
|
|
string bn = Path.GetFileName(onDiskPath);
|
|
if (!blobFiles.Contains(bn)) blobFiles.Add(bn);
|
|
var (status, id, ver, roomName, err) = RoomDB.WrapLooseRoom(onDiskPath, importerId);
|
|
switch (status)
|
|
{
|
|
case RoomDB.LooseWrapStatus.Created:
|
|
exportRooms++; roomNames.Add(roomName);
|
|
importedFiles.Add($"{bn} → new room '{roomName}' (id {id}, save v{ver})"); break;
|
|
case RoomDB.LooseWrapStatus.TooNew:
|
|
errors.Add($"{bn}: save version {ver} is newer than this client can load (max {Campus_live_rewrite.api.server.ConfigCampus.ClientMaxRoomPersistenceVersion}) — blob hosted, no room created (it would freeze/kick to dorm)."); break;
|
|
case RoomDB.LooseWrapStatus.NameExists:
|
|
errors.Add($"{bn}: hosted the blob but a room named '{roomName}' already exists (rename the file to make another)."); break;
|
|
case RoomDB.LooseWrapStatus.Error:
|
|
errors.Add($"{bn}: {err}"); break;
|
|
// AlreadyUsed -> silently fine (blob already linked to a room)
|
|
}
|
|
}
|
|
|
|
foreach (var f in Request.Form.Files)
|
|
{
|
|
string name = Path.GetFileName(f.FileName ?? "");
|
|
if (string.IsNullOrWhiteSpace(name)) continue;
|
|
string ext = Path.GetExtension(name).ToLowerInvariant();
|
|
try
|
|
{
|
|
if (ext == ".zip")
|
|
{
|
|
// Full room-export zip: extract, place blobs+image, import RoomDetails.json.
|
|
string zipPath = Path.Combine(importsDir, name);
|
|
using (var fs = new FileStream(zipPath, FileMode.Create, FileAccess.Write)) await f.CopyToAsync(fs);
|
|
string exDir = Path.Combine(importsDir, Path.GetFileNameWithoutExtension(name) + "_" + DateTime.UtcNow.ToString("yyyyMMddHHmmss"));
|
|
Directory.CreateDirectory(exDir);
|
|
ZipFile.ExtractToDirectory(zipPath, exDir, overwriteFiles: true);
|
|
|
|
// A UGC avatar-item pack (folders of avatarItem.json + .assetbundle + .png)?
|
|
// Route it to the avatar importer: host bundles -> AvatarBlobs, thumbs/textures
|
|
// -> Images, and add each item to the UGC catalog / dev locker.
|
|
if (Campus_live_rewrite.api.server.AvatarItemImporter.LooksLikeAvatarPack(exDir))
|
|
{
|
|
var ar = Campus_live_rewrite.api.server.AvatarItemImporter.ImportDir(exDir);
|
|
importedFiles.Add($"{name} → {ar.Items} avatar item(s), {ar.BundlesHosted} bundle(s), {ar.ThumbsHosted} image(s)");
|
|
foreach (var m in ar.MissingAssets.Distinct().Take(50)) errors.Add($"{name}: missing asset {m}");
|
|
foreach (var e in ar.Errors.Take(50)) errors.Add($"{name}: {e}");
|
|
avatarItemsImported += ar.Items;
|
|
}
|
|
else if (RoomDB.LooksLikeRoomExport(exDir))
|
|
{
|
|
// Rec Room's OFFICIAL room export (RoomDetails.json single object +
|
|
// super_room_data.binpb + SubRoom_*/persisted_room_data.binpb). Place the
|
|
// .binpb blobs under their DataBlob names (hash-matched) so the room loads
|
|
// its actual build, not a bare scene.
|
|
var rr = await RoomDB.ImportRoomExport(exDir, ownerOverride: importerId);
|
|
exportRooms += rr.Rooms;
|
|
exportBlobs += rr.Blobs;
|
|
roomNames.AddRange(rr.Names);
|
|
importedFiles.Add($"{name} → {rr.Rooms} room(s): {string.Join(", ", rr.Names.Take(10))}; {rr.Blobs} blob(s), {rr.Images} image(s)");
|
|
foreach (var e in rr.Errors.Take(50)) errors.Add($"{name}: {e}");
|
|
}
|
|
else if (Directory.EnumerateFiles(exDir, "*.room", SearchOption.AllDirectories).Any())
|
|
{
|
|
// A zip that's just a bunch of loose .room blobs (no RoomDetails.json). Wrap
|
|
// every loadable one into its own joinable room; too-new ones are hosted +
|
|
// reported. Any non-.room files (.META room-level blobs, images) are hosted.
|
|
var roomFiles = Directory.EnumerateFiles(exDir, "*.room", SearchOption.AllDirectories).ToList();
|
|
foreach (var file in Directory.EnumerateFiles(exDir, "*", SearchOption.AllDirectories))
|
|
if (!file.EndsWith(".room", StringComparison.OrdinalIgnoreCase)) RouteFile(file);
|
|
foreach (var rf in roomFiles) WrapLooseRoomFile(rf);
|
|
importedFiles.Add($"{name} → scanned {roomFiles.Count} loose .room file(s)");
|
|
}
|
|
else
|
|
{
|
|
// Legacy path: the server's own {Results:[...]} dump whose .room/.meta blobs
|
|
// are already present as loose, correctly-named files.
|
|
foreach (var file in Directory.EnumerateFiles(exDir, "*", SearchOption.AllDirectories))
|
|
RouteFile(file);
|
|
|
|
var manifest = Directory.EnumerateFiles(exDir, "RoomDetails.json", SearchOption.AllDirectories).FirstOrDefault()
|
|
?? Directory.EnumerateFiles(exDir, "*.json", SearchOption.AllDirectories).FirstOrDefault();
|
|
if (manifest != null)
|
|
{
|
|
await RoomDB.ImportRooms(manifest, ownerOverride: importerId);
|
|
importedFiles.Add(name + " → " + Path.GetFileName(manifest));
|
|
}
|
|
else errors.Add($"{name}: no RoomDetails.json, avatarItem.json, or .room files found in the zip");
|
|
}
|
|
}
|
|
else if (ext == ".json")
|
|
{
|
|
string dest = Path.Combine(importsDir, name);
|
|
using (var fs = new FileStream(dest, FileMode.Create, FileAccess.Write)) await f.CopyToAsync(fs);
|
|
await RoomDB.ImportRooms(dest, ownerOverride: importerId);
|
|
importedFiles.Add(name);
|
|
}
|
|
else
|
|
{
|
|
// Host the loose blob on the CDN.
|
|
string dest = Path.Combine(blobDir, name);
|
|
using (var fs = new FileStream(dest, FileMode.Create, FileAccess.Write)) await f.CopyToAsync(fs);
|
|
if (!blobFiles.Contains(name)) blobFiles.Add(name);
|
|
|
|
// A loose .room blob is a single subroom's build with no room record. Wrap it
|
|
// in a joinable @Importer room (if loadable). .META / other blobs are just hosted
|
|
// (they're room-level / referenced assets, not standalone rooms).
|
|
if (ext == ".room") WrapLooseRoomFile(dest);
|
|
}
|
|
}
|
|
catch (Exception ex) { errors.Add($"{name}: {ex.Message}"); }
|
|
}
|
|
|
|
ulong after = RoomDB.GetAllRoomsCount();
|
|
|
|
// Which .room blobs are still missing across ALL @Importer rooms?
|
|
var missing = new List<string>();
|
|
foreach (var room in RoomDB.GetAllRooms())
|
|
foreach (var sub in room.SubRooms ?? new List<RoomDBClasses.SubRoom>())
|
|
foreach (var save in sub.AllSaves ?? new List<RoomDBClasses.CurrentSave>())
|
|
{
|
|
var blob = save.DataBlob;
|
|
if (string.IsNullOrWhiteSpace(blob)) continue;
|
|
var fi = new FileInfo(Path.Combine(blobDir, blob));
|
|
if ((!fi.Exists || fi.Length == 0) && !missing.Contains(blob)) missing.Add(blob);
|
|
}
|
|
|
|
Console.WriteLine($"[import-upload] {account.Username}: {importedFiles.Count} file(s), export {exportRooms} room(s)/{exportBlobs} blob(s), {blobFiles.Count} loose blob(s), {avatarItemsImported} avatar item(s); rooms {before}->{after}; {missing.Count} blob(s) still missing");
|
|
|
|
// Build a message from what ACTUALLY happened. The old line only counted legacy loose
|
|
// blobs, so a fully-successful room-export import (which places blobs via the CDN path,
|
|
// not blobFiles) reported "Saved 0 blob(s)" and looked broken.
|
|
var parts = new List<string>();
|
|
if (exportRooms > 0)
|
|
parts.Add($"Imported {exportRooms} room(s){(roomNames.Count > 0 ? " (" + string.Join(", ", roomNames.Take(5)) + ")" : "")} under @Importer with {exportBlobs} build blob(s) placed on the CDN");
|
|
else if (exportBlobs > 0)
|
|
parts.Add($"Re-placed {exportBlobs} build blob(s) — that room was already imported (rooms are keyed by name, so re-dropping the same export doesn't duplicate it)");
|
|
if (avatarItemsImported > 0)
|
|
parts.Add($"Imported {avatarItemsImported} avatar item(s) into the dev locker ({CustomItemsCache.ImportedCount} total)");
|
|
if (blobFiles.Count > 0)
|
|
parts.Add($"Saved {blobFiles.Count} loose blob(s)");
|
|
if (parts.Count == 0)
|
|
parts.Add($"Processed {importedFiles.Count} file(s) but imported nothing — is this a room export (RoomDetails.json + *room_data*.binpb), a UGC pack (avatarItem.json), or a legacy JSON dump?");
|
|
string message = string.Join(". ", parts) + ".";
|
|
if (missing.Count > 0)
|
|
message += $" Note: {missing.Count} .room blob(s) referenced by imported rooms aren't on the CDN yet (from other/older imports).";
|
|
|
|
return Json(new
|
|
{
|
|
success = true,
|
|
message,
|
|
roomsImported = exportRooms,
|
|
roomBlobsPlaced = exportBlobs,
|
|
roomNames,
|
|
avatarItemsImported,
|
|
avatarItemsTotal = CustomItemsCache.ImportedCount,
|
|
jsonFiles = importedFiles,
|
|
blobFiles,
|
|
blobsMissing = missing,
|
|
errors
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Bulk-import UGC avatar packs already staged ON the server, WITHOUT a browser upload.
|
|
/// This is the path for a large library (e.g. the 178GB archive): rsync/scp the extracted
|
|
/// packs into Data/AvatarImportStaging/ on the box, then POST here. Scans that fixed folder
|
|
/// (no caller-supplied path - no traversal surface), hosts bundles+images, and registers
|
|
/// every avatarItem.json into the UGC catalog / dev locker.
|
|
/// </summary>
|
|
[HttpPost("/admin/api/import_avatar_staging")]
|
|
public IActionResult AdminImportAvatarStaging()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null) return error;
|
|
|
|
string staging = Path.Combine(Environment.CurrentDirectory, "Data", "AvatarImportStaging");
|
|
Directory.CreateDirectory(staging);
|
|
|
|
if (!Campus_live_rewrite.api.server.AvatarItemImporter.LooksLikeAvatarPack(staging))
|
|
return Json(new { success = false, message = $"No avatarItem.json found under Data/AvatarImportStaging. rsync your extracted UGC packs there first ({staging})." });
|
|
|
|
// Assets are MOVED out of staging into their served folders (see ImportDir), so the
|
|
// pack doesn't need to exist twice on the box; the staging tree can be deleted after.
|
|
var r = Campus_live_rewrite.api.server.AvatarItemImporter.ImportDir(staging, moveAssets: true);
|
|
Console.WriteLine($"[import-avatar-staging] {account.Username}: {r.Items} items, {r.BundlesHosted} bundles, {r.ThumbsHosted} images, {r.MissingAssets.Count} missing");
|
|
// Bundles from a NEWER Unity than the clients (PC 2021.3 / Quest 2020.3) can never load,
|
|
// however correct the metadata is - say so up front rather than let the wardrobe find out.
|
|
int unloadable = r.BundleUnityVersions.Where(kv => Campus_live_rewrite.api.server.AvatarItemImporter.IsUnloadableUnityVersion(kv.Key)).Sum(kv => kv.Value);
|
|
string versions = string.Join(", ", r.BundleUnityVersions.Select(kv => $"{kv.Key} x{kv.Value}"));
|
|
string warn = unloadable > 0
|
|
? $" WARNING: {unloadable} bundle(s) were built with a newer Unity than the game clients can load (clients: PC 2021.3.41f1, Quest 2020.3.33f1) - those items will show in the locker but fail to render. Bundle versions: {versions}."
|
|
: (r.BundlesHosted > 0 ? $" Bundle versions: {versions}." : "");
|
|
if (unloadable > 0) Console.WriteLine($"[import-avatar-staging] {unloadable} bundle(s) unloadable by the clients: {versions}");
|
|
return Json(new
|
|
{
|
|
success = true,
|
|
message = $"Imported {r.Items} avatar item(s) into the dev locker ({CustomItemsCache.ImportedCount} total). Hosted {r.BundlesHosted} bundle(s), {r.ThumbsHosted} image(s).{warn}",
|
|
bundleUnityVersions = r.BundleUnityVersions,
|
|
unloadableBundles = unloadable,
|
|
avatarItemsImported = r.Items,
|
|
avatarItemsTotal = CustomItemsCache.ImportedCount,
|
|
bundlesHosted = r.BundlesHosted,
|
|
imagesHosted = r.ThumbsHosted,
|
|
missingAssets = r.MissingAssets.Distinct().Take(100),
|
|
errors = r.Errors.Take(100)
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Every .room / .META blob in Data/CDN/RoomBlobs, and which subroom (if any) points
|
|
/// at it.
|
|
///
|
|
/// Rec Room is gone, so no blob can ever be downloaded again - the files already on
|
|
/// disk are the only room content that will ever exist. Several of them are ORPHANS:
|
|
/// real saved builds that no subroom references, because the save record was lost
|
|
/// while the file survived. A room whose save is missing loads its bare Unity scene,
|
|
/// so an orphan is a room's contents sitting there doing nothing.
|
|
///
|
|
/// Zero-byte files are listed separately: they are failed downloads from the old CDN
|
|
/// caching bug, not content, and are treated as missing everywhere.
|
|
/// </summary>
|
|
[HttpGet("/admin/api/room_blobs")]
|
|
public ContentResult AdminListRoomBlobs()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null) return error;
|
|
|
|
string dir = Path.Combine(Environment.CurrentDirectory, "Data", "CDN", "RoomBlobs");
|
|
if (!Directory.Exists(dir))
|
|
return Json(new { success = false, message = "Data/CDN/RoomBlobs does not exist." });
|
|
|
|
// blob filename -> "room 84 / subroom 748"
|
|
var referencedBy = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var room in RoomDB.GetAllRooms())
|
|
{
|
|
foreach (var sub in room.SubRooms ?? new List<RoomDBClasses.SubRoom>())
|
|
{
|
|
foreach (var save in sub.AllSaves ?? new List<RoomDBClasses.CurrentSave>())
|
|
if (!string.IsNullOrWhiteSpace(save.DataBlob))
|
|
referencedBy[save.DataBlob!] = $"room {room.RoomId} ({room.Name}) / subroom {sub.SubRoomId}";
|
|
|
|
if (sub.CurrentSave != null && !string.IsNullOrWhiteSpace(sub.CurrentSave.DataBlob))
|
|
referencedBy[sub.CurrentSave.DataBlob!] = $"room {room.RoomId} ({room.Name}) / subroom {sub.SubRoomId}";
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(room.DataBlob))
|
|
referencedBy[room.DataBlob!] = $"room {room.RoomId} ({room.Name}) [room-level]";
|
|
}
|
|
|
|
var blobs = new DirectoryInfo(dir).GetFiles()
|
|
.OrderByDescending(f => f.Length)
|
|
.Select(f => new
|
|
{
|
|
name = f.Name,
|
|
size = f.Length,
|
|
empty = f.Length == 0,
|
|
modified = f.LastWriteTimeUtc,
|
|
usedBy = referencedBy.TryGetValue(f.Name, out var who) ? who : null
|
|
})
|
|
.ToList();
|
|
|
|
return Json(new
|
|
{
|
|
success = true,
|
|
total = blobs.Count,
|
|
empty = blobs.Count(b => b.empty),
|
|
orphans = blobs.Count(b => !b.empty && b.usedBy == null),
|
|
blobs
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Points a room's subroom at an existing blob, i.e. records it as that subroom's
|
|
/// save. This is how an orphaned build gets reconnected to a room.
|
|
///
|
|
/// The blob must already exist and be non-empty. Nothing is downloaded and no file is
|
|
/// written - only the save record changes, so getting it wrong costs nothing: attach a
|
|
/// different blob, or attach the right one to a different room.
|
|
/// </summary>
|
|
[HttpPost("/admin/api/attach_blob")]
|
|
public ContentResult AdminAttachBlob()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null) return error;
|
|
|
|
string blob = Path.GetFileName((Request.Form["blob"].ToString() ?? "").Trim());
|
|
if (string.IsNullOrWhiteSpace(blob))
|
|
return Json(new { success = false, message = "No blob given." });
|
|
|
|
if (!ulong.TryParse(Request.Form["roomId"].ToString(), out ulong roomId))
|
|
return Json(new { success = false, message = "Invalid room id." });
|
|
|
|
var room = RoomDB.GetRoom(roomId);
|
|
if (room == null)
|
|
return Json(new { success = false, message = $"No room {roomId}." });
|
|
|
|
// Default to the room's only/first subroom - these rooms have exactly one.
|
|
ulong subRoomId;
|
|
string rawSub = Request.Form["subRoomId"].ToString() ?? "";
|
|
if (!ulong.TryParse(rawSub, out subRoomId) || subRoomId == 0)
|
|
{
|
|
var first = (room.SubRooms ?? new List<RoomDBClasses.SubRoom>()).FirstOrDefault();
|
|
if (first == null)
|
|
return Json(new { success = false, message = $"Room {roomId} has no subrooms." });
|
|
subRoomId = first.SubRoomId;
|
|
}
|
|
|
|
var fi = new FileInfo(Path.Combine(Environment.CurrentDirectory, "Data", "CDN", "RoomBlobs", blob));
|
|
if (!fi.Exists || fi.Length == 0)
|
|
return Json(new { success = false, message = $"{blob} is missing or empty in Data/CDN/RoomBlobs." });
|
|
|
|
RoomDB.SetRoomSubroomRoomFile(
|
|
roomId, subRoomId, blob, room.Description ?? "", account.Id,
|
|
Platforms.All, persistenceVersion: 227, omVersion: 0, ugcSubVersion: 227);
|
|
|
|
Console.WriteLine($"[admin] {account.Username} ({account.Id}) attached {blob} " +
|
|
$"({fi.Length} bytes) to room {roomId} subroom {subRoomId}");
|
|
|
|
return Json(new
|
|
{
|
|
success = true,
|
|
message = $"Attached {blob} to room {roomId} ('{room.Name}') subroom {subRoomId}. " +
|
|
$"Load the room to see which build it is."
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Every subroom and the scene the client loads for it, grouped so placeholders stand
|
|
/// out. A scene shared by many unrelated rooms is a placeholder that came in with the
|
|
/// import, not that room's real map.
|
|
/// </summary>
|
|
[HttpGet("/admin/api/room_scenes")]
|
|
public ContentResult AdminListRoomScenes()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null) return error;
|
|
|
|
var rows = new List<object>();
|
|
var usage = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (var room in RoomDB.GetAllRooms())
|
|
foreach (var sub in room.SubRooms ?? new List<RoomDBClasses.SubRoom>())
|
|
{
|
|
string scene = sub.UnitySceneId ?? "";
|
|
if (scene.Length > 0) usage[scene] = usage.TryGetValue(scene, out var n) ? n + 1 : 1;
|
|
}
|
|
|
|
foreach (var room in RoomDB.GetAllRooms())
|
|
foreach (var sub in room.SubRooms ?? new List<RoomDBClasses.SubRoom>())
|
|
{
|
|
string scene = sub.UnitySceneId ?? "";
|
|
rows.Add(new
|
|
{
|
|
roomId = room.RoomId,
|
|
roomName = room.Name,
|
|
isRRO = room.IsRRO,
|
|
subRoomId = sub.SubRoomId,
|
|
subRoomName = sub.Name,
|
|
scene,
|
|
sharedWith = scene.Length > 0 && usage.TryGetValue(scene, out var n) ? n : 0
|
|
});
|
|
}
|
|
|
|
return Json(new { success = true, total = rows.Count, rooms = rows });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Points a subroom at a different Unity scene. See RoomDB.SetSubRoomScene for what
|
|
/// that means and why an imported room can load the wrong map.
|
|
/// </summary>
|
|
// Social sign-in apps (Settings > Services on the website). GET = ids + whether a secret
|
|
// is stored + the redirect URI to register; POST = save one provider's credentials into
|
|
// Data/social_apps.json (a blank secret keeps the stored one). Dev-only.
|
|
[HttpGet("/admin/api/social_apps")]
|
|
public ContentResult AdminSocialApps()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null) return error;
|
|
return Json(new { success = true, providers = Campus_live_rewrite.api.server.SocialLinks.AdminView() });
|
|
}
|
|
|
|
[HttpPost("/admin/api/social_apps")]
|
|
public ContentResult AdminSaveSocialApp()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null) return error;
|
|
string provider = (Request.Form["provider"].ToString() ?? "").Trim().ToLowerInvariant();
|
|
string? clientId = Request.Form.ContainsKey("clientId") ? Request.Form["clientId"].ToString() : null;
|
|
string? clientSecret = Request.Form.ContainsKey("clientSecret") ? Request.Form["clientSecret"].ToString() : null;
|
|
string? apiKey = Request.Form.ContainsKey("apiKey") ? Request.Form["apiKey"].ToString() : null;
|
|
bool ok = Campus_live_rewrite.api.server.SocialLinks.AdminSave(provider, clientId, clientSecret, apiKey, out var message);
|
|
Console.WriteLine($"[admin] {account.Username} ({account.Id}) social app '{provider}': {(ok ? "saved" : "FAILED " + message)}");
|
|
return Json(new { success = ok, message });
|
|
}
|
|
|
|
[HttpPost("/admin/api/set_room_scene")]
|
|
public ContentResult AdminSetRoomScene()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null) return error;
|
|
|
|
if (!ulong.TryParse(Request.Form["roomId"].ToString(), out ulong roomId))
|
|
return Json(new { success = false, message = "Invalid room id." });
|
|
|
|
string scene = (Request.Form["scene"].ToString() ?? "").Trim();
|
|
if (!Guid.TryParse(scene, out _))
|
|
return Json(new { success = false, message = "Scene must be a GUID." });
|
|
|
|
var room = RoomDB.GetRoom(roomId);
|
|
if (room == null)
|
|
return Json(new { success = false, message = $"No room {roomId}." });
|
|
|
|
ulong subRoomId;
|
|
if (!ulong.TryParse(Request.Form["subRoomId"].ToString(), out subRoomId) || subRoomId == 0)
|
|
{
|
|
var first = (room.SubRooms ?? new List<RoomDBClasses.SubRoom>()).FirstOrDefault();
|
|
if (first == null) return Json(new { success = false, message = "Room has no subrooms." });
|
|
subRoomId = first.SubRoomId;
|
|
}
|
|
|
|
bool ok = RoomDB.SetSubRoomScene(roomId, subRoomId, scene);
|
|
Console.WriteLine($"[admin] {account.Username} ({account.Id}) set room {roomId} subroom {subRoomId} scene -> {scene}");
|
|
|
|
return Json(new
|
|
{
|
|
success = ok,
|
|
message = ok
|
|
? $"Room {roomId} ('{room.Name}') subroom {subRoomId} now loads scene {scene}. Rejoin to see it."
|
|
: "Subroom not found."
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Every photo in the game, newest first, each attributed to the player who took it.
|
|
/// Backs the admin gallery. The image url is the same /img path the client fetches, so
|
|
/// the panel can show a thumbnail; PlayerId names the photographer.
|
|
/// </summary>
|
|
// Shows the OUTFIT state stored in the database for one player, plus the running build
|
|
// stamp - so we can tell instantly whether a save is landing. Open /admin/api/outfitdebug/6
|
|
// after saving an outfit in-game: if savedOutfits is populated the save IS in the DB (and
|
|
// any "empty wardrobe" is a client display issue); if it's empty the save never reached the
|
|
// server, and /admin/api/apilog will show which endpoint the client hit and its status.
|
|
[HttpGet("/admin/api/outfitdebug/{playerid}")]
|
|
public IActionResult AdminOutfitDebug(ulong playerid)
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null) return error;
|
|
|
|
string build = "";
|
|
try
|
|
{
|
|
var bp = Path.Combine(Environment.CurrentDirectory, "Data", "BUILD.txt");
|
|
if (System.IO.File.Exists(bp)) build = System.IO.File.ReadAllText(bp).Trim();
|
|
}
|
|
catch { }
|
|
|
|
var p = PlayerDB.GetFullAccount(playerid);
|
|
var savedRaw = PlayerDB.GetAvatarSaved(playerid); // JSON array string of SavedOutfits
|
|
int savedCount = p?.player_Extra?.SavedOutfits?.Count ?? 0;
|
|
|
|
return Content(JsonConvert.SerializeObject(new
|
|
{
|
|
build,
|
|
playerid,
|
|
playerRowExists = p != null,
|
|
playerExtraExists = p?.player_Extra != null,
|
|
hasWornAvatar = !string.IsNullOrEmpty(p?.player_Extra?.avatar),
|
|
savedOutfitCount = savedCount,
|
|
savedOutfits = savedRaw
|
|
}, Formatting.Indented), "application/json");
|
|
}
|
|
|
|
// The tail of Data/APILog.txt (every request the middleware logs as
|
|
// "<time> <METHOD> <PATH> -> <status>"). Dev-gated. Open in a browser after reproducing a
|
|
// bug to see exactly what the client called and which routes 404'd/500'd - e.g.
|
|
// /admin/api/apilog?lines=400
|
|
// /admin/api/apilog?lines=400&find=/api/ (only lines containing "/api/")
|
|
// Returns plain text so it can be read straight in the browser and copied.
|
|
[HttpGet("/admin/api/apilog")]
|
|
public IActionResult AdminApiLog([FromQuery] int lines = 300, [FromQuery] string? find = null)
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null) return error;
|
|
|
|
if (lines < 1) lines = 1;
|
|
if (lines > 5000) lines = 5000;
|
|
|
|
// Primary source: the in-memory ring buffer the Log middleware fills on every
|
|
// request (see Log.cs). It is instant and never blocked by disk state, and it is
|
|
// populated regardless of the VerboseRequestLogging flag - which is why the tab
|
|
// works now where it used to sit empty.
|
|
if (Log.HasBufferedLines)
|
|
{
|
|
var buf = Log.Recent(lines, find);
|
|
return Content(string.Join("\n", buf), "text/plain");
|
|
}
|
|
|
|
// Fallback: the persisted file (history from before this process started, or if the
|
|
// buffer somehow hasn't filled yet).
|
|
var path = Path.Combine(Environment.CurrentDirectory, "Data", "APILog.txt");
|
|
if (!System.IO.File.Exists(path))
|
|
return Content("(no requests logged yet - hit the server and refresh)", "text/plain");
|
|
|
|
string[] all;
|
|
try
|
|
{
|
|
// Open shared so we can read while the server is still appending to it.
|
|
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
|
using var sr = new StreamReader(fs);
|
|
all = sr.ReadToEnd().Split('\n');
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return Content($"(failed to read log: {ex.Message})", "text/plain");
|
|
}
|
|
|
|
IEnumerable<string> rows = all;
|
|
if (!string.IsNullOrEmpty(find))
|
|
rows = rows.Where(l => l.Contains(find, StringComparison.OrdinalIgnoreCase));
|
|
|
|
var tail = rows.Where(l => l.Length > 0).TakeLast(lines);
|
|
return Content(string.Join("\n", tail), "text/plain");
|
|
}
|
|
|
|
[HttpGet("/admin/api/photos")]
|
|
public ContentResult AdminListPhotos()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null) return error;
|
|
|
|
var all = ImageMetadataDB.GetAllImages();
|
|
|
|
// One bulk name lookup for every photographer + tagged player, so the gallery can
|
|
// show @names instead of raw ids without a query per row.
|
|
var ids = all.Select(i => i.PlayerId)
|
|
.Concat(all.SelectMany(i => i.TaggedPlayerIds ?? new List<ulong>()))
|
|
.Where(x => x != 0).Distinct().ToList();
|
|
var names = new Dictionary<ulong, string>();
|
|
foreach (var pid in ids)
|
|
{
|
|
var acc = PlayerDB.GetFullAccount(pid);
|
|
names[pid] = acc?.player?.Username ?? pid.ToString();
|
|
}
|
|
string NameOf(ulong pid) => pid == 0 ? "" : (names.TryGetValue(pid, out var n) ? n : pid.ToString());
|
|
|
|
var photos = all.Select(i => new
|
|
{
|
|
id = i.Id,
|
|
imageName = i.ImageName,
|
|
imageUrl = string.IsNullOrWhiteSpace(i.ImageName) ? null : $"/img/{i.ImageName}?sig=p1",
|
|
takenBy = i.PlayerId,
|
|
takenByName = NameOf(i.PlayerId),
|
|
roomId = i.RoomId,
|
|
createdAt = i.CreatedAt,
|
|
description = i.Description ?? "",
|
|
cheers = i.CheerCount,
|
|
accessibility = i.Accessibility.ToString(),
|
|
taggedPlayers = (i.TaggedPlayerIds ?? new List<ulong>()).Select(NameOf).ToList()
|
|
});
|
|
|
|
return Json(new { success = true, count = all.Count, photos });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes one photo - its metadata, its cheers, and the file on disk. There was no way
|
|
/// to remove a photo before, so an abusive one could only be handled in the database.
|
|
/// </summary>
|
|
[HttpPost("/admin/api/delete_photo")]
|
|
public ContentResult AdminDeletePhoto()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null) return error;
|
|
|
|
if (!int.TryParse(Request.Form["id"].ToString(), out int id))
|
|
return Json(new { success = false, message = "Invalid photo id." });
|
|
|
|
var deleted = ImageMetadataDB.DeleteImage(id);
|
|
if (deleted == null)
|
|
return Json(new { success = false, message = $"No photo with id {id}." });
|
|
|
|
Console.WriteLine($"[admin] {account.Username} ({account.Id}) deleted photo {id} " +
|
|
$"'{deleted.ImageName}' taken by {deleted.PlayerId}");
|
|
|
|
return Json(new { success = true, message = $"Deleted photo {id}." });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Every room comment across all rooms, newest first - for moderation. A comment is a note
|
|
/// a player pins in a room's scene (RoomCommentsController); there was no way to see or
|
|
/// remove an abusive one except by editing the database by hand. Resolves the author id to
|
|
/// an @name in one bulk pass, same as the photo gallery.
|
|
/// </summary>
|
|
[HttpGet("/admin/api/room_comments")]
|
|
public ContentResult AdminListRoomComments()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null) return error;
|
|
|
|
var all = RoomCommentsDB.GetAll();
|
|
|
|
var ids = all.Select(c => c.AccountId).Where(x => x != 0).Distinct().ToList();
|
|
var names = new Dictionary<ulong, string>();
|
|
foreach (var pid in ids)
|
|
{
|
|
var acc = PlayerDB.GetFullAccount(pid);
|
|
names[pid] = acc?.player?.Username ?? pid.ToString();
|
|
}
|
|
string NameOf(ulong pid) => pid == 0 ? "" : (names.TryGetValue(pid, out var n) ? n : pid.ToString());
|
|
|
|
var comments = all.Select(c => new
|
|
{
|
|
id = c.CommentId,
|
|
roomId = c.RoomId,
|
|
subRoomId = c.SubRoomId,
|
|
authorId = c.AccountId,
|
|
authorName = NameOf(c.AccountId),
|
|
message = c.Message,
|
|
createdAt = c.CreatedAt
|
|
});
|
|
|
|
return Json(new { success = true, count = all.Count, comments });
|
|
}
|
|
|
|
/// <summary>Deletes one pinned room comment. No prior way to remove one from the panel.</summary>
|
|
[HttpPost("/admin/api/delete_room_comment")]
|
|
public ContentResult AdminDeleteRoomComment()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null) return error;
|
|
|
|
if (!int.TryParse(Request.Form["id"].ToString(), out int id))
|
|
return Json(new { success = false, message = "Invalid comment id." });
|
|
|
|
bool ok = RoomCommentsDB.Delete(id);
|
|
Console.WriteLine($"[admin] {account.Username} ({account.Id}) deleted room comment {id}");
|
|
|
|
return Json(new
|
|
{
|
|
success = ok,
|
|
message = ok ? $"Deleted comment {id}." : $"No comment with id {id}."
|
|
});
|
|
}
|
|
|
|
/// <summary>Every player-created event, newest first - for moderation.</summary>
|
|
[HttpGet("/admin/api/events")]
|
|
public ContentResult AdminListEvents()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null) return error;
|
|
|
|
var events = Campus_live_rewrite.api.dbs.EventDB.SearchEventByQuery("")
|
|
.OrderByDescending(e => e.StartTime)
|
|
.Select(e => new
|
|
{
|
|
e.PlayerEventId,
|
|
e.CreatorPlayerId,
|
|
e.Name,
|
|
e.Description,
|
|
e.RoomId,
|
|
e.StartTime,
|
|
e.EndTime,
|
|
e.AttendeeCount
|
|
});
|
|
|
|
return Json(new { success = true, events });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes a player event. There was no way to remove one at all - not in-game and not
|
|
/// from the panel - so an event with an abusive name could only be dealt with by editing
|
|
/// the database by hand.
|
|
/// </summary>
|
|
[HttpPost("/admin/api/delete_event")]
|
|
public ContentResult AdminDeleteEvent()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null) return error;
|
|
|
|
string raw = Request.Form["eventId"].ToString();
|
|
if (!ulong.TryParse(raw, out ulong eventId))
|
|
return Json(new { success = false, message = "Invalid event id." });
|
|
|
|
var ev = Campus_live_rewrite.api.dbs.EventDB.GetFullEventById(eventId);
|
|
if (ev == null)
|
|
return Json(new { success = false, message = $"No event with id {eventId}." });
|
|
|
|
bool ok = Campus_live_rewrite.api.dbs.EventDB.DeleteEvent(eventId);
|
|
Console.WriteLine($"[admin] {account.Username} ({account.Id}) deleted event {eventId} " +
|
|
$"'{ev.Name}' by player {ev.CreatorPlayerId}");
|
|
|
|
return Json(new
|
|
{
|
|
success = ok,
|
|
message = ok ? $"Deleted event {eventId} ('{ev.Name}')." : "Delete failed."
|
|
});
|
|
}
|
|
|
|
[HttpGet("/admin/api/rooms")]
|
|
public IActionResult AdminGetRoomsAPI(int skip = 0, int take = 200, string? q = null)
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null)
|
|
return error;
|
|
|
|
// Was: return every room, full objects WITH subrooms. At thousands of rooms (after a bulk
|
|
// import) that's a multi-MB payload the panel can't render -> blank table. Now: newest-first,
|
|
// optional search, paged, and projected to just the columns the table shows (no subrooms).
|
|
if (take <= 0 || take > 1000) take = 200;
|
|
if (skip < 0) skip = 0;
|
|
|
|
IEnumerable<RoomDBClasses.RoomRoot> rooms = RoomDB.GetAllRoomsLite();
|
|
if (!string.IsNullOrWhiteSpace(q))
|
|
{
|
|
string ql = q.Trim().ToLowerInvariant();
|
|
rooms = rooms.Where(r => (r.Name ?? "").ToLowerInvariant().Contains(ql)
|
|
|| (r.Description ?? "").ToLowerInvariant().Contains(ql)
|
|
|| r.RoomId.ToString().Contains(ql)
|
|
|| r.CreatorAccountId.ToString() == ql);
|
|
}
|
|
var ordered = rooms.OrderBy(r => r.RoomId).ToList(); // lowest -> highest room id
|
|
var page = ordered.Skip(skip).Take(take).Select(r => new
|
|
{
|
|
r.RoomId,
|
|
ImageName = r.ImageName ?? "",
|
|
Name = r.Name ?? "",
|
|
Description = r.Description ?? "",
|
|
r.CreatorAccountId,
|
|
r.MaxPlayers
|
|
}).ToList();
|
|
|
|
return Ok(new { total = ordered.Count, skip, take, rooms = page });
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------------------
|
|
// Role management: enable/disable dev / mod / screenshare bit flags on a target account.
|
|
// Bit values match Campus_live_rewrite.api.server.Classes.db_class.DevFlag:
|
|
// screenshare = 1, dev = 2, mod = 4.
|
|
// Caller must already be dev.
|
|
// -----------------------------------------------------------------------------------------
|
|
[HttpPost("/admin/api/set_role")]
|
|
public ContentResult AdminSetRole()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null) return error;
|
|
|
|
string rawId = HttpContext.Request.Form["playerId"].ToString();
|
|
string roleName = (HttpContext.Request.Form["role"].ToString() ?? "").ToLowerInvariant();
|
|
string enabledS = HttpContext.Request.Form["enabled"].ToString();
|
|
|
|
if (!ulong.TryParse(rawId, out ulong playerId))
|
|
return Json(new { success = false, message = "Invalid player id." });
|
|
bool enable = enabledS == "1" || string.Equals(enabledS, "true", StringComparison.OrdinalIgnoreCase);
|
|
|
|
// Influencer is NOT a DevFlag - it's the partner-program flag on
|
|
// player_Extra.Influencer.isInfluencer (drives the influencer APIs / creator support).
|
|
// Handle it before the DevFlag switch.
|
|
if (roleName == "influencer")
|
|
{
|
|
PlayerDB.SetIsInfluencer(playerId, enable);
|
|
PushEvent(playerId, Campus_live_rewrite.api.WebsocketEvents.CreateResponseReputationUpdate(playerId), "role/influencer", toAll: true);
|
|
Console.WriteLine($"[admin] {account.Username} ({account.Id}) set influencer={enable} on player {playerId}");
|
|
return Json(new { success = true, role = roleName, enabled = enable });
|
|
}
|
|
|
|
DevFlag flag;
|
|
switch (roleName)
|
|
{
|
|
case "dev": flag = DevFlag.dev; break;
|
|
case "mod": flag = DevFlag.mod; break;
|
|
case "screenshare": flag = DevFlag.screenshare; break;
|
|
case "support": flag = DevFlag.support; break;
|
|
default: return Json(new { success = false, message = "Unknown role. Use dev / mod / screenshare / support / influencer." });
|
|
}
|
|
|
|
// Don't let an admin strip their OWN dev flag — that would lock them out.
|
|
if (playerId == account.Id && flag == DevFlag.dev && !enable)
|
|
return Json(new { success = false, message = "Refusing to remove dev from your own account." });
|
|
|
|
bool ok = PlayerDB.SetRoleFlag(playerId, flag, enable);
|
|
|
|
// Live push: the dev flag is the RecRoomDeveloper badge carried in the reputation
|
|
// payload, so broadcast a reputation update (proven-safe SendToAll event) - everyone
|
|
// sees the badge appear/disappear instantly.
|
|
if (ok)
|
|
PushEvent(playerId, Campus_live_rewrite.api.WebsocketEvents.CreateResponseReputationUpdate(playerId), "role/badge", toAll: true);
|
|
|
|
Console.WriteLine($"[admin] {account.Username} ({account.Id}) set role {roleName}={enable} on player {playerId} — ok={ok}");
|
|
return Json(new { success = ok, role = roleName, enabled = enable });
|
|
}
|
|
|
|
// ---- Database backups + server control (admin "Database"/"Server" tabs) ----------
|
|
|
|
// Exit the process; systemd (Restart=always, RestartSec=3) brings it back in a few seconds.
|
|
private void ScheduleRestart(int delayMs = 600)
|
|
{
|
|
_ = Task.Run(async () =>
|
|
{
|
|
await Task.Delay(delayMs);
|
|
Console.WriteLine("[admin] restart requested — exiting for systemd to relaunch");
|
|
Environment.Exit(0);
|
|
});
|
|
}
|
|
|
|
[HttpGet("/admin/api/backups")]
|
|
public ContentResult AdminListBackups()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null) return error;
|
|
return Json(new { success = true, backups = BackupService.List() });
|
|
}
|
|
|
|
[HttpPost("/admin/api/backup_now")]
|
|
public ContentResult AdminBackupNow()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null) return error;
|
|
string name = BackupService.CreateBackup();
|
|
Console.WriteLine($"[admin] {account.Username} ({account.Id}) ran a manual backup -> {name}");
|
|
return Json(new { success = true, name, backups = BackupService.List() });
|
|
}
|
|
|
|
// Stage the chosen backup and restart; the restore is applied on startup before any DB opens.
|
|
[HttpPost("/admin/api/restore_backup")]
|
|
public ContentResult AdminRestoreBackup()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null) return error;
|
|
|
|
string name = HttpContext.Request.Form["name"].ToString();
|
|
if (!BackupService.StageRestore(name))
|
|
return Json(new { success = false, message = "No such backup." });
|
|
|
|
Console.WriteLine($"[admin] {account.Username} ({account.Id}) requested RESTORE of '{name}' — restarting");
|
|
ScheduleRestart(800);
|
|
return Json(new { success = true, message = $"Restoring '{name}'. The server is restarting; it will be back in a few seconds." });
|
|
}
|
|
|
|
[HttpPost("/admin/api/restart")]
|
|
public ContentResult AdminRestartServer()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null) return error;
|
|
Console.WriteLine($"[admin] {account.Username} ({account.Id}) requested a server RESTART");
|
|
ScheduleRestart(600);
|
|
return Json(new { success = true, message = "Server restarting… it will be back in a few seconds." });
|
|
}
|
|
|
|
// ---- Featured rooms ---------------------------------------------------
|
|
// The ONE curated featured-rooms list, shared by the game (Room_server/featuredrooms/current)
|
|
// and the website (/api/site/rooms/featured). Room ids are returned as STRINGS - a room id
|
|
// is a 19-digit ulong that overflows JavaScript's 53-bit integer precision.
|
|
|
|
[HttpGet("/admin/api/featured_rooms")]
|
|
public ContentResult AdminGetFeaturedRooms()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null) return error;
|
|
|
|
var rooms = Campus_live_rewrite.api.server.FeaturedRoomsStore.GetIds()
|
|
.Select(id =>
|
|
{
|
|
var r = RoomDB.GetRoom(id);
|
|
return new { id = id.ToString(), name = r?.Name ?? "(missing room)", exists = r != null };
|
|
})
|
|
.ToList();
|
|
|
|
return Json(new { success = true, rooms });
|
|
}
|
|
|
|
// The Featured tab: featured PLAYERS (first = the Featured Creator on the community board
|
|
// and the Play tab) and the board announcement. Rooms are the routes below.
|
|
[HttpGet("/admin/api/featured")]
|
|
public ContentResult AdminGetFeatured()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null) return error;
|
|
var fs = Campus_live_rewrite.api.server.FeaturedStore.Get();
|
|
var players = fs.PlayerIds.Select(id =>
|
|
{
|
|
var a = PlayerDB.GetAccountById(id);
|
|
return new { id = id.ToString(), username = a?.Username ?? "(missing player)", displayName = a?.DisplayName ?? "", exists = a != null };
|
|
}).ToList();
|
|
ulong effective = Campus_live_rewrite.api.server.DiscoveryLists.FeaturedCreator();
|
|
var eff = effective != 0 ? PlayerDB.GetAccountById(effective) : null;
|
|
return Json(new
|
|
{
|
|
success = true,
|
|
players,
|
|
announcement = fs.Announcement ?? "",
|
|
announcementUrl = fs.AnnouncementUrl ?? "",
|
|
effectiveCreator = new { id = effective.ToString(), username = eff?.Username ?? "" }
|
|
});
|
|
}
|
|
|
|
[HttpPost("/admin/api/set_featured_players")]
|
|
public ContentResult AdminSetFeaturedPlayers()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null) return error;
|
|
|
|
string raw = Request.Form["playerIds"].ToString() ?? "";
|
|
var ids = new List<ulong>();
|
|
foreach (var part in raw.Split(new[] { ',', ';', ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
|
|
{
|
|
string p = part.Trim().TrimStart('@');
|
|
if (ulong.TryParse(p, out var pid) && pid != 0) { if (!ids.Contains(pid)) ids.Add(pid); continue; }
|
|
// A username works too.
|
|
ulong byName = PlayerDB.GetAccountIdByUsernameCI(p);
|
|
if (byName != 0 && !ids.Contains(byName)) ids.Add(byName);
|
|
}
|
|
var fs = Campus_live_rewrite.api.server.FeaturedStore.Get();
|
|
fs.PlayerIds = ids;
|
|
if (Request.Form.ContainsKey("announcement")) fs.Announcement = Request.Form["announcement"].ToString().Trim();
|
|
if (Request.Form.ContainsKey("announcementUrl")) fs.AnnouncementUrl = Request.Form["announcementUrl"].ToString().Trim();
|
|
Campus_live_rewrite.api.server.FeaturedStore.Set(fs);
|
|
Console.WriteLine($"[admin] {account.Username} ({account.Id}) set featured players: {string.Join(", ", ids)}");
|
|
|
|
var missing = ids.Where(id => PlayerDB.GetAccountById(id) == null).Select(id => id.ToString()).ToList();
|
|
string msg = $"Featured players updated ({ids.Count}).";
|
|
if (missing.Count > 0) msg += $" Warning - no player found for: {string.Join(", ", missing)}.";
|
|
return Json(new { success = true, message = msg, count = ids.Count });
|
|
}
|
|
|
|
// ---- Bug reports (the in-game "Report a bug") ----
|
|
[HttpGet("/admin/api/bugreports")]
|
|
public ContentResult AdminBugReports()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null) return error;
|
|
bool all = Request.Query["all"].ToString() == "1";
|
|
var list = BugReportDB.Recent(all).Select(r => new
|
|
{
|
|
id = r.Id, accountId = r.AccountId.ToString(), username = r.Username, title = r.Title, description = r.Description,
|
|
category = r.Category, room = r.Room, platform = r.Platform, raw = r.Raw, files = r.Files,
|
|
at = r.At.ToString("o"), resolved = r.Resolved, resolvedBy = r.ResolvedBy, note = r.Note
|
|
}).ToList();
|
|
return Json(new { success = true, reports = list, openCount = BugReportDB.OpenCount() });
|
|
}
|
|
|
|
[HttpPost("/admin/api/bugreports/resolve")]
|
|
public ContentResult AdminResolveBug()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null) return error;
|
|
int id = int.TryParse(Request.Form["id"], out var i) ? i : 0;
|
|
bool reopen = Request.Form["reopen"].ToString() == "1";
|
|
bool ok = BugReportDB.Resolve(id, account.Username ?? account.Id.ToString(), Request.Form["note"].ToString(), !reopen);
|
|
return Json(new { success = ok, message = ok ? (reopen ? "Reopened." : "Resolved.") : "No such report." });
|
|
}
|
|
|
|
[HttpGet("/admin/api/bugreports/file/{name}")]
|
|
public IActionResult AdminBugFile(string name)
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null) return error;
|
|
string safe = Path.GetFileName(name);
|
|
string path = Path.Combine(Environment.CurrentDirectory, "Data", "BugReports", safe);
|
|
if (!System.IO.File.Exists(path)) return NotFound();
|
|
string ext = Path.GetExtension(safe).ToLowerInvariant();
|
|
string ct = ext is ".png" ? "image/png" : ext is ".jpg" or ".jpeg" ? "image/jpeg" : ext is ".txt" or ".log" ? "text/plain" : "application/octet-stream";
|
|
return PhysicalFile(path, ct);
|
|
}
|
|
|
|
// ---- Creator earnings ledger ----
|
|
[HttpGet("/admin/api/earnings")]
|
|
public ContentResult AdminEarnings()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null) return error;
|
|
var rows = EarningsDB.Recent(300).Select(e => new
|
|
{
|
|
id = e.Id, seller = e.SellerId.ToString(), sellerName = PlayerDB.GetAccountById(e.SellerId)?.Username ?? "",
|
|
buyer = e.BuyerId.ToString(), buyerName = PlayerDB.GetAccountById(e.BuyerId)?.Username ?? "",
|
|
kind = e.Kind, item = e.ItemName, spent = e.Spent, share = e.Share, currency = e.CurrencyType,
|
|
boughtAt = e.BoughtAt.ToString("o"), payoutAt = e.PayoutAt.ToString("o"), paidAt = e.PaidAt?.ToString("o")
|
|
}).ToList();
|
|
return Json(new { success = true, earnings = rows, pending = rows.Where(r => r.paidAt == null).Sum(r => r.share) });
|
|
}
|
|
|
|
[HttpPost("/admin/api/earnings/paynow")]
|
|
public ContentResult AdminPayEarningsNow()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null) return error;
|
|
int n = EarningsDB.PayDue();
|
|
return Json(new { success = true, message = $"Paid {n} due earning(s)." });
|
|
}
|
|
|
|
[HttpPost("/admin/api/set_featured_rooms")]
|
|
public ContentResult AdminSetFeaturedRooms()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null) return error;
|
|
|
|
string raw = Request.Form["roomIds"].ToString() ?? "";
|
|
var ids = new List<ulong>();
|
|
foreach (var part in raw.Split(new[] { ',', ';', ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
|
|
if (ulong.TryParse(part.Trim(), out var rid) && rid != 0 && !ids.Contains(rid)) ids.Add(rid);
|
|
|
|
Campus_live_rewrite.api.server.FeaturedRoomsStore.SetIds(ids);
|
|
Console.WriteLine($"[admin] {account.Username} ({account.Id}) set featured rooms: {string.Join(", ", ids)}");
|
|
|
|
// Report which ids don't resolve to a room, so the admin catches a typo.
|
|
var missing = ids.Where(id => RoomDB.GetRoom(id) == null).Select(id => id.ToString()).ToList();
|
|
string msg = $"Featured rooms updated ({ids.Count}).";
|
|
if (missing.Count > 0) msg += $" Warning - no room found for: {string.Join(", ", missing)}.";
|
|
return Json(new { success = true, message = msg, count = ids.Count });
|
|
}
|
|
|
|
// ---- Economy / progression admin -------------------------------------
|
|
// All follow the existing pattern: dev-token gated, form-urlencoded in,
|
|
// {success, message} JSON out, every action logged to the console.
|
|
|
|
/// <summary>Read a player's XP, Level and all currency balances.</summary>
|
|
[HttpGet("/admin/api/get_economy")]
|
|
public ContentResult AdminGetEconomy()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null) return error;
|
|
|
|
string rawId = HttpContext.Request.Query["playerId"].ToString();
|
|
if (!ulong.TryParse(rawId, out ulong playerId))
|
|
return Json(new { success = false, message = "Invalid player id." });
|
|
|
|
var acc = PlayerDB.GetFullAccount(playerId);
|
|
if (acc?.player == null)
|
|
return Json(new { success = false, message = $"No player with id {playerId}." });
|
|
|
|
var balances = PlayerDB.GetAllBalances(playerId)
|
|
.Select(kv => new
|
|
{
|
|
CurrencyType = kv.Key,
|
|
Name = System.Enum.IsDefined(typeof(Campus_live_rewrite.api.server.Classes.CurrencyType), kv.Key)
|
|
? ((Campus_live_rewrite.api.server.Classes.CurrencyType)kv.Key).ToString()
|
|
: $"Unknown({kv.Key})",
|
|
Amount = kv.Value
|
|
})
|
|
.OrderBy(b => b.CurrencyType)
|
|
.ToList();
|
|
|
|
return Json(new
|
|
{
|
|
success = true,
|
|
playerId = playerId,
|
|
username = acc.player.Username,
|
|
xp = acc.player.XP,
|
|
level = acc.player.Level,
|
|
balances = balances
|
|
});
|
|
}
|
|
|
|
/// <summary>Set XP and/or Level. Blank fields are left unchanged.</summary>
|
|
[HttpPost("/admin/api/set_progression")]
|
|
public ContentResult AdminSetProgression()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null) return error;
|
|
|
|
string rawId = HttpContext.Request.Form["playerId"].ToString();
|
|
string rawXp = HttpContext.Request.Form["xp"].ToString();
|
|
string rawLv = HttpContext.Request.Form["level"].ToString();
|
|
|
|
if (!ulong.TryParse(rawId, out ulong playerId))
|
|
return Json(new { success = false, message = "Invalid player id." });
|
|
|
|
int? xp = null, level = null;
|
|
if (!string.IsNullOrWhiteSpace(rawXp))
|
|
{
|
|
if (!int.TryParse(rawXp, out int vx))
|
|
return Json(new { success = false, message = "XP must be a whole number." });
|
|
xp = vx;
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(rawLv))
|
|
{
|
|
if (!int.TryParse(rawLv, out int vl))
|
|
return Json(new { success = false, message = "Level must be a whole number." });
|
|
level = vl;
|
|
}
|
|
if (xp == null && level == null)
|
|
return Json(new { success = false, message = "Enter an XP and/or Level value." });
|
|
|
|
var result = PlayerDB.SetProgression(playerId, xp, level);
|
|
if (result == null)
|
|
return Json(new { success = false, message = $"No player with id {playerId}." });
|
|
|
|
// Live push: broadcast the level to everyone (same event + SendToAll the game uses in
|
|
// PlayerDB.SetPlayerLevel), so the player and anyone looking at them updates at once.
|
|
PushEvent(playerId, Campus_live_rewrite.api.WebsocketEvents.createResponse_levelupdate(
|
|
playerId, result.Value.Level,
|
|
Campus_live_rewrite.api.server.Classes.Progress.CumulativeXpForLevel(result.Value.Level, result.Value.XP)),
|
|
"level", toAll: true);
|
|
|
|
Console.WriteLine($"[admin] {account.Username} ({account.Id}) set progression on {playerId} -> XP={result.Value.XP} Level={result.Value.Level}");
|
|
return Json(new { success = true, message = $"Saved. XP {result.Value.XP}, Level {result.Value.Level}.", xp = result.Value.XP, level = result.Value.Level });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Toggle the in-game account creation TEST switch (ConfigCampus.AdminTestNewAccountFlow):
|
|
/// on = allowlisted admin IPs get an empty cached-login picker so their client creates a new
|
|
/// account in-game. GET reports the state; POST with on=1|0 sets it.
|
|
/// </summary>
|
|
[HttpGet("/admin/api/test_newaccount")]
|
|
[HttpPost("/admin/api/test_newaccount")]
|
|
public ContentResult AdminTestNewAccount()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null) return error;
|
|
string path = Path.Combine(Environment.CurrentDirectory, "Data", "admin_test_newaccount.txt");
|
|
if (HttpContext.Request.Method == "POST")
|
|
{
|
|
string on = HttpContext.Request.Form["on"].ToString();
|
|
bool enable = on == "1" || string.Equals(on, "true", StringComparison.OrdinalIgnoreCase);
|
|
try
|
|
{
|
|
if (enable) System.IO.File.WriteAllText(path, $"on by {account.Username} at {DateTime.UtcNow:u}\n");
|
|
else if (System.IO.File.Exists(path)) System.IO.File.Delete(path);
|
|
}
|
|
catch (Exception ex) { return Json(new { success = false, message = ex.Message }); }
|
|
Console.WriteLine($"[admin] {account.Username} ({account.Id}) set new-account test switch {(enable ? "ON" : "OFF")}");
|
|
}
|
|
bool state = ConfigCampus.AdminTestNewAccountFlow;
|
|
return Json(new { success = true, enabled = state, message = state
|
|
? "ON: your next game launch from an allowlisted IP creates a NEW account in-game (empty picker)."
|
|
: "OFF: normal login." });
|
|
}
|
|
|
|
/// <summary>A player's stored settings (the client's "recroom prefs"), for debugging onboarding.</summary>
|
|
[HttpGet("/admin/api/player_settings")]
|
|
public ContentResult AdminPlayerSettings([FromQuery] ulong playerId)
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null) return error;
|
|
var settings = SettingsDB.LoadSettings(playerId);
|
|
return Json(new { success = true, playerId, settings = settings.Select(s => new { s.Key, s.Value }) });
|
|
}
|
|
|
|
/// <summary>Grant currency, or remove it with a negative amount.</summary>
|
|
/// <summary>Every account id in the player store. The "send to all" fan-out list.</summary>
|
|
private static List<ulong> AllPlayerIds()
|
|
{
|
|
var col = PlayerDB.PlayerDBFile.GetCollection<player_data>("players");
|
|
return col.FindAll().Where(p => p?.player != null).Select(p => p.playerid).ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Give the same balance / XP / level to EVERY account, by re-entering AdminGrantBalance
|
|
/// once per player with that player's id substituted in. Replaying the real handler is
|
|
/// deliberate: the currency, XP and level branches each have their own delivery rules, and
|
|
/// a second copy of them here would be the thing that silently rots.
|
|
/// </summary>
|
|
private ContentResult GrantBalanceToEveryone(player_info account, string rawCur, string rawAmt)
|
|
{
|
|
if (!long.TryParse(rawAmt, out long amount) || amount == 0)
|
|
return Json(new { success = false, message = "Amount must be a non-zero whole number." });
|
|
|
|
var ids = AllPlayerIds();
|
|
int done = 0, failed = 0;
|
|
|
|
// The form is read-only, so swap in a mutable copy per player and put the original back.
|
|
var original = HttpContext.Request.Form;
|
|
try
|
|
{
|
|
foreach (var pid in ids)
|
|
{
|
|
try
|
|
{
|
|
var fields = original.ToDictionary(kv => kv.Key, kv => kv.Value);
|
|
fields["playerId"] = pid.ToString();
|
|
HttpContext.Request.Form = new Microsoft.AspNetCore.Http.FormCollection(fields);
|
|
|
|
var res = AdminGrantBalance();
|
|
if (res.Content != null && res.Content.Contains("\"success\":true")) done++; else failed++;
|
|
}
|
|
catch (Exception ex) { failed++; Console.WriteLine($"[admin] grant_balance to {pid} failed: {ex.Message}"); }
|
|
}
|
|
}
|
|
finally { HttpContext.Request.Form = original; }
|
|
|
|
Console.WriteLine($"[admin] {account.Username} ({account.Id}) granted {amount} {rawCur} to EVERYONE - {done} ok, {failed} failed");
|
|
return Json(new { success = true, message = $"Applied to {done} player(s)" + (failed > 0 ? $" ({failed} failed)." : ".") });
|
|
}
|
|
|
|
[HttpPost("/admin/api/grant_balance")]
|
|
public ContentResult AdminGrantBalance()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null) return error;
|
|
|
|
string rawId = HttpContext.Request.Form["playerId"].ToString();
|
|
string rawCur = HttpContext.Request.Form["currencyType"].ToString();
|
|
string rawAmt = HttpContext.Request.Form["amount"].ToString();
|
|
|
|
// playerId -1 (or "all") = EVERY account, the same convention /admin/api/gift/give
|
|
// already uses. Handled by replaying this endpoint once per player rather than by
|
|
// duplicating the currency / XP / level logic, so "to everyone" can never drift from
|
|
// what the single-player button does.
|
|
bool toAll = rawId == "-1" || string.Equals(rawId, "all", StringComparison.OrdinalIgnoreCase);
|
|
if (toAll) return GrantBalanceToEveryone(account, rawCur, rawAmt);
|
|
|
|
if (!ulong.TryParse(rawId, out ulong playerId))
|
|
return Json(new { success = false, message = "Invalid player id." });
|
|
if (!long.TryParse(rawAmt, out long amount) || amount == 0)
|
|
return Json(new { success = false, message = "Amount must be a non-zero whole number." });
|
|
|
|
if (PlayerDB.GetFullAccount(playerId)?.player == null)
|
|
return Json(new { success = false, message = $"No player with id {playerId}." });
|
|
|
|
// ---- "level": set the player TO that level (XP = that level's XP), applied now ----
|
|
if (string.Equals(rawCur, "level", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
int lvl = (int)Math.Clamp(amount, 1, Campus_live_rewrite.api.server.Classes.Progress.MAX_LEVEL);
|
|
var res = PlayerDB.SetProgression(playerId, 0, lvl);
|
|
if (res == null) return Json(new { success = false, message = $"No player with id {playerId}." });
|
|
PushEvent(playerId, Campus_live_rewrite.api.WebsocketEvents.createResponse_levelupdate(
|
|
playerId, res.Value.Level,
|
|
Campus_live_rewrite.api.server.Classes.Progress.CumulativeXpForLevel(res.Value.Level, res.Value.XP)),
|
|
"level", toAll: true);
|
|
Console.WriteLine($"[admin] {account.Username} ({account.Id}) set {playerId} to level {res.Value.Level}");
|
|
return Json(new { success = true, message = $"Set to Level {res.Value.Level} ({Campus_live_rewrite.api.server.Classes.Progress.CumulativeXpForLevel(res.Value.Level, 0)} XP)." });
|
|
}
|
|
|
|
// ---- "xp": added straight to the bar. The 2025 client has no drawable XP-only box: every
|
|
// shape was tried live (reference toGameRewardDrop shape; null AvatarItemDesc; contexts
|
|
// 50, 1, 2, 3, 5) and each rendered "Can't load item", and a currency-carried label
|
|
// reads the currency's own name. So XP is banked now: the bar moves live and any level
|
|
// crossed pays its level-up box.
|
|
if (string.Equals(rawCur, "xp", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
if (amount < 0) return Json(new { success = false, message = "XP can only be given, not taken. Use Level to set a lower level." });
|
|
var r = PlayerDB.AddXp(playerId, (int)Math.Min(amount, int.MaxValue));
|
|
if (r == null) return Json(new { success = false, message = $"No player with id {playerId}." });
|
|
Console.WriteLine($"[admin] {account.Username} ({account.Id}) gave {amount} XP to {playerId} -> level {r.Value.Level} (+{r.Value.LevelsGained})");
|
|
return Json(new { success = true, message = $"Gave {amount} XP. Now Level {r.Value.Level}" + (r.Value.LevelsGained > 0 ? $" (+{r.Value.LevelsGained}, level-up box sent)." : ".") });
|
|
}
|
|
|
|
if (!int.TryParse(rawCur, out int currencyType))
|
|
return Json(new { success = false, message = "Pick a currency." });
|
|
|
|
string name = System.Enum.IsDefined(typeof(Campus_live_rewrite.api.server.Classes.CurrencyType), currencyType)
|
|
? ((Campus_live_rewrite.api.server.Classes.CurrencyType)currencyType).ToString()
|
|
: $"Currency {currencyType}";
|
|
long total = PlayerDB.GetBalance(playerId, currencyType);
|
|
|
|
// A REMOVAL is balance-only, applied now. A GRANT arrives as a gift box (announced over
|
|
// the hub right now so it drops in front of the player) and the amount is credited when
|
|
// they OPEN it - the counter moves with the reveal. The box's look comes from the
|
|
// panel's box type (GiftContext: 51 tokens, 50 XP box, 4003 Golden, ...) and its text
|
|
// from the message field.
|
|
if (amount < 0)
|
|
{
|
|
total = PlayerDB.AddBalance(playerId, currencyType, amount);
|
|
PushEvent(playerId, Campus_live_rewrite.api.WebsocketEvents.createResponse_balanceupdate(currencyType, total), "balance");
|
|
}
|
|
else
|
|
{
|
|
// The text on the box comes from the panel's "Box message" field; blank falls back
|
|
// to the default. Capped so a novel can't be pasted onto a box.
|
|
string boxMsg = HttpContext.Request.Form["message"].ToString().Trim();
|
|
if (boxMsg.Length > 120) boxMsg = boxMsg.Substring(0, 120);
|
|
if (string.IsNullOrWhiteSpace(boxMsg)) boxMsg = "A gift for you <3";
|
|
int boxType = int.TryParse(HttpContext.Request.Form["giftContext"].ToString(), out var bt) ? bt
|
|
: Campus_live_rewrite.api.server.QuestRewards.GiftContextGameRewardTokens;
|
|
var box = new Campus_live_rewrite.api.server.Classes.db_class.PendingGiftBox
|
|
{
|
|
GiftContext = boxType,
|
|
Message = boxMsg,
|
|
FromAccountId = 0,
|
|
FriendlyName = $"{amount} {(currencyType == (int)Campus_live_rewrite.api.server.Classes.CurrencyType.RecCenterTokens ? "Tokens" : name)}",
|
|
ThumbnailImageName = Campus_live_rewrite.api.server.QuestRewards.TokenThumbnail,
|
|
CurrencyType = currencyType,
|
|
Currency = amount,
|
|
Rarity = Campus_live_rewrite.api.server.QuestRewards.TokenRarity(amount),
|
|
PayOnOpen = true
|
|
};
|
|
if (!PlayerDB.AddPendingGiftBoxFull(playerId, box))
|
|
return Json(new { success = false, message = "Could not store the gift box." });
|
|
Campus_live_rewrite.api.server.GiftBoxes.Push(playerId, box, Campus_live_rewrite.api.server.GiftBoxes.FrameReceivedImmediate, "admin token box");
|
|
}
|
|
|
|
Console.WriteLine($"[admin] {account.Username} ({account.Id}) {(amount > 0 ? "boxed" : "removed")} {System.Math.Abs(amount)} {name} for {playerId}{(amount > 0 ? " (paid on open)" : $" - new total {total}")}");
|
|
return Json(new
|
|
{
|
|
success = true,
|
|
message = amount > 0
|
|
? $"Gift box with {amount} {name} sent - it pays out when they open it."
|
|
: $"Removed {System.Math.Abs(amount)} {name}. New total: {total}.",
|
|
total = total
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Send a player a GAME REWARD: exactly what POST /api/gamerewards/v1/request does after a
|
|
/// game - a three-card reward picker on their watch, in the chosen activity's box, that pays
|
|
/// when they pick and open. No cooldown here: the panel decides.
|
|
/// </summary>
|
|
[HttpPost("/admin/api/send_gamereward")]
|
|
public ContentResult AdminSendGameReward()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null) return error;
|
|
|
|
string rawId = HttpContext.Request.Form["playerId"].ToString();
|
|
string rawCtx = HttpContext.Request.Form["giftContext"].ToString();
|
|
string message = HttpContext.Request.Form["message"].ToString().Trim();
|
|
int giftContext = int.TryParse(rawCtx, out var gc) ? gc : 0;
|
|
if (message.Length > 120) message = message.Substring(0, 120);
|
|
|
|
// playerId -1 / "all" = everyone (same convention as gift/give and grant_balance).
|
|
if (rawId == "-1" || string.Equals(rawId, "all", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
int sent = 0, failed = 0;
|
|
foreach (var pid in AllPlayerIds())
|
|
{
|
|
try
|
|
{
|
|
var one = Campus_live_rewrite.api.server.RewardSelections.Offer(
|
|
pid, giftContext, null, string.IsNullOrWhiteSpace(message) ? "Game Complete" : message);
|
|
if (one != null) sent++; else failed++;
|
|
}
|
|
catch (Exception ex) { failed++; Console.WriteLine($"[admin] game reward to {pid} failed: {ex.Message}"); }
|
|
}
|
|
Console.WriteLine($"[admin] {account.Username} ({account.Id}) sent game reward (box {giftContext}) to EVERYONE - {sent} sent, {failed} failed");
|
|
return Json(new { success = true, message = $"Reward picker sent to {sent} player(s)" + (failed > 0 ? $" ({failed} failed)." : ".") });
|
|
}
|
|
|
|
if (!ulong.TryParse(rawId, out ulong playerId))
|
|
return Json(new { success = false, message = "Invalid player id." });
|
|
var acc = PlayerDB.GetFullAccount(playerId);
|
|
if (acc?.player == null)
|
|
return Json(new { success = false, message = $"No player with id {playerId}." });
|
|
|
|
var sel = Campus_live_rewrite.api.server.RewardSelections.Offer(playerId, giftContext, null, string.IsNullOrWhiteSpace(message) ? "Game Complete" : message);
|
|
if (sel == null)
|
|
return Json(new { success = false, message = "Could not build a reward selection." });
|
|
Console.WriteLine($"[admin] {account.Username} ({account.Id}) sent game reward (box {giftContext}) to {playerId} - selection {sel.RewardSelectionId}");
|
|
return Json(new { success = true, message = $"Reward picker sent to @{acc.player.Username} (box {giftContext}): {string.Join(" / ", sel.Drops.Select(d => d.Currency + " tokens"))}." });
|
|
}
|
|
|
|
/// <summary>Gift a gift box to a player.</summary>
|
|
[HttpPost("/admin/api/gift_giftbox")]
|
|
public ContentResult AdminGiftGiftBox()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null) return error;
|
|
|
|
string rawId = HttpContext.Request.Form["playerId"].ToString();
|
|
string rawCtx = HttpContext.Request.Form["giftContext"].ToString();
|
|
string rawAmt = HttpContext.Request.Form["amount"].ToString();
|
|
string message = HttpContext.Request.Form["message"].ToString() ?? "";
|
|
|
|
if (!ulong.TryParse(rawId, out ulong playerId))
|
|
return Json(new { success = false, message = "Invalid player id." });
|
|
// Accept EITHER a numeric gift context OR a mode name (e.g. "Discgolf_Propulsion"), so the
|
|
// owner can grant a real gift box straight from that mode's reward pool.
|
|
int giftContext = int.TryParse(rawCtx, out var gcParsed) ? gcParsed : 0;
|
|
string? giftMode = int.TryParse(rawCtx, out _) ? null : (string.IsNullOrWhiteSpace(rawCtx) ? null : rawCtx.Trim());
|
|
if (giftContext == 0 && string.IsNullOrWhiteSpace(giftMode))
|
|
return Json(new { success = false, message = "Pick a gift box (context id or mode name)." });
|
|
|
|
// Amount is optional; blank means "just queue the box, pay nothing".
|
|
long amount = 0;
|
|
if (!string.IsNullOrWhiteSpace(rawAmt) && !long.TryParse(rawAmt, out amount))
|
|
return Json(new { success = false, message = "Amount must be a whole number." });
|
|
if (amount < 0) amount = 0;
|
|
|
|
var acc = PlayerDB.GetFullAccount(playerId);
|
|
if (acc?.player == null)
|
|
return Json(new { success = false, message = $"No player with id {playerId}." });
|
|
|
|
// Deliver a REAL gift box: roll an item from the reward pool for this context/mode
|
|
// (Data/APIS/quest-rewards.json) and grant it fully (it enters the wardrobe too). Only
|
|
// if the pool has nothing for it do we fall back to the old contentless box.
|
|
var giftPick = Campus_live_rewrite.api.server.QuestRewards.Pick(giftContext, giftMode);
|
|
if (giftPick != null
|
|
&& (!string.IsNullOrWhiteSpace(giftPick.AvatarItemDesc)
|
|
|| !string.IsNullOrWhiteSpace(giftPick.ConsumableItemDesc)
|
|
|| !string.IsNullOrWhiteSpace(giftPick.EquipmentPrefabName)))
|
|
{
|
|
var rolledBox = new Campus_live_rewrite.api.server.Classes.db_class.PendingGiftBox
|
|
{
|
|
GiftContext = giftContext,
|
|
Message = message,
|
|
FromAccountId = account.Id,
|
|
AvatarItemDesc = giftPick.AvatarItemDesc,
|
|
ConsumableItemDesc = giftPick.ConsumableItemDesc,
|
|
EquipmentPrefabName = giftPick.EquipmentPrefabName,
|
|
EquipmentModificationGuid = giftPick.EquipmentModificationGuid,
|
|
Rarity = giftPick.GiftRarity
|
|
};
|
|
if (PlayerDB.AddPendingGiftBoxFull(playerId, rolledBox))
|
|
Campus_live_rewrite.api.server.GiftBoxes.Push(playerId, rolledBox, Campus_live_rewrite.api.server.GiftBoxes.FrameReceivedImmediate, "admin gift box");
|
|
Console.WriteLine($"[admin] gift box for {playerId}: mode='{giftMode ?? giftContext.ToString()}' -> item '{giftPick.AvatarItemDesc ?? giftPick.EquipmentPrefabName ?? giftPick.ConsumableItemDesc}'");
|
|
}
|
|
else
|
|
{
|
|
PlayerDB.AddPendingGiftBox(playerId, giftContext, message, account.Id);
|
|
}
|
|
|
|
// Pay out regardless. Currency IS confirmed working end-to-end, so the gift
|
|
// is worth something to the recipient even while the box visual is unresolved.
|
|
string payout = "no currency";
|
|
if (amount > 0)
|
|
{
|
|
var currency = Campus_live_rewrite.api.server.Classes.GiftContextIds.CurrencyForGiftBox(giftContext);
|
|
long total = PlayerDB.AddBalance(playerId, (int)currency, amount);
|
|
payout = $"{amount} {currency} (new total {total})";
|
|
// Live push: new balance to the owner.
|
|
PushEvent(playerId, Campus_live_rewrite.api.WebsocketEvents.createResponse_balanceupdate((int)currency, total), "giftbox balance");
|
|
}
|
|
|
|
// Live push: nudge the client to re-read the account/gifts (same as gift/give does).
|
|
PushAccountRefresh(playerId, "giftbox");
|
|
|
|
Console.WriteLine($"[admin] {account.Username} ({account.Id}) gifted box {giftContext} to {playerId} - {payout}");
|
|
_ = Task.Run(() => Webhook.Send(
|
|
playerId, "prod", "Gift box",
|
|
$"@{acc.player.Username} ({playerId}) was gifted gift box {giftContext} by @{account.Username} - {payout}",
|
|
5898927, null));
|
|
|
|
return Json(new
|
|
{
|
|
success = true,
|
|
message = amount > 0
|
|
? $"Sent to @{acc.player.Username}: {payout}."
|
|
: $"Gift box queued for @{acc.player.Username} (no currency - set an amount to pay out)."
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------
|
|
// Give a real ITEM as a proper gift box (with content) or straight to the wardrobe.
|
|
// Ported from the 2023 server's admin GiveItem. The old 2025 admin gift queued a CONTENTLESS
|
|
// box (no item), which the client can't render and can never open - "random gifts" that
|
|
// won't clear. This resolves the item from the storefront catalog (its real GiftDropId,
|
|
// Rarity and RAW descriptor) so the box opens correctly and the item lands in the wardrobe.
|
|
// ---------------------------------------------------------------------------------------
|
|
[HttpGet("/admin/api/gift/search")]
|
|
public IActionResult AdminGiftSearch([FromQuery] string? q)
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null) return error;
|
|
|
|
// TWO sources, storefront first.
|
|
//
|
|
// This used to search only the storefront catalogs, which list what is FOR SALE - so
|
|
// most of avatar_items.json could not be found at all ("thief hat" returned the three
|
|
// on the Watch store and nothing else). The store entries still come first because
|
|
// they carry a real GiftDropId, which is what lets the gift BOX render the item; a
|
|
// catalog-only item is identified by its descriptor instead.
|
|
const int cap = 60;
|
|
var store = string.IsNullOrWhiteSpace(q)
|
|
? new List<Campus_live_rewrite.api.server.StoreCatalog.GiftDropInfo>()
|
|
: Campus_live_rewrite.api.server.StoreCatalog.SearchItems(q, cap);
|
|
|
|
var rows = new List<object>();
|
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (var m in store)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(m.Reward))
|
|
seen.Add(PlayerDB.NormalizeAvatarItemDesc(m.Reward));
|
|
rows.Add(new
|
|
{
|
|
purchasableItemId = m.PurchasableItemId,
|
|
storefrontType = m.StorefrontType,
|
|
avatarItemDesc = m.Reward ?? "",
|
|
name = string.IsNullOrWhiteSpace(m.Name) ? "(unnamed)" : m.Name,
|
|
rarity = m.Rarity,
|
|
giftDropId = m.GiftDropId,
|
|
source = "store"
|
|
});
|
|
}
|
|
|
|
int catalogTotal = 0;
|
|
if (!string.IsNullOrWhiteSpace(q))
|
|
{
|
|
catalogTotal = Campus_live_rewrite.api.server.AvatarCatalog.CountMatches(q);
|
|
foreach (var e in Campus_live_rewrite.api.server.AvatarCatalog.Search(q, cap))
|
|
{
|
|
if (rows.Count >= cap) break;
|
|
// Skip anything the storefront half already offered - one item, one row.
|
|
if (!seen.Add(PlayerDB.NormalizeAvatarItemDesc(e.Desc))) continue;
|
|
rows.Add(new
|
|
{
|
|
purchasableItemId = 0,
|
|
storefrontType = -1,
|
|
avatarItemDesc = e.Desc,
|
|
name = string.IsNullOrWhiteSpace(e.Name) ? "(unnamed)" : e.Name,
|
|
rarity = e.Rarity,
|
|
giftDropId = 0,
|
|
source = "catalog"
|
|
});
|
|
}
|
|
}
|
|
|
|
return Json(new
|
|
{
|
|
success = true,
|
|
count = rows.Count,
|
|
total = store.Count + catalogTotal, // before the cap, so the UI can say "showing N of M"
|
|
items = rows
|
|
});
|
|
}
|
|
|
|
[HttpPost("/admin/api/gift/give")]
|
|
public IActionResult AdminGiftGive()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null) return error;
|
|
|
|
var form = HttpContext.Request.Form;
|
|
string rawPid = form["playerId"].ToString();
|
|
bool toAll = rawPid == "-1" || string.Equals(rawPid, "all", StringComparison.OrdinalIgnoreCase);
|
|
ulong onePlayer = 0;
|
|
if (!toAll && !ulong.TryParse(rawPid, out onePlayer))
|
|
return Json(new { success = false, message = "Invalid player id." });
|
|
|
|
int.TryParse(form["purchasableItemId"].ToString(), out int itemId);
|
|
int storefront = int.TryParse(form["storefrontType"].ToString(), out var sf) ? sf : -1;
|
|
string rawDesc = (form["avatarItemDesc"].ToString() ?? "").Trim();
|
|
bool wardrobe = string.Equals(form["mode"].ToString(), "wardrobe", StringComparison.OrdinalIgnoreCase);
|
|
string? message = string.IsNullOrWhiteSpace(form["message"].ToString()) ? null : form["message"].ToString().Trim();
|
|
int xp = int.TryParse(form["xp"].ToString(), out var x) && x > 0 ? x : 0;
|
|
|
|
// An item now arrives one of two ways: a storefront drop (PurchasableItemId) or a bare
|
|
// avatar_items.json entry (AvatarItemDesc). Both are resolved into the same
|
|
// GiftDropInfo so everything below - box, wardrobe, give-to-all - stays one path.
|
|
Campus_live_rewrite.api.server.StoreCatalog.GiftDropInfo? item = null;
|
|
|
|
if (itemId > 0)
|
|
item = Campus_live_rewrite.api.server.StoreCatalog.FindStoreItem(itemId, storefront);
|
|
|
|
if ((item == null || string.IsNullOrWhiteSpace(item.Reward)) && !string.IsNullOrWhiteSpace(rawDesc))
|
|
{
|
|
// Prefer a storefront entry for the same descriptor when one exists: it carries the
|
|
// real GiftDropId, and that is what the gift BOX needs to draw the item. Only fall
|
|
// back to a synthesised record when nothing sells this item.
|
|
item = Campus_live_rewrite.api.server.StoreCatalog
|
|
.SearchItems(rawDesc, 5)
|
|
.FirstOrDefault(s => string.Equals(
|
|
PlayerDB.NormalizeAvatarItemDesc(s.Reward),
|
|
PlayerDB.NormalizeAvatarItemDesc(rawDesc),
|
|
StringComparison.OrdinalIgnoreCase));
|
|
|
|
if (item == null)
|
|
{
|
|
var cat = Campus_live_rewrite.api.server.AvatarCatalog.ByDesc(rawDesc);
|
|
if (cat != null)
|
|
{
|
|
item = new Campus_live_rewrite.api.server.StoreCatalog.GiftDropInfo
|
|
{
|
|
Reward = cat.Desc, // RAW - the client parses this
|
|
Name = cat.Name,
|
|
Rarity = cat.Rarity,
|
|
AvatarItemType = cat.AvatarItemType,
|
|
GiftDropId = 0, // not a sold drop; see the wardrobe note below
|
|
ConsumableItemDesc = "",
|
|
EquipmentPrefabName = "",
|
|
EquipmentModificationGuid = "",
|
|
StorefrontType = -1
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
if (item == null || string.IsNullOrWhiteSpace(item.Reward))
|
|
return Json(new { success = false, message = "That item is not in the storefronts or the avatar catalog." });
|
|
|
|
string friendly = string.IsNullOrWhiteSpace(item.Name) ? "Gift" : item.Name;
|
|
string key = PlayerDB.NormalizeAvatarItemDesc(item.Reward);
|
|
|
|
void GiveOne(ulong pid)
|
|
{
|
|
if (wardrobe)
|
|
{
|
|
PlayerDB.GrantAvatarItem(pid, item.Reward);
|
|
// A skin row has no avatar item; it goes into the equipment locker instead.
|
|
PlayerDB.GrantEquipmentSkin(pid, item.EquipmentPrefabName, item.EquipmentModificationGuid);
|
|
if (xp > 0) PlayerDB.AddXp(pid, xp);
|
|
}
|
|
else
|
|
{
|
|
var box = new PendingGiftBox
|
|
{
|
|
NumericId = Campus_live_rewrite.api.server.StoreCatalog.NextPurchaseGiftId(),
|
|
GiftContext = 0,
|
|
Message = message ?? "A gift for you <3",
|
|
FromAccountId = 0, // 0 = from the server / admin
|
|
Anonymous = false,
|
|
AvatarItemDesc = item.Reward, // RAW - the client parses this
|
|
ConsumableItemDesc = item.ConsumableItemDesc,
|
|
AvatarItemType = item.AvatarItemType,
|
|
GiftDropId = item.GiftDropId, // real id so the box resolves, not the placeholder
|
|
FriendlyName = friendly,
|
|
Rarity = item.Rarity,
|
|
EquipmentPrefabName = item.EquipmentPrefabName,
|
|
EquipmentModificationGuid = item.EquipmentModificationGuid
|
|
};
|
|
PlayerDB.AddPendingGiftBoxFull(pid, box); // grants ownership + queues the box
|
|
// Announce it now (GiftPackageReceivedImmediate) so the box drops in front of the
|
|
// player instead of waiting for their next gifts poll / relog.
|
|
Campus_live_rewrite.api.server.GiftBoxes.Push(pid, box, Campus_live_rewrite.api.server.GiftBoxes.FrameReceivedImmediate, "admin gift");
|
|
if (xp > 0) PlayerDB.AddXp(pid, xp);
|
|
Notifications.FireAndForget(Notifications.RefreshAccount(pid), "admin gift refresh");
|
|
}
|
|
}
|
|
|
|
int given = 0;
|
|
if (toAll)
|
|
{
|
|
var col = PlayerDB.PlayerDBFile.GetCollection<player_data>("players");
|
|
foreach (var p in col.FindAll().ToList())
|
|
{
|
|
if (p?.player == null) continue;
|
|
// Skip anyone who already owns it, so nobody gets a duplicate box.
|
|
if (PlayerDB.OwnedAvatarItemDescs(p.playerid).Contains(key)) continue;
|
|
GiveOne(p.playerid);
|
|
given++;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
var target = PlayerDB.GetFullAccount(onePlayer);
|
|
if (target?.player == null)
|
|
return Json(new { success = false, message = $"No player with id {onePlayer}." });
|
|
GiveOne(onePlayer);
|
|
given = 1;
|
|
}
|
|
|
|
Console.WriteLine($"[admin] {account.Username} ({account.Id}) gave '{friendly}' ({item.Reward}) " +
|
|
$"{(wardrobe ? "to wardrobe" : "as gift box")} to {(toAll ? $"{given} player(s)" : rawPid)}" +
|
|
$"{(xp > 0 ? $" +{xp} XP" : "")}");
|
|
|
|
return Json(new
|
|
{
|
|
success = true,
|
|
message = $"Gave '{friendly}' {(wardrobe ? "straight to the wardrobe" : "as a gift box")} to " +
|
|
$"{(toAll ? $"{given} player(s)" : "the player")}{(xp > 0 ? $" (+{xp} XP)" : "")}."
|
|
});
|
|
}
|
|
|
|
[HttpPost("/admin/api/delete_player")]
|
|
public ContentResult AdminDeletePlayerAPI()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null) return error;
|
|
|
|
string raw = HttpContext.Request.Form["playerId"].ToString();
|
|
if (!ulong.TryParse(raw, out ulong playerId))
|
|
return Json(new { success = false, message = "Invalid player id." });
|
|
|
|
if (playerId == account.Id)
|
|
return Json(new { success = false, message = "You can't delete your own account from the panel." });
|
|
|
|
var target = PlayerDB.GetFullAccount(playerId);
|
|
if (target == null)
|
|
return Json(new { success = false, message = "Player not found." });
|
|
|
|
var ok = PlayerDB.DeletePlayerFull(playerId);
|
|
Console.WriteLine($"[admin] {account.Username} ({account.Id}) deleted player {playerId} ({target.player?.Username}) — ok={ok}");
|
|
return Json(new { success = ok, message = ok ? "Player deleted." : "Delete failed." });
|
|
}
|
|
|
|
private ContentResult Json(object o) => new ContentResult
|
|
{
|
|
Content = JsonConvert.SerializeObject(o),
|
|
ContentType = "application/json",
|
|
StatusCode = 200
|
|
};
|
|
|
|
[HttpPost("/admin/api/create_player")]
|
|
public ContentResult AdminCreatePlayerAPI()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null)
|
|
return error;
|
|
|
|
account_data NewAccount = null;
|
|
// ADD oculus support later when we get that ingame
|
|
string platformid = Request.Form["platform_id"];
|
|
|
|
Platforms platform = Platforms.All;
|
|
if (platformid.StartsWith("765611"))
|
|
{
|
|
platform = Platforms.Steam;
|
|
}
|
|
else
|
|
{
|
|
platform = Platforms.Oculus;
|
|
}
|
|
|
|
var acc = PlayerDB.CreateAccount(Platforms.Steam, ulong.Parse(platformid), Request.Form["device_id"], ulong.Parse(Request.Form["discord_id"]), out NewAccount);
|
|
return new ContentResult
|
|
{
|
|
Content = NewAccount.accountId.ToString(),
|
|
ContentType = "application/json"
|
|
};
|
|
}
|
|
|
|
[HttpPost("/admin/api/ban_player")]
|
|
public ContentResult AdminAPIBanPlayer()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
|
|
if (error != null)
|
|
return error;
|
|
|
|
string playerIdStr = Request.Form["playerId"];
|
|
string durationStr = Request.Form["duration"];
|
|
string categoryStr = Request.Form["category"];
|
|
string message = Request.Form["message"];
|
|
|
|
if (!ulong.TryParse(playerIdStr, out ulong playerId))
|
|
{
|
|
return new ContentResult
|
|
{
|
|
Content = JsonConvert.SerializeObject(new
|
|
{
|
|
success = false,
|
|
error = "Invalid Player Id"
|
|
}),
|
|
ContentType = "application/json",
|
|
StatusCode = 200
|
|
};
|
|
}
|
|
|
|
if (playerId == 1)
|
|
{
|
|
return new ContentResult
|
|
{
|
|
Content = JsonConvert.SerializeObject(new
|
|
{
|
|
success = false,
|
|
error = "Cannot ban this player"
|
|
}),
|
|
ContentType = "application/json",
|
|
StatusCode = 200
|
|
};
|
|
}
|
|
|
|
if (PlayerDB.CheckDevFlag(playerId))
|
|
{
|
|
return new ContentResult
|
|
{
|
|
Content = JsonConvert.SerializeObject(new
|
|
{
|
|
success = false,
|
|
error = "Cannot ban this player"
|
|
}),
|
|
ContentType = "application/json",
|
|
StatusCode = 200
|
|
};
|
|
}
|
|
|
|
if (!int.TryParse(durationStr, out int duration) || duration < 0)
|
|
{
|
|
return new ContentResult
|
|
{
|
|
Content = JsonConvert.SerializeObject(new
|
|
{
|
|
success = false,
|
|
error = "Invalid duration"
|
|
}),
|
|
ContentType = "application/json",
|
|
StatusCode = 200
|
|
};
|
|
}
|
|
|
|
if (!Enum.TryParse<Moderation_Detail.ReportCategory>(categoryStr, ignoreCase: true, out var category))
|
|
{
|
|
category = Moderation_Detail.ReportCategory.Moderator;
|
|
}
|
|
|
|
string result = PlayerDB.Set_moderation_block(
|
|
playerId,
|
|
category,
|
|
duration,
|
|
gameSessionId: -1,
|
|
isHostKick: false,
|
|
message: message,
|
|
playerIdReporter: 0,
|
|
isBan: true,
|
|
bannedByPlayerId: account.Id
|
|
);
|
|
|
|
// Optional device / IP bans on top of the account ban.
|
|
//
|
|
// An account ban alone is trivially defeated - make a new account and you are
|
|
// back in. These are checked at LOGIN (see AuthController), before an account
|
|
// is even looked up, so the whole machine or address is refused.
|
|
bool banDevice = Truthy(Request.Form["banDevice"]);
|
|
bool banIp = Truthy(Request.Form["banIp"]);
|
|
|
|
string? deviceId = null;
|
|
var ips = new List<string>();
|
|
int alsoBanned = 0;
|
|
var extra = new List<string>();
|
|
|
|
if (banDevice)
|
|
{
|
|
deviceId = PlayerDB.GetDeviceId(playerId);
|
|
if (string.IsNullOrWhiteSpace(deviceId))
|
|
{
|
|
extra.Add("no device id on record (they have never logged in), device NOT banned");
|
|
}
|
|
else
|
|
{
|
|
BanDB.BanDevice(deviceId!, playerId, account.Id, message, duration);
|
|
alsoBanned++;
|
|
|
|
// Say what else this takes out, so it is never a surprise.
|
|
var alts = PlayerDB.AccountsOnDevice(deviceId).Where(x => x != playerId).ToList();
|
|
extra.Add(alts.Count > 0
|
|
? $"device banned (also blocks {alts.Count} other account(s): {string.Join(", ", alts)})"
|
|
: "device banned");
|
|
}
|
|
}
|
|
|
|
if (banIp)
|
|
{
|
|
ips = PlayerDB.GetDecryptedIpAddresses(playerId) ?? new List<string>();
|
|
if (ips.Count == 0)
|
|
{
|
|
extra.Add("no IP on record, IP NOT banned");
|
|
}
|
|
else
|
|
{
|
|
foreach (var ip in ips)
|
|
BanDB.BanIp(ip, playerId, account.Id, message, duration);
|
|
alsoBanned++;
|
|
extra.Add($"{ips.Count} IP(s) banned");
|
|
}
|
|
}
|
|
|
|
Console.WriteLine($"[admin] {account.Username} ({account.Id}) banned {playerId} " +
|
|
$"for {duration}s{(extra.Count > 0 ? " - " + string.Join("; ", extra) : "")}");
|
|
|
|
return new ContentResult
|
|
{
|
|
Content = JsonConvert.SerializeObject(new
|
|
{
|
|
success = true,
|
|
error = "",
|
|
message = extra.Count > 0 ? string.Join("; ", extra) : "Account banned.",
|
|
deviceBanned = banDevice && !string.IsNullOrWhiteSpace(deviceId),
|
|
ipsBanned = banIp ? ips.Count : 0
|
|
}),
|
|
ContentType = "application/json",
|
|
StatusCode = 200
|
|
};
|
|
}
|
|
|
|
/// <summary>Matches any character outside plain ASCII.</summary>
|
|
private const string NonAsciiPattern = @"[^ |