the files

This commit is contained in:
niko
2026-10-14 06:59:51 +02:00
parent f6175893fc
commit 2040419525
1855 changed files with 6735668 additions and 0 deletions
@@ -0,0 +1,184 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Campus_live_rewrite.api.server;
namespace Campus_live_rewrite.Controllers
{
[ApiController]
[Route("Ai")]
public class AIController : ControllerBase
{
[HttpGet("roomieai/user/access")]
public IActionResult GetRoomieAccess()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(new
{
success = true,
error_id = (string?)null,
error = (string?)null,
value = new
{
MaxEnergyFromSubscriptions = int.MaxValue,
EnergyLeft = int.MaxValue,
NextSubscriptionEnergyRechargeAt = (string?)null,
OutputAudioEnabled = true,
}
});
}
// Maker AI access check. The VALUELESS envelope only - {Success, Error, error_id}, no Value
// key; access is granted purely by Success (wire-shapes: makerai-user-access). Was a 404 on
// every launch.
[HttpGet("makerai/user/access")]
public ContentResult MakerAiUserAccess() => new ContentResult
{
Content = JsonConvert.SerializeObject(new { Success = true, Error = (string?)null, error_id = (string?)null }),
ContentType = "application/json",
StatusCode = 200
};
[HttpGet("makerai/user/balances")]
public IActionResult GetMakerAIBalance()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(new
{
UsageDollars = 0,
UsersMaxUsageDollars = 0,
RRPlusUsageDollars = 0.0,
UsersMaxRRPlusUsageDollars = 0,
TimeBalanceStatus = "Empty",
TimeExpiresAt = "0001-01-01T00:00:00",
UsageBalanceStatus = "Good",
UsagePercent = 0,
RRPlusUsageBalanceStatus = "Good",
RRPlusUsagePercent = 0
});
}
[HttpGet("gameai/user/access")]
public IActionResult GetGameAIAccess([FromQuery] ulong? roomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(new
{
success = false,
error_id = "AI.RoomDoesNotSupportGameAI",
error = "This room does not support Rec Room Game AI"
});
}
[HttpGet("gameai/room/{roomId}/spendsummary")]
public IActionResult GetGameAISpendSummaryForRoom(ulong roomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(new
{
success = false,
error_id = "AI.RoomDoesNotSupportGameAI",
error = "This room does not support Rec Room Game AI",
value = (object?)null
});
}
[HttpPost("realtime-session/create")] // me when rr leak their open ai key
public async Task<IActionResult> CreateAISession([FromBody] JsonObject request)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
_ = Task.Run(() => Webhook.Send(
account.Id,
"roomie",
"Roomie Info",
$"@{account.Username} ({account.Id}) has pulled out their {request["AIType"]}",
5898927,
null
));
// PROBES 1 AND 2 (2026-08-23): both inconclusive, both reverted.
//
// Returning a well-formed session DID work at the handshake level - the
// client accepted it (97ms) and moved on to /Ai/roomieai/user/facts. Probe 2
// additionally handed back a correctly shaped "sk-" + 48 char secret, so any
// client-side format check would have passed. In both cases the client still
// reported "RoomieAI is currently unavailable", re-ran the whole handshake in
// a loop, and NO onward connection ever appeared in the patch log.
//
// That is expected: a realtime connection (WebRTC / native socket) does not
// go through BestHTTP, so the patch's URI hook cannot see it. Neither probe
// could reveal the destination - a packet capture is the only way left.
//
// Refusing immediately is correct until there is a real backend: the client
// shows "unavailable" once and stops, instead of hammering this endpoint plus
// user/facts in a tight loop. The 10-second Task.Delay that used to sit here
// is deliberately NOT restored - it stalled every Roomie summon for nothing.
return Ok(new
{
success = false,
error = "RoomieAI backend not configured",
error_id = "",
value = (string?)null
});
/*return Ok(new
{
success = true,
error = "",
error_id = "",
value = new
{
ClientSecret = "sk-OsMMq65tXdfOIlTUYtocSL7NCsmA7CerN77OkEv29dODg1EA",
SessionId = $"sess_{Utils.GetRandomString(20)}" // sess_DGuWxBuKIgOZMO0fHkAHE
}
});*/
}
[HttpGet("roomieai/user/facts")]
public async Task<IActionResult> GetUserFactsForRoomie() // Todo
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
/*
Response from https://ai.rec.net
{
"UserContext" : "Here's a concise user profile:\n\nThis individual is a gay man who's generally easygoing and enjoys a relaxed pace of life. He's currently based in [Location - *if provided, otherwise omit*] and is focused on [Work/School - *if provided, otherwise omit*]. In his free time, he's passionate about [Interests - *if provided, otherwise omit*]. He prefers to communicate in a straightforward and approachable manner.",
"UserFacts" : [
{
"CreatedAt" : "2026-03-07T22:56:56",
"Emotion" : "neutral",
"Id" : "63cdd375-57ef-431e-be87-709cb14b487e",
"Object" : "gay",
"Predicate" : "identifies as"
}
]
}
*/
return Ok(new
{
UserContext = "",
UserFacts = Array.Empty<object>()
});
}
}
}
@@ -0,0 +1,7378 @@
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.Contracts;
using System.Net.Mime;
using System.Net.WebSockets;
using System.Numerics;
using System.Security.Cryptography.Xml;
using System.Security.Principal;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using Discord;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.Extensions.Configuration.UserSecrets;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Campus_live_rewrite.api;
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server;
using Campus_live_rewrite.api.server.Classes;
using Campus_live_rewrite.api.server.Classes.db_class;
using CampusLiveSource.api.admin;
using CampusLiveSource.Controllers;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Metadata;
using static ImageMetadataDB;
using static Campus_live_rewrite.api.server.Classes.db_class.EventDBClasses;
using static Campus_live_rewrite.api.server.Classes.db_class.RoomDBClasses;
using static Campus_live_rewrite.api.server.Classes.RRPlus;
using static Campus_live_rewrite.api.WebsocketEvents;
using System.Text.Json.Nodes;
using System.Text.Json;
using System.IO;
namespace Campus_live_rewrite.api.Controllers
{
[ApiController]
public class APIController : ControllerBase
{
private static readonly string UPTIME = DateTime.UtcNow.ToString("o");
[HttpGet("/api/versioncheck/v4")]
public IActionResult VersionCheck([FromQuery(Name = "v"), Required] string appVersion, [FromQuery(Name = "p"), Required] int? platform)
{
/*if (appVersion == "20250731.01")
{
return Ok(new VersionCheck
{
ValidVersion = VersionStatus.ValidForPlay,
VersionStatus = VersionStatus.ValidForPlay,
UpdateNotificationStage = UpdateNoti.None,
IsVersionIslanded = false,
IsCrossPlayDisabled = false
});
}
return Ok(new VersionCheck
{
ValidVersion = VersionStatus.UpdateRequired,
VersionStatus = VersionStatus.UpdateRequired,
UpdateNotificationStage = UpdateNoti.Prompt,
IsVersionIslanded = true,
IsCrossPlayDisabled = false
});*/
return Ok(new VersionCheck
{
ValidVersion = VersionStatus.ValidForPlay,
VersionStatus = VersionStatus.ValidForPlay,
UpdateNotificationStage = UpdateNoti.None,
IsVersionIslanded = false,
IsCrossPlayDisabled = false
});
}
[HttpGet("/api/versioncheck/islandedversions")]
public IActionResult IslandedVersions()
{
return Ok(ConfigCampus.Bracket);
}
[HttpGet("/api/gameconfigs/v1/all")]
public IActionResult Gameconfigs()
{
string path = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "Gameconfigs.json");
if (!System.IO.File.Exists(path))
{
Console.WriteLine("Warning: Gameconfigs.json not found, returning []");
return Ok(ConfigCampus.Bracket);
}
// The Level Rewards tiles read their picture from Lifecycle.PSF.Progression.RewardMap
// (one entry per level) and UnknownRewardImage. Both are rewritten here to
// ConfigCampus.LevelRewardImage, cached on the file's mtime, so the picture is one
// config line rather than fifty JSON entries - and the file itself is left alone.
string cfg = Campus_live_rewrite.api.server.GameConfigs.WithRewardImage(path);
// Keep the conditional GET PhysicalFile used to give for free: the client sends
// If-None-Match on every launch and this file is ~300 KB, so a 304 is worth having.
string etag = "\"" + Convert.ToHexString(System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes(cfg))).ToLowerInvariant() + "\"";
Response.Headers["ETag"] = etag;
if (Request.Headers.TryGetValue("If-None-Match", out var inm) && inm.ToString() == etag)
return new StatusCodeResult(304);
return new ContentResult
{
Content = cfg,
ContentType = "application/json",
StatusCode = 200
};
}
// Voice-MODERATION config (the speech-moderation vendor creds), NOT a voice on/off switch.
// RecFlare's moderation worker returns exactly { AccountId: "", ApiKey: "" }: both blank,
// both present, both STRINGS. The client treats blank credentials as "voice moderation is
// off" and continues setting up voice - but a MISSING key or a null trips its parser and
// fails voice setup entirely, which is why the old { enabled:false, regions:[] } shape (no
// AccountId/ApiKey at all) killed voice: players could open the mic but nobody could hear
// them. AccountId is a string even though it reads as a number.
[HttpGet("/voice/config")]
public IActionResult VoiceConfig() => Ok(new
{
AccountId = "",
ApiKey = ""
});
// The caller's moderation restrictions, as a bare array. RecFlare always answers [] (an
// empty, unrestricted list) - and the client refills its restriction list from THIS: a
// chat/voice restriction is a record being present here (with an EndDate). This route was
// missing, so a 404 could leave the client on a stale or safe-default restricted state.
// Empty array = nothing restricted = voice and room chat allowed.
// svc 1 = Auth, so the client asks <auth base>/privileges/me/restrictions - the root-only
// spelling 404'd every launch (wire-shapes: privileges-me-restrictions).
[HttpGet("/Auth/privileges/me/restrictions")]
[HttpGet("/privileges/me/restrictions")]
public IActionResult MyRestrictions()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
return Ok(Array.Empty<object>());
}
// Azure Speech text-to-speech config — same story: we don't run TTS, return empty.
[HttpGet("/api/config/v1/azurespeech")]
public IActionResult AzureSpeechConfig() => Ok(new
{
enabled = false,
regions = Array.Empty<object>(),
voiceProfiles = Array.Empty<object>()
});
[HttpGet("/api/config/v1/amplitude")] // Get amplitude, analytics stuff - e12354
public ContentResult Amplitude()
{
return new ContentResult
{
Content = JsonConvert.SerializeObject(new Amplitude2023
{
AmplitudeKey = "cb2fb2ecb9953512c29af5bca58f2b4a",
UseRudderStack = true,
RudderStackKey = "23NiJHIgu3koaGNCZIiuYvIQNCu",
UseStatSig = ConfigCampus.StatsigEnabled,
StatSigKey = "client-SBZkOrjD3r1Cat3f3W8K6sBd11WKlXZXIlCWj6l4Aje",
StatSigEnvironment = 0
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/avatar/v1/defaultunlocked")]
public IActionResult DefaultUnlockeItems()
{
// EMPTY, deliberately - this is what the 2023 server returns and it is the only
// avatar endpoint the 2025 server answered differently.
//
// It used to serve the whole 3061-item avatar_items.json, from a "everyone gets
// everything for free" idea that the ownership model has since replaced. Two
// problems with that:
//
// - The client fetches this BEFORE it logs in (it sits between
// /api/versioncheck/islandedversions and /Auth/connect/token in the request
// log), so it was handing out the entire catalog unauthenticated.
// - The client then received the same 3061 items twice, once here and again
// from /api/avatar/v4/items, and registering an item from two sources is a
// good way to end up with entries it cannot file into a wardrobe category -
// the "None does not have an associated browsable avatar item filter"
// warning, which is exactly the symptom of items not appearing while
// remaining findable by search.
//
// What a player owns is answered by /api/avatar/v4/items. This endpoint means
// "unlocked for everyone regardless of account", and the answer is nothing.
return new ContentResult
{
Content = "[]",
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/customAvatarItems/v1/isCreationAllowedForAccount")]
public ContentResult IsCreationAllowedForAccount()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return new ContentResult
{
Content = JsonConvert.SerializeObject(new SuccessResponse
{
success = true,
value = null
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/objectives/v1/myprogress")]
public ContentResult MyObjectivesProgress()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var (objectives, groups) = PlayerDB.GetObjectivesProgress(account.Id);
return new ContentResult
{
Content = JsonConvert.SerializeObject(new MyObjectives
{
// The client owns the objective catalog; we only report per-(Group,Index)
// progress. Field names match the client's Objective / ObjectiveGroup DTOs.
Objectives = objectives.Select(o => (object)new
{
o.Group,
o.Index,
o.Progress,
o.VisualProgress,
o.IsCompleted,
o.HasClaimedReward
}).ToList(),
ObjectiveGroups = groups.Select(g => (object)new
{
g.Group,
g.IsCompleted,
ClearedAt = g.ClearedAt
}).ToList()
}),
ContentType = "application/json",
StatusCode = 200
};
}
// A player report, raised from the in-game report UI.
//
// THIS ROUTE DID NOT EXIST. Every report a player filed got a 404 and was thrown away,
// which is the whole of "reporting doesn't work" - the client shows its confirmation
// either way, so it looked like it had worked. Form-encoded, and the reporter is the
// BEARER TOKEN, never a body field (reference: POST /api/PlayerReporting/v3/create).
// Only PlayerIdReported is required; the client omits whatever it has no value for, so a
// report raised outside a room carries no RoomId. ReportCategory and RoomInstanceType are
// stored verbatim - neither enum is mapped here.
//
// Answers the real service's { success, error } envelope, error being an EMPTY STRING and
// not null, and the refusal uses the same shape so the client only parses one.
[HttpPost("/api/PlayerReporting/v3/create")]
[HttpPost("/api/PlayerReporting/v2/create")]
[HttpPost("/api/PlayerReporting/v1/create")]
public IActionResult CreatePlayerReport()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string F(string k) => Request.HasFormContentType ? (Request.Form[k].ToString() ?? "") : "";
ulong U(string k) => ulong.TryParse(F(k), out var v) ? v : 0;
int I(string k) => int.TryParse(F(k), out var v) ? v : 0;
double? D(string k) => double.TryParse(F(k), System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var v) ? v : (double?)null;
ulong reported = U("PlayerIdReported");
if (reported == 0)
return new ContentResult
{
Content = JsonConvert.SerializeObject(new { success = false, error = "PlayerIdReported is required" }),
ContentType = "application/json",
StatusCode = 400
};
// Reporting yourself is not a thing, and it is the obvious way to spam the queue.
if (reported == account.Id)
return new ContentResult
{
Content = JsonConvert.SerializeObject(new { success = true, error = "" }),
ContentType = "application/json",
StatusCode = 200
};
// 0 / -1 are the client's "no room" values - store null rather than a bogus id.
ulong roomId = U("RoomId");
try
{
Campus_live_rewrite.api.dbs.ReportsDB.Create(new Campus_live_rewrite.api.dbs.ReportsDB.Report
{
ReporterPlayerId = account.Id,
ReportedPlayerId = reported,
ReportCategory = I("ReportCategory"),
Details = string.IsNullOrWhiteSpace(F("Details")) ? null : F("Details"),
HeightReporter = D("HeightReporter"),
HeightReported = D("HeightReported"),
RoomId = roomId > 0 ? roomId : (ulong?)null,
RoomInstanceType = string.IsNullOrWhiteSpace(F("RoomInstanceType")) ? null : F("RoomInstanceType")
});
}
catch (Exception ex)
{
// A store failure must not tell the reporter their report bounced - log it and
// take the report, the same way the real service does.
Console.WriteLine($"[report] could not store {account.Id} -> {reported}: {ex.Message}");
}
return new ContentResult
{
Content = JsonConvert.SerializeObject(new { success = true, error = "" }),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPost("/api/PlayerReporting/v1/moderationBlockDetails")]
public IActionResult ModerationBlockDetails()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(PlayerDB.Get_moderation_block(account.Id));
}
// The client's own tamper/health reporting. We always answer "false" (not a
// violation) - but the client ALSO acts locally, so a report here is a record of
// something the client already did to itself, not a decision we get to make.
//
// Worth logging: "Dropping to dormroom due to error [385120]" is preceded, to the
// second, by a Hile of Type=Time with an empty message. That is the client's clock
// check firing - so the random dorm drop is a client-side anti-cheat trip, not a
// server error. Recording Type and Message here is what identifies the next one
// without having to correlate journald against Player.log by hand.
[HttpPost("/api/PlayerReporting/v1/hile")]
public IActionResult HileAPI()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// Read by hand: a Type value the HileTypes enum doesn't list failed model binding and
// the whole call was a 400 (210 of them today) - and a report that isn't recorded is
// the one you wanted to see. Unknown values are logged as their raw text.
string typeRaw = Request.HasFormContentType ? Request.Form["Type"].ToString() : Request.Query["Type"].ToString();
string? Message = Request.HasFormContentType ? Request.Form["Message"].ToString() : Request.Query["Message"].ToString();
string typeText = Enum.TryParse<Hile.HileTypes>(typeRaw, true, out var t) ? $"{t} ({(int)t})" : $"'{typeRaw}'";
Console.WriteLine($"[hile] {account.Id} reported {typeText}" +
$"{(string.IsNullOrWhiteSpace(Message) ? " - no message" : $": {Message}")}");
return Ok("false");
}
[HttpGet("/api/avatar/v2")]
public IActionResult myAvatarV2()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
ulong targetId = account.Id;
player_data player = PlayerDB.GetFullAccount(targetId);
if (player == null)
{
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 400
};
}
if (string.IsNullOrEmpty(player.player_Extra.avatar))
{
return Ok(new
{
LegacyData = new
{
SelectionsV1 = (string?)null,
SelectionsV2 = (string?)null,
FaceFeatures = (string?)null,
SkinColor = (string?)null,
HairColor = (string?)null
},
Selections = Array.Empty<object>(),
DataVersion = 9,
CustomizationSettings = (string?)null,
ThumbnailFileName = (string?)null,
Name = (string?)null,
Accessibility = 0,
Slot = 0
});
}
return new ContentResult()
{
Content = player.player_Extra.avatar,
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/avatar/v2/{accid?}")]
public ContentResult AvatarV2(ulong? accid = null)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
ulong targetId = accid ?? account.Id;
player_data player = PlayerDB.GetFullAccount(targetId);
if (player == null)
{
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 400
};
}
if (string.IsNullOrEmpty(player.player_Extra.avatar))
{
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
FaceFeatures = "",
HairColor = "",
OutfitSelections = "",
SkinColor = ""
}),
ContentType = "application/json",
StatusCode = 200
};
}
return new ContentResult()
{
Content = player.player_Extra.avatar,
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/avatar/v4/items")]
public async Task<IActionResult> GetAvatarItems()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string path = Path.Combine(
Environment.CurrentDirectory,
"Data",
"APIS",
"items",
"avatar_items.json"
);
if (!System.IO.File.Exists(path))
return Ok(ConfigCampus.Bracket);
// avatar_items.json is the CATALOG - every item that exists - not a statement
// that the caller owns them. Serving it wholesale is why every account owned
// all 3061 items. Developers keep that (the dev locker); everyone else gets
// only what they own, which starts at DefaultAvatarItems.json and grows with
// purchases. See PlayerDB.OwnedAvatarItemDescs.
//
// Per-player from here on, so it must not be cached by URL.
Response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate";
Response.Headers["Pragma"] = "no-cache";
bool isDev = PlayerDB.HasDevLocker(account.Id);
var owned = isDev ? null : PlayerDB.OwnedAvatarItemDescs(account.Id);
try
{
var catalog = JArray.Parse(await System.IO.File.ReadAllTextAsync(path));
// Optional: drop items this client build has no asset for. Off unless
// Data/APIS/items/ClientKnownGuids.json exists - see ItemGuids.
int before = catalog.Count;
var renderable = new JArray(catalog.Where(e =>
ItemGuids.ClientCanRender((string?)e["AvatarItemDesc"])));
if (renderable.Count != before)
Console.WriteLine($"[items] filtered {before - renderable.Count} item(s) the client has no asset for");
catalog = renderable;
// Serve the items RAW, exactly as avatar_items.json stores them (PascalCase, and
// whatever guid form the descriptor uses). Reshaping this - camelCase projection or
// short->full guid conversion - was tried and either did nothing or dropped items,
// so the raw record is what the client actually wants here.
// Hair dyes are not in avatar_items.json, so they have to be APPENDED rather than
// filtered in - see HairGrants.FreeHairDyeItems. Everyone owns them, so they go on
// both the dev locker and the per-player list, and the Modify Hair swatches stop
// showing a price because the client reads them as already owned.
var dyes = Campus_live_rewrite.api.server.HairGrants.FreeHairDyeItems();
if (isDev)
{
var devAll = new JArray(catalog);
foreach (var d in dyes) devAll.Add(d.DeepClone());
return new ContentResult
{
Content = devAll.ToString(Newtonsoft.Json.Formatting.None),
ContentType = "application/json",
StatusCode = 200
};
}
// Base items come free with the account. They are the structural UGC shirt
// bases - (UGCTee_Shirt), (UGCPulloverHoodie_Shirt) - that custom shirts are
// built on, so a player without them cannot wear ANY custom shirt. The 2023
// server grants these to non-devs for the same reason.
var mine = new JArray(catalog.Where(e =>
(bool?)e["IsBaseAvatarItem"] == true ||
owned.Contains(PlayerDB.NormalizeAvatarItemDesc((string?)e["AvatarItemDesc"]))));
int fromCatalog = mine.Count;
foreach (var d in dyes) mine.Add(d.DeepClone());
Console.WriteLine($"[items] {account.Id} owns {fromCatalog} of {catalog.Count} avatar items" +
(dyes.Count > 0 ? $" + {dyes.Count} hair dye colour(s)" : ""));
return new ContentResult
{
Content = mine.ToString(Newtonsoft.Json.Formatting.None),
ContentType = "application/json",
StatusCode = 200
};
}
catch (Exception ex)
{
// A broken catalog must not leave the player with no wardrobe at all.
Console.WriteLine($"[items] could not filter catalog for {account.Id}: {ex.Message}");
return new ContentResult
{
Content = await System.IO.File.ReadAllTextAsync(path),
ContentType = "application/json",
StatusCode = 200
};
}
}
[HttpGet("/api/checklist/v1/current")]
[HttpGet("/api/checklist/v2/current")] // v1 and v2 serve the same list (RecFlare)
public ContentResult CurrentChecklist()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return new ContentResult()
{
Content = "[]",
ContentType = "application/json",
StatusCode = 200
};
}
// ---- Small missing endpoints ported from RecFlare (were 404s the client treats as ----
// ---- failed loads). Shapes and defaults taken straight from the reference. ------------
// Mark a NUX checklist row done. Stubbed like RecFlare: nothing records the completion and
// nothing is granted (no reward ledger to make the 25-token grant once-only), but the
// balance-update envelope is well-formed so the flow completes instead of erroring.
[HttpPost("/api/checklist/v1/complete")]
[HttpPost("/api/checklist/v2/complete")]
public IActionResult CompleteChecklist()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
return Ok(new
{
BalanceUpdates = new[] { new { UpdateResponse = 303, Data = Array.Empty<object>() } }, // 303 = CHECKLIST_REWARD_CONTEXT
Balance = 0,
CurrencyType = (int)Campus_live_rewrite.api.server.Classes.CurrencyType.RecCenterTokens,
BalanceType = -2
});
}
// Report one objective's progress. Persists per-(Group,Index) progress; the first time an
// objective completes it grants a fixed XP reward exactly once (PlayerDB latches it), then
// hands back the parent group's state in the client's shape.
[HttpPost("/api/objectives/v1/updateobjective")]
public IActionResult UpdateObjective()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
long group = 0;
int index = 0;
double progress = 0, visualProgress = 0;
bool isCompleted = false;
try
{
using var reader = new StreamReader(HttpContext.Request.Body);
var body = reader.ReadToEnd();
if (!string.IsNullOrWhiteSpace(body))
{
var jo = Newtonsoft.Json.Linq.JObject.Parse(body);
long GetL(string k) => jo.TryGetValue(k, StringComparison.OrdinalIgnoreCase, out var v) && v.Type == Newtonsoft.Json.Linq.JTokenType.Integer ? v.Value<long>() : 0;
double GetD(string k) => jo.TryGetValue(k, StringComparison.OrdinalIgnoreCase, out var v) && (v.Type == Newtonsoft.Json.Linq.JTokenType.Integer || v.Type == Newtonsoft.Json.Linq.JTokenType.Float) ? v.Value<double>() : 0;
bool GetB(string k) => jo.TryGetValue(k, StringComparison.OrdinalIgnoreCase, out var v) && v.Type == Newtonsoft.Json.Linq.JTokenType.Boolean && v.Value<bool>();
group = GetL("Group");
index = (int)GetL("Index");
progress = GetD("Progress");
visualProgress = GetD("VisualProgress");
isCompleted = GetB("IsCompleted");
}
}
catch { }
bool justCompleted = PlayerDB.UpdateObjective(account.Id, group, index, progress, visualProgress, isCompleted, out var groupState);
if (justCompleted)
{
if (ConfigCampus.ObjectiveCompletionXp > 0) PlayerDB.AddXp(account.Id, ConfigCampus.ObjectiveCompletionXp);
Console.WriteLine($"[objectives] {account.Id} completed objective (group {group}, index {index}) -> +{ConfigCampus.ObjectiveCompletionXp} XP");
}
return Ok(new
{
group = groupState.Group,
isCompleted = groupState.IsCompleted,
clearedAt = groupState.ClearedAt ?? DateTime.UtcNow.ToString("o")
});
}
// Clear an objectives group - marks it completed and stamps the cleared time. Individual
// objectives are left intact (the client re-renders them; new challenges overwrite by key).
[HttpGet("/api/objectives/v1/cleargroup")]
[HttpPost("/api/objectives/v1/cleargroup")]
public ContentResult ClearObjectivesGroup()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return new ContentResult { Content = "[]", ContentType = "application/json", StatusCode = 200 };
long group = 0;
try
{
if (Request.Query.TryGetValue("group", out var qg) && long.TryParse(qg, out var gq)) group = gq;
else if (Request.Query.TryGetValue("Group", out var qG) && long.TryParse(qG, out var gG)) group = gG;
else
{
using var reader = new StreamReader(HttpContext.Request.Body);
var body = reader.ReadToEnd();
if (!string.IsNullOrWhiteSpace(body))
{
var jo = Newtonsoft.Json.Linq.JObject.Parse(body);
if (jo.TryGetValue("Group", StringComparison.OrdinalIgnoreCase, out var g) && g.Type == Newtonsoft.Json.Linq.JTokenType.Integer)
group = g.Value<long>();
}
}
}
catch { }
var g2 = PlayerDB.ClearObjectiveGroup(account.Id, group);
return new ContentResult
{
Content = JsonConvert.SerializeObject(new { group = g2.Group, isCompleted = g2.IsCompleted, clearedAt = g2.ClearedAt }),
ContentType = "application/json",
StatusCode = 200
};
}
// Minimum token price to publish a public custom avatar item (RecFlare: 100).
[HttpGet("/api/customAvatarItems/v1/minPriceForPublicItem")]
public IActionResult MinPriceForPublicItem() => Content("100", "application/json");
// "Hot" custom avatar items - none stored, so [].
[HttpGet("/api/customAvatarItems/v1/hot")]
public ContentResult HotCustomAvatarItems() => new ContentResult { Content = "[]", ContentType = "application/json", StatusCode = 200 };
// Legacy custom-item current saves - an empty map (nothing stores custom items).
[HttpGet("/api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems")]
public IActionResult LegacyCustomItemSaves() => Ok(new { customAvatarItemSavesByAvatarItemDesc = new { } });
// Store ad carousel - one placeholder banner (RecFlare's ad-carousel-items.json).
[HttpGet("/api/storefronts/v1/adcarouselitems")]
public IActionResult AdCarouselItems()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
return Ok(new[]
{
new { AdCarouselItemId = 1, Description = "No description.", ImageName = "AdCarouselItem.png", PurchasableItemIds = Array.Empty<int>(), Title = "Lorem ipsum" }
});
}
// Bulk UGC purchasables - none, so [].
[HttpGet("/api/ugcPurchasables/v1/items/bulk")]
public ContentResult UgcPurchasablesBulk() => new ContentResult { Content = "[]", ContentType = "application/json", StatusCode = 200 };
[HttpGet("/api/players/v2/progression/bulk")]
[HttpGet("/api/players/v1/progression/bulk")] // older clients call v1
[HttpGet("/api/v1/progression/bulk")] // and a legacy path
public ContentResult BulkPlayersProgression([FromQuery] List<ulong> id)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
if (id == null || id.Count == 0)
return new ContentResult()
{
Content = "[]",
ContentType = "application/json",
StatusCode = 200
};
var result = id.Select(Progress.GetPlayerLevel);
return new ContentResult()
{
Content = JsonConvert.SerializeObject(result),
ContentType = "application/json",
StatusCode = 200
};
}
// A single player's level + XP - what the profile panel reads when it opens one person
// (the People page uses the bulk form above). Only the bulk route existed, so this 404'd
// on every profile open, and the client left the level/progress area of the profile
// blank. Same { PlayerId, Level, XP } shape as one bulk entry. v1 is what the reference
// exposes; v2 is aliased for safety.
// The client polls this for the "XP earned today" display. It was 404 (matched the {id}
// route with id="xpEarnedToday", which can't bind to ulong), spamming the log. Literal
// segments win over the {id} parameter route, so this handles it. We don't track a per-day
// XP total, so report 0 earned today.
[HttpGet("/api/players/v1/progression/xpEarnedToday")]
[HttpGet("/api/players/v2/progression/xpEarnedToday")]
public ContentResult XpEarnedToday()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return new ContentResult { Content = "0", ContentType = "application/json", StatusCode = 200 };
}
[HttpGet("/api/players/v1/progression/{id}")]
[HttpGet("/api/players/v2/progression/{id}")]
public ContentResult SinglePlayerProgression(ulong id)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var prog = Progress.GetPlayerLevel(id);
// Mirror the reference's HubSendProgressionUpdate: pushing the caller's OWN progression
// back on this read keeps their crown + progress bar in sync with the server's stored
// level/XP without a relog. Only for their own (not when opening someone else's
// profile), so it doesn't spam a push per profile view.
if (id == account.Id)
Notifications.FireAndForget(
Notifications.SendToPlayer(id, JsonConvert.SerializeObject(
WebsocketEvents.createResponse_levelupdate(id, prog.Level, prog.XP))),
"progression self-sync");
return new ContentResult()
{
Content = JsonConvert.SerializeObject(prog),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/announcement/v1/get")]
public ContentResult GetAnnouncements()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return new ContentResult()
{
Content = "[]",
ContentType = "application/json",
StatusCode = 200
};
}
// Equipping a skin. The client sends the full entry for the prefab it just
// changed, e.g. [{"PrefabName":"[ShareCamera]","ModificationGuid":"48c97ace-...",
// "Favorited":true,...}]. This route did not exist, so the POST 404'd and the
// choice was lost at logout. Persist it and getUnlocked below replays it.
[HttpPost("/api/equipment/v1/update")]
public IActionResult UpdateEquipment([FromBody] List<EquipmentDB.EquippedItem>? items)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var loadout = EquipmentDB.Update(account.Id, items);
foreach (var i in items ?? new List<EquipmentDB.EquippedItem>())
Console.WriteLine($"[equipment] {account.Id} {(i.Favorited ? "EQUIPPED" : "cleared ")} " +
$"{i.PrefabName} -> {i.FriendlyName} ({i.ModificationGuid})");
return new ContentResult
{
Content = JsonConvert.SerializeObject(loadout),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/equipment/v2/getUnlocked")]
public IActionResult GetUnlockedEquipment()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string path = Path.Combine(
Environment.CurrentDirectory,
"Data",
"APIS",
"items",
"Equipments.json"
);
if (!System.IO.File.Exists(path))
return Ok(Array.Empty<object>());
// This response is now PER PLAYER, so it must never be cached.
//
// The no-saved-loadout branch used to answer with PhysicalFile, which stamps an
// ETag and Last-Modified from Equipments.json - a file that is identical for
// everyone. The URL carries no account id either, so after switching accounts
// the client could reuse another account's cached copy and show that account's
// equipped skins. Switching away and back was exactly the case that "took the
// skin off".
Response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate";
Response.Headers["Pragma"] = "no-cache";
var saved = EquipmentDB.Get(account.Id);
// Replay the player's own choices over THEIR catalog. Skins are no longer unlocked for
// everyone: the dev locker still gets every skin, everyone else gets what they own
// (PlayerDB.OwnedEquipmentSkins - filled by purchases and admin gifts) and otherwise
// the tools' default looks. The 187 skin rows in the Watch store are how they get more.
try
{
var catalog = JArray.Parse(System.IO.File.ReadAllText(path));
if (!ConfigCampus.SkinsForEveryone && !PlayerDB.HasDevLocker(account.Id))
{
var owned = PlayerDB.OwnedEquipmentSkins(account.Id);
int before = catalog.Count;
if (ConfigCampus.ListUnownedSkinsMasked)
{
// Every skin stays in the list so a box / a friend's tool can resolve it;
// the ones not owned are masked off every platform (PlatformMask 0).
int ownedCount = 0;
foreach (var e in catalog)
{
bool has = owned.Contains(PlayerDB.EquipmentSkinKey((string?)e["PrefabName"], (string?)e["ModificationGuid"]));
e["PlatformMask"] = has ? -1 : 0;
if (has) ownedCount++;
}
Console.WriteLine($"[skins] {account.Id} owns {ownedCount} of {before} equipment skin(s) (rest masked)");
}
else
{
catalog = new JArray(catalog.Where(e =>
owned.Contains(PlayerDB.EquipmentSkinKey((string?)e["PrefabName"], (string?)e["ModificationGuid"]))));
Console.WriteLine($"[skins] {account.Id} owns {catalog.Count} of {before} equipment skin(s)");
}
}
if (saved.Count == 0)
return new ContentResult
{
Content = catalog.ToString(Newtonsoft.Json.Formatting.None),
ContentType = "application/json",
StatusCode = 200
};
foreach (var item in saved)
{
var match = catalog.FirstOrDefault(e =>
string.Equals((string?)e["PrefabName"], item.PrefabName, StringComparison.OrdinalIgnoreCase) &&
string.Equals((string?)e["ModificationGuid"], item.ModificationGuid, StringComparison.OrdinalIgnoreCase));
if (match == null)
{
// Equipped something the catalog does not list - carry it anyway
// rather than silently dropping the player's choice.
catalog.Add(JObject.FromObject(item));
continue;
}
match["Favorited"] = item.Favorited;
if (!string.IsNullOrEmpty(item.ThumbnailImageName))
match["ThumbnailImageName"] = item.ThumbnailImageName;
}
return new ContentResult
{
Content = catalog.ToString(Newtonsoft.Json.Formatting.None),
ContentType = "application/json",
StatusCode = 200
};
}
catch (Exception ex)
{
// A bad catalog must not cost the player their equipment list.
Console.WriteLine($"[equipment] could not merge saved loadout for {account.Id}: {ex.Message}");
return new ContentResult
{
Content = System.IO.File.ReadAllText(path),
ContentType = "application/json",
StatusCode = 200
};
}
}
[HttpGet("/api/consumables/v2/getUnlocked")]
public IActionResult GetUnlockedConsumables()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string path = Path.Combine(
Environment.CurrentDirectory,
"Data",
"APIS",
"items",
"Consumables.json"
);
if (!System.IO.File.Exists(path))
return Ok(Array.Empty<object>());
Response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate";
Response.Headers["Pragma"] = "no-cache";
// Consumables.json is the CATALOG (540 rows, infinite counts). Everyone used to get
// all of it. Now: the dev locker keeps the whole file; everyone else sees only what
// they own, with their real counts (see PlayerDB.OwnedConsumables / gift boxes).
if (ConfigCampus.ConsumablesForEveryone || PlayerDB.HasDevLocker(account.Id))
return PhysicalFile(path, "application/json");
try
{
var catalog = JArray.Parse(System.IO.File.ReadAllText(path));
var owned = PlayerDB.OwnedConsumables(account.Id);
var mine = new JArray();
foreach (var row in catalog.OfType<JObject>())
{
string key = PlayerDB.NormalizeConsumableDesc((string?)row["ConsumableItemDesc"]);
if (key.Length == 0 || !owned.TryGetValue(key, out var count) || count <= 0) continue;
var copy = (JObject)row.DeepClone();
copy["Count"] = count;
copy["InitialCount"] = count;
mine.Add(copy);
}
Console.WriteLine($"[consumables] {account.Id} owns {mine.Count} of {catalog.Count} consumable(s)");
return new ContentResult { Content = mine.ToString(Newtonsoft.Json.Formatting.None), ContentType = "application/json", StatusCode = 200 };
}
catch (Exception ex)
{
Console.WriteLine($"[consumables] could not filter for {account.Id}: {ex.Message}");
return PhysicalFile(path, "application/json");
}
}
[HttpGet("/api/playerReputation/v2/bulk")]
[HttpGet("/api/playerReputation/v1/bulk")] // older clients call v1 (same query shape)
public ContentResult BulkPlayersReputation([FromQuery] List<ulong> id)
{
if (id == null || id.Count == 0)
return new ContentResult()
{
Content = "[]",
ContentType = "application/json",
StatusCode = 200
};
var reps = new List<Rep>();
// One entry per REQUESTED id, in order - even for a player with no account row.
//
// This used to `continue` past a missing player, so the reputation list came back
// SHORTER than the id list the client sent. The People page (Recent / Here / a
// profile) lines reputation up against the parallel account and progression lists
// by position; a short list slides every entry after the gap onto the wrong player,
// which reads as profiles that half-load or show someone else's cheers. Presence
// and progression already return one-per-id; reputation now matches, handing a
// default rep for an id with no account instead of dropping it.
foreach (var playerId in id)
{
var acc = PlayerDB.GetFullAccount(playerId);
var rep = acc?.player?.PlayerReputation ?? new mPlayerReputation();
reps.Add(new Rep
{
AccountId = playerId,
IsCheerful = true, // reference ALWAYS serves true; a stored/default false reads to the client as "not in good standing" and restricts club/room creation ("too many moderation incidents")
Noteriety = 0,
SelectedCheer = rep.SelectedCheer,
CheerCredit = rep.CheerCredit,
CheerGeneral = rep.CheerGeneral,
CheerHelpful = rep.CheerHelpful,
CheerCreative = rep.CheerCreative,
CheerGreatHost = rep.CheerGreatHost,
CheerSportsman = rep.CheerSportsman,
SubscriberCount = acc?.player_Extra?.sub_count ?? 0,
SubscribedCount = acc?.player_Extra?.SubbedTo?.Count ?? 0,
});
}
return new ContentResult()
{
Content = JsonConvert.SerializeObject(reps),
ContentType = "application/json",
StatusCode = 200
};
}
// A single player's reputation (the cheer counters shown on their profile). Only the bulk
// route existed, so this 404'd when a profile opened one person, leaving the cheers area
// blank. Same per-player shape as one bulk entry; a player with no account reads back as a
// default (all-zero) rep rather than 404ing. v1 is the reference route; v2 aliased.
[HttpGet("/api/playerReputation/v1/{id}")]
[HttpGet("/api/playerReputation/v2/{id}")]
public ContentResult SinglePlayerReputation(ulong id)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var acc = PlayerDB.GetFullAccount(id);
var rep = acc?.player?.PlayerReputation ?? new mPlayerReputation();
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new Rep
{
AccountId = id,
IsCheerful = true, // reference ALWAYS serves true; a stored/default false reads to the client as "not in good standing" and restricts club/room creation ("too many moderation incidents")
Noteriety = 0,
SelectedCheer = rep.SelectedCheer,
CheerCredit = rep.CheerCredit,
CheerGeneral = rep.CheerGeneral,
CheerHelpful = rep.CheerHelpful,
CheerCreative = rep.CheerCreative,
CheerGreatHost = rep.CheerGreatHost,
CheerSportsman = rep.CheerSportsman,
SubscriberCount = acc?.player_Extra?.sub_count ?? 0,
SubscribedCount = acc?.player_Extra?.SubbedTo?.Count ?? 0,
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/avatar/v1/defaultbaseavataritems")]
public ContentResult DefaultBaseAvatarItems()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// Keep this narrow. `defaultbaseavataritems` = the BASE body templates (mannequin
// forms the client uses when building an avatar), NOT the full inventory. Returning
// the whole avatar_items.json here would tell the client every hat/gun/shirt is a
// base body template and break the customization pipeline. ConfigCampus ships the
// two correct UGC base templates (UGCTee_Shirt + UGCPulloverHoodie_Shirt).
return new ContentResult()
{
Content = ConfigCampus.DefaultBaseAvatarItems,
ContentType = "application/json",
StatusCode = 200
};
}
// The client POSTs this with a JSON body {AvatarItemDescriptions:[...]} (wire-shapes:
// avatar-lockeditems-bulk); GET-only answered 405 and the wardrobe never learned which
// items are locked. /lockeditems (no /bulk) is the same answer for the whole account.
[HttpGet("/api/avatar/v1/lockeditems")]
[HttpPost("/api/avatar/v1/lockeditems")]
public ContentResult GetLockedItems()
{
return new ContentResult { Content = "[]", ContentType = "application/json", StatusCode = 200 };
}
[HttpGet("/api/avatar/v1/lockeditems/bulk")]
[HttpPost("/api/avatar/v1/lockeditems/bulk")]
public ContentResult GetLockedItemsBulk()
{
// Nothing is locked - everyone has every avatar item and equipment skin. Returning
// an empty BARE ARRAY here means the client's wardrobe UI never draws a padlock overlay
// and every item is freely equippable. (The request body is deliberately not read.)
return new ContentResult
{
Content = "[]",
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/config/v1/backtrace")]
public ContentResult Backtrace()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
ReportBudget = 0,
FilterType = 0,
SampleRate = 0.025,
LogLineCount = 50,
CaptureNativeCrashes = 1,
ANRThresholdMs = 0,
MessageCount = 1000,
MessageRegex = "^Cannot set the parent of the GameObject .* while its new parent|^\\>\\x2010x\\:\\x20|'LabelTheme' contains missing PaletteTheme reference on",
VersionRegex = ".*"
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/config/v2")]
public ContentResult ConfigV2()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// The client renders its XP bar against this table, so it MUST be the same curve the
// server levels players on (Progress.LEVEL_REQUIRED_XP) and it must cover every level a
// player can reach. The old table was a made-up "RequiredXp = level + 1" that only went
// to level 20 - so a player above 20 (e.g. Level 50) had no curve entry, the client
// couldn't reconcile their Level with their XP, and the anti-cheat check dropped them
// to their dorm. Now it sends the real curve, all the way to MAX_LEVEL.
var levels = new List<LevelProgressionMap>();
for (int i = 0; i <= Progress.MAX_LEVEL; i++)
{
levels.Add(new LevelProgressionMap
{
Level = i,
RequiredXp = Progress.LEVEL_REQUIRED_XP[i],
// The Progress tab draws each level's reward via Lifecycle.PSF.Progression.RewardMap
// (GiftDropId -> image). Every level pays tokens here, so each gets a synthetic
// id that the map points at the token pile (see Gameconfigs.json).
GiftDropId = i == 0 ? (int?)null : Progress.LEVEL_REWARD_GIFTDROP_BASE + i
});
}
var dailySet = new List<DailyObjective>
{
new DailyObjective { Type = 20, Score = 1, Xp = 200 },
new DailyObjective { Type = 21, Score = 1, Xp = 200 },
new DailyObjective { Type = 22, Score = 1, Xp = 200 }
};
var dailyObjectives = new List<List<DailyObjective>>();
for (int i = 0; i < 7; i++)
dailyObjectives.Add(new List<DailyObjective>(dailySet));
string url = $"{Request.Scheme}://{Request.Host}";
var config = new Config
{
MessageOfTheDay = "Welcome to Campus live!",
CdnBaseUri = $"{url}",
ShareBaseUrl = $"{url}/share/{0}",
LevelProgressionMaps = levels,
MatchmakingParams = new MatchmakingParams
{
PreferFullRoomsFrequency = 1.0,
PreferEmptyRoomsFrequency = 0.0
},
DailyObjectives = dailyObjectives,
ServerMaintainence = new ServerMaintenance
{
StartsInMinutes = 0
},
AutoMicMutingConfig = new AutoMicMutingConfig
{
MicSpamVolumeThreshold = 10.0,
MicVolumeSampleInterval = 1.0,
MicVolumeSampleRollingWindowLength = 15.0,
MicSpamSamplePercentageForWarning = 100.0,
MicSpamSamplePercentageForWarningToEnd = 0.0,
MicSpamSamplePercentageForForceMute = 500.0,
MicSpamSamplePercentageForForceMuteToEnd = 0.0,
MicSpamWarningStateVolumeMultiplier = 10.0
},
StorefrontConfig = new StorefrontConfig
{
MinPlayerLevelForGifting = 0
},
RoomKeyConfig = new RoomKeyConfig
{
MaxKeysPerRoom = 100
},
RoomCurrencyConfig = new RoomCurrencyConfig
{
AwardCurrencyCooldownSeconds = 10.0
},
ConfigTable = new List<ConfigTableEntry>
{
new ConfigTableEntry { Key = "Gift.DropChance", Value = "1" },
new ConfigTableEntry { Key = "Gift.XP", Value = "0.5" }
},
PhotonConfig = new PhotonConfig
{
CloudRegion = "us",
CrcCheckEnabled = false,
EnableServerTracingAfterDisconnect = false
}
};
return new ContentResult
{
Content = JsonConvert.SerializeObject(config, Formatting.None),
ContentType = "application/json",
StatusCode = 200
};
}
// Gift drop stores. Each store is a captured rec.net response saved under
// Data/APIS/GiftDropStores/{StorefrontType}.json:
//
// 1.json Laser Tag 40 items pays in LaserTagTickets (CurrencyType 1)
// 3.json Watch 968 items pays in RecCenterTokens (CurrencyType 2)
// 100.json Isle 22 items pays in LostSkullsGold (CurrencyType 100)
// 101.json Crescendo 15 items pays in DraculaSilver (CurrencyType 101)
// 102.json Crimson 24 items pays in RecCenterTokens (CurrencyType 2)
//
// The {StoreId} in the URL IS the StorefrontType (see Classes/StorefrontType.cs).
// Drop another captured store into that folder named after its type and it is served
// with no code change.
[HttpGet("/api/storefronts/v3/giftdropstore/{StoreId}")]
public IActionResult GetGiftdropStore(int StoreId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string dir = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "GiftDropStores");
string specific = Path.Combine(dir, $"{StoreId}.json");
string name = System.Enum.IsDefined(typeof(Campus_live_rewrite.api.server.Classes.StorefrontType), StoreId)
? ((Campus_live_rewrite.api.server.Classes.StorefrontType)StoreId).ToString()
: $"Unknown({StoreId})";
// The Watch store is GENERATED for this client (reference sf3-2025.json): the captured
// rows with no avatar item behind them, plus one row per sellable catalog item under a
// catalog id from 10000 up. See StoreGenerator for why the captured file on its own
// showed the wrong item on click and sold a third of the catalog.
// Wishlist (1300) is per player: the rows of the items they hearted, looked up in the
// Watch store first (ids there are what the store page hands out), then the other
// catalogs. Never a file.
if (StoreId == (int)Campus_live_rewrite.api.server.Classes.StorefrontType.Wishlist)
{
ulong who = account.Id;
foreach (var k in new[] { "accountId", "playerId", "id" })
if (ulong.TryParse(Request.Query[k], out var q) && q > 0) { who = q; break; }
var wanted = WishlistDB.For(who).Select(r => r.PurchasableItemId).Distinct().ToList();
var rows = new JArray();
var found = new HashSet<int>();
var watch = Campus_live_rewrite.api.server.StoreGenerator.MergedWatchStore()["StoreItems"] as JArray ?? new JArray();
foreach (var it in watch)
{
int pid = (int?)it["PurchasableItemId"] ?? 0;
if (wanted.Contains(pid) && found.Add(pid)) rows.Add(it.DeepClone());
}
foreach (int pid in wanted.Where(p => !found.Contains(p)))
{
var info = Campus_live_rewrite.api.server.StoreCatalog.FindStoreItem(pid);
if (info == null || info.StorefrontType < 0) continue;
try
{
var file = Campus_live_rewrite.api.server.StoreCatalog.StoreFileForId(info.StorefrontType);
if (file.Length == 0) continue;
var arr = JObject.Parse(System.IO.File.ReadAllText(file))["StoreItems"] as JArray;
var row = arr?.FirstOrDefault(x => ((int?)x["PurchasableItemId"] ?? 0) == pid);
if (row != null && found.Add(pid)) { var c = (JObject)row.DeepClone(); c["CanBeGifted"] = true; rows.Add(c); }
}
catch { }
}
Console.WriteLine($"[gift] giftdropstore/1300 (Wishlist) for {who} -> {rows.Count}/{wanted.Count} item(s)");
return new ContentResult
{
Content = new JObject
{
["NewUntil"] = null,
["NextUpdate"] = "2222-02-22T22:22:00Z",
["StoreItems"] = rows,
["StorefrontName"] = "Wishlist",
["StorefrontType"] = StoreId,
["SubscriberDiscountPercent"] = 10
}.ToString(Newtonsoft.Json.Formatting.None),
ContentType = "application/json",
StatusCode = 200
};
}
if (StoreId == Campus_live_rewrite.api.server.StoreGenerator.WatchStorefront)
{
Console.WriteLine($"[gift] giftdropstore/{StoreId} ({name}) -> generated Watch store");
return new ContentResult
{
Content = Campus_live_rewrite.api.server.StoreGenerator.MergedWatchStoreJson(),
ContentType = "application/json",
StatusCode = 200
};
}
if (System.IO.File.Exists(specific))
{
Console.WriteLine($"[gift] giftdropstore/{StoreId} ({name}) -> {StoreId}.json");
// The Modify Hair swatches take their price badge from this response, so the dyes
// have to be zeroed on the way out as well as at the till (StoreCatalog). Cached
// per file+mtime - 3.json is 1.5 MB and re-parsing it per request would be silly.
string? baseJson = null;
if (ConfigCampus.FreeHairDyes || ConfigCampus.HiddenItemsStorefronts)
baseJson = HairGrants.StoreJsonWithFreeDyes(specific);
// Every row giftable (CanBeGifted was never in the captures).
string? giftable = Campus_live_rewrite.api.server.StoreCatalog.StoreJsonGiftable(specific, baseJson);
if (giftable != null)
return new ContentResult { Content = giftable, ContentType = "application/json", StatusCode = 200 };
if (baseJson != null)
return new ContentResult { Content = baseJson, ContentType = "application/json", StatusCode = 200 };
return PhysicalFile(specific, "application/json");
}
// A storefront with no catalog serves an EMPTY store - never someone else's stock.
//
// The 2023 server learned this the hard way: it used to fall back to the Watch
// catalog for any unknown id, so Wishlist, Player_Profile, Room_Save, RoomKeys and
// an unstocked Rec Center each returned all 968 Watch items as if they were their
// own. Only Watch (3) may serve the Watch catalog, and it has its own file anyway.
Console.WriteLine($"[gift] giftdropstore/{StoreId} ({name}) -> empty (no {StoreId}.json)");
return Ok(new
{
NewUntil = (string?)null,
NextUpdate = "2222-02-22T22:22:00Z",
StoreItems = Array.Empty<object>(),
StorefrontName = (string?)null,
StorefrontType = StoreId,
SubscriberDiscountPercent = 10
});
}
// Buy (or gift) a store item. Ported from the 2023 Campus server
// (D:\Puck\Campus-Server, StoreController.BuyItem), which had this working.
//
// Real spend + delivery: parses the client's body
// { PurchasableItemId, CurrencyType, StorefrontType, RequestedPrice,
// Gift: { ToPlayerId, Message, Anonymous, GiftContext } }
// charges the buyer, and delivers to the gift target if present, else the buyer.
//
// ONE DELIBERATE DIFFERENCE from 2023: no AlreadyOwned check. The 2023 server
// tracked per-player ownership and refused to sell something you already had.
// This server hands every player the entire 3061-item avatar catalog up front
// (see /api/avatar/v1/defaultunlocked), so that check would reject EVERY purchase.
// What a purchase buys here is the spend and the gift-box reveal, not exclusivity.
[HttpPost("/api/storefronts/v2/buyItem")]
public async Task<IActionResult> BuyItem()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string body = "";
try
{
using var reader = new StreamReader(HttpContext.Request.Body);
body = await reader.ReadToEndAsync();
}
catch { }
int purchasableItemId = 0;
int currencyType = (int)Campus_live_rewrite.api.server.Classes.CurrencyType.RecCenterTokens;
int giftContext = 0, storefrontType = -1, requestedPrice = -1;
ulong toPlayerId = 0;
string? giftMessage = null;
bool giftAnonymous = false;
try
{
using var doc = System.Text.Json.JsonDocument.Parse(string.IsNullOrWhiteSpace(body) ? "{}" : body);
var root = doc.RootElement;
int N(System.Text.Json.JsonElement e, string k, int fallback)
=> e.TryGetProperty(k, out var v) && v.ValueKind == System.Text.Json.JsonValueKind.Number ? v.GetInt32() : fallback;
purchasableItemId = N(root, "PurchasableItemId", 0);
currencyType = N(root, "CurrencyType", currencyType);
storefrontType = N(root, "StorefrontType", -1);
// What the client thinks it is paying (the RR+ price when subscribed).
// Logged as a cross-check only - the real price always comes from the store file.
requestedPrice = N(root, "RequestedPrice", -1);
if (root.TryGetProperty("Gift", out var g) && g.ValueKind == System.Text.Json.JsonValueKind.Object)
{
if (g.TryGetProperty("ToPlayerId", out var tp) && tp.ValueKind == System.Text.Json.JsonValueKind.Number)
toPlayerId = (ulong)Math.Max(0, tp.GetInt64());
if (g.TryGetProperty("Message", out var m) && m.ValueKind == System.Text.Json.JsonValueKind.String)
giftMessage = m.GetString();
if (g.TryGetProperty("Anonymous", out var an)
&& (an.ValueKind == System.Text.Json.JsonValueKind.True || an.ValueKind == System.Text.Json.JsonValueKind.False))
giftAnonymous = an.GetBoolean();
giftContext = N(g, "GiftContext", 0);
}
}
catch { }
var item = Campus_live_rewrite.api.server.StoreCatalog.FindStoreItem(purchasableItemId, storefrontType);
// The price is decided HERE, from the store file. RequestedPrice is the client
// telling us what it expects to pay; trusting it would let anything set its own price.
int cost = item?.Cost ?? 0;
bool subDiscount = false;
if (item != null)
{
int sub = Campus_live_rewrite.api.server.StoreCatalog.SubscriberPriceFor(item);
if (sub < cost) { cost = sub; subDiscount = true; }
}
string storeName = storefrontType >= 0
? Campus_live_rewrite.api.server.StoreCatalog.StorefrontName(storefrontType)
: "store not specified";
Console.WriteLine($"[buy] item {purchasableItemId} from {storeName} -> " +
(item == null
? "NOT FOUND in any catalog"
: $"'{item.Name}' ({item.StoreFile}, {cost} of currency {item.Currency}" +
(subDiscount ? $"; RR+ price, normally {item.Cost}" : "") + ")"));
// If these drift apart the store UI is showing players the wrong price.
if (item != null && requestedPrice > 0 && requestedPrice != cost)
Console.WriteLine($"[buy] note: client expected to pay {requestedPrice}, we charge {cost}");
int currency = item?.Currency ?? currencyType;
ulong recipient = toPlayerId > 0 ? toPlayerId : account.Id;
long balance = PlayerDB.GetBalance(account.Id, currency);
if (item == null)
return Ok(new { Result = 4, Success = false, Error = "ItemNotFound", CurrencyType = currency, Balance = balance });
// Gifting something the recipient already owns is refused (Result 3 = AlreadyOwned).
if (recipient != account.Id && string.IsNullOrEmpty(item.ConsumableItemDesc) && RecipientAlreadyHas(recipient, item))
{
Console.WriteLine($"[buy] {account.Id} -> gift {purchasableItemId} to {recipient} refused: already owned");
return Ok(new { Result = 3, Success = false, Error = "AlreadyOwned", CurrencyType = currency, Balance = balance });
}
// The item's currency is the store's, not the buyer's: a Laser Tag drop is priced in
// Laser Tag Tickets and cannot be bought with tokens, whatever CurrencyType the
// request says. The charge already reads item.Currency; this refuses the mismatch
// outright instead of silently charging a currency the client did not offer.
if (currencyType != 0 && currencyType != currency)
{
Console.WriteLine($"[buy] @{account.Username} offered currency {currencyType} for '{item.Name}', which is sold in {currency} - refused");
return Ok(new { Result = 5, Success = false, Error = "WrongCurrency", CurrencyType = currency, Balance = balance });
}
// A gift to another player arrives in the player-gift box. The client sends the
// context it wants in the Gift block; when it sends none (0) the box came up in the
// plain default look rather than the wrapped gift. Logged so the value the client
// actually sends is on record.
if (toPlayerId > 0 && toPlayerId != account.Id)
{
// Only token-priced items can be gifted - never a room-currency / ticket item.
if (currency != (int)Campus_live_rewrite.api.server.Classes.CurrencyType.RecCenterTokens)
return Ok(new { Result = 4, Success = false, Error = "Only items sold for tokens can be gifted.", CurrencyType = currency, Balance = balance });
// Gifting is a Level 15+ thing (ConfigCampus.GiftMinLevel), like live Rec Room.
int lvl = Campus_live_rewrite.api.server.Classes.Progress.GetPlayerLevel(account.Id)?.Level ?? 1;
if (lvl < ConfigCampus.GiftMinLevel && !PlayerDB.CheckDevFlag(account.Id))
{
Console.WriteLine($"[buy] @{account.Username} (level {lvl}) tried to gift '{item.Name}' to {toPlayerId} - gifting needs level {ConfigCampus.GiftMinLevel}");
return Ok(new { Result = 4, Success = false, Error = $"You need to be Level {ConfigCampus.GiftMinLevel} to send gifts.", CurrencyType = currency, Balance = balance });
}
if (PlayerDB.GetAccountById(toPlayerId) == null)
return Ok(new { Result = 4, Success = false, Error = "That player does not exist.", CurrencyType = currency, Balance = balance });
Console.WriteLine($"[buy] gift to {toPlayerId}: client GiftContext={giftContext}" + (giftContext == 0 ? $" -> using {ConfigCampus.PlayerGiftContext}" : ""));
if (giftContext == 0) giftContext = ConfigCampus.PlayerGiftContext;
}
if (cost > 0 && balance < cost)
return Ok(new { Result = 4, Success = false, Error = "InsufficientFunds", CurrencyType = currency, Balance = balance });
if (cost > 0)
PlayerDB.AddBalance(account.Id, currency, -cost);
bool isGiftToOther = toPlayerId > 0 && toPlayerId != account.Id;
int giftId = Campus_live_rewrite.api.server.StoreCatalog.NextPurchaseGiftId();
ulong fromPlayerIdResponse = 0;
// The store's descriptor is handed to the client EXACTLY as written, trailing
// commas and all - see PendingGiftBox.AvatarItemDesc for why stripping breaks it.
string rawDesc = item.Reward ?? "";
var box = new PendingGiftBox
{
NumericId = giftId,
GiftContext = giftContext,
Message = isGiftToOther ? (giftMessage ?? "") : "A Gift for you <3",
FromAccountId = giftAnonymous ? 0 : account.Id,
Anonymous = giftAnonymous,
AvatarItemDesc = rawDesc,
ConsumableItemDesc = item.ConsumableItemDesc,
AvatarItemType = item.AvatarItemType,
GiftDropId = item.GiftDropId,
FriendlyName = item.Name,
Rarity = item.Rarity,
EquipmentPrefabName = item.EquipmentPrefabName,
EquipmentModificationGuid = item.EquipmentModificationGuid,
CustomAvatarItemId = string.IsNullOrWhiteSpace(item.CustomAvatarItemId) ? null : item.CustomAvatarItemId,
ThumbnailImageName = string.IsNullOrWhiteSpace(item.ThumbnailImageName) ? null : item.ThumbnailImageName
};
if (isGiftToOther)
{
fromPlayerIdResponse = giftAnonymous ? 0 : account.Id;
PlayerDB.AddPendingGiftBoxFull(recipient, box);
// The box drops in front of the recipient now (GiftPackageReceivedImmediate).
Campus_live_rewrite.api.server.GiftBoxes.Push(recipient, box, Campus_live_rewrite.api.server.GiftBoxes.FrameReceivedImmediate, "store gift");
Notifications.FireAndForget(
Notifications.RefreshAccount(recipient), "gift refresh");
}
else
{
// Buying for yourself. The item is already unlocked for everyone here, so
// there is nothing to grant - the gift payload below is what makes the box
// reveal in the purchase UI. Still recorded so the box survives a relog.
PlayerDB.AddPendingGiftBoxFull(account.Id, box);
}
long newBalance = PlayerDB.GetBalance(account.Id, currency);
Console.WriteLine($"[buy] @{account.Username} item {purchasableItemId} ({item.Name}) cost {cost} currency {currency} -> " +
$"{(isGiftToOther ? $"gift box {giftId} for {recipient}" : "self")}; bal {balance}->{newBalance}");
// The purchase response the client waits on: the reward rides in
// BalanceUpdates[].Data[] (the gift-drop payload the client reveals + adds),
// plus the updated Balance so the token counter settles and the spinner finishes.
var giftData = new List<object>
{
new
{
Id = giftId,
// The store's GiftDropId - the box needs it to resolve what to display,
// otherwise it falls back to the "Update your game" placeholder.
GiftDropId = item.GiftDropId,
FromPlayerId = fromPlayerIdResponse,
ConsumableItemDesc = item.ConsumableItemDesc,
// Null unless the box holds an avatar item - a skin with "" / 0 here opens
// as an avatar item with nothing to load (StoreCatalog.AvatarTypeFor).
AvatarItemDesc = Campus_live_rewrite.api.server.StoreCatalog.AvatarDescFor(item),
AvatarItemType = Campus_live_rewrite.api.server.StoreCatalog.AvatarTypeFor(item),
CustomAvatarItemId = string.IsNullOrWhiteSpace(item.CustomAvatarItemId) ? null : item.CustomAvatarItemId,
CurrencyType = item.GdCurrencyType,
Currency = item.GdCurrency,
Xp = 0,
Level = 0,
PackageType = 0,
Message = isGiftToOther ? (giftMessage ?? "") : "A Gift for you <3",
EquipmentPrefabName = item.EquipmentPrefabName,
EquipmentModificationGuid = item.EquipmentModificationGuid,
GiftContext = giftContext,
GiftRarity = item.Rarity,
Platform = -1,
PlatformsToSpawnOn = -1,
// -2 = the account-wide bucket (reference toBalanceUpdateData). Was null.
BalanceType = Campus_live_rewrite.api.server.Classes.GameCurrency.AllPlatformsBucket
}
};
return Ok(new
{
Result = 0,
Success = true,
Error = (string?)null,
CurrencyType = currency,
Balance = newBalance,
BalanceUpdates = new List<object>
{
new
{
UpdateResponse = 0,
CurrencyType = currency,
Balance = newBalance,
Data = giftData
}
}
});
}
// Buy a bag of storefront items at once - the shopping bag. Ported from RecFlare's
// econ.app.ts (POST /api/items/bulkpurchase). The client posts every line (an item id,
// DuplicateItemCount copies, the price it rendered, an optional Gift), plus the one
// StorefrontType and one CurrencyType they all share.
//
// The response is NOT buyItem's. It is { Success, Error, error_id, Value } - error_id
// lowercase because the client renames that one member - wrapping:
// Value = { Balance = RESULTING total, CurrencyType, Platform = -2 (the account-wide
// balance bucket), BalanceUpdates = one entry per REQUESTED line in order }
// Each entry: { UpdateResponse, Data = { GiftPackage, PurchasableItemId, CustomAvatarItem } }.
//
// UpdateResponse codes: 0 OK, 2 NotEnoughCredit, 4 NoItemAvailable, 5 CouponNotApplicable,
// 7 RequestedAmountNotAllowed. (6 RequestedPriceDoesNotMatch is deliberately NOT used -
// buyItem here charges the catalog price and never rejects on RequestedPrice, and one
// purchase path refusing what the other allows would be worse than either. We charge
// the same min(cost, RR+ price) buyItem does and log a mismatch.)
//
// Whole-bag rules: one catalog resolve, one atomic debit for the total, lines taken in
// request order while the balance covers them. Without AllowPartialSuccess, one
// unaffordable or unbuyable line refuses the whole bag and nothing is charged. Nothing
// sold at all is a refusal ({ Success=false, Value=null }), not an empty success. 400
// is reserved for a request that can't be evaluated (no lines, non-spendable currency,
// over the 200-copy cap). Count above 1 is only for consumables (they stack); anything
// owned once is refused with RequestedAmountNotAllowed rather than charged twice.
[HttpPost("/api/items/bulkpurchase")]
public async Task<IActionResult> BulkPurchase()
{
const int OK = 0, NotEnoughCredit = 2, NoItemAvailable = 4, CouponNotApplicable = 5, RequestedAmountNotAllowed = 7;
const int BulkCap = 200;
const int AllPlatformsBucket = Campus_live_rewrite.api.server.Classes.GameCurrency.AllPlatformsBucket;
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
string body = "";
try { using var reader = new StreamReader(HttpContext.Request.Body); body = await reader.ReadToEndAsync(); }
catch { }
IActionResult Refuse(string msg, int status = 200) => new ContentResult
{
Content = JsonConvert.SerializeObject(new { Success = false, Error = msg, error_id = (string?)null, Value = (object?)null }),
ContentType = "application/json",
StatusCode = status
};
System.Text.Json.JsonElement root;
try
{
using var doc = System.Text.Json.JsonDocument.Parse(string.IsNullOrWhiteSpace(body) ? "{}" : body);
root = doc.RootElement.Clone();
}
catch { return Refuse("Malformed request body", 400); }
int RootInt(string k, int fb) => root.ValueKind == System.Text.Json.JsonValueKind.Object &&
root.TryGetProperty(k, out var v) && v.ValueKind == System.Text.Json.JsonValueKind.Number ? v.GetInt32() : fb;
bool RootBool(string k) => root.ValueKind == System.Text.Json.JsonValueKind.Object &&
root.TryGetProperty(k, out var v) && v.ValueKind == System.Text.Json.JsonValueKind.True;
if (!root.TryGetProperty("PurchaseItemRequests", out var linesEl) || linesEl.ValueKind != System.Text.Json.JsonValueKind.Array || linesEl.GetArrayLength() == 0)
return Refuse("PurchaseItemRequests must be a non-empty array", 400);
int storefrontType = RootInt("StorefrontType", -1);
int currencyType = RootInt("CurrencyType", -1);
if (storefrontType < 0 || currencyType < 0)
return Refuse("StorefrontType and CurrencyType are required", 400);
if (!Campus_live_rewrite.api.server.Classes.GameCurrency.IsSpendable(currencyType))
return Refuse("Currency type is not spendable", 400);
bool allowPartial = RootBool("AllowPartialSuccess");
bool skipGiftBox = RootBool("BypassGiftPackages");
// ---- resolve every line against the catalog ------------------------------------
var resolved = new List<BulkLine>();
foreach (var line in linesEl.EnumerateArray())
{
var bl = new BulkLine();
if (line.TryGetProperty("ItemPurchaseMethodId", out var mid) && mid.ValueKind == System.Text.Json.JsonValueKind.Object)
{
if (mid.TryGetProperty("NumberId", out var nid) && nid.ValueKind == System.Text.Json.JsonValueKind.Number)
bl.PurchasableItemId = nid.GetInt32();
if (mid.TryGetProperty("Type", out var ty) && ty.ValueKind == System.Text.Json.JsonValueKind.Number)
bl.MethodType = ty.GetInt32();
if (mid.TryGetProperty("Guid", out var gd) && gd.ValueKind == System.Text.Json.JsonValueKind.String)
bl.Caid = gd.GetString();
}
bl.Count = line.TryGetProperty("DuplicateItemCount", out var dc) && dc.ValueKind == System.Text.Json.JsonValueKind.Number ? dc.GetInt32() : 1;
bl.RequestedPrice = line.TryGetProperty("RequestedPrice", out var rp) && rp.ValueKind == System.Text.Json.JsonValueKind.Number ? rp.GetInt32() : -1;
bl.HasCoupon = line.TryGetProperty("CouponConsumablePlayerMappingId", out var cc) &&
cc.ValueKind != System.Text.Json.JsonValueKind.Null && cc.ValueKind != System.Text.Json.JsonValueKind.Undefined;
if (line.TryGetProperty("Gift", out var g) && g.ValueKind == System.Text.Json.JsonValueKind.Object)
{
if (g.TryGetProperty("ToPlayerId", out var tp) && tp.ValueKind == System.Text.Json.JsonValueKind.Number)
bl.GiftTo = (ulong)Math.Max(0, tp.GetInt64());
if (g.TryGetProperty("Message", out var m) && m.ValueKind == System.Text.Json.JsonValueKind.String)
bl.GiftMessage = m.GetString();
if (g.TryGetProperty("Anonymous", out var an) && an.ValueKind == System.Text.Json.JsonValueKind.True)
bl.GiftAnonymous = true;
if (g.TryGetProperty("GiftContext", out var gc) && gc.ValueKind == System.Text.Json.JsonValueKind.Number)
bl.GiftContext = gc.GetInt32();
}
// A guid id is a UGC item (Type 1): sold at its creator's price, in tokens, once.
// These were refused as "not carried", so nobody could buy custom clothing.
if (bl.MethodType != 0 && !string.IsNullOrEmpty(bl.Caid))
{
var rec = CustomItemsCache.GetRec(bl.Caid);
if (rec == null || rec.Accessibility == 0) { bl.Code = NoItemAvailable; resolved.Add(bl); continue; }
if (bl.HasCoupon) { bl.Code = CouponNotApplicable; resolved.Add(bl); continue; }
if (bl.Count != 1) { bl.Code = RequestedAmountNotAllowed; resolved.Add(bl); continue; }
if (currencyType != (int)Campus_live_rewrite.api.server.Classes.CurrencyType.RecCenterTokens) { bl.Code = NoItemAvailable; resolved.Add(bl); continue; }
ulong owner = bl.GiftTo > 0 ? bl.GiftTo : account.Id;
if (rec.Creator == owner || PlayerDB.OwnsCustomItem(owner, rec.Id)) { bl.Code = 3; resolved.Add(bl); continue; } // AlreadyOwned
bl.Ugc = rec;
bl.UnitPrice = rec.Price > 0 ? Math.Max(Campus_live_rewrite.api.server.StoreGenerator.DefaultPrice, rec.Price) : 0; // clothing floor
if (bl.RequestedPrice > 0 && bl.RequestedPrice != bl.UnitPrice)
Console.WriteLine($"[bulkbuy] ugc {rec.Id}: client expected {bl.RequestedPrice}, charging {bl.UnitPrice}");
bl.Buyable = true;
resolved.Add(bl);
continue;
}
if (bl.MethodType != 0 || bl.PurchasableItemId == 0) { bl.Code = NoItemAvailable; resolved.Add(bl); continue; }
if (bl.HasCoupon) { bl.Code = CouponNotApplicable; resolved.Add(bl); continue; }
if (bl.Count < 1) { bl.Code = RequestedAmountNotAllowed; resolved.Add(bl); continue; }
var item = Campus_live_rewrite.api.server.StoreCatalog.FindStoreItem(bl.PurchasableItemId, storefrontType);
if (item == null) { bl.Code = NoItemAvailable; resolved.Add(bl); continue; }
bool isConsumable = !string.IsNullOrEmpty(item.ConsumableItemDesc);
if (bl.Count > 1 && !isConsumable) { bl.Code = RequestedAmountNotAllowed; resolved.Add(bl); continue; }
// A GIFT of something the recipient already owns (or already has waiting in a
// box) is refused as AlreadyOwned - the buyer's tokens would only buy a duplicate
// they can't use. Buying for yourself is unchanged (the reveal is the purchase).
if (bl.GiftTo > 0 && bl.GiftTo != account.Id && !isConsumable && RecipientAlreadyHas(bl.GiftTo, item))
{
Console.WriteLine($"[bulkbuy] {account.Id} -> gift {bl.PurchasableItemId} to {bl.GiftTo} refused: already owned");
bl.Code = 3; resolved.Add(bl); continue; // AlreadyOwned
}
// Price: the catalog decides, same as buyItem (min of cost and the RR+ price).
int cost = item.Cost;
int sub = Campus_live_rewrite.api.server.StoreCatalog.SubscriberPriceFor(item);
if (sub < cost) cost = sub;
if (bl.RequestedPrice > 0 && bl.RequestedPrice != cost)
Console.WriteLine($"[bulkbuy] item {bl.PurchasableItemId}: client expected {bl.RequestedPrice}, charging {cost}");
bl.Item = item;
bl.UnitPrice = cost;
bl.Buyable = true;
resolved.Add(bl);
}
var buyable = resolved.Where(l => l.Buyable).ToList();
int totalCopies = buyable.Sum(l => l.Count);
if (totalCopies > BulkCap)
return Refuse($"A bulk purchase is capped at {BulkCap} items", 400);
var firstFailure = resolved.FirstOrDefault(l => !l.Buyable);
if (!allowPartial && firstFailure != null)
return Refuse(BulkFailureMessage(firstFailure.Code));
// Gift recipients must exist, or the buyer pays and the box is stranded.
foreach (var to in buyable.Where(l => l.GiftTo > 0 && l.GiftTo != account.Id).Select(l => l.GiftTo).Distinct())
if (PlayerDB.GetFullAccount(to)?.player == null)
return Refuse("No such player to gift to", 404);
if (buyable.Any(l => l.GiftTo > 0 && l.GiftTo != account.Id))
{
if (currencyType != (int)Campus_live_rewrite.api.server.Classes.CurrencyType.RecCenterTokens)
return Refuse("Only items sold for tokens can be gifted");
int lvl = Campus_live_rewrite.api.server.Classes.Progress.GetPlayerLevel(account.Id)?.Level ?? 1;
if (lvl < ConfigCampus.GiftMinLevel && !PlayerDB.CheckDevFlag(account.Id))
return Refuse($"You need to be Level {ConfigCampus.GiftMinLevel} to send gifts");
}
// Affordability, in request order, against the CURRENT balance for this currency.
long balance = PlayerDB.GetBalance(account.Id, currencyType);
var bought = new HashSet<BulkLine>();
long total = 0;
foreach (var l in buyable)
{
long lineCost = (long)l.UnitPrice * l.Count;
if (total + lineCost > balance) continue;
total += lineCost;
bought.Add(l);
}
if (!allowPartial && bought.Count != buyable.Count)
return Refuse("Insufficient balance");
if (bought.Count == 0)
return Refuse(firstFailure != null ? BulkFailureMessage(firstFailure.Code) : "Insufficient balance");
// One atomic debit for the whole bag.
if (total > 0) PlayerDB.AddBalance(account.Id, currencyType, -total);
// Grant what sold and keep each line's box for the response.
foreach (var l in bought)
{
ulong recipient = l.GiftTo > 0 ? l.GiftTo : account.Id;
bool giftToOther = recipient != account.Id;
l.GiftId = Campus_live_rewrite.api.server.StoreCatalog.NextPurchaseGiftId();
// UGC: ownership, the creator's cut, and (for a gift) a box carrying the item.
if (l.Ugc != null)
{
PlayerDB.GrantCustomItem(recipient, l.Ugc.Id);
EarningsDB.Record(l.Ugc.Creator, account.Id, "ugc", l.Ugc.Id, l.Ugc.Name, currencyType, l.UnitPrice);
Console.WriteLine($"[bulkbuy] @{account.Username} bought UGC '{l.Ugc.Name}' ({l.Ugc.Id}) for {l.UnitPrice} -> {(giftToOther ? $"gift to {recipient}" : "self")}");
if (giftToOther && !skipGiftBox)
{
var ubox = new PendingGiftBox
{
NumericId = l.GiftId,
GiftContext = l.GiftContext == 0 ? ConfigCampus.PlayerGiftContext : l.GiftContext,
Message = l.GiftMessage ?? "",
FromAccountId = l.GiftAnonymous ? 0 : account.Id,
Anonymous = l.GiftAnonymous,
AvatarItemDesc = "",
CustomAvatarItemId = l.Ugc.Id,
FriendlyName = l.Ugc.Name,
Rarity = 0
};
PlayerDB.AddPendingGiftBoxFull(recipient, ubox);
Campus_live_rewrite.api.server.GiftBoxes.Push(recipient, ubox, Campus_live_rewrite.api.server.GiftBoxes.FrameReceivedImmediate, "ugc gift");
}
continue;
}
if (!skipGiftBox)
{
var box = new PendingGiftBox
{
NumericId = l.GiftId,
GiftContext = l.GiftContext,
Message = giftToOther ? (l.GiftMessage ?? "") : "A Gift for you <3",
FromAccountId = l.GiftAnonymous ? 0 : account.Id,
Anonymous = l.GiftAnonymous,
AvatarItemDesc = l.Item!.Reward ?? "",
ConsumableItemDesc = l.Item.ConsumableItemDesc,
AvatarItemType = l.Item.AvatarItemType,
GiftDropId = l.Item.GiftDropId,
FriendlyName = l.Item.Name,
Rarity = l.Item.Rarity,
EquipmentPrefabName = l.Item.EquipmentPrefabName,
EquipmentModificationGuid = l.Item.EquipmentModificationGuid,
ConsumableCount = l.Item.ItemCount * Math.Max(1, l.Count),
CustomAvatarItemId = string.IsNullOrWhiteSpace(l.Item.CustomAvatarItemId) ? null : l.Item.CustomAvatarItemId,
ThumbnailImageName = string.IsNullOrWhiteSpace(l.Item.ThumbnailImageName) ? null : l.Item.ThumbnailImageName
};
PlayerDB.AddPendingGiftBoxFull(recipient, box);
if (giftToOther)
{
Campus_live_rewrite.api.server.GiftBoxes.Push(recipient, box, Campus_live_rewrite.api.server.GiftBoxes.FrameReceivedImmediate, "store gift");
Notifications.FireAndForget(Notifications.RefreshAccount(recipient), "gift refresh");
}
}
}
long newBalance = PlayerDB.GetBalance(account.Id, currencyType);
Console.WriteLine($"[bulkbuy] @{account.Username}: {bought.Count}/{buyable.Count} line(s), {totalCopies} copies, " +
$"spent {total} of currency {currencyType}; bal {balance}->{newBalance}");
// One entry per REQUESTED line, in order - failures included.
var updates = resolved.Select(l =>
{
if (!l.Buyable)
return (object)new { UpdateResponse = l.Code, Data = new { GiftPackage = (object?)null, PurchasableItemId = (int?)l.PurchasableItemId, CustomAvatarItem = (object?)null } };
bool sold = bought.Contains(l);
// A UGC line has no storefront item behind it - answer it before the package is built.
if (sold && l.Ugc != null)
return (object)new { UpdateResponse = OK, Data = new { GiftPackage = (object?)null, PurchasableItemId = (int?)null, CustomAvatarItem = (object?)l.Ugc.ToJObject() } };
object? pkg = (sold && !skipGiftBox && l.Item != null) ? new
{
Id = l.GiftId,
PlayerId = l.GiftTo > 0 ? l.GiftTo : account.Id,
FromPlayerId = l.GiftAnonymous ? 0 : account.Id,
ConsumableItemDesc = l.Item!.ConsumableItemDesc,
AvatarItemType = Campus_live_rewrite.api.server.StoreCatalog.AvatarTypeFor(l.Item),
AvatarItemDesc = Campus_live_rewrite.api.server.StoreCatalog.AvatarDescFor(l.Item),
CustomAvatarItemId = string.IsNullOrWhiteSpace(l.Item.CustomAvatarItemId) ? null : l.Item.CustomAvatarItemId,
EquipmentPrefabName = l.Item.EquipmentPrefabName,
EquipmentModificationGuid = l.Item.EquipmentModificationGuid,
CurrencyType = l.Item.GdCurrencyType,
Currency = l.Item.GdCurrency,
Xp = 0,
GiftContext = l.GiftContext,
GiftRarity = l.Item.Rarity,
Message = l.GiftTo > 0 && l.GiftTo != account.Id ? (l.GiftMessage ?? "") : "A Gift for you <3",
Signature = Campus_live_rewrite.api.server.GiftBoxes.SignatureFor(l.GiftId,
l.GiftTo > 0 ? l.GiftTo : account.Id, l.GiftAnonymous ? 0 : account.Id, l.Item.Name,
l.GiftTo > 0 && l.GiftTo != account.Id ? (l.GiftMessage ?? "") : "A Gift for you <3", l.Item.GdCurrency, l.Item.GdCurrencyType),
IsSignatureValid = true,
Platform = -1,
PlatformsToSpawnOn = -1,
BalanceType = AllPlatformsBucket
} : null;
return (object)new { UpdateResponse = sold ? OK : NotEnoughCredit, Data = new { GiftPackage = pkg, PurchasableItemId = (int?)l.PurchasableItemId, CustomAvatarItem = (object?)null } };
}).ToList();
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
Success = true,
Error = (string?)null,
error_id = (string?)null,
Value = new
{
Balance = newBalance, // RESULTING total, not the change
CurrencyType = currencyType,
Platform = AllPlatformsBucket, // the account-wide bucket the client sums
BalanceUpdates = updates
}
}),
ContentType = "application/json",
StatusCode = 200
};
}
private sealed class BulkLine
{
public int PurchasableItemId;
public int MethodType;
public int Count = 1;
public int RequestedPrice = -1;
public bool HasCoupon;
public ulong GiftTo;
public string? GiftMessage;
public bool GiftAnonymous;
public int GiftContext;
public bool Buyable;
public int Code; // UpdateResponse when not buyable
public int UnitPrice;
public int GiftId;
public Campus_live_rewrite.api.server.StoreCatalog.GiftDropInfo? Item;
// A UGC (custom avatar item) line: guid-keyed, priced by the item's creator.
public string? Caid;
public CustomItemsCache.Rec? Ugc;
}
/// <summary>Does this player already own (or have boxed) what a store row delivers?</summary>
private static bool RecipientAlreadyHas(ulong playerId, Campus_live_rewrite.api.server.StoreCatalog.GiftDropInfo item)
{
try
{
string? desc = Campus_live_rewrite.api.server.StoreCatalog.AvatarDescFor(item);
if (!string.IsNullOrWhiteSpace(desc))
{
string key = PlayerDB.NormalizeAvatarItemDesc(desc);
if (key.Length > 0 && PlayerDB.GetOwnedAvatarItemKeys(playerId).Contains(key)) return true;
}
if (!string.IsNullOrWhiteSpace(item.CustomAvatarItemId) && PlayerDB.OwnsCustomItem(playerId, item.CustomAvatarItemId)) return true;
if (!string.IsNullOrWhiteSpace(item.EquipmentPrefabName) && !string.IsNullOrWhiteSpace(item.EquipmentModificationGuid)
&& PlayerDB.OwnedEquipmentSkins(playerId).Contains(PlayerDB.EquipmentSkinKey(item.EquipmentPrefabName, item.EquipmentModificationGuid))) return true;
// Already waiting in an unopened box.
foreach (var b in PlayerDB.GetPendingGiftBoxes(playerId))
{
if (!string.IsNullOrWhiteSpace(desc) && string.Equals(PlayerDB.NormalizeAvatarItemDesc(b.AvatarItemDesc), PlayerDB.NormalizeAvatarItemDesc(desc), StringComparison.OrdinalIgnoreCase)) return true;
if (!string.IsNullOrWhiteSpace(item.CustomAvatarItemId) && string.Equals(b.CustomAvatarItemId, item.CustomAvatarItemId, StringComparison.OrdinalIgnoreCase)) return true;
if (!string.IsNullOrWhiteSpace(item.EquipmentModificationGuid) && string.Equals(b.EquipmentModificationGuid, item.EquipmentModificationGuid, StringComparison.OrdinalIgnoreCase)) return true;
}
}
catch (Exception ex) { Console.WriteLine($"[bulkbuy] ownership check for {playerId} failed: {ex.Message}"); }
return false;
}
private static string BulkFailureMessage(int code) => code switch
{
3 => "They already own that item",
4 => "One or more items are not available",
5 => "Coupons are not supported",
7 => "An item was requested in a quantity that is not allowed",
_ => "One or more items could not be purchased"
};
[HttpGet("/api/challenge/v2/getCurrent")]
public ContentResult GetCurrentChallenges()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// Serve the weekly-challenge map with each challenge's Complete filled in from the
// caller's stored status. No definition loaded -> [] (client shows no active challenge).
if (!Campus_live_rewrite.api.server.WeeklyChallenge.Loaded)
return new ContentResult { Content = "[]", ContentType = "application/json", StatusCode = 200 };
var completed = PlayerDB.GetCompletedChallengeIds(account.Id, Campus_live_rewrite.api.server.WeeklyChallenge.MapId);
var map = Campus_live_rewrite.api.server.WeeklyChallenge.Build(completed);
return new ContentResult()
{
Content = map?.ToString(Newtonsoft.Json.Formatting.None) ?? "[]",
ContentType = "application/json",
StatusCode = 200
};
}
// Report weekly-challenge progress.
//
// getCurrent serves no challenges yet (there is no weekly rotation content on this
// server), so this is an accept-and-echo: persist nothing, hand the reported fields
// straight back in the shape the client expects. That stops the POST 404ing and lets
// the client's progress loop complete. When a real rotation is added, this is where
// completion would latch and award the weekly gift (see RecFlare econ updateProgress).
[HttpPost("/api/challenge/v2/updateProgress")]
public IActionResult UpdateChallengeProgress([FromBody] System.Text.Json.JsonElement body)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string GetStr(string k) => body.ValueKind == System.Text.Json.JsonValueKind.Object &&
body.TryGetProperty(k, out var v) ? v.ToString() : "";
int GetInt(string k) => int.TryParse(GetStr(k), out var n) ? n : 0;
int mapId = GetInt("ChallengeMapId");
int challengeId = GetInt("ChallengeId");
bool complete = GetStr("Complete").Equals("true", StringComparison.OrdinalIgnoreCase);
// Latch this challenge's completion (false -> true only, within the rotation).
bool stored = PlayerDB.RecordChallengeProgress(account.Id, mapId, challengeId, complete);
// Completing EVERY challenge in the current map grants the map's Gift, exactly once.
if (stored
&& Campus_live_rewrite.api.server.WeeklyChallenge.Loaded
&& mapId == Campus_live_rewrite.api.server.WeeklyChallenge.MapId
&& Campus_live_rewrite.api.server.WeeklyChallenge.ChallengeCount > 0)
{
var done = PlayerDB.GetCompletedChallengeIds(account.Id, mapId);
if (done.Count >= Campus_live_rewrite.api.server.WeeklyChallenge.ChallengeCount
&& PlayerDB.ClaimChallengeGift(account.Id, mapId))
{
var gift = Campus_live_rewrite.api.server.WeeklyChallenge.Gift;
try
{
PlayerDB.AddPendingGiftBoxFull(account.Id, new Campus_live_rewrite.api.server.Classes.db_class.PendingGiftBox
{
GiftContext = (int?)gift?["GiftContext"] ?? 0,
GiftDropId = (int?)gift?["GiftDropId"] ?? 0,
AvatarItemDesc = (string?)gift?["AvatarItemDesc"],
AvatarItemType = (int?)gift?["AvatarItemType"] ?? 0,
ConsumableItemDesc = (string?)gift?["ConsumableItemDesc"],
EquipmentPrefabName = (string?)gift?["EquipmentPrefabName"],
EquipmentModificationGuid = (string?)gift?["EquipmentModificationGuid"],
Rarity = (int?)gift?["GiftRarity"] ?? 0,
Message = "Weekly Challenge complete!",
FromAccountId = 0
});
int giftXp = (int?)gift?["Xp"] ?? 0;
if (giftXp > 0) PlayerDB.AddXp(account.Id, giftXp);
Console.WriteLine($"[challenge] {account.Id} completed map {mapId} -> granted weekly gift " +
$"(drop {(int?)gift?["GiftDropId"] ?? 0}, equip '{(string?)gift?["EquipmentPrefabName"]}')");
}
catch (Exception ex) { Console.WriteLine($"[challenge] {account.Id} gift grant failed: {ex.Message}"); }
}
}
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
ChallengeMapId = GetStr("ChallengeMapId"),
ChallengeId = GetStr("ChallengeId"),
Config = GetStr("Config"),
Complete = stored
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/PlayerReporting/v1/voteToKickReasons")]
public ContentResult VoteToKickReasons()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var reasons = new[]
{
new { Reason = "Discriminatory language", ReportCategory = 102 },
new { Reason = "Discriminatory behavior", ReportCategory = 102 },
new { Reason = "Threats or encouraging suicide", ReportCategory = 102 },
new { Reason = "Toxic behavior", ReportCategory = 102 },
new { Reason = "Sexual behavior in public", ReportCategory = 101 },
new { Reason = "Sexual language in public", ReportCategory = 101 },
new { Reason = "Non-consensual sexual behavior", ReportCategory = 101 },
new { Reason = "Player in walls or floor", ReportCategory = 103 },
new { Reason = "Friendly fire", ReportCategory = 103 },
new { Reason = "Microphone spam", ReportCategory = 103 },
new { Reason = "Abusing bugs or exploits", ReportCategory = 103 },
new { Reason = "Spawn camping", ReportCategory = 103 },
new { Reason = "Inactive in games (AFK)", ReportCategory = 6 },
new { Reason = "Prefab swapping", ReportCategory = 6 },
new { Reason = "Not following game rules", ReportCategory = 6 }
};
return new ContentResult()
{
Content = JsonConvert.SerializeObject(reasons),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/images/v2/named")]
public ContentResult NamedImages()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return new ContentResult()
{
Content = "[]",
ContentType = "application/json",
StatusCode = 200
};
}
// Gift boxes waiting for this player. Populated by the admin panel
// (/admin/api/gift_giftbox) and by end-of-game rewards.
//
// CAVEAT: the exact element shape rec.net returns here was never captured, so the
// fields below are our own. The client may not render them as real gift boxes
// until that shape is known - the grant itself IS stored either way, so nothing
// is lost when the correct shape is filled in.
// Open (consume) a gift box.
//
// The item was already granted when the box was created (buyItem / rewards write it
// into the wardrobe immediately), so opening is just the cosmetic reveal: mark the box
// opened so it stops coming back in the pending list. This route did not exist, so the
// client's POST 404'd, the box was never marked opened, and it reappeared every time.
//
// ALWAYS answers the { error, success, value } envelope with 200 - even with no token,
// a zero id, or a box already gone - because the client parses it to finish the open
// animation. Opening someone else's box is refused (their box is not in the caller's
// list, so nothing is consumed). Trailing slash tolerated: the client posts
// "/gifts/consume/".
// ONE route only. ASP.NET Core's matcher already treats "/consume" and "/consume/" as the
// same path, so declaring BOTH templates made the trailing-slash URL the client actually
// posts ("/gifts/consume/") match two identical endpoints -> AmbiguousMatchException -> 500.
// The consume then never ran, the box was never marked opened, and it reappeared forever.
[HttpPost("/api/avatar/v2/gifts/consume")]
public IActionResult ConsumeGift([FromForm] string? Id)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
object envelope = new { error = "", success = true, value = (object?)null };
if (error == null && account != null && !string.IsNullOrWhiteSpace(Id))
{
int numericId = int.TryParse(Id, out var n) ? n : 0;
var opened = PlayerDB.ConsumeGiftBox(account.Id, Id, numericId);
if (opened == null)
{
Console.WriteLine($"[gifts] {account.Id} tried to open {Id} - no matching unopened box");
// Reference: a box that is another player's is 403 (empty body); the caller's
// own already-opened box is a harmless success. The client's Open All walks
// every box it can see in the room, so a foreign one must fail fast rather
// than be answered "success" and then throw "Gift is missing from the list".
if (numericId != 0 && PlayerDB.GiftBoxBelongsToSomeoneElse(account.Id, numericId))
return StatusCode(403, "");
}
else
{
Campus_live_rewrite.api.server.GiftBoxes.PayOnOpen(account.Id, opened); // tokens land as the box opens
// A consumable box: ConsumableMappingAdded (70) so the item shows up in the
// inventory without a refetch (reference pushConsumableAdded). Avatar-item and
// skin boxes carry no ConsumableItemDesc and get nothing here.
if (!string.IsNullOrWhiteSpace(opened.ConsumableItemDesc))
{
try
{
var addedFrame = JsonConvert.SerializeObject(new WebsocketEvents.Reponse
{
Id = ((int)WebsocketEvents.ResponseResults.ConsumableMappingAdded).ToString(),
Msg = new
{
Id = opened.NumericId,
ConsumableItemDesc = opened.ConsumableItemDesc,
CreatedAt = DateTime.UtcNow,
Count = 1,
InitialCount = 0,
IsActive = false,
ActiveDurationMinutes = 0,
IsTransferable = false
}
});
Campus_live_rewrite.api.server.Notifications.FireAndForget(
Campus_live_rewrite.api.server.Notifications.SendToPlayer(account.Id, addedFrame), "consumable added");
}
catch { }
}
// gift.manualconsumed (reference notification-types): tells the client the
// package is gone so the opened box despawns instead of lingering.
try
{
var goneFrame = JsonConvert.SerializeObject(new WebsocketEvents.Reponse
{
Id = "gift.manualconsumed",
Msg = new { GiftPackageId = opened.NumericId != 0 ? (object)opened.NumericId : opened.Id }
});
Campus_live_rewrite.api.server.Notifications.FireAndForget(
Campus_live_rewrite.api.server.Notifications.SendToPlayer(account.Id, goneFrame), "gift consumed");
}
catch { }
}
}
return new ContentResult
{
Content = JsonConvert.SerializeObject(envelope),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/avatar/v2/gifts")]
public ContentResult Gifts()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// Drop boxes that carry NOTHING - no avatar item, no consumable, no equipment
// and no gift drop to resolve one from.
//
// The client rejects the ENTIRE array when it meets one of these, logging
// "Malformed Response: '[{...\"AvatarItemDesc\":\"\",\"FriendlyName\":\"\"...}]'",
// so a handful of contentless test gifts takes out every real gift with them.
// They can never be opened either, so nothing is lost by not sending them.
// Retire contentless boxes so they don't linger forever. They are filtered out of the
// response below (the client rejects the whole array if one is empty), and if left
// unopened they keep counting toward the gift badge as "random gifts" that can never
// be opened. Marking them opened here clears them for good.
int cleaned = PlayerDB.MarkEmptyGiftBoxesOpened(account.Id);
if (cleaned > 0)
Console.WriteLine($"[gifts] {account.Id}: retired {cleaned} empty gift box(es)");
// Give every box a numeric Id (older reward/select boxes had NumericId 0 -> guid Id ->
// client rejects the whole array as "Malformed Response"). Backfills + persists.
PlayerDB.EnsureGiftBoxNumericIds(account.Id);
var boxes = PlayerDB.GetPendingGiftBoxes(account.Id);
var usable = boxes.Where(Campus_live_rewrite.api.server.GiftBoxes.HasContent).ToList();
if (usable.Count != boxes.Count)
Console.WriteLine($"[gifts] {account.Id}: skipped {boxes.Count - usable.Count} empty gift box(es) " +
$"the client cannot parse, sent {usable.Count}");
// One shape for the list and the hub frames - see GiftBoxes. The box's own currency,
// XP and thumbnail ride along; they were hardcoded 0 / absent here, which is why a
// token box read "0 Tokens" and no box showed its XP when opened from the list.
var pending = usable.Select(b => Campus_live_rewrite.api.server.GiftBoxes.ToGiftContent(b, account.Id)).ToList();
return new ContentResult()
{
Content = JsonConvert.SerializeObject(pending),
ContentType = "application/json",
StatusCode = 200
};
}
// The caller's reward selections that were offered and never picked, in the same shape
// the RewardSelectionReceived push carries - so a client that re-reads its pending
// selections (on login, or when it opens the picker from the watch) finds the one it was
// just pushed instead of an empty list. Was a hardcoded "[]".
[HttpGet("/api/gamerewards/v1/pending")]
public ContentResult PendingGameRewards()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var pending = Campus_live_rewrite.api.server.RewardSelections.PendingFor(account.Id);
Console.WriteLine($"[gamereward] {account.Id} read pending selections: {pending.Count}");
return new ContentResult()
{
Content = JsonConvert.SerializeObject(pending.Select(Campus_live_rewrite.api.server.RewardSelections.ToPayload).ToList()),
ContentType = "application/json",
StatusCode = 200
};
}
// End-of-game reward request. Real client sends form-urlencoded:
// GiftContext={int}&Message={string}&RewardType={PostGameActivity|...}
// GiftContext identifies which gift box (4003 Golden, etc - see GiftContextIds).
//
// KNOWN GAP: the exact success RESPONSE econ.rec.net returns here was not captured
// (the reference tools only send the request and print r.text). This returns a
// minimal success envelope so the call does not error; if the client expects the
// granted gift-box contents echoed back to render them, that shape still needs a
// capture or a live probe. The request IS recorded, and a currency reward is
// credited so the reward is tangible.
[HttpPost("/api/gamerewards/v1/request")]
public ContentResult RequestGameReward()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// Read the fields MANUALLY, not via [FromForm] int GiftContext. The client sends
// giftContext as the ACTIVITY NAME (e.g. "giftContext=Dodgeball"), which cannot bind to
// an int - that model-bind failure was the 400 this endpoint kept returning. Keep both a
// numeric GiftContext (when it happens to be a number) and the raw name for the reward
// pool lookup.
string? RewardType = null, Message = null, giftContextRaw = null;
if (Request.HasFormContentType)
{
RewardType = Request.Form["rewardType"].ToString();
Message = Request.Form["Message"].ToString();
giftContextRaw = Request.Form["giftContext"].ToString();
}
int GiftContext = int.TryParse(giftContextRaw, out var gcNum) ? gcNum : 0;
string? giftContextName = string.IsNullOrWhiteSpace(giftContextRaw) ? null : giftContextRaw;
_ = Task.Run(() => Webhook.Send(
account.Id, "prod", "Game reward",
$"@{account.Username} ({account.Id}) requested reward - GiftContext={GiftContext}, Type={RewardType}",
5898927, null));
Console.WriteLine($"[gamereward] account {account.Id} GiftContext={GiftContext} RewardType={RewardType} Message='{Message}'");
// Amount and currency depend on the reward type (see GameRewardRules).
var rewardType = Campus_live_rewrite.api.server.Classes.GameRewardRules.Parse(RewardType);
// Pay in the CURRENT ROOM's currency, same rule as /storefronts/v2/balance:
// finishing a game in Crescendo pays Silver, Isle pays Gold, and everything
// else pays Rec Center Tokens. Falls back to the reward type's default when
// the room is unknown.
string? rewardRoom = HeartbeatDB.GetPlayerHeartbeat(account.Id)?.roomInstance?.name;
var roomCur = Campus_live_rewrite.api.server.Classes.GameCurrency.ForRoomName(rewardRoom);
int RewardCurrency = (int)(roomCur ?? Campus_live_rewrite.api.server.Classes.GameRewardRules.CurrencyFor(rewardType));
long RewardAmount = Campus_live_rewrite.api.server.Classes.GameRewardRules.AmountFor(rewardType);
// Rec Center Tokens (and any non-room currency) are server/admin only - a player
// reward can never mint them. When a reward would pay one, pay 0 currency but still
// grant XP below, so levelling from play keeps working while tokens stay locked down.
// EXCEPTION: a once-per-day reward (FirstActivityOfDay - the daily first-game bonus) is
// rate-limited to one claim per day, so it CANNOT be farmed even in Rec Center Tokens.
// Without this it paid 0 in any token-currency room and the daily bonus "did nothing".
if (!Campus_live_rewrite.api.server.Classes.GameCurrency.IsPlayerGrantable(RewardCurrency)
&& !Campus_live_rewrite.api.server.Classes.GameRewardRules.IsOncePerDay(rewardType))
{
Console.WriteLine($"[gamereward] {account.Id} currency {RewardCurrency} not player-grantable " +
$"(server/admin only) - awarding 0 currency, XP only");
RewardAmount = 0;
}
// Once-per-day rewards must actually be once per day. FirstActivityOfDay is
// requested by the client on activity, not only the first of the day, so
// without this gate it paid out on every single request and could be farmed
// by simply replaying the call.
if (Campus_live_rewrite.api.server.Classes.GameRewardRules.IsOncePerDay(rewardType))
{
if (!PlayerDB.TryClaimDailyReward(account.Id, rewardType.ToString()))
{
Console.WriteLine($"[gamereward] {account.Id} already claimed {rewardType} today - awarding nothing");
// No selection to show: null, not a status object (see the end of this method).
return new ContentResult() { Content = "null", ContentType = "application/json", StatusCode = 200 };
}
// The day's first: the game complete that comes with it rides through the
// per-minute limit below (GameRewardGate).
Campus_live_rewrite.api.server.GameRewardGate.NoteDailyAccepted(account.Id);
}
// Game completes: one per minute per player, the whole payout - not just the box.
// A replayed PostGameActivity used to pay currency + XP every time.
if (!Campus_live_rewrite.api.server.GameRewardGate.Allow(account.Id, rewardType, out string limited))
{
Console.WriteLine($"[gamereward] {account.Id} rate-limited: {limited} - awarding nothing");
return new ContentResult() { Content = "null", ContentType = "application/json", StatusCode = 200 };
}
long newTotal = PlayerDB.AddBalance(account.Id, RewardCurrency, RewardAmount);
// The corner counter moves now, not on the next relog (reference: pushBalanceUpdate).
Campus_live_rewrite.api.server.GiftBoxes.PushBalance(account.Id, RewardCurrency, newTotal, "gamereward");
// Grant XP too - a game reward pays XP as well as currency, and nothing was doing it,
// so players never levelled up. GAME_REWARD_XP (5) per claim, level-ups applied on the
// real curve (see Progress). The currency reward already gated once-per-day rewards
// above, so we only reach here when a reward is actually being paid.
var xpResult = PlayerDB.AddXp(account.Id, Progress.GAME_REWARD_XP);
if (xpResult.HasValue && xpResult.Value.LevelsGained > 0)
Console.WriteLine($"[progression] {account.Id} reached Level {xpResult.Value.Level} " +
$"(+{xpResult.Value.LevelsGained}) from a {rewardType} reward");
// Offer the three-choice reward SELECTION (the watch picker). Mint three distinct drops
// for this GiftContext / current room, record them, and push "RewardSelectionReceived"
// over the notifications hub - the client shows the watch and the player picks one with
// POST /api/gamerewards/v1/select, which is what actually grants the item. Cooldown-gated
// (QuestRewards.TryClaimGiftCooldown) so a spammy client can't spawn a selection per call.
// Every activity pays TOKENS for now (ConfigCampus.RewardItemsEnabled): three random
// amounts from the 10..1000 ladder, in the ACTIVITY's own box - a Crescendo win comes
// out of the Crescendo box (its GiftContext), an unknown activity out of the plain
// tokens box. RewardSelections.Offer is the same path the admin panel's "Send Game
// Reward" uses.
Campus_live_rewrite.api.server.RewardSelections.Selection? offered = null;
// The request gate above (GameRewardGate) already paced this request; the older
// box-only cooldown (QuestRewards.TryClaimGiftCooldown) is not applied on top of it,
// or the game complete the daily grace let through would pay but offer no box.
if (Campus_live_rewrite.api.server.QuestRewards.Loaded)
{
offered = Campus_live_rewrite.api.server.RewardSelections.Offer(account.Id, GiftContext, giftContextName ?? rewardRoom, Message);
}
// The RESPONSE is the selection itself, the same object the RewardSelectionReceived
// frame carries. The client builds its watch picker from this response body directly
// (the hub frame is the cross-device copy): the old {success, Awarded, Balance} answer
// deserialised into a selection with no drops - three blank "Reward" cards showing
// "0 Tokens", RewardSelectionId 0 and a default CreatedAt that the expiry timer read
// as long past, which is the "picker opens blank / auto-picks card 1 and closes"
// report. Nothing offered (cooldown, daily already claimed) answers null.
return new ContentResult()
{
Content = offered != null
? JsonConvert.SerializeObject(Campus_live_rewrite.api.server.RewardSelections.ToPayload(offered))
: "null",
ContentType = "application/json",
StatusCode = 200
};
}
// Claim one of the three rewards an earlier selection offered (the watch picker's pick).
// The selection (minted + pushed by /gamerewards/v1/request) must be the caller's, unconsumed,
// and must actually contain the claimed drop - otherwise 403, so a player can't mint a reward
// they were never offered or redeem one twice. On success the chosen item is granted as a gift
// box (also entering the wardrobe) plus any XP it carries, and announced over the hub as
// GiftPackageRewardSelectionReceived ("32"). Items + XP only (tokens are server/admin).
[HttpPost("/api/gamerewards/v1/select")]
public ContentResult SelectGameReward()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
int selId = 0, giftDropId = 0;
try
{
if (Request.HasFormContentType)
{
int.TryParse(Request.Form["rewardSelectionId"], out selId);
int.TryParse(Request.Form["giftDropId"], out giftDropId);
}
else
{
using var reader = new StreamReader(HttpContext.Request.Body);
var body = reader.ReadToEnd();
if (!string.IsNullOrWhiteSpace(body))
{
var jo = Newtonsoft.Json.Linq.JObject.Parse(body);
selId = (int?)jo["rewardSelectionId"] ?? (int?)jo["RewardSelectionId"] ?? 0;
giftDropId = (int?)jo["giftDropId"] ?? (int?)jo["GiftDropId"] ?? 0;
}
}
}
catch { }
if (giftDropId == 0)
return new ContentResult { Content = "{\"error\":\"giftDropId is required\"}", ContentType = "application/json", StatusCode = 400 };
var sel = selId <= 0 ? null : Campus_live_rewrite.api.server.RewardSelections.Get(selId);
var chosen = sel?.Drops.FirstOrDefault(d => d.Id == giftDropId);
// The client re-sends the pick (a double tap, or a retry after the box popped). A
// selection this account already claimed answers the SAME drop again with 200 - it
// used to 403, which the client surfaced as "Failed to consume reward".
if (sel != null && sel.AccountId == account.Id && sel.Consumed && chosen != null)
{
var already = sel.ChosenDropId != 0 ? sel.Drops.FirstOrDefault(d => d.Id == sel.ChosenDropId) ?? chosen : chosen;
Console.WriteLine($"[gamereward-select] {account.Id} re-sent selection {selId}: already claimed drop {already.Id}, answering it again");
return new ContentResult { Content = JsonConvert.SerializeObject(Campus_live_rewrite.api.server.QuestRewards.ToDrop(already)), ContentType = "application/json", StatusCode = 200 };
}
if (sel == null || sel.AccountId != account.Id || sel.Consumed || chosen == null)
{
Console.WriteLine($"[gamereward] {account.Id} select refused: selection {selId} " +
(sel == null ? "unknown (expired, or minted before a restart)" : sel.AccountId != account.Id ? "belongs to someone else" : sel.Consumed ? "already claimed" : $"does not offer drop {giftDropId}"));
return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 403 };
}
// Consume conditionally: two racing claims mean the second one loses.
if (!Campus_live_rewrite.api.server.RewardSelections.Consume(sel.RewardSelectionId, giftDropId))
return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 403 };
bool tokens = Campus_live_rewrite.api.server.QuestRewards.IsTokens(chosen);
int boxXp = chosen.Xp > 0 ? chosen.Xp : Campus_live_rewrite.api.server.Classes.Progress.GAME_REWARD_XP;
var box = new Campus_live_rewrite.api.server.Classes.db_class.PendingGiftBox
{
// The box is filed under the DROP's context (reference: giftContext ?? drop.Context):
// an activity's item under the activity's box, tokens under GameRewardTokens (51)
// so the client draws the token box and plays its sound, not the XP box's.
GiftContext = chosen.Context != 0 ? chosen.Context : sel.GiftContext,
Message = sel.Message,
FromAccountId = 0,
GiftDropId = chosen.Id,
FriendlyName = tokens ? $"{chosen.Currency} Tokens" : chosen.Message,
ThumbnailImageName = tokens ? Campus_live_rewrite.api.server.QuestRewards.TokenThumbnail : null,
AvatarItemDesc = chosen.AvatarItemDesc,
ConsumableItemDesc = chosen.ConsumableItemDesc,
EquipmentPrefabName = chosen.EquipmentPrefabName,
EquipmentModificationGuid = chosen.EquipmentModificationGuid,
Rarity = chosen.GiftRarity,
CurrencyType = chosen.Currency > 0 ? chosen.CurrencyType : 0,
Currency = chosen.Currency > 0 ? chosen.Currency : 0,
Xp = boxXp
};
try { PlayerDB.AddPendingGiftBoxFull(account.Id, box); }
catch (Exception ex) { Console.WriteLine($"[gamereward-select] {account.Id} grant failed: {ex.Message}"); }
// A currency drop (a duplicate converted to tokens, or Laser Tag's tickets) is paid when
// the player OPENS the box (GiftBoxes.PayOnOpen from /gifts/consume), so the counter
// moves with the reveal rather than silently at pick time.
box.PayOnOpen = box.Currency > 0 && box.CurrencyType != 0;
if (box.PayOnOpen) PlayerDB.SetGiftBoxPayOnOpen(account.Id, box);
// Announce the gift box over the hub (GiftPackageRewardSelectionReceived = 32) so it pops
// without waiting for the next /api/avatar/v2/gifts poll. BEFORE the XP lands: a level-up
// box announced by AddXp should arrive after the reward that earned it.
Campus_live_rewrite.api.server.GiftBoxes.Push(account.Id, box,
Campus_live_rewrite.api.server.GiftBoxes.FrameRewardSelection, "GiftPackageRewardSelectionReceived");
if (boxXp > 0) PlayerDB.AddXp(account.Id, boxXp);
Console.WriteLine($"[gamereward-select] {account.Id} claimed drop {giftDropId} from selection {sel.RewardSelectionId}");
return new ContentResult
{
Content = JsonConvert.SerializeObject(Campus_live_rewrite.api.server.QuestRewards.ToDrop(chosen)),
ContentType = "application/json",
StatusCode = 200
};
}
// Referral / gift generation. Same request shape as the reward request; same
// captured-response gap. Records + acknowledges.
[HttpPost("/api/avatar/v3/gifts/generate")]
[HttpPost("/api/avatar/v2/gifts/generate")] // older clients call v2
public ContentResult GenerateAvatarGift([FromForm] int GiftContext, [FromForm] string? Message, [FromForm] string? RewardType)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
Console.WriteLine($"[giftgen] account {account.Id} GiftContext={GiftContext} RewardType={RewardType}");
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
success = true,
error = "",
error_id = "",
GiftContext = GiftContext,
RewardType = RewardType ?? ""
}),
ContentType = "application/json",
StatusCode = 200
};
}
// Friendotron / free-gift button. The client posts MultiRecipientFreeGiftRequestDTO as
// JSON: { ToPlayerIds: [int], Message, GiftContext (1300 Friendotron_Gift) }. It 404'd, so
// the machine said "Failed to Send Gift" every time. Each recipient gets a token box in
// the Friendotron look, from the sender, paid when opened, pushed live. The client
// enforces its own once-a-day limit; the server caps recipients per call.
[HttpPost("/api/freegifts/v1/sendmultiple")]
public IActionResult SendFreeGifts()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var ids = new List<ulong>();
string message = "";
int giftContext = 1300;
try
{
using var reader = new StreamReader(HttpContext.Request.Body);
var body = reader.ReadToEnd();
if (!string.IsNullOrWhiteSpace(body))
{
var jo = Newtonsoft.Json.Linq.JObject.Parse(body);
var arr = jo["ToPlayerIds"] as Newtonsoft.Json.Linq.JArray ?? jo["toPlayerIds"] as Newtonsoft.Json.Linq.JArray
?? jo["PlayerIds"] as Newtonsoft.Json.Linq.JArray ?? new Newtonsoft.Json.Linq.JArray();
foreach (var t in arr) { if (long.TryParse(t.ToString(), out var v) && v > 0) ids.Add((ulong)v); }
message = (string?)jo["Message"] ?? (string?)jo["message"] ?? "";
giftContext = (int?)jo["GiftContext"] ?? (int?)jo["giftContext"] ?? 1300;
}
}
catch (Exception ex) { Console.WriteLine($"[freegift] {account.Id}: bad body - {ex.Message}"); }
ids = ids.Distinct().Take(10).ToList();
if (ids.Count == 0)
return new ContentResult { Content = "{\"Result\":1,\"Success\":false,\"Error\":\"No recipients\"}", ContentType = "application/json", StatusCode = 400 };
string boxMsg = string.IsNullOrWhiteSpace(message) ? $"A free gift from @{account.Username}!" : message.Trim();
if (boxMsg.Length > 120) boxMsg = boxMsg.Substring(0, 120);
int sent = 0;
foreach (var pid in ids)
{
if (PlayerDB.GetFullAccount(pid)?.player == null) continue;
long tokens = Campus_live_rewrite.api.server.QuestRewards.RollTokenAmount();
var box = new Campus_live_rewrite.api.server.Classes.db_class.PendingGiftBox
{
GiftContext = giftContext != 0 ? giftContext : 1300,
Message = boxMsg,
FromAccountId = account.Id,
FriendlyName = $"{tokens} Tokens",
ThumbnailImageName = Campus_live_rewrite.api.server.QuestRewards.TokenThumbnail,
CurrencyType = (int)Campus_live_rewrite.api.server.Classes.CurrencyType.RecCenterTokens,
Currency = tokens,
Rarity = Campus_live_rewrite.api.server.QuestRewards.TokenRarity(tokens),
PayOnOpen = true
};
if (!PlayerDB.AddPendingGiftBoxFull(pid, box)) continue;
Campus_live_rewrite.api.server.GiftBoxes.Push(pid, box, Campus_live_rewrite.api.server.GiftBoxes.FrameReceivedImmediate, "friendotron gift");
sent++;
}
Console.WriteLine($"[freegift] @{account.Username} ({account.Id}) sent Friendotron gifts to {sent}/{ids.Count} player(s): [{string.Join(",", ids)}]");
return new ContentResult
{
Content = JsonConvert.SerializeObject(new { Result = 0, Success = true, Sent = sent }),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/communityboard/v2/current")]
public ContentResult CurrentCommunityBoard()
{
string authtoken = HttpContext.Request.Headers["Authorization"].ToString();
if (string.IsNullOrEmpty(authtoken))
{
authtoken = HttpContext.Request.Cookies["Authorization"];
}
if (string.IsNullOrEmpty(authtoken))
{
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 401
};
}
string playerdata = ClientSecurity.DecodeToken(authtoken);
if (playerdata == null)
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 401
};
if (playerdata.Contains("error_token"))
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 401
};
player_info account = JsonConvert.DeserializeObject<player_info>(playerdata);
string filePath = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "communityboard.json");
string content = System.IO.File.ReadAllText(filePath);
// The board's featured player, featured rooms and announcement come from the admin
// panel's Featured tab (FeaturedStore / FeaturedRoomsStore) when set there; the file
// is the fallback for anything the panel leaves blank.
try
{
var jo = JObject.Parse(content);
var fs = Campus_live_rewrite.api.server.FeaturedStore.Get();
ulong fp = Campus_live_rewrite.api.server.DiscoveryLists.FeaturedCreator();
if (fp != 0)
{
jo["FeaturedPlayer"] ??= new JObject();
jo["FeaturedPlayer"]!["Id"] = fp;
}
var rooms = new JArray();
foreach (var rid in Campus_live_rewrite.api.server.FeaturedRoomsStore.GetIds())
{
var r = RoomDB.GetRoom(rid);
if (r == null || r.IsDorm) continue;
rooms.Add(new JObject
{
["RoomId"] = r.RoomId, ["RoomName"] = r.Name ?? "", ["ImageName"] = r.ImageName ?? "",
["IsRecRoomApproved"] = r.IsRecRoomApproved, ["ExcludeFromLists"] = false, ["ExcludeFromSearch"] = false
});
}
if (rooms.Count > 0)
{
jo["FeaturedRoomGroup"] ??= new JObject { ["FeaturedRoomGroupId"] = 1, ["Name"] = "Featured Rooms", ["StartAt"] = "2020-01-01T00:00:00Z", ["EndAt"] = "9999-12-31T00:00:00Z" };
jo["FeaturedRoomGroup"]!["Rooms"] = rooms;
}
if (!string.IsNullOrWhiteSpace(fs.Announcement))
{
jo["CurrentAnnouncement"] ??= new JObject();
jo["CurrentAnnouncement"]!["Message"] = fs.Announcement;
jo["CurrentAnnouncement"]!["MoreInfoUrl"] = string.IsNullOrWhiteSpace(fs.AnnouncementUrl) ? null : fs.AnnouncementUrl;
}
content = jo.ToString(Newtonsoft.Json.Formatting.None);
}
catch (Exception ex) { Console.WriteLine($"[communityboard] overlay failed: {ex.Message}"); }
return new ContentResult()
{
Content = content,
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/storefronts/v4/balance/{balance_type}")]
public ContentResult StorefrontBalance(int balance_type = 0)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
long stored = PlayerDB.GetBalance(account.Id, balance_type);
var balance = new[]
{
new
{
Balance = stored,
BalanceType = balance_type,
CurrencyType = balance_type,
// The account-wide bucket. Was 0 (SteamPurchased) while the purchase response
// and the 61 push said -2, so a client that bought something summed two
// buckets and showed the wrong total until a relog.
Platform = Campus_live_rewrite.api.server.Classes.GameCurrency.AllPlatformsBucket
}
};
return new ContentResult()
{
Content = JsonConvert.SerializeObject(balance),
ContentType = "application/json",
StatusCode = 200
};
}
// Add currency. Request (from the real client / farm tools):
// {"CurrencyType":N,"BalanceAdds":[{"Multiplier":1,"BalanceAddType":M}]}
// Response shape mirrors econ.rec.net: a BalanceUpdates array, one per add, each
// with an UpdateResponse (0 = OK) and a Data block the client reads for the award.
//
// NOTE: this is the same endpoint the currency farm tools abuse. On this private
// server that is acceptable (it is also how the game legitimately awards currency,
// e.g. laser tag tickets), but it applies the add for whoever is authenticated -
// there is no server-side rate limit. Add one here if that becomes a problem.
// The award DEFINITION the client fetches when a balance room loads, before any checkpoint:
// GET /api/storefronts/v1/balanceAddType/{currency}/{type} (was a 404, four times a run)
// It answers the same RewardBalanceModificationDTO shape a balance add returns, with the
// base per unit and no total. The currency is decided by the room the caller is standing
// in, never by the URL - a client asking for Silver from the Rec Center gets no award type.
[HttpGet("/api/storefronts/v1/balanceAddType/{currencyType}/{addType}")]
public ContentResult BalanceAddTypeDefinition(int currencyType, int addType)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var live = HeartbeatDB.GetPlayerHeartbeat(account.Id)?.roomInstance;
var roomCur = Campus_live_rewrite.api.server.Classes.GameCurrency.ForBalanceRoom(live?.name);
if (roomCur == null || (int)roomCur.Value != currencyType)
{
Console.WriteLine($"[balance] {account.Id} asked for add type {addType} of currency {currencyType} in '{live?.name ?? "no room"}' - not that room's currency");
return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 404 };
}
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
BalanceAddType = addType,
BalanceInGiftBox = false, // a bool on the wire - "expected:'true | false', actual:'0'" (Player.log)
BaseAward = addType <= 0 ? 1 : addType,
BonusAward = 0,
CurrencyType = currencyType,
CurrentCount = 0,
Platform = Campus_live_rewrite.api.server.Classes.GameCurrency.AllPlatformsBucket,
RateLimit = 0,
Total = PlayerDB.GetBalance(account.Id, currencyType)
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPost("/api/storefronts/v2/balance")]
public ContentResult AddStorefrontBalance()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string body;
using (var reader = new StreamReader(HttpContext.Request.Body))
body = reader.ReadToEndAsync().GetAwaiter().GetResult();
int currencyType = 0;
long bodyInstanceId = 0, bodyRoomId = 0;
var adds = new List<(int addType, double multiplier)>();
try
{
var j = Newtonsoft.Json.Linq.JObject.Parse(body);
currencyType = (int?)j["CurrencyType"] ?? 0;
// Optional client-supplied room context. Only used to CROSS-CHECK the server's
// authoritative heartbeat instance below - never trusted on its own.
bodyInstanceId = (long?)j["RoomInstanceId"] ?? (long?)j["roomInstanceId"] ?? 0;
bodyRoomId = (long?)j["RoomId"] ?? (long?)j["roomId"] ?? 0;
foreach (var a in j["BalanceAdds"] as Newtonsoft.Json.Linq.JArray ?? new Newtonsoft.Json.Linq.JArray())
{
// Multiplier is a DECIMAL and it is where the score lives - a real
// Crescendo checkpoint sends {"BalanceAddType":1,"Multiplier":999.90}.
// Reading it as an int truncated the score and threw away the fraction.
double mult = (double?)a["Multiplier"] ?? 1d;
int at = (int?)a["BalanceAddType"] ?? 0;
adds.Add((at, mult <= 0d ? 1d : mult));
}
}
catch
{
return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 400 };
}
if (adds.Count == 0)
adds.Add((0, 1));
// Empty award set = a 200 with no updates. Nothing to mint, nothing to police.
ContentResult NoAward() => new ContentResult
{
// Same DTO as a real answer (see the end of this method) - a bare BalanceUpdates is
// what the client refuses as malformed.
Content = JsonConvert.SerializeObject(new
{
BalanceUpdates = Array.Empty<object>(),
Balance = PlayerDB.GetBalance(account.Id, currencyType),
CurrencyType = currencyType,
Platform = Campus_live_rewrite.api.server.Classes.GameCurrency.AllPlatformsBucket
}),
ContentType = "application/json",
StatusCode = 200
};
// ---- SERVER-SIDE room + instance gate ---------------------------------------
//
// The award is decided entirely from the player's LIVE room instance (the heartbeat),
// never from the request body. This is what stops "generating balance they ain't meant
// to have": you can only mint the currency of the room you are actually standing in.
var liveInstance = HeartbeatDB.GetPlayerHeartbeat(account.Id)?.roomInstance;
// (1) Must be in a real room instance - "need your RoomInstanceId and RoomId".
if (liveInstance == null || liveInstance.roomInstanceId == 0 || liveInstance.roomId == 0)
{
Console.WriteLine($"[balance] {account.Id} DENY - not in a live room instance (no RoomInstanceId/RoomId)");
return NoAward();
}
// If the client bothered to send its own ids, they must match the server's truth.
if ((bodyInstanceId != 0 && bodyInstanceId != liveInstance.roomInstanceId) ||
(bodyRoomId != 0 && bodyRoomId != liveInstance.roomId))
{
Console.WriteLine($"[balance] {account.Id} DENY - body instance/room ({bodyInstanceId}/{bodyRoomId}) " +
$"!= live ({liveInstance.roomInstanceId}/{liveInstance.roomId})");
return NoAward();
}
// (2) Only the whitelisted balance rooms mint anything, and the room decides the
// currency. Everything else (incl. Rec Center Token rooms) mints nothing here.
var roomCurrency = Campus_live_rewrite.api.server.Classes.GameCurrency.ForBalanceRoom(liveInstance.name);
if (roomCurrency == null)
{
Console.WriteLine($"[balance] {account.Id} in '{liveInstance.name}' DENY - not a balance room");
return NoAward();
}
currencyType = (int)roomCurrency.Value;
// (3) Rec Center Tokens (and any non-room currency) are server/admin only.
if (!Campus_live_rewrite.api.server.Classes.GameCurrency.IsPlayerGrantable(currencyType))
{
Console.WriteLine($"[balance] {account.Id} DENY - currency {currencyType} is not player-grantable");
return NoAward();
}
// ---- Anti-farm guard: 1-minute rate limit + long-session cap (deny, never ban) ----
var guard = Campus_live_rewrite.api.server.BalanceGuard.Evaluate(account.Id, liveInstance.roomInstanceId);
if (guard.decision == Campus_live_rewrite.api.server.BalanceGuard.Decision.Deny)
{
Console.WriteLine($"[balance][DENY] {account.Id} - {guard.reason}");
return NoAward();
}
Console.WriteLine($"[balance] {account.Id} room='{liveInstance.name}' inst={liveInstance.roomInstanceId} " +
$"currency={currencyType} adds=[{string.Join(", ", adds.Select(a => $"base{a.addType}x{a.multiplier}"))}]");
var updates = new List<object>();
long lastTotal = PlayerDB.GetBalance(account.Id, currencyType);
foreach (var (addType, multiplier) in adds)
{
// How much this award is worth.
//
// Real client request (Crescendo checkpoint):
// {"BalanceAdds":[{"BalanceAddType":1,"Multiplier":999.90}],"CurrencyType":101}
//
// BalanceAddType is the BASE per unit (1), not the amount. Multiplier is
// the score. So the award is base x multiplier, rounded - 1 x 999.90 = 1000
// Silver for that checkpoint.
//
// Clamped per currency: a Crescendo checkpoint is worth at most 30,000
// Silver (150,000 over five), so a wrong or forged Multiplier cannot mint
// more than a single checkpoint is worth.
long baseAward = addType;
long bonus = 0;
long raw = (long)Math.Round(baseAward * multiplier, MidpointRounding.AwayFromZero);
var cur = (Campus_live_rewrite.api.server.Classes.CurrencyType)currencyType;
long awarded = Campus_live_rewrite.api.server.Classes.GameCurrency.ClampAward(cur, raw + bonus);
if (awarded != raw + bonus)
Console.WriteLine($"[balance] {account.Id} award {raw + bonus} {cur} clamped to {awarded}");
long total = PlayerDB.AddBalance(account.Id, currencyType, awarded);
lastTotal = total;
updates.Add(new
{
UpdateResponse = 0, // 0 = success
// Data = the client's RewardBalanceModificationDTO, which it copies straight
// into a CurrencyAward { baseAward, bonusAward, currentCount, rateLimit,
// balanceAddType, currencyType, platformType } and then PLAYS - the coins that
// fly into the counter are baseAward + bonusAward. So BaseAward has to be the
// amount actually granted (base x score), not the base unit: with the unit here
// the player watched "+1" arrive while 1000 had been credited.
Data = new
{
BalanceAddType = addType,
BalanceInGiftBox = false, // a bool on the wire - "expected:'true | false', actual:'0'" (Player.log)
BaseAward = awarded,
BonusAward = 0,
CurrencyType = currencyType,
CurrentCount = 0,
Platform = Campus_live_rewrite.api.server.Classes.GameCurrency.AllPlatformsBucket,
RateLimit = 0,
Total = total
}
});
}
// The client deserialises this as BalanceUpdateResponseDTO<T>: BalanceUpdates PLUS the
// BalanceResponseDTO base - Balance (the resulting total), CurrencyType and Platform
// (BalanceType renamed on the wire). Sending BalanceUpdates alone was rejected by the
// client as "Malformed Response" - the checkpoint silver was credited server-side and
// then thrown away by the client, which is why a Crescendo checkpoint paid nothing
// visible. (wire-shapes: econ-bulkpurchase, the same DTO.)
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
BalanceUpdates = updates,
Balance = lastTotal,
CurrencyType = currencyType,
Platform = Campus_live_rewrite.api.server.Classes.GameCurrency.AllPlatformsBucket
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/customAvatarItems/v1/isCreationEnabled")]
public ContentResult IsCreationEnabled()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
player_data FullAccount = PlayerDB.GetFullAccount(account.Id);
return new ContentResult()
{
Content = "true",
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/customAvatarItems/v1/isRenderingEnabled")]
public ContentResult IsRenderingEnabled()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
player_data FullAccount = PlayerDB.GetFullAccount(account.Id);
return new ContentResult()
{
Content = "true",
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPost("/api/CampusCard/v1/UpdateAndGetSubscription")]
public IActionResult RRPlus()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var subscription = new SubscriptionResponse
{
subscription = new Subscription
{
subscriptionId = 507911,
recNetPlayerId = account.Id,
platformType = 0,
platformId = "76561198216660599",
platformPurchaseId = "4069635207998545083",
@event = null,
type = 0,
// SubscriptionLevel: Gold = 0, Platinum = 1 (see api/server/Classes/RRPlus.cs).
// Platinum is the top tier, so nothing gated on level stays locked.
level = (int)Campus_live_rewrite.api.server.Classes.RRPlus.SubscriptionLevel.Platinum,
// SubscriptionPeriod: Month = 0, Year = 1.
period = (int)Campus_live_rewrite.api.server.Classes.RRPlus.SubscriptionPeriod.Year,
// Was: 2024-03-20 - an expiry two years in the past, so the client
// correctly read the subscription as lapsed and showed the
// "Unlock Maker AI with RR+ / Subscribe Now" upsell. Far-future so
// it never lapses again.
expirationDate = new DateTime(2099, 1, 1, 0, 0, 0, DateTimeKind.Utc),
isAutoRenewing = true,
createdAt = new DateTime(2024, 2, 18, 7, 15, 0, 284, DateTimeKind.Utc).AddTicks(5308),
modifiedAt = new DateTime(2024, 3, 20, 7, 1, 55, DateTimeKind.Utc).AddTicks(3454572)
},
platformAccountSubscribedPlayerId = null
};
return Content(System.Text.Json.JsonSerializer.Serialize(subscription), "application/json");
}
// The RR+ season running now. The 20250718 client reads ONE season object here (the
// reference's `[]` was for a 2023 client): `[]` failed its parse and logged
// "GetCurrentSeason failed" on every boot and every RR+ page. No season runs here, so the
// answer is JSON null - "no current season", which the object reader accepts.
[HttpGet("/api/subscriptionseasons/v1/seasons/current")]
public ContentResult RRPlus2()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return new ContentResult()
{
Content = "null",
ContentType = "application/json",
StatusCode = 200
};
}
// The global slideshow feed: the most recent PUBLIC ShareCamera photos across all rooms,
// newest first, each joined to its creator's username and room name. Ported from RecFlare
// (getSlideshowImages); was a hardcoded "[]" so every slideshow surface sat empty.
//
// Deliberately PUBLIC (no token): it surfaces only already-public images and backs the
// anonymous homepage slideshow. `take` defaults to 10, capped at 100 so an unauthenticated
// caller can't ask for the whole image table. Answers { Images, ValidTill } where ValidTill
// is a ~2-minute cache hint the client refreshes against.
[HttpGet("/api/images/v1/slideshow")]
public ContentResult Slideshow([FromQuery] int? take = null)
{
var rows = ImageMetadataDB.GetSlideshowImages(take ?? ImageMetadataDB.SlideshowLimit);
// Bulk-resolve usernames and room names once, so the join is not a query per row.
var names = new Dictionary<ulong, string>();
foreach (var pid in rows.Select(r => r.PlayerId).Where(p => p != 0).Distinct())
names[pid] = PlayerDB.GetFullAccount(pid)?.player?.Username ?? $"Player{pid}";
var roomNames = new Dictionary<int, string?>();
foreach (var rid in rows.Select(r => r.RoomId).Where(r => r != 0).Distinct())
roomNames[rid] = RoomDB.GetRoom((ulong)rid)?.Name;
var images = rows.Select(img => new
{
SavedImageId = img.Id,
ImageName = img.ImageName,
Username = img.PlayerId != 0 && names.TryGetValue(img.PlayerId, out var n) ? n : $"Player{img.PlayerId}",
RoomName = img.RoomId != 0 && roomNames.TryGetValue(img.RoomId, out var rn) ? rn : null,
RoomId = img.RoomId == 0 ? (int?)null : img.RoomId,
SavedImageType = (int)img.Type,
PlayerEventId = img.PlayerEventId == 0 ? (ulong?)null : img.PlayerEventId,
Accessibility = (int)img.Accessibility,
PlayerIds = img.TaggedPlayerIds ?? new List<ulong>()
});
var body = new
{
Images = images,
ValidTill = DateTime.UtcNow.AddMinutes(2).ToString("o")
};
return new ContentResult()
{
Content = Newtonsoft.Json.JsonConvert.SerializeObject(body),
ContentType = "application/json",
StatusCode = 200
};
}
// The item wishlist (the heart on a store item / the Wishlist page). The 2023 server's
// working shape: the reads are a bare array of PurchasableItemIds, the Wishlist
// storefront (giftdropstore/1300) is built per player from those ids. The 2025 client
// posts add/remove to /wishlist/add and /wishlist/remove; the item id is read from the
// body, the query or the route, whichever it used, and the raw fields are logged so the
// spelling can be pinned.
private static long WishlistItemId(Dictionary<string, string> f, long fromRoute = 0)
{
if (fromRoute > 0) return fromRoute;
return RoomModeration.Long(f, "PurchasableItemId", "purchasableItemId", "itemId", "ItemId", "id", "Id", "item");
}
// The 2025 client posts { purchasableItemId, customAvatarItemId } - a UGC wish carries
// purchasableItemId -1 and the item GUID. The client reads the wishlist as a LIST OF ENTRY
// OBJECTS (real RecNet: WishlistItemId / AccountId / PurchasableItemId / CreatedAt); the
// bare id array we answered before failed its parser the moment it was non-empty
// ("Failed to add item to wishlist" / "Failed to get wishlist for player: An error occurred").
private static string? WishlistCustomId(Dictionary<string, string> f) =>
RoomModeration.Str(f, "customAvatarItemId", "CustomAvatarItemId", "customItemId") is { Length: > 0 } s && s != "null" ? s : null;
[HttpPost("/api/itemWishlists/v1/wishlist/add")]
[HttpPut("/api/itemWishlists/v1/wishlist/add")]
[HttpPost("/api/itemWishlists/v1/wishlist/add/{itemId:long}")]
[HttpPost("/api/itemWishlists/v1/wishlist/me/{itemId:long}")]
[HttpPut("/api/itemWishlists/v1/wishlist/me/{itemId:long}")]
[HttpPost("/api/itemWishlists/v1/wishlist/me")]
[HttpPut("/api/itemWishlists/v1/wishlist/me")]
public IActionResult WishlistAdd(long itemId = 0)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var f = RoomModeration.Fields(Request);
long item = WishlistItemId(f, itemId);
string? custom = WishlistCustomId(f);
int sf = (int)RoomModeration.Long(f, "StorefrontType", "storefrontType", "Storefront");
Console.WriteLine($"[wishlist] {account.Id} add {Request.Path}: {RoomModeration.Dump(f)} -> item {item} ugc {custom ?? "-"}");
if (item <= 0 && custom == null) return BadRequest(new { success = false, error = "no item id" });
var row = WishlistDB.Add(account.Id, sf, item > 0 ? (int)item : -1, custom, RoomModeration.Dump(f));
return Ok(WishlistDB.ToWire(row));
}
[HttpPost("/api/itemWishlists/v1/wishlist/remove")]
[HttpDelete("/api/itemWishlists/v1/wishlist/remove")]
[HttpPost("/api/itemWishlists/v1/wishlist/delete")]
[HttpDelete("/api/itemWishlists/v1/wishlist/delete")]
[HttpPost("/api/itemWishlists/v1/wishlist/remove/{itemId:long}")]
[HttpDelete("/api/itemWishlists/v1/wishlist/remove/{itemId:long}")]
[HttpDelete("/api/itemWishlists/v1/wishlist/me/{itemId:long}")]
[HttpDelete("/api/itemWishlists/v1/wishlist/me")]
[HttpDelete("/api/itemWishlists/v1/wishlist/add")]
[HttpDelete("/api/itemWishlists/v1/wishlist/{itemId:long}")]
[HttpDelete("/api/itemWishlists/v1/wishlist")]
public IActionResult WishlistRemove(long itemId = 0)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var f = RoomModeration.Fields(Request);
long item = WishlistItemId(f, itemId);
string? custom = WishlistCustomId(f);
// Removal by the entry's WishlistItemId (the guid the list handed out) - route tail,
// body or query - is resolved back to the row it names.
string? wid = RoomModeration.Str(f, "wishlistItemId", "WishlistItemId", "id", "Id");
string tail = Request.Path.Value?.TrimEnd('/').Split('/').LastOrDefault() ?? "";
if (Guid.TryParse(wid, out var g1) || Guid.TryParse(tail, out g1))
{
var row = WishlistDB.For(account.Id).FirstOrDefault(r => WishlistDB.WireId(r) == g1);
if (row != null) { item = row.PurchasableItemId; custom = row.CustomAvatarItemId; }
}
Console.WriteLine($"[wishlist] {account.Id} remove {Request.Path}: {RoomModeration.Dump(f)} -> item {item} ugc {custom ?? "-"}");
if (item <= 0 && custom == null) return BadRequest(new { success = false, error = "no item id" });
WishlistDB.Remove(account.Id, (int)item, custom);
return Ok(WishlistDB.WireFor(account.Id));
}
// The caller's creator earnings: what is pending, what was paid, and when the next
// Sunday payout lands.
[HttpGet("/api/earnings/v1/mine")]
[HttpGet("/api/site/earnings")]
public IActionResult MyEarnings()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var (pending, paid, next) = EarningsDB.Summary(account.Id);
var rows = EarningsDB.ForPlayer(account.Id).Select(e => new
{
e.Id, e.Kind, e.ItemName, e.Spent, e.Share, e.CurrencyType, e.BoughtAt, e.PayoutAt, e.PaidAt,
Buyer = PlayerDB.GetAccountById(e.BuyerId)?.Username ?? e.BuyerId.ToString()
}).ToList();
return Ok(new { Pending = pending, Paid = paid, NextPayout = next, Percent = ConfigCampus.CreatorEarningsPercent, PendDays = ConfigCampus.CreatorEarningsPendDays, Sales = rows });
}
[HttpGet("/api/itemWishlists/v1/wishlist/me")]
public IActionResult MyItemWishlists()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(WishlistDB.WireFor(account.Id));
}
[HttpGet("/api/itemWishlists/v1/wishlist/{playerId:long}")]
public IActionResult GetWishlistForPlayer(ulong playerId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(WishlistDB.WireFor(playerId));
}
// "Is each of these on my wishlist?" - one bool per id, in order (nonobject-responses: List<bool>).
[HttpPost("/api/itemWishlists/v1/isonwishlist/bulk")]
[HttpGet("/api/itemWishlists/v1/isonwishlist/bulk")]
public IActionResult IsOnWishlistBulk()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var f = RoomModeration.Fields(Request);
var ids = RoomModeration.ULongs(f, "purchasableItemIds", "PurchasableItemIds", "ids", "id", "itemIds");
var mine = WishlistDB.For(account.Id).Select(r => (ulong)Math.Max(0, r.PurchasableItemId)).ToHashSet();
return Ok(ids.Select(i => mine.Contains(i)).ToList());
}
// Anything else under itemWishlists: logged by name so a new spelling is one line in the
// journal instead of a silent 404. A guid in the path on a DELETE/POST is treated as a
// removal by WishlistItemId (e.g. DELETE .../wishlist/{guid}).
[Route("/api/itemWishlists/{**rest}")]
public IActionResult WishlistFallback(string? rest)
{
var f = RoomModeration.Fields(Request);
Console.WriteLine($"[wishlist] UNHANDLED {Request.Method} {Request.Path}{Request.QueryString} {RoomModeration.Dump(f)}");
string tail = (rest ?? "").TrimEnd('/').Split('/').LastOrDefault() ?? "";
if (Guid.TryParse(tail, out _) && !HttpMethods.IsGet(Request.Method)) return WishlistRemove(0);
return Ok(Array.Empty<long>());
}
[HttpGet("/api/server/status")]
public ContentResult status()
{
string key = HttpContext.Request.Headers["key"].ToString();
if (key == ConfigCampus.VALID_STATUS_KEY)
{
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
uptime = UPTIME,
}),
ContentType = "application/json",
StatusCode = 200
};
}
else
{
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
message = "Forbidden!"
}),
ContentType = "application/json",
StatusCode = 403
};
}
}
[HttpPost("/api/sanitize/v1")]
public IActionResult APISanitizeV1([FromBody] Sanitize request)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string value = request?.Value ?? "";
if (ConfigCampus.AllowSwears == true)
{
return Content(JsonConvert.SerializeObject(value), "application/json");
}
// The client tells us what to mask with (ReplacementChar, "*" in practice).
char mask = string.IsNullOrEmpty(request?.ReplacementChar) ? '*' : request.ReplacementChar[0];
string sanitized = Sanitize.SanitizeMessage(value, mask);
if (ConfigCampus.LogMessagesFromSanitize == true)
{
_ = Task.Run(() =>
{
try
{
Webhook.Send(account.Id, "mod", "Reporting Info - Sanitize", $"({account.Id}) @{account.Username} \n**Sanitize requested!**\n" +
$"**Current Room:** {HeartbeatDB.GetPlayerCurrentRoomName(account.Id)}\n" +
$"**Request Message:** \"{value}\"\n" +
$"**Sanitized Message:** \"{sanitized}\"\n" +
$"**Context:** \"{request.Context}\""
, 5898927);
}
catch (Exception ex)
{
Console.Error.WriteLine(ex);
}
});
}
return Content(JsonConvert.SerializeObject(sanitized), "application/json");
}
[HttpPost("/api/sanitize/v1/isPure")]
public IActionResult ApiSanitizeV1IsPure([FromBody] Sanitize request)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var isPure = !Sanitize.ContainsSwears(request.Value);
return Ok(new
{
IsPure = isPure
});
}
[HttpGet("/api/customAvatarItems/v2/fromCreator/{PlayerId}")]
public ContentResult CustomAvatarItemsFromCreator(ulong PlayerId, [FromQuery] int skip = 0, [FromQuery] int take = 1000)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
if (take > 1000) take = 1000;
if (take < 0) take = 0;
if (skip < 0) skip = 0;
// The UGC this player created, newest first, from the in-memory index. Another
// player's profile only shows what the creator PUBLISHED (Accessibility != 0) - the
// creator sees their private / unpublished items too, the way the reference does.
bool self = PlayerId == account.Id;
var mine = CustomItemsCache.ByCreator(PlayerId)
.Where(x => self || ((int?)x["Accessibility"] ?? 0) != 0)
.OrderByDescending(x => ((string?)x["CreatedAt"]) ?? "")
.ToList();
var page = mine.Skip(skip).Take(take).ToList();
return new ContentResult()
{
Content = new JObject
{
["Results"] = new JArray(page),
["TotalResults"] = mine.Count
}.ToString(Newtonsoft.Json.Formatting.None),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/influencerpartnerprogram/influencer")]
public ContentResult influencerpartnerprogram(ulong accountId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// Bare number, always 200 (a 404 here is the "404 blanks the profile" trap). The value
// is the influencer THIS ACCOUNT SUPPORTS (0 = nobody) - the same thing /myinfluencer
// answers for the caller. "Is X an influencer" is a different question, answered by
// the /influencers id list the client badges creators from. Answering X's own id here
// whenever X was an influencer made every influencer's profile read as supporting
// themselves, whoever they had actually picked.
string body = (PlayerDB.GetMyInfluencer(accountId) ?? 0).ToString();
return new ContentResult()
{
Content = body,
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPost("/api/avatar/v2/set")]
public async Task<ContentResult> SetAvatar()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string jsonData = "";
try
{
using var reader = new StreamReader(HttpContext.Request.Body);
jsonData = await reader.ReadToEndAsync();
}
catch
{
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 400
};
}
if (string.IsNullOrWhiteSpace(jsonData))
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 400
};
try
{
Newtonsoft.Json.Linq.JToken.Parse(jsonData);
}
catch
{
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 400
};
}
var player = PlayerDB.GetFullAccount(account.Id);
if (player == null)
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 401
};
try
{
PlayerDB.set_player_avatar(jsonData, player.playerid);
}
catch (Exception ex)
{
Console.WriteLine($"[Avatar Set Error] {ex}");
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 400
};
}
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPost("/api/consumables/v1/consume")]
public IActionResult ConsumeConsumable()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok("");
}
[HttpGet("/api/online_players")]
public IActionResult OnlinePlayers()
{
var onlineList = new List<object>();
foreach (var (playerId, socket) in NotificationsController.WebSockets)
{
onlineList.Add(new
{
PlayerId = playerId,
CurrentRoom = HeartbeatDB.GetPlayerCurrentRoomName(playerId)
});
}
return Ok(onlineList);
}
[HttpGet("/api/ping")]
public ContentResult ServerPing()
{
return new ContentResult
{
Content = "true",
ContentType = "application/json",
StatusCode = 200
};
}
// todo check if it a real image file and return a 400 error if it not a image file - Waeboi69
[HttpPost("/api/images/v4/uploadsaved")]
public async Task<ContentResult> UploadImage()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
HttpContext.Request.EnableBuffering();
byte[] array;
using (var memoryStream = new MemoryStream(capacity: 4 * 1024 * 1024))
{
await HttpContext.Request.Body.CopyToAsync(memoryStream, bufferSize: 131072);
array = memoryStream.ToArray();
}
HttpContext.Request.Body.Position = 0;
// Only a photo the game's camera encoded gets stored (see UploadGuard). Checked
// before anything touches disk.
byte[]? photo = Utils.ParseData(array, "file.bin", out bool photoParsed);
var refused = UploadGuard.Gate(HttpContext, account, UploadGuard.Kind.Image, photoParsed ? photo : null, "images/v4/uploadsaved");
if (refused != null) return refused;
var saveImageTask = Task.Run(() => Images.SaveImageFile(array));
var formTask = HttpContext.Request.ReadFormAsync();
var file = await saveImageTask.ConfigureAwait(false);
var form = await formTask.ConfigureAwait(false);
if (!form.TryGetValue("imgMeta", out var imgMetaValue) || string.IsNullOrEmpty(imgMetaValue))
{
return new ContentResult
{
Content = "",
ContentType = "application/json",
StatusCode = 400
};
}
ImageMetadataDB.ImageMetadata? metadata = null;
if (!string.IsNullOrEmpty(imgMetaValue))
{
metadata = JsonConvert.DeserializeObject<ImageMetadataDB.ImageMetadata>(imgMetaValue!);
}
var newImage = new FullSavedImage
{
Id = 0,
RoomId = metadata.roomId,
PlayerId = account.Id,
ImageName = file.rawfilename,
Description = metadata.description,
Accessibility = metadata.accessibility,
TaggedPlayerIds = metadata.playerIds,
CreatedAt = DateTime.UtcNow
};
if (metadata.savedImageType != SavedImageTypeEnum.OutfitThumbnail)
{
await Task.Run(() => ImageMetadataDB.InsertImage(newImage)).ConfigureAwait(false);
if (metadata.accessibility == ImageAccessibility.Public)
{
_ = Task.Run(() => Webhook.Send(
account.Id, "prod", "Server info",
$"({account.Id}) @{account.Username} has uploaded an image",
5898927, $"https://gameauth.ddns.net/img/{file.rawfilename}"
));
}
}
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
success = true,
error = "",
ImageName = file.rawfilename
}),
ContentType = "application/json",
StatusCode = 200
};
}
// The client expects ONE BARE 13-key image record here, never an array (wire-shapes:
// api-images-v6). Both spellings are live: ?name= and the path form /api/images/v6/{id},
// which used to 404. An unknown image answers a stub record rather than [] - the client's
// post-deserialize hook only normalises null ids to 0, so a stub is safe and an array is a
// Malformed Response.
[HttpGet("/api/images/v6/{savedImageId:long}")]
public ContentResult GetImageById(long savedImageId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var rec = ImageMetadataDB.GetImageV6ById(savedImageId);
return new ContentResult
{
Content = JsonConvert.SerializeObject(rec ?? ImageMetadataDB.EmptyImageV6("")),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/images/v6")]
public ContentResult GetImageByImageName(string name)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
try
{
var image = ImageMetadataDB.GetImageV6ByName(name) ?? ImageMetadataDB.EmptyImageV6(name);
return new ContentResult
{
Content = JsonConvert.SerializeObject(image),
ContentType = "application/json",
StatusCode = 200
};
}
catch (Exception ex)
{
Console.WriteLine($"[Image V6 Error] {ex}");
return new ContentResult
{
Content = "[]",
ContentType = "application/json",
StatusCode = 200
};
}
}
[HttpGet("/api/images/v5/cheered/bulk")]
[HttpPost("/api/images/v5/cheered/bulk")]
public ContentResult ImagesCheered([FromQuery] int[] id)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// The 2025 client POSTs the ids (form or JSON "id"/"ids"/"imageIds") - 405'd as GET-only.
if (id == null || id.Length == 0)
{
var f = RoomModeration.Fields(Request);
id = RoomModeration.ULongs(f, "id", "ids", "imageIds", "savedImageIds", "SavedImageIds").Select(u => (int)u).ToArray();
}
var results = id.Select(imageId => new
{
SavedImageId = imageId,
IsCheered = ImageMetadataDB.HasUserCheered(imageId, account.Id)
}).ToList();
return new ContentResult
{
Content = JsonConvert.SerializeObject(results),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/images/v4/room/{id}")]
public ContentResult GetRoomImages(int id, int sort = 0, int filter = 0, int take = 50, int skip = 0)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var images = ImageMetadataDB.GetRoomImages(account.Id, id, sort, filter, take, skip);
return new ContentResult
{
Content = JsonConvert.SerializeObject(images, Formatting.Indented),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPost("/api/images/v1/cheer")]
public ContentResult CheerImage([FromBody] CheerRequest request)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
bool isCheered = ImageMetadataDB.ToggleCheer(request.SavedImageId, account.Id, request.Cheer);
int cheerCount = ImageMetadataDB.GetCheerCount(request.SavedImageId);
ImageMetadataDB.UpdateImageCheerCount(request.SavedImageId, cheerCount);
return new ContentResult
{
Content = "",
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/images/v5/player/{playerid}")]
public IActionResult GetPlayerImages(ulong playerid, int sort = 0, int skip = 0, int take = 0)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var playerImages = ImageMetadataDB.GetPlayerImages(account.Id, playerid, sort, skip, take);
return Ok(playerImages);
}
// The photo tab on a profile: the images this player took PLUS the ones they're tagged in,
// newest first (RecFlare /api/images/v3/feed/player). Only v3/feed/GLOBAL and v5/player
// existed, so the profile's photo feed 404'd - a big reason a profile opened blank. Serves
// the same ImagesPlayer projection (bare array) as the other player photo lists.
[HttpGet("/api/images/v3/feed/player/{playerid}")]
public IActionResult GetPlayerFeed(ulong playerid, [FromQuery] int skip = 0, [FromQuery] int take = 100, [FromQuery] int sort = 0)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(ImageMetadataDB.GetPlayerFeed(account.Id, playerid, skip, take, sort: sort));
}
// A player's own photos (RecFlare /api/images/v4/player) - the older sibling of v5/player,
// same ImagesPlayer projection. Was missing, so a client that asks for v4 got a 404 and an
// empty photo grid.
[HttpGet("/api/images/v4/player/{playerid}")]
public IActionResult GetPlayerImagesV4(ulong playerid, [FromQuery] int skip = 0, [FromQuery] int take = 100, [FromQuery] int sort = 0)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(ImageMetadataDB.GetPlayerImages(account.Id, playerid, sort, skip, take));
}
[HttpGet("/api/images/v5/bulk")]
public ContentResult GetImagesByBulkIds()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var idsRaw = Request.Query["ids"];
List<int> idList = idsRaw
.SelectMany(x => x.Split(',', StringSplitOptions.RemoveEmptyEntries))
.Select(x => x.Trim())
.Where(x => int.TryParse(x, out _))
.Select(int.Parse)
.ToList();
if (idList.Count == 0)
{
return new ContentResult
{
Content = "",
ContentType = "application/json",
StatusCode = 400
};
}
var images = ImageMetadataDB.GetImagesByIds(idList);
return new ContentResult
{
Content = JsonConvert.SerializeObject(images),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/rooms/v1/filters")]
public ContentResult GetRoomTags()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var roomTags = new
{
PinnedFilters = new[]
{
"recroomoriginal", "community", "featured", "quest", "pvp", "hangout", "game",
"art", "store", "tutorial", "fandom", "performance", "action", "horror"
},
PopularFilters = new[]
{
"pvp", "quest", "game", "hangout", "art"
},
TrendingFilters = new[]
{
"roleplay", "nomp", "rp", "casual", "fun", "action", "military", "sports"
}
};
return new ContentResult
{
Content = JsonConvert.SerializeObject(roomTags),
ContentType = "application/json",
StatusCode = 200
};
}
// Whether the caller holds at least `role` in a room - the gate the client checks before
// letting someone use the Maker Pen / room settings in-room (reference: verifyRole). A bare
// true/false; every failure is false rather than an error status. The creator always
// passes; anyone else needs a Roles entry at that level or higher. This answered "[]" for
// everyone, so a co-owner (and anyone the client asked about) was told "no" - the "can't
// do anything in a room I'm co-owner of" complaint. Params from the form body, falling
// back to the query string; `context` (e.g. MakerPen) is accepted and ignored.
[HttpPost("/api/rooms/v1/verifyRole")]
public ContentResult VerifyRole()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return new ContentResult { Content = "false", ContentType = "application/json", StatusCode = 200 };
string Param(string name)
{
try
{
if (Request.HasFormContentType)
{
var v = Request.Form[name].ToString();
if (!string.IsNullOrEmpty(v)) return v;
}
}
catch { }
return Request.Query[name].ToString();
}
ulong.TryParse(Param("roomId"), out var roomId);
int.TryParse(Param("role"), out var role);
if (roomId == 0) roomId = (ulong)Math.Max(0, long.TryParse(Param("RoomId"), out var r2) ? r2 : 0);
bool ok = roomId != 0 && RoomDB.PlayerHasRoleAtLeast(roomId, account.Id, role);
Console.WriteLine($"[verifyRole] {account.Id} room {roomId} role>={role} ctx='{Param("context")}' -> {ok}");
return new ContentResult { Content = ok ? "true" : "false", ContentType = "application/json", StatusCode = 200 };
}
[HttpGet("/api/avatar/v3/saved")]
public ContentResult GetOutfit()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return new ContentResult()
{
Content = PlayerDB.GetAvatarSaved(account.Id),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPost("/api/avatar/v4/saved/set")]
public async Task<ContentResult> SaveOutfit()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
using var reader = new StreamReader(HttpContext.Request.Body);
string body = await reader.ReadToEndAsync();
avatar_data_saved avatar;
try
{
avatar = JsonConvert.DeserializeObject<avatar_data_saved>(body);
if (avatar == null)
throw new Exception("Deserialization returned null");
Console.WriteLine($"Slot: {avatar.Slot}, Name: {avatar.Name}, SkinColor: {avatar.SkinColor}");
// Keep the raw document the client posted so a preset round-trips without losing
// any field our structured type doesn't model.
if (string.IsNullOrEmpty(avatar.Avatar)) avatar.Avatar = body;
PlayerDB.SetAvatarSaved(account.Id, new List<avatar_data_saved> { avatar });
}
catch (Exception ex)
{
Console.WriteLine("Failed to parse or save avatar JSON: " + ex.Message);
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 400
};
}
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
Success = true,
Slot = avatar.Slot
}),
ContentType = "application/json",
StatusCode = 200
};
}
// v3 of the save-outfit route. SAME payload, table and slot-overwrite semantics as
// /api/avatar/v4/saved/set above - the reference shares one handler for both and only the
// RESPONSE differs (v3 echoes the whole saved outfit back; v4 answers { Success, Slot }).
// This route did not exist, so a client that saves through v3 got a 404 and the outfit
// never reached the database. Persists into the same store (PlayerDB.SetAvatarSaved ->
// player_Extra.SavedOutfits) that /api/avatar/v3/saved and /outfits/me/saved read back.
[HttpPost("/api/avatar/v3/saved/set")]
public async Task<ContentResult> SaveOutfitV3()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
using var reader = new StreamReader(HttpContext.Request.Body);
string body = await reader.ReadToEndAsync();
avatar_data_saved avatar;
try
{
avatar = JsonConvert.DeserializeObject<avatar_data_saved>(body);
if (avatar == null)
throw new Exception("Deserialization returned null");
Console.WriteLine($"[v3/saved/set] Slot: {avatar.Slot}, Name: {avatar.Name}");
if (string.IsNullOrEmpty(avatar.Avatar)) avatar.Avatar = body;
PlayerDB.SetAvatarSaved(account.Id, new List<avatar_data_saved> { avatar });
}
catch (Exception ex)
{
Console.WriteLine("[v3/saved/set] failed to parse or save avatar JSON: " + ex.Message);
return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 400 };
}
// v3 echoes the saved outfit back (v4 returns the lean ack instead).
return new ContentResult
{
Content = JsonConvert.SerializeObject(avatar),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPost("/api/PlayerCheer/v1/SetSelectedCheer")]
public async Task<ContentResult> SetSelectedCheer()
{
try
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// Form, query or JSON - Request.Form threw on a non-form body and the catch below
// turned every such call into a 400.
var cf = Campus_live_rewrite.api.server.RoomModeration.Fields(Request);
string? cheerCategoryStr = Campus_live_rewrite.api.server.RoomModeration.Str(cf, "CheerCategory", "cheerCategory", "category", "value");
if (string.IsNullOrEmpty(cheerCategoryStr))
{
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 200
};
}
if (!Enum.TryParse<CheerCategoryEnum>(cheerCategoryStr, out var cheerCategory))
{
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 400
};
}
if (account == null)
{
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 401
};
}
await Task.Run(() => PlayerDB.SetPlayerCheer(cheerCategory, account.Id));
try
{
var selfUpdate = JsonConvert.SerializeObject(WebsocketEvents.CreateResponseReputationUpdate(account.Id));
var globalUpdate = JsonConvert.SerializeObject(WebsocketEvents.CreateResponseReputationUpdate(account.Id));
var notifySelfTask = Notifications.SendToPlayer(account.Id, selfUpdate);
var notifyAllTask = Notifications.SendToAll(globalUpdate);
await Task.WhenAll(notifySelfTask, notifyAllTask);
}
catch (Exception ex)
{
Console.WriteLine($"Failed to send notification: {ex.Message}");
}
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 200
};
}
catch (Exception ex)
{
Console.WriteLine($"SetSelectedCheer Error: {ex}");
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 400
};
}
}
[HttpGet("/api/incentivizedreferrals/progress")]
public ContentResult get_incentivizedreferrals_incentivizedreferrals()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
success = true,
value = new
{
ReferralsVerifiedCount = 0,
PlayerReferralRewards = new List<object>()
},
/*
[{
"UnlockedAt": null,
"ClaimedAt": null,
"Status": 0,
"ReferralRewardTier": {
"ReferralRewardTierId": 1,
"RequiredReferralCount": 1,
"RewardSelectionCount": 1
}
}]
*/
}),
ContentType = "application/json",
StatusCode = 200
};
}
//https://econ.rec.net/api/influencerpartnerprogram/influencer?accountId=1503269750 return a 404 or a influencer account id
// /api/influencerpartnerprogram/support
// /api/inventions/v1/fromcreators // todo: add this
[HttpGet("/api/influencerpartnerprogram/influencers")]
public ContentResult GetAllInfluencers()
{
// https://econ.rec.net/api/influencerpartnerprogram/influencers?take=1000
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
InfluencerIds = PlayerDB.GetInfluencerIds(),
ContinuationToken = (string?)null
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPost("/api/influencerpartnerprogram/support")]
public ContentResult SupportInfluencer()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string? SupportIdStr = HttpContext.Request.Form["influencerAccountId"].ToString();
if (!ulong.TryParse(SupportIdStr, out ulong SupportId)) SupportId = 0;
// you can only support someone ELSE, and only an actual partner-program influencer
if (SupportId == account.Id || (SupportId != 0 && !PlayerDB.IsInfluencer(SupportId)))
return new ContentResult
{
Content = JsonConvert.SerializeObject(new { success = false, error = SupportId == account.Id ? "You can't support yourself." : "That player isn't in the creator program.", value = (string?)null }),
ContentType = "application/json",
StatusCode = 200
};
PlayerDB.SupportInfluencer(account.Id, SupportId);
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
success = true,
error = "",
value = (string?)null
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPost("/api/influencerpartnerprogram/remove")]
public ContentResult RemoveInfluencer()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
PlayerDB.SupportInfluencer(account.Id, 0);
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
success = true,
error = "",
value = (string?)null
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/influencerpartnerprogram/myinfluencer")]
public IActionResult MyInfluencer()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// Bare number, always 200 - see the note on /influencer above. The reference returns 0
// for everyone (no partner program); returning the stored tier (0 when none) matches
// that and drops the 404 that a profile-open can choke on.
var myInfluencer = PlayerDB.GetMyInfluencer(account.Id);
return Content(myInfluencer.ToString(), "application/json");
}
// /api/relationships/v2/sendfriendrequest?id={playerid}
// /api/relationships/v2/addfriend?id={playerid}
// /api/relationships/v2/acceptfriendrequest?id={playerid}
// Both of these 404'd on every call (bulkignoreplatformusers 13x in two days).
// They are platform-friend plumbing: the client asks which Steam/Xbox users are
// blocked, and which platform friends referred it. This server has no platform
// friend graph, so the honest answer is an empty list - but it has to BE a list.
// A 404 leaves the client's social panel waiting on a request that never resolves.
[HttpPost("/api/relationships/v1/bulkignoreplatformusers")]
[HttpGet("/api/relationships/v1/bulkignoreplatformusers")]
public ContentResult BulkIgnorePlatformUsers()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return new ContentResult
{
Content = "[]",
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPost("/api/externalfriendinvite/v1/getplatformreferrers")]
[HttpGet("/api/externalfriendinvite/v1/getplatformreferrers")]
public ContentResult GetPlatformReferrers()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return new ContentResult
{
Content = "[]",
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/relationships/v2/get")]
public ContentResult GetRelationships()
{
string authtoken = HttpContext.Request.Headers["Authorization"].ToString();
if (string.IsNullOrEmpty(authtoken))
{
authtoken = HttpContext.Request.Cookies["Authorization"];
}
if (string.IsNullOrEmpty(authtoken))
{
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 401
};
}
string playerdata = ClientSecurity.DecodeToken(authtoken);
if (playerdata == null)
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 401
};
if (playerdata.Contains("error_token"))
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 401
};
player_info? account = JsonConvert.DeserializeObject<player_info>(playerdata);
return new ContentResult()
{
Content = JsonConvert.SerializeObject(
FriendsDB.GetRelationships(account.Id).Select(RelationshipDto).ToList()
),
ContentType = "application/json",
StatusCode = 200
};
}
// The client's RelationshipResponse is EXACTLY these five fields (RecFlare's
// RelationshipDto, asserted with `toEqual`). Our internal RelationshipDetail also carries
// Id, OtherPlayerID and VoiceVolume, and Id is a ~1.79-trillion value that overflows an
// int32 - emitting those made the client's strict parser drop the WHOLE array, so the
// friends list came back empty (it never even fetched the account cards - no
// /Accounts/account/bulk followed the relationships GET). Project to the exact shape,
// flags as 0/1.
private static object RelationshipDto(RelationshipDetail r) => new
{
PlayerID = r.PlayerID,
RelationshipType = (int)r.RelationshipType,
Favorited = r.Favorited == ReciprocalStatus.None ? 0 : 1,
Ignored = FriendsDB.IgnoredWire(r.Ignored), // 1 mine / 2 theirs / 3 both - see FriendsDB.IgnoredWire
Muted = r.Muted == ReciprocalStatus.None ? 0 : 1
};
[HttpGet("/api/relationships/v2/sendfriendrequest")]
public async Task<ContentResult> SendFriendRequest(ulong id) // player id
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
if (PlayerDB.CheckDevFlag(account.Id) && id == 1)
{
RelationshipDetail rs = FriendsDB.SendFriendRequest(account.Id, id);
_ = Task.Run(async () =>
{
await Task.Delay(1000);
FriendsDB.AcceptFriendRequest(account.Id, 1);
});
return new ContentResult()
{
Content = JsonConvert.SerializeObject(RelationshipDto(rs)),
ContentType = "application/json",
StatusCode = 200
};
}
RelationshipDetail relationship = FriendsDB.SendFriendRequest(account.Id, id);
return new ContentResult()
{
Content = JsonConvert.SerializeObject(RelationshipDto(relationship)),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/relationships/v2/acceptfriendrequest")]
public ContentResult AcceptFriendRequest(ulong id) // player id
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// Todo: add rate limiting
RelationshipDetail relationship = FriendsDB.AcceptFriendRequest(id, account.Id);
if (relationship == null)
{
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 400
};
}
return new ContentResult()
{
Content = JsonConvert.SerializeObject(RelationshipDto(relationship)),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/relationships/v2/addfriend")]
public ContentResult AddFriendViaHandshakeInGame(ulong id) // player id
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// Befriend DIRECTLY, no pending-request step (reference: addfriend - "Become friends
// with no pending-request step. Already being friends is a no-op."). This is the
// in-game handshake: both players have already agreed physically. It used to only
// ACCEPT an existing request, so a handshake between two players with no request
// pending returned null, threw (500) and made no friends.
if (id == 0 || id == account.Id)
return new ContentResult { Content = "{\"error\":\"invalid player id\"}", ContentType = "application/json", StatusCode = 400 };
RelationshipDetail? relationship = FriendsDB.AddFriendDirect(account.Id, id);
if (relationship == null)
return new ContentResult { Content = "{\"error\":\"could not add friend\"}", ContentType = "application/json", StatusCode = 400 };
Console.WriteLine($"[handshake] {account.Id} and {id} are now friends");
return new ContentResult()
{
Content = JsonConvert.SerializeObject(RelationshipDto(relationship)),
ContentType = "application/json",
StatusCode = 200
};
}
// Per-player voice volume (the slider on a player's card). Stored on the caller's own side
// of the relationship row; nothing else reads it back but the row keeps it. Was a 404.
[HttpPost("/api/relationships/v2/updateVoiceVolume")]
[HttpPut("/api/relationships/v2/updateVoiceVolume")]
public IActionResult UpdateVoiceVolume()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var f = RoomModeration.Fields(Request);
ulong target = RoomModeration.ULong(f, "PlayerId", "playerId", "id", "OtherPlayerId");
long vol = RoomModeration.Long(f, "VoiceVolume", "voiceVolume", "Volume", "volume");
if (target == 0 || target == account.Id) return BadRequest(new { error = "invalid player id" });
FriendsDB.SetVoiceVolume(account.Id, target, (int)Math.Clamp(vol, 0, 200));
return Ok(new { success = true, error = "" });
}
// Report one chat message (reference api POST /api/chatreport/createChatReport): the report
// is filed against the message's SENDER, and only a member of the thread may file it.
[HttpPost("/api/chatreport/createChatReport")]
public IActionResult CreateChatReport()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var f = RoomModeration.Fields(Request);
long messageId = RoomModeration.Long(f, "ChatMessageId", "chatMessageId", "MessageId");
long threadId = RoomModeration.Long(f, "ChatThreadId", "chatThreadId", "ThreadId");
string details = RoomModeration.Str(f, "ReportDescription", "Details", "Description") ?? "";
var catRaw = RoomModeration.Str(f, "ReportCategory", "Category") ?? "";
int category = int.TryParse(catRaw, out var cn) ? cn
: Enum.TryParse<Moderation_Detail.ReportCategory>(catRaw, true, out var ce) ? (int)ce : 0;
var msg = ChatDB.GetMessage(messageId);
if (msg == null || !ChatDB.IsMember(msg.ChatThreadId, account.Id) || msg.SenderPlayerId <= 0)
return Ok(new { success = false, error = "That message could not be found." });
Campus_live_rewrite.api.dbs.ReportsDB.Create(new Campus_live_rewrite.api.dbs.ReportsDB.Report
{
ReporterPlayerId = account.Id,
ReportedPlayerId = (ulong)msg.SenderPlayerId,
ReportCategory = category,
Details = $"[chat message {messageId} in thread {msg.ChatThreadId}] {(int.TryParse(catRaw, out _) ? "" : catRaw + ": ")}{details}".Trim(),
});
Console.WriteLine($"[report] {account.Id} reported chat message {messageId} by {msg.SenderPlayerId} ({catRaw})");
return Ok(new { success = true, error = "" });
}
[HttpGet("/api/relationships/v2/removefriend")]
public ContentResult RemoveFriend(ulong id) // player id
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
//Todo: add rate limiting
RelationshipDetail relationship = FriendsDB.RemoveFriend(id, account.Id);
return new ContentResult()
{
Content = JsonConvert.SerializeObject(RelationshipDto(relationship)),
ContentType = "application/json",
StatusCode = 200
};
}
// ---------------------------------------------------------------------------------------
// Per-side relationship flags: favorite / ignore / mute and their inverses. Ported from
// RecFlare's social routes; none of these existed before, so favoriting ("liking") a
// friend, ignoring, and muting all 404'd. The flag lands on the CALLER's own side of the
// pair (FriendsDB.SetRelationshipFlag) - and can be set on a player you were never friends
// with, which just creates a bare (None) row to hold it. The client calls these as a GET
// with the target in ?id=; POST + a PlayerId form field is accepted too. Body is the
// { Success, Message } ack RecFlare returns; the caller re-reads /relationships/v2/get to
// see the new flag (favorited friends pin to the top of the list).
// ---------------------------------------------------------------------------------------
private ContentResult RelationshipFlagResult(FriendInfo flag, bool value)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
ulong target = 0;
string q = Request.Query["id"].ToString();
if (string.IsNullOrEmpty(q)) q = Request.Query["playerId"].ToString();
if (string.IsNullOrEmpty(q) && Request.HasFormContentType)
{
q = Request.Form["PlayerId"].ToString();
if (string.IsNullOrEmpty(q)) q = Request.Form["playerId"].ToString();
if (string.IsNullOrEmpty(q)) q = Request.Form["Id"].ToString();
}
if (!ulong.TryParse(q, out target) || target == 0 || target == account.Id)
{
return new ContentResult
{
Content = JsonConvert.SerializeObject(new { error = "invalid player id" }),
ContentType = "application/json",
StatusCode = 400
};
}
// Ignore is BLOCK: two-sided, and only the blocker can lift it (FriendsDB.SetBlock).
var rel = flag == FriendInfo.Ignored
? FriendsDB.SetBlock(account.Id, target, value)
: FriendsDB.SetRelationshipFlag(account.Id, target, flag, value);
// The real RecNet answers the updated relationship (research/RecNet API.postman:
// /api/relationships/v1/favorite -> {Id, PlayerID, RelationshipType, Favorited, Muted,
// Ignored}), which is what the client's star / mute toggles update themselves from.
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
Id = rel.Id,
PlayerID = rel.PlayerID,
RelationshipType = (int)rel.RelationshipType,
Favorited = rel.Favorited == ReciprocalStatus.None ? 0 : 1,
Muted = rel.Muted == ReciprocalStatus.None ? 0 : 1,
Ignored = FriendsDB.IgnoredWire(rel.Ignored)
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/relationships/v1/favorite")]
[HttpPost("/api/relationships/v1/favorite")]
public ContentResult FavoritePlayer() => RelationshipFlagResult(FriendInfo.Favorited, true);
[HttpGet("/api/relationships/v1/unfavorite")]
[HttpPost("/api/relationships/v1/unfavorite")]
public ContentResult UnfavoritePlayer() => RelationshipFlagResult(FriendInfo.Favorited, false);
[HttpGet("/api/relationships/v1/ignore")]
[HttpPost("/api/relationships/v1/ignore")]
public ContentResult IgnorePlayer() => RelationshipFlagResult(FriendInfo.Ignored, true);
[HttpGet("/api/relationships/v1/unignore")]
[HttpPost("/api/relationships/v1/unignore")]
public ContentResult UnignorePlayer() => RelationshipFlagResult(FriendInfo.Ignored, false);
[HttpGet("/api/relationships/v1/mute")]
[HttpPost("/api/relationships/v1/mute")]
public ContentResult MutePlayer() => RelationshipFlagResult(FriendInfo.Muted, true);
[HttpGet("/api/relationships/v1/unmute")]
[HttpPost("/api/relationships/v1/unmute")]
public ContentResult UnmutePlayer() => RelationshipFlagResult(FriendInfo.Muted, false);
// Presence for the caller's favorited friends. Like RecFlare, presence isn't joined here
// yet, so this is an empty list - but it must answer 200 [] instead of 404ing, which the
// client treats as an error on the friends panel.
[HttpGet("/api/messages/v1/favoriteFriendOnlineStatus")]
public ContentResult FavoriteFriendOnlineStatus()
{
return new ContentResult
{
Content = "[]",
ContentType = "application/json",
StatusCode = 200
};
}
/*
[HttpPost("/api/customAvatarItems/v1")]
public async Task<ContentResult> CreateCustomAvatar()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
HttpContext.Request.EnableBuffering();
byte[] array;
using (var memoryStream = new MemoryStream(capacity: 4 * 1024 * 1024))
{
await HttpContext.Request.Body.CopyToAsync(memoryStream, bufferSize: 131072);
array = memoryStream.ToArray();
}
HttpContext.Request.Body.Position = 0;
// todo: get the desinflesti elif g
var saveImageTask = Task.Run(() => Images.SaveImageFile(array));
var formTask = HttpContext.Request.ReadFormAsync();
var file = await saveImageTask.ConfigureAwait(false);
var form = await formTask.ConfigureAwait(false);
if (!form.TryGetValue("metadata", out var metadatavalue) || string.IsNullOrEmpty(metadatavalue))
{
return new ContentResult
{
Content = "",
ContentType = "application/json",
StatusCode = 400
};
}
CreateCustomAvatarItemResponse metadata = JsonConvert.DeserializeObject<CreateCustomAvatarItemResponse>(metadatavalue);
if (metadata.Accessibility == 1)
{
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
Success = false,
Value = (string?)null,
ErrorId = "Rooms.DoesntExist",
Error = "Sorry, that option is not available yet. Switch to 'Just Me'"
}),
ContentType = "application/json",
StatusCode = 200
};
}
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
Success = true,
Value = metadata,
}),
ContentType = "application/json",
StatusCode = 200
};
}
*/
[HttpPost("/api/customAvatarItems/v1/bulk")]
[HttpGet("/api/customAvatarItems/v1/bulk")]
public IActionResult BulkCustomAvatarItems([FromForm] List<string>? customAvatarItemIds)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// The GET spelling (?customAvatarItemIds=a&customAvatarItemIds=b, or comma-joined) was
// a 405 - it is how a client resolves an item it sees on SOMEONE ELSE's avatar.
if (customAvatarItemIds == null || customAvatarItemIds.Count == 0)
{
customAvatarItemIds = new List<string>();
foreach (var k in new[] { "customAvatarItemIds", "CustomAvatarItemIds", "ids", "id" })
foreach (var v in Request.Query[k])
foreach (var part in (v ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries))
customAvatarItemIds.Add(part.Trim());
}
// Resolve the requested custom-item ids from the in-memory UGC index (was re-reading +
// parsing customitems.json per call). This is the outfit-build lookup - an equipped UGC
// item that resolves here renders instead of failing "customAvatarItemId not found".
var items = CustomItemsCache.GetByIds(customAvatarItemIds ?? new List<string>());
return new ContentResult
{
Content = new JArray(items).ToString(Newtonsoft.Json.Formatting.None),
ContentType = "application/json",
StatusCode = 200
};
}
// Search published custom avatar items (reference: GET /api/customAvatarItems/v1/search).
// Filters the custom-item catalog (customitems.json Results) and returns a BARE ARRAY,
// newest first: searchQuery matches Name or Description (case-insensitive substring),
// outfitTypes is a whitelist (none = no filter), minPrice/maxPrice bound Price inclusively,
// skip/take page (take capped at 200), unpublished items (Accessibility 0) are left out.
// Empty while no UGC items exist - the same answer the reference gives for an empty table.
[HttpGet("/api/customAvatarItems/v1/search")]
public IActionResult SearchCustomAvatarItems(
[FromQuery] string? searchQuery,
[FromQuery] List<int> outfitTypes,
[FromQuery] int? minPrice,
[FromQuery] int? maxPrice,
[FromQuery] ulong? creatorAccountId,
[FromQuery] int skip = 0,
[FromQuery] int take = 50,
[FromQuery] bool includeCoachItems = true,
[FromQuery] int ordering = 0,
[FromQuery] bool? isOwned = null,
[FromQuery] bool? isFeatured = null)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
if (take > 1000) take = 1000; // the creator-filtered path below; the broad search re-clamps to 200
if (take < 0) take = 0;
if (skip < 0) skip = 0;
// creatorAccountId IS A REAL FILTER. It was not declared here at all, so ASP.NET
// dropped it silently and every request that carried one got the WHOLE published
// catalog back - 24k items. That is what put strangers' clothing on everybody's
// profile: the Portfolio's Custom Clothing card is a StoreItemList bound to
// PortfolioAccountId, and it asks for it with exactly this query:
//
// /api/customAvatarItems/v1/search?creatorAccountId=295&includePurchaseInfos=True
// &includeCoachItems=False&skip=0&take=1000&...
//
// Answered from the SAME index as /v2/fromCreator/{id}, deliberately - not by
// scanning All for a matching CreatorAccountId. The base catalog's creator ids are
// the original RecNet ones (2830 of them literally say "1"), so a scan would hand
// account 1 - and anyone whose id collided - thousands of items they never made.
// CustomItemsCache only attributes items actually created on this server, and these
// two routes must agree or the card and its "View More" page disagree.
if (creatorAccountId.HasValue && creatorAccountId.Value != 0)
{
bool own = creatorAccountId.Value == account.Id;
var byCreator = CustomItemsCache.ByCreator(creatorAccountId.Value)
.Where(x => own || ((int?)x["Accessibility"] ?? 0) != 0) // only the creator sees their unpublished drafts
.OrderByDescending(x => ((string?)x["CreatedAt"]) ?? "")
.Skip(skip).Take(take)
.ToList();
return new ContentResult
{
Content = new JArray(byCreator).ToString(),
ContentType = "application/json",
StatusCode = 200
};
}
// Unfiltered store search. Left at the page size it has always served - the client
// asks for 1000, but this one walks the whole 24k catalog and nothing needed the
// bigger page before now.
if (take > 200) take = 200;
// From the in-memory UGC index (was re-parsing customitems.json every search).
// Published only, and - while HiddenItemsStorefronts is on - no bundle-backed item:
// those are the Clothing tab, and none of them load (CustomItemsCache.StoreListable).
// Compact records all the way to the page: only the items actually returned are
// materialised as JSON objects (CustomItemsCache.Rec).
IEnumerable<CustomItemsCache.Rec> q = CustomItemsCache.AllRecs.Where(x => x.Accessibility != 0 && CustomItemsCache.StoreListable(x));
if (outfitTypes != null && outfitTypes.Count > 0)
q = q.Where(x => outfitTypes.Contains(x.OutfitType));
if (minPrice.HasValue)
q = q.Where(x => x.Price >= minPrice.Value);
if (maxPrice.HasValue)
q = q.Where(x => x.Price <= maxPrice.Value);
if (isFeatured.HasValue)
q = q.Where(x => x.Featured == isFeatured.Value);
if (isOwned.HasValue)
{
// "Owned" is what /econ/customAvatarItems/v1/owned would list: the caller's own
// creations, plus the imported set for a dev. The Filters dialog sends
// isOwned=False to hide what the player already has.
var owned = new HashSet<string>(CustomItemsCache.ByCreatorRecs(account.Id).Select(x => x.Id), StringComparer.OrdinalIgnoreCase);
if (PlayerDB.CheckDevFlag(account.Id))
owned.UnionWith(CustomItemsCache.ImportedIds);
q = q.Where(x => owned.Contains(x.Id) == isOwned.Value);
}
// includeCoachItems=false would drop this server's stock content; none is flagged, so no-op.
// Ranked: what was typed is matched word by word against the name (and, weakly, the
// description) and the best matches come first - see CustomItemSearch. Was a raw
// substring test in date order, which is why "eyepatches" found nothing and "eye"
// led with a belt.
var page = Campus_live_rewrite.api.server.CustomItemSearch.Search(q, searchQuery, ordering)
.Skip(skip).Take(take)
.Select(r => r.ToJObject())
.ToList();
return new ContentResult
{
Content = new JArray(page).ToString(),
ContentType = "application/json",
StatusCode = 200
};
}
// Report a custom avatar item (reference: POST /api/customAvatarItems/v1/{id}/report).
// Accepts the report and answers the {success,error} envelope. We don't persist UGC reports
// yet (no UGC items exist); a moderator flow would file it like a player report.
[HttpPost("/api/customAvatarItems/v1/{id}/report")]
public IActionResult ReportCustomAvatarItem(string id)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
return new ContentResult
{
Content = JsonConvert.SerializeObject(new { success = true, error = "" }),
ContentType = "application/json",
StatusCode = 200
};
}
// One Maker-Pen circuit-chip palette (reference: GET /api/CircuitChipLists/{list}).
// Always empty - nothing records which chips a player has used/favourited - and an unknown
// name is empty too rather than a 404 (a 404 renders as a palette that failed to load).
[HttpGet("/api/CircuitChipLists/{list}")]
public ContentResult CircuitChipList(string list)
=> new ContentResult { Content = "[]", ContentType = "application/json", StatusCode = 200 };
// Discord-presence room lookup. Returns a room's display NAME and whether it's PRIVATE -
// the SAME source the admin panel uses (the live heartbeat instance: name + isPrivate),
// falling back to the static room. The patch's presence calls this so it works for DORMS
// (which the static rooms API doesn't describe the same way) and can label a private room
// as [PRIVATE]. No JWT (the patch's presence fetch is unauthenticated) - it carries the
// X-Campus-Patch header, so the patch gate lets it through, and it exposes nothing but a
// room name + a private flag.
// Discord-presence SELF lookup - everything the patch needs to decide what to show, from
// the caller's OWN heartbeat: their current room (name + isPrivate instance flag), that
// room's accessibility (to tell a dev/unpublished map apart from a normal room), and
// whether they appear online to EVERYONE. If "Appear Online To" isn't All (statusVisibility
// 1 Friends / 2 Favorites / 3 No One), appearOnlineToAll is false and the patch hides the
// room. accessibility is -1 when the room isn't a known published room (dev/unpublished).
// No JWT (the patch's presence fetch is unauthenticated) - it carries X-Campus-Patch.
[HttpGet("/api/presence/self/{accountId}")]
public IActionResult PresenceSelf(ulong accountId)
{
var (room, statusVisibility) = HeartbeatDB.GetSelfPresence(accountId);
// Hide only for the explicit non-everyone choices; anything else (0 Everyone, or a
// legacy/default value) counts as "appear to all".
bool appearOnlineToAll = !(statusVisibility == 1 || statusVisibility == 2 || statusVisibility == 3);
if (room == null)
return new ContentResult
{
Content = JsonConvert.SerializeObject(new { found = false, appearOnlineToAll }),
ContentType = "application/json",
StatusCode = 200
};
int accessibility = -1;
var r = RoomDB.GetRoom((ulong)room.roomId);
if (r != null) accessibility = (int)r.Accessibility;
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
found = true,
name = room.name ?? "",
isPrivate = room.isPrivate,
accessibility,
appearOnlineToAll
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/presence/room/{roomId}")]
public IActionResult PresenceRoom(ulong roomId)
{
var inst = HeartbeatDB.FindInstanceByRoomId((long)roomId);
if (inst != null)
return new ContentResult
{
Content = JsonConvert.SerializeObject(new { found = true, name = inst.name ?? "", isPrivate = inst.isPrivate }),
ContentType = "application/json",
StatusCode = 200
};
var room = RoomDB.GetRoom(roomId);
if (room != null)
return new ContentResult
{
Content = JsonConvert.SerializeObject(new { found = true, name = room.Name ?? "", isPrivate = room.Accessibility != RoomAccessibility.Public }),
ContentType = "application/json",
StatusCode = 200
};
return new ContentResult
{
Content = JsonConvert.SerializeObject(new { found = false, name = "", isPrivate = false }),
ContentType = "application/json",
StatusCode = 200
};
}
// Purchase info for a bag of items (reference: POST /api/items/purchaseInfos).
// Resolves Ids[] ({itemType,itemId}) against the custom-item catalog and answers how each
// may be bought. Only itemType 3 (custom avatar item) is served; other types and unknown
// ids are dropped, so the response is one entry per RESOLVED id in request order.
[HttpPost("/api/items/purchaseInfos")]
public async Task<IActionResult> ItemPurchaseInfos()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
// Read the body ourselves: MVC's System.Text.Json formatter cannot bind a JObject
// parameter (it threw on "$.Ids" -> a 500 on every purchase-info lookup).
JObject? body = null;
try
{
using var reader = new StreamReader(Request.Body);
string raw = await reader.ReadToEndAsync();
if (!string.IsNullOrWhiteSpace(raw)) body = JObject.Parse(raw);
}
catch (Exception ex) { Console.WriteLine($"[purchaseInfos] {account.Id}: bad body - {ex.Message}"); }
var outList = new JArray();
var ids = body?["Ids"] as JArray;
if (ids != null && ids.Count > 0)
{
foreach (var idRef in ids)
{
int itemType = (int?)idRef["itemType"] ?? -1;
string itemId = ((string?)idRef["itemId"]) ?? "";
if (itemType != 3 || string.IsNullOrEmpty(itemId)) continue; // only custom avatar items
var item = CustomItemsCache.Get(itemId);
if (item == null) continue; // unknown id dropped
int price = (int?)item["Price"] ?? 0;
outList.Add(new JObject
{
["ItemId"] = new JObject { ["itemType"] = itemType, ["itemId"] = itemId },
["PurchaseMethodId"] = new JObject { ["Type"] = 1, ["NumberId"] = null, ["Guid"] = itemId },
["Prices"] = new JArray { new JObject {
// 2 = RecCenterTokens. This said 1 with a "RecCenterTokens" comment,
// and 1 is LaserTagTickets (Classes/CurrencyType.cs) - so every custom
// item was priced in the wrong currency and a buy tried to debit a
// balance nobody has. Reference: toItemPurchaseInfo.
["CurrencyType"] = (int)Campus_live_rewrite.api.server.Classes.CurrencyType.RecCenterTokens,
["Price"] = price,
["StorefrontSaleData"] = new JObject { ["SalePercent"] = 0, ["SaleStartDate"] = null, ["SaleEndDate"] = null }
}},
["NewUntil"] = null,
["AvailableAt"] = null,
["AvailableUntil"] = null,
["CanBeGifted"] = true,
["CanApplySubscriberDiscount"] = false,
["SubscribersOnly"] = false,
["IsFeatured"] = false
});
}
}
return new ContentResult { Content = outList.ToString(), ContentType = "application/json", StatusCode = 200 };
}
[HttpGet("/api/customAvatarItems/v1/featured")]
public IActionResult FeaturedCustomAvatarItems()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// Featured UGC only, from the in-memory index (was streaming the ENTIRE catalog file -
// fine when it was empty, an 87MB response now). Capped, published only, newest first.
var featured = CustomItemsCache.AllRecs
.Where(x => x.Featured && x.Accessibility != 0 && CustomItemsCache.StoreListable(x))
.OrderByDescending(x => x.Created)
.Take(100)
.Select(r => r.ToJObject())
.ToList();
// BARE ARRAY - /v1/search, /v1/featured and /v1/bulk all deserialize into a
// List<CustomAvatarItem>; a {Results,TotalResults} object raises the client's
// Malformed Response throw and the featured row renders empty (wire-shapes:
// customavataritems-routes).
return new ContentResult
{
Content = new JArray(featured).ToString(Newtonsoft.Json.Formatting.None),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/testcasemanagement/v1/{route}")]
public ContentResult TestCaseManagement(string route)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
if (route == "testplans")
{
return new ContentResult()
{
Content = "[]", // todo db
ContentType = "application/json",
StatusCode = 200
};
}
if (route == "testpasssummary")
{
return new ContentResult()
{
Content = "[]", // todo db
ContentType = "application/json",
StatusCode = 200
};
}
return new ContentResult()
{
Content = "[]", // todo db
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/playerevents/v1/searchlive")]
public ContentResult SearchLivePlayerEvents()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// Was a hardcoded "[]" marked "todo db", so the live-events list was always empty.
// REVERT: return Content = "[]" here to restore that.
var live = EventDB.GetLiveEvents();
return new ContentResult()
{
Content = JsonConvert.SerializeObject(live.Select(EventWire)),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/messages/v2/get")]
public ContentResult GetMessages()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return new ContentResult()
{
Content = JsonConvert.SerializeObject(PlayerDB.GetMessages(account.Id)),
ContentType = "application/json",
StatusCode = 200
};
}
// Send one message to another player - the "invite me!" / party-invite prompts the
// client fires. Mirrors RecFlare's POST /api/messages/v2/send: FromPlayerId is the
// token's subject (never a body field), the recipient gets a MessageReceived frame
// (inner Id "2"), and the body is the { success, error } envelope. Type defaults to 0
// and Data to "". An offline recipient simply isn't delivered (SendToPlayer no-ops);
// that is still a success, since there's nothing to fail.
[HttpPost("/api/messages/v2/send")]
public async Task<IActionResult> SendMessageToPlayer([FromForm] ulong ToPlayerId, [FromForm] int Type = 0, [FromForm] string? Data = null)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
if (ToPlayerId == 0)
return BadRequest(new { success = false, error = "ToPlayerId is required" });
await PushPlayerMessage(account.Id, ToPlayerId, Type, Data ?? "");
return Ok(new { success = true, error = "" });
}
// The bulk form: one message to several recipients (JSON body
// {"ToPlayerIds":[..],"Type":..,"Data":".."}). Ids are de-duplicated and non-positive
// ones dropped; each recipient gets its own frame. Mirrors RecFlare's
// POST /api/messages/v1/sendMultiple.
[HttpPost("/api/messages/v1/sendMultiple")]
public async Task<IActionResult> SendMultipleMessages([FromBody] SendMultipleMessagesRequest? body)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var ids = (body?.ToPlayerIds ?? new List<ulong>())
.Where(i => i > 0)
.Distinct()
.ToList();
if (ids.Count == 0)
return BadRequest(new { success = false, error = "ToPlayerIds is required" });
foreach (var toId in ids)
await PushPlayerMessage(account.Id, toId, body!.Type, body.Data ?? "");
return Ok(new { success = true, error = "" });
}
// Push a single MessageReceived frame from one player to another. Shared by the single
// and bulk send endpoints. Not stored (unlike cheers) - the notification IS the delivery,
// matching the reference; a hub hiccup is logged, not surfaced, since there's no store to
// reconcile against.
private static async Task PushPlayerMessage(ulong fromPlayerId, ulong toPlayerId, int type, string data)
{
var msg = new MessageData
{
Id = (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
FromPlayerId = fromPlayerId,
SentTime = DateTime.UtcNow,
Type = (WebsocketEvents.MessageType)type,
Data = data,
RoomId = null,
PlayerEventId = null
};
try
{
var frame = JsonConvert.SerializeObject(WebsocketEvents.CreateMessageResponse(msg));
await Notifications.SendToPlayer(toPlayerId, frame);
}
catch (Exception ex)
{
Console.WriteLine($"[messages/send] push to {toPlayerId} failed: {ex.Message}");
}
}
[HttpPost("/api/PlayerCheer/v1/create")]
public async Task<IActionResult> SendCheer([FromForm] ulong PlayerIdTo, [FromForm] int CheerCategory, [FromForm] ulong RoomId, [FromForm] bool Anonymous)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
/* What the game requests:
PlayerIdTo=1
CheerCategory=0
RoomId=1
Anonymous=False
*/
Campus_live_rewrite.api.WebsocketEvents.MessageType cheerType = Anonymous ? Campus_live_rewrite.api.WebsocketEvents.MessageType.PlayerCheerAnonymous : Campus_live_rewrite.api.WebsocketEvents.MessageType.PlayerCheer;
if (account.PlayerReputation.CheerCredit <= 0)
{
return Ok(new
{
success = false,
error = "You have no more cheers.",
value = (string?)null
});
}
if (PlayerIdTo == account.Id)
{
return Ok(new
{
success = false,
error = "You cannot cheer yourself!",
value = (string?)null
});
}
// decrease cheer credit by 1
await Task.Run(() =>
PlayerDB.SetCheerCredit(account.PlayerReputation.CheerCredit - 1, account.Id)
);
var newMsg = PlayerDB.AddMessage(
toPlayerId: PlayerIdTo,
fromPlayerId: account.Id,
type: cheerType,
data: CheerCategory.ToString(),
roomId: RoomId,
playerEventId: null
);
try
{
var response = JsonConvert.SerializeObject(
WebsocketEvents.CreateMessageResponse(newMsg)
);
var notifyTask = Notifications.SendToPlayer(PlayerIdTo, response);
await Task.WhenAll(notifyTask);
}
catch (Exception ex)
{
Console.WriteLine($"Failed to send notification: {ex.Message}");
}
return Ok(new
{
success = true,
error = "",
value = (string?)null
});
}
// Body is {"MessageIds":[long,...]} (wire-shapes: messages-v3-delete); the answer is never
// read. [FromBody] bound through MVC's formatter and answered 400 on every call (21 today),
// so a message the player deleted came straight back on the next refetch. Read by hand.
[HttpPost("/api/messages/v3/delete")]
public async Task<IActionResult> DeleteMessages()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var ids = new List<ulong>();
try
{
if (Request.HasFormContentType)
{
foreach (var k in new[] { "MessageIds", "messageIds", "ids", "id" })
foreach (var v in Request.Form[k])
foreach (var part in (v ?? "").Split(','))
if (ulong.TryParse(part.Trim(), out var n)) ids.Add(n);
}
else
{
using var reader = new StreamReader(Request.Body);
string raw = await reader.ReadToEndAsync();
if (!string.IsNullOrWhiteSpace(raw))
{
var tok = JToken.Parse(raw);
var arr = tok is JArray ja ? ja : (tok["MessageIds"] ?? tok["messageIds"] ?? tok["ids"]) as JArray;
if (arr != null) foreach (var t in arr) if (ulong.TryParse(t.ToString(), out var n)) ids.Add(n);
}
}
}
catch (Exception ex) { Console.WriteLine($"[messages] delete: unreadable body - {ex.Message}"); }
foreach (var id in ids) PlayerDB.DeleteMessage(account.Id, id);
SiteNotifications.Notify(account.Id); // game deleted -> website inbox refreshes
if (ids.Count > 0) Console.WriteLine($"[messages] {account.Id} deleted {ids.Count} message(s)");
return NoContent();
}
[HttpGet("/api/playerevents/v1/room/{RoomId}")]
public ContentResult PlayerEventsForRoom(ulong RoomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// Was a hardcoded "[]" marked "todo db", so a room never listed the events booked
// in it. REVERT: return Content = "[]" here to restore that.
var events = EventDB.GetEventsForRoom(RoomId);
return new ContentResult()
{
Content = JsonConvert.SerializeObject(events.Select(EventWire)),
ContentType = "application/json",
StatusCode = 200
};
}
/// <summary>
/// The wire shape for a player event.
///
/// Every event listing built its own anonymous object and they disagreed: none of them
/// sent Tags - so the tag filters from /api/playerevents/v1/tagfilters had nothing to
/// match on and filtering by tag always came back empty - and /all always sent an empty
/// Responses list, so nobody's RSVP was ever visible. One shape, used everywhere.
/// </summary>
private static object EventWire(FullPlayerEvent e) => new
{
e.PlayerEventId,
e.CreatorPlayerId,
e.ImageName,
e.RoomId,
e.SubRoomId,
e.ClubId,
e.Name,
e.Description,
e.StartTime,
e.EndTime,
e.AttendeeCount,
e.State,
e.Accessibility,
e.IsMultiInstance,
e.SupportMultiInstanceRoomChat,
e.DefaultBroadcastPermissions,
e.CanRequestBroadcastPermissions,
e.BroadcastingRoomInstanceId,
e.RecurrenceSchedule,
Tags = e.Tags ?? new List<FullTag>(),
Responses = e.Responses ?? new List<FullEventResponses>()
};
// GET /api/playerevents/v1/bulk?id=1&id=2 - the events behind ids the client already
// holds. This is what the EVENT DETAIL PAGE fetches when you open an event; it was
// missing (only bulkInvite existed), so the detail page rendered blank. Bare array, in
// request order, skipping ids with no event.
[HttpGet("/api/playerevents/v1/bulk")]
public ContentResult PlayerEventsBulk()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var ids = Request.Query["id"]
.Select(s => ulong.TryParse(s, out var v) ? v : 0UL)
.Where(v => v != 0)
.ToList();
var list = new List<object>();
foreach (var id in ids)
{
var e = EventDB.GetFullEventById(id);
if (e != null) list.Add(EventWire(e));
}
Console.WriteLine($"[events] bulk fetch {ids.Count} ids -> {list.Count} events");
return new ContentResult()
{
Content = JsonConvert.SerializeObject(list),
ContentType = "application/json",
StatusCode = 200
};
}
// GET /api/playerevents/v1/{EventId} - a SINGLE event (the event detail page fetches
// this; it was 404ing, so the page rendered blank with only the RSVP bar). Bare event
// object (EventWire), 404 if unknown. {EventId:long} so literal siblings (all/search/
// clubs/bulk/respond/report) still win.
[HttpGet("/api/playerevents/v1/{EventId:long}")]
public ContentResult GetSinglePlayerEvent(ulong EventId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var ev = EventDB.GetFullEventById(EventId);
if (ev == null)
return new ContentResult { Content = "{}", ContentType = "application/json", StatusCode = 404 };
return new ContentResult { Content = JsonConvert.SerializeObject(EventWire(ev)), ContentType = "application/json", StatusCode = 200 };
}
// GET /api/playerevents/v1/clubs - events across the caller's clubs (bare array, empty).
[HttpGet("/api/playerevents/v1/clubs")]
public ContentResult PlayerEventsForMyClubs() =>
new ContentResult { Content = "[]", ContentType = "application/json", StatusCode = 200 };
// GET /api/playerevents/v1/club/{clubId} - one club's events (paged envelope, empty page).
[HttpGet("/api/playerevents/v1/club/{clubId:long}")]
public ContentResult PlayerEventsForClub(long clubId) =>
new ContentResult { Content = "{\"ContinuationToken\":\"\",\"Events\":[]}", ContentType = "application/json", StatusCode = 200 };
[HttpGet("/api/playerevents/v1/tagfilters")]
public ContentResult PlayerEventsTagFilters()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var tagfilters = new
{
PinnedFilters = new[]
{
"workshops",
"celebration",
"game",
"meetup",
"performance",
"coop",
"grandopening",
"class",
"competition"
},
PopularFilters = new[]
{
"workshops",
"celebration",
"class",
"coop",
"competition",
"game",
"grandopening",
"meetup",
"performance"
},
TrendingFilters = (string?)null
};
return new ContentResult()
{
Content = JsonConvert.SerializeObject(tagfilters),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPost("/api/playerevents/v2")]
public ContentResult CreatePlayerEvent([FromBody] EventDBClasses.CreateEventRequest request)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// Event names and descriptions are player-authored text shown to everyone, so they
// go through the same filter as room names, usernames and bios. They were the only
// player-authored strings on the server with no check at all - which is how
// "come rape childeren with me" ended up listed as a public event.
//
// REVERT: ConfigCampus.AllowSwears = true disables this along with every other
// text filter (see Sanitize.ContainsSwears).
if (!string.IsNullOrWhiteSpace(request?.Name) && Sanitize.ContainsSwears(request.Name))
{
Console.WriteLine($"[events] REFUSED create by {account.Id}: name failed the filter");
return new ContentResult
{
Content = JsonConvert.SerializeObject(new { Result = EventResults.InappropriateName, TagModifyResult = (string?)null, PlayerEvent = (object?)null }),
ContentType = "application/json",
StatusCode = 200
};
}
if (!string.IsNullOrWhiteSpace(request?.Description) && Sanitize.ContainsSwears(request.Description))
{
Console.WriteLine($"[events] REFUSED create by {account.Id}: description failed the filter");
return new ContentResult
{
Content = JsonConvert.SerializeObject(new { Result = EventResults.InappropriateDescription, TagModifyResult = (string?)null, PlayerEvent = (object?)null }),
ContentType = "application/json",
StatusCode = 200
};
}
FullPlayerEvent NewlyCreatedEvent = EventDB.CreateEvent(account.Id, request);
Console.WriteLine($"[events] {account.Id} created event {NewlyCreatedEvent.PlayerEventId} " +
$"'{NewlyCreatedEvent.Name}' in room {NewlyCreatedEvent.RoomId}");
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
Result = EventResults.Success,
TagModifyResult = (string?)null,
PlayerEvent = new
{
Tags = NewlyCreatedEvent.Tags,
PlayerEventId = NewlyCreatedEvent.PlayerEventId,
CreatorPlayerId = NewlyCreatedEvent.CreatorPlayerId,
ImageName = NewlyCreatedEvent.ImageName,
RoomId = NewlyCreatedEvent.RoomId,
SubRoomId = NewlyCreatedEvent.SubRoomId,
ClubId = NewlyCreatedEvent.ClubId,
Name = NewlyCreatedEvent.Name,
Description = NewlyCreatedEvent.Description,
StartTime = NewlyCreatedEvent.StartTime,
EndTime = NewlyCreatedEvent.EndTime,
AttendeeCount = NewlyCreatedEvent.AttendeeCount,
State = NewlyCreatedEvent.State,
ImageAccessibility = NewlyCreatedEvent.Accessibility,
IsMultiInstance = NewlyCreatedEvent.IsMultiInstance,
SupportMultiInstanceRoomChat = NewlyCreatedEvent.SupportMultiInstanceRoomChat,
DefaultBroadcastPermissions = NewlyCreatedEvent.DefaultBroadcastPermissions,
CanRequestBroadcastPermissions = NewlyCreatedEvent.CanRequestBroadcastPermissions,
RecurrenceSchedule = NewlyCreatedEvent.RecurrenceSchedule
}
}), // todo db
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/playerevents/v1/{EventId}/responses")]
public ContentResult PlayerEventResponsesForEventId(ulong EventId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var playerEvent = EventDB.GetFullEventById(EventId);
if (playerEvent == null)
{
return new ContentResult
{
Content = "[]",
ContentType = "application/json",
StatusCode = 404
};
}
return new ContentResult
{
Content = JsonConvert.SerializeObject(playerEvent.Responses ?? new List<FullEventResponses>()),
ContentType = "application/json",
StatusCode = 200
};
}
public class EventRespondBody { public ulong PlayerEventId { get; set; } public int Type { get; set; } }
// POST /api/playerevents/v1/respond {PlayerEventId, Type} (Type: 0 Going, 1 Interested,
// 2 Can't go). RSVP to an event - the endpoint was missing, so "I'm going" did nothing
// (EventDB.SetResponse already existed, nothing called it). One row per player per event;
// only Going counts toward AttendeeCount. Answers the same envelope create does so the
// client re-renders the event from it.
[HttpPost("/api/playerevents/v1/respond")]
public ContentResult RespondToPlayerEvent()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
// Form, query or JSON - the client's param helper posts a form body, and [FromBody]
// answered that with a 415 before anything ran.
var f = RoomModeration.Fields(Request);
var body = new EventRespondBody
{
PlayerEventId = RoomModeration.ULong(f, "PlayerEventId", "EventId", "Id"),
Type = (int)RoomModeration.Long(f, "Type", "Response", "ResponseType")
};
string typeText = RoomModeration.Str(f, "Type", "Response", "ResponseType") ?? "";
if (body.Type == 0 && typeText.Length > 0 && !char.IsDigit(typeText[0]))
body.Type = typeText.ToLowerInvariant() switch { "yes" or "going" or "attending" => 0, "interested" or "maybe" => 1, "no" or "notgoing" or "declined" => 2, _ => 0 };
if (body.PlayerEventId == 0 || body.Type < 0 || body.Type > 2)
return new ContentResult { Content = "{}", ContentType = "application/json", StatusCode = 400 };
// client Type 0/1/2 == server enum Yes/Interested/No (0/1/2)
var updated = EventDB.SetResponse(body.PlayerEventId, account.Id, (EventDBClasses.PlayerEventResponseType)body.Type);
if (updated == null)
return new ContentResult { Content = "{}", ContentType = "application/json", StatusCode = 404 };
Console.WriteLine($"[events] {account.Id} responded {body.Type} to event {body.PlayerEventId}");
return EventEnvelope(updated);
}
// POST /api/playerevents/v1/report {PlayerEventId, ReportCategory, Details}
// Files a report against an event (reported player = the event's creator). Answers the
// {success,error} envelope. Was missing -> reporting an event 404'd.
public class EventReportBody { public ulong PlayerEventId { get; set; } public int ReportCategory { get; set; } public string? Details { get; set; } }
[HttpPost("/api/playerevents/v1/report")]
public ContentResult ReportPlayerEvent([FromBody] EventReportBody? body)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
if (body == null || body.PlayerEventId == 0)
return new ContentResult { Content = JsonConvert.SerializeObject(new { success = false, error = "PlayerEventId is required" }), ContentType = "application/json", StatusCode = 400 };
var ev = EventDB.GetFullEventById(body.PlayerEventId);
if (ev == null)
return new ContentResult { Content = JsonConvert.SerializeObject(new { success = false, error = "No such event" }), ContentType = "application/json", StatusCode = 404 };
Console.WriteLine($"[events] {account.Id} reported event {body.PlayerEventId} (creator {ev.CreatorPlayerId}, category {body.ReportCategory})");
return new ContentResult { Content = JsonConvert.SerializeObject(new { success = true, error = "" }), ContentType = "application/json", StatusCode = 200 };
}
[HttpGet("/api/playerevents/v1")]
public ContentResult GetAllEvents()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var events = EventDB.SearchEventByQuery("");
return new ContentResult
{
Content = JsonConvert.SerializeObject(events.Select(e => new
{
e.PlayerEventId,
e.CreatorPlayerId,
e.ImageName,
e.RoomId,
e.SubRoomId,
e.ClubId,
e.Name,
e.Description,
e.StartTime,
e.EndTime,
e.AttendeeCount,
e.State,
e.Accessibility,
e.IsMultiInstance,
e.SupportMultiInstanceRoomChat,
e.DefaultBroadcastPermissions,
e.CanRequestBroadcastPermissions,
e.BroadcastingRoomInstanceId,
e.RecurrenceSchedule
})),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/playerevents/v1/all")]
public ContentResult GetAllEvents2()
{
// Auth stays optional here (it was commented out and the client relies on that),
// but the caller is resolved when a token IS present so their own RSVPs can be
// returned. No token simply means no personal responses.
ulong viewerId = 0;
try
{
var (acc, err) = AuthHelper.ValidateToken(HttpContext);
if (err == null && acc != null) viewerId = acc.Id;
}
catch { }
var events = EventDB.SearchEventByQuery("");
return new ContentResult
{
// Created now carries Tags (so the tag filters work) and each event's real
// Responses. Responses at the top level is every response the caller has made,
// which is what the client uses to show "you are attending".
Content = JsonConvert.SerializeObject(new
{
Created = events.Select(EventWire),
Responses = events
.SelectMany(e => e.Responses ?? new List<FullEventResponses>())
.Where(r => r.PlayerId == viewerId)
.ToList(),
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/playerevents/v1/search")]
public ContentResult SearchEvents(string query = "", string sort = "Attendance", string scheduleFilter = "Upcoming")
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var events = EventDB.SearchEventByQuery(query);
return new ContentResult
{
Content = JsonConvert.SerializeObject(events.Select(e => new
{
e.PlayerEventId,
e.CreatorPlayerId,
e.ImageName,
e.RoomId,
e.SubRoomId,
e.ClubId,
e.Name,
e.Description,
e.StartTime,
e.EndTime,
e.AttendeeCount,
e.State,
e.Accessibility,
e.IsMultiInstance,
e.SupportMultiInstanceRoomChat,
e.DefaultBroadcastPermissions,
e.CanRequestBroadcastPermissions,
e.BroadcastingRoomInstanceId,
e.RecurrenceSchedule
})),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPut("/api/playerevents/v2/{EventId}/name")] // was missing the /api prefix -> client 404'd every rename
public ContentResult SetEventName(ulong EventId, [FromForm] string name)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
FullPlayerEvent playerEvent = EventDB.GetFullEventById(EventId);
if (playerEvent == null)
{
Utils.ErrorEvent(EventResults.DoesNotExist);
}
if (playerEvent.CreatorPlayerId != account.Id)
{
Utils.ErrorEvent(EventResults.DoesNotOwnEvent);
}
if (string.IsNullOrEmpty(name) || name.Length > 64)
{
Utils.ErrorEvent(EventResults.InappropriateName);
}
if (Sanitize.ContainsSwears(name))
{
Utils.ErrorEvent(EventResults.InappropriateName);
}
FullPlayerEvent UpdatedEvent = EventDB.SetEventName(EventId, name);
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
Result = EventResults.Success,
TagModifyResult = (string?)null,
PlayerEvent = new
{
Tags = UpdatedEvent.Tags,
PlayerEventId = UpdatedEvent.PlayerEventId,
CreatorPlayerId = UpdatedEvent.CreatorPlayerId,
ImageName = UpdatedEvent.ImageName,
RoomId = UpdatedEvent.RoomId,
SubRoomId = UpdatedEvent.SubRoomId,
ClubId = UpdatedEvent.ClubId,
Name = UpdatedEvent.Name,
Description = UpdatedEvent.Description,
StartTime = UpdatedEvent.StartTime,
EndTime = UpdatedEvent.EndTime,
AttendeeCount = UpdatedEvent.AttendeeCount,
State = UpdatedEvent.State,
ImageAccessibility = UpdatedEvent.Accessibility,
IsMultiInstance = UpdatedEvent.IsMultiInstance,
SupportMultiInstanceRoomChat = UpdatedEvent.SupportMultiInstanceRoomChat,
DefaultBroadcastPermissions = UpdatedEvent.DefaultBroadcastPermissions,
CanRequestBroadcastPermissions = UpdatedEvent.CanRequestBroadcastPermissions,
RecurrenceSchedule = UpdatedEvent.RecurrenceSchedule
}
}),
ContentType = "application/json",
StatusCode = 200
};
}
// The v2 event envelope every edit answers - the updated event under Result/PlayerEvent.
private ContentResult EventEnvelope(FullPlayerEvent ev) => new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
Result = EventResults.Success,
TagModifyResult = (string?)null,
PlayerEvent = new
{
Tags = ev.Tags,
PlayerEventId = ev.PlayerEventId,
CreatorPlayerId = ev.CreatorPlayerId,
ImageName = ev.ImageName,
RoomId = ev.RoomId,
SubRoomId = ev.SubRoomId,
ClubId = ev.ClubId,
Name = ev.Name,
Description = ev.Description,
StartTime = ev.StartTime,
EndTime = ev.EndTime,
AttendeeCount = ev.AttendeeCount,
State = ev.State,
ImageAccessibility = ev.Accessibility,
IsMultiInstance = ev.IsMultiInstance,
SupportMultiInstanceRoomChat = ev.SupportMultiInstanceRoomChat,
DefaultBroadcastPermissions = ev.DefaultBroadcastPermissions,
CanRequestBroadcastPermissions = ev.CanRequestBroadcastPermissions,
RecurrenceSchedule = ev.RecurrenceSchedule
}
}),
ContentType = "application/json",
StatusCode = 200
};
// Creator-only guard shared by the edits below. Returns the event when the caller may
// edit it, else null with `err` set to the response to return.
private FullPlayerEvent? EditableEvent(ulong eventId, out ContentResult? err)
{
err = null;
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) { err = error; return null; }
var ev = EventDB.GetFullEventById(eventId);
if (ev == null) { err = new ContentResult { Content = "", ContentType = "application/json", StatusCode = 404 }; return null; }
if (ev.CreatorPlayerId != account.Id) { err = new ContentResult { Content = "", ContentType = "application/json", StatusCode = 403 }; return null; }
return ev;
}
[HttpPut("/api/playerevents/v2/{EventId}/description")]
public ContentResult SetEventDescription(ulong EventId, [FromForm] string? description)
{
var ev = EditableEvent(EventId, out var err); if (err != null) return err;
var updated = EventDB.SetEventDescription(EventId, (description ?? "").Trim());
return EventEnvelope(updated!);
}
[HttpPut("/api/playerevents/v2/{EventId}/time")]
public ContentResult SetEventTime(ulong EventId, [FromForm] string? startTime, [FromForm] string? endTime)
{
var ev = EditableEvent(EventId, out var err); if (err != null) return err;
DateTime start = DateTime.TryParse(startTime, null, System.Globalization.DateTimeStyles.AdjustToUniversal, out var s) ? s : ev!.StartTime;
DateTime end = DateTime.TryParse(endTime, null, System.Globalization.DateTimeStyles.AdjustToUniversal, out var e) ? e : ev!.EndTime;
var updated = EventDB.SetEventTime(EventId, start, end);
return EventEnvelope(updated!);
}
// 400'd every time as [FromForm] int - the value is read by hand now (a number or the
// enum name, from form, query or JSON), so an event can actually be made Public.
[HttpPut("/api/playerevents/v2/{EventId}/accessibility")]
[HttpPost("/api/playerevents/v2/{EventId}/accessibility")]
public ContentResult SetEventAccessibility(ulong EventId)
{
var ev = EditableEvent(EventId, out var err); if (err != null) return err;
var f = RoomModeration.Fields(Request);
string raw = RoomModeration.Str(f, "accessibility", "Accessibility", "playerEventAccessibility", "value") ?? "";
if (raw.Length == 0 && f.Count == 1) raw = f.Values.First();
EventDBClasses.PlayerEventAccessibility acc;
if (int.TryParse(raw, out var n)) acc = (EventDBClasses.PlayerEventAccessibility)n;
else if (!Enum.TryParse(raw, true, out acc))
{
Console.WriteLine($"[events] accessibility for {EventId}: could not read '{raw}' from {RoomModeration.Dump(f)}");
return new ContentResult { Content = JsonConvert.SerializeObject(new { Result = EventResults.SomethingWentWrong, TagModifyResult = (object?)null, PlayerEvent = (object?)null }), ContentType = "application/json", StatusCode = 400 };
}
var updated = EventDB.SetEventAccessibility(EventId, acc);
Console.WriteLine($"[events] {EventId} accessibility -> {acc}");
return EventEnvelope(updated!);
}
// POST api/playerevents/v2/{id} - the FULL update (the 12-parameter edit): the same JSON
// body as create, every field present replacing the stored one. Was a 404, so "Save"
// on the event editor failed while the per-field PUTs worked.
[HttpPost("/api/playerevents/v2/{EventId:long}")]
[HttpPut("/api/playerevents/v2/{EventId:long}")]
public async Task<ContentResult> UpdatePlayerEvent(ulong EventId)
{
var ev = EditableEvent(EventId, out var err); if (err != null) return err;
Newtonsoft.Json.Linq.JObject b;
try
{
using var reader = new StreamReader(HttpContext.Request.Body);
string raw = await reader.ReadToEndAsync();
b = string.IsNullOrWhiteSpace(raw) || !raw.TrimStart().StartsWith("{") ? new Newtonsoft.Json.Linq.JObject() : Newtonsoft.Json.Linq.JObject.Parse(raw);
}
catch { b = new Newtonsoft.Json.Linq.JObject(); }
if (b.Count == 0)
foreach (var kv in RoomModeration.Fields(Request)) b[kv.Key] = kv.Value;
Newtonsoft.Json.Linq.JToken? T(params string[] keys) { foreach (var k in keys) { var t = b[k]; if (t != null && t.Type != Newtonsoft.Json.Linq.JTokenType.Null) return t; } return null; }
string? name = (string?)T("Name", "name");
string? desc = (string?)T("Description", "description");
if (!string.IsNullOrWhiteSpace(name) && Sanitize.ContainsSwears(name))
return new ContentResult { Content = JsonConvert.SerializeObject(new { Result = EventResults.InappropriateName, TagModifyResult = (object?)null, PlayerEvent = (object?)null }), ContentType = "application/json", StatusCode = 200 };
if (!string.IsNullOrWhiteSpace(desc) && Sanitize.ContainsSwears(desc))
return new ContentResult { Content = JsonConvert.SerializeObject(new { Result = EventResults.InappropriateDescription, TagModifyResult = (object?)null, PlayerEvent = (object?)null }), ContentType = "application/json", StatusCode = 200 };
var updated = EventDB.Mutate(EventId, e =>
{
if (!string.IsNullOrWhiteSpace(name)) e.Name = name.Trim();
if (desc != null) e.Description = desc.Trim();
var img = (string?)T("ImageName", "imageName"); if (!string.IsNullOrWhiteSpace(img)) e.ImageName = img;
var rid = (ulong?)T("RoomId", "roomId"); if (rid is > 0) e.RoomId = rid.Value;
if (b.ContainsKey("SubRoomId") || b.ContainsKey("subRoomId")) e.SubRoomId = (ulong?)T("SubRoomId", "subRoomId");
if (b.ContainsKey("ClubId") || b.ContainsKey("clubId")) e.ClubId = (ulong?)T("ClubId", "clubId");
if (DateTime.TryParse((string?)T("StartTime", "startTime"), null, System.Globalization.DateTimeStyles.AdjustToUniversal, out var st)) e.StartTime = st;
if (DateTime.TryParse((string?)T("EndTime", "endTime"), null, System.Globalization.DateTimeStyles.AdjustToUniversal, out var et)) e.EndTime = et;
var acc = T("Accessibility", "accessibility");
if (acc != null) { if (int.TryParse(acc.ToString(), out var an)) e.Accessibility = (EventDBClasses.PlayerEventAccessibility)an; else if (Enum.TryParse(acc.ToString(), true, out EventDBClasses.PlayerEventAccessibility ae)) e.Accessibility = ae; }
var mi = (bool?)T("IsMultiInstance", "isMultiInstance"); if (mi != null) e.IsMultiInstance = mi.Value;
var mc = (bool?)T("SupportMultiInstanceRoomChat", "supportMultiInstanceRoomChat"); if (mc != null) e.SupportMultiInstanceRoomChat = mc.Value;
var dbp = (int?)T("DefaultBroadcastPermissions", "defaultBroadcastPermissions"); if (dbp != null) e.DefaultBroadcastPermissions = (EventDBClasses.BroadcastPermissionsEnum)dbp.Value;
var crp = (int?)T("CanRequestBroadcastPermissions", "canRequestBroadcastPermissions"); if (crp != null) e.CanRequestBroadcastPermissions = (EventDBClasses.BroadcastPermissionsEnum)crp.Value;
if (T("Tags", "tags") is Newtonsoft.Json.Linq.JArray ta)
e.Tags = ta.Select(x => x.Type == Newtonsoft.Json.Linq.JTokenType.String ? (string?)x : (string?)x["Tag"]).Where(t => !string.IsNullOrWhiteSpace(t))
.Select(t => new EventDBClasses.FullTag { Tag = t!.Trim(), Type = EventDBClasses.TagType.Unknown1 }).ToList();
});
Console.WriteLine($"[events] {EventId} updated by the editor ({b.Count} field(s))");
return EventEnvelope(updated!);
}
[HttpPut("/api/playerevents/v2/{EventId}/image")]
[HttpPost("/api/playerevents/v2/{EventId}/image")]
public ContentResult SetEventImage(ulong EventId)
{
var ev = EditableEvent(EventId, out var err); if (err != null) return err;
var f = RoomModeration.Fields(Request);
string img = RoomModeration.Str(f, "imageName", "ImageName", "image", "value") ?? "";
if (img.Length == 0 && f.Count == 1) img = f.Values.First();
return EventEnvelope(EventDB.Mutate(EventId, e => { if (img.Length > 0) e.ImageName = img; })!);
}
[HttpPut("/api/playerevents/v2/{EventId}/room")]
[HttpPost("/api/playerevents/v2/{EventId}/room")]
public ContentResult SetEventRoom(ulong EventId)
{
var ev = EditableEvent(EventId, out var err); if (err != null) return err;
var f = RoomModeration.Fields(Request);
long rid = RoomModeration.Long(f, "roomId", "RoomId");
long sid = RoomModeration.Long(f, "subRoomId", "SubRoomId");
return EventEnvelope(EventDB.Mutate(EventId, e => { if (rid > 0) e.RoomId = (ulong)rid; e.SubRoomId = sid > 0 ? (ulong)sid : null; })!);
}
[HttpPut("/api/playerevents/v2/{EventId}/club")]
[HttpPost("/api/playerevents/v2/{EventId}/club")]
public ContentResult SetEventClub(ulong EventId)
{
var ev = EditableEvent(EventId, out var err); if (err != null) return err;
var f = RoomModeration.Fields(Request);
long cid = RoomModeration.Long(f, "clubId", "ClubId");
return EventEnvelope(EventDB.Mutate(EventId, e => e.ClubId = cid > 0 ? (ulong)cid : null)!);
}
[HttpPut("/api/playerevents/v2/{EventId}/multiinstance")]
[HttpPost("/api/playerevents/v2/{EventId}/multiinstance")]
public ContentResult SetEventMultiInstance(ulong EventId)
{
var ev = EditableEvent(EventId, out var err); if (err != null) return err;
var f = RoomModeration.Fields(Request);
bool B(string fallbackKeyless, params string[] keys) { string v = RoomModeration.Str(f, keys) ?? (f.Count == 1 ? f.Values.First() : ""); return v.Equals("true", StringComparison.OrdinalIgnoreCase) || v == "1"; }
bool multi = B("", "isMultiInstance", "IsMultiInstance", "multiInstance", "value");
bool chat = B("", "supportMultiInstanceRoomChat", "SupportMultiInstanceRoomChat");
return EventEnvelope(EventDB.Mutate(EventId, e => { e.IsMultiInstance = multi; e.SupportMultiInstanceRoomChat = chat; })!);
}
[HttpPut("/api/playerevents/v2/{EventId}/tags")]
public ContentResult SetEventTags(ulong EventId, [FromForm] List<string>? tags)
{
var ev = EditableEvent(EventId, out var err); if (err != null) return err;
var updated = EventDB.SetEventTags(EventId, tags ?? new List<string>());
return EventEnvelope(updated!);
}
[HttpPost("/api/playerevents/v2/delete/{EventId}")]
[HttpDelete("/api/playerevents/v2/delete/{EventId}")]
public ContentResult DeleteEvent(ulong EventId)
{
var ev = EditableEvent(EventId, out var err); if (err != null) return err;
EventDB.DeleteEvent(EventId);
return new ContentResult { Content = JsonConvert.SerializeObject(new { Result = EventResults.Success }), ContentType = "application/json", StatusCode = 200 };
}
// A moderator's warning (the vmod menu's "Warn": WarnedPlayerId, ReportCategory,
// DisplayReason, ModeratorNote). Staff only - moderators as well as developers; it used to
// require the developer flag, so a vmod's warning 403'd. The warned player gets the
// ModerationKick frame with IsWarning set (the dialog, not a removal). It is NOT stored as
// the account's moderation block any more: that overwrote an active ban with a 0-second
// record and lifted it.
[HttpPost("/api/playerwarnings")]
public IActionResult CreatePlayerWarning() // WarnedPlayerId=3&ReportCategory=103&DisplayReason=Intentional%20team%20killing&ModeratorNote=
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
if (!PlayerDB.CheckDevFlag(account.Id) && !PlayerDB.CheckModFlag(account.Id))
return StatusCode(403, JsonConvert.SerializeObject(new { success = false, error = "Staff only" }));
var f = RoomModeration.Fields(Request);
ulong target = RoomModeration.ULong(f, "WarnedPlayerId", "PlayerId", "AccountId", "Id");
if (target == 0)
return BadRequest(new { success = false, error = "WarnedPlayerId is required" });
var category = RoomModeration.Category(f, "ReportCategory", "Category");
string reason = RoomModeration.Str(f, "DisplayReason", "Message", "Reason") ?? "";
string note = RoomModeration.Str(f, "ModeratorNote", "Note") ?? "";
var hb = HeartbeatDB.GetPlayerHeartbeat(target);
var block = new Moderation_Detail.ModerationBlockDetails
{
ReportCategory = category,
Duration = 0,
GameSessionId = hb?.roomInstance?.roomInstanceId ?? 0,
IsHostKick = false,
Message = reason,
PlayerIdReporter = account.Id,
IsBan = false,
IsWarning = true,
TimeoutStartedAt = DateTime.UtcNow
};
try { Notifications.SendToPlayer(target, JsonConvert.SerializeObject(WebsocketEvents.CreateBan(block))); } catch { }
ModerationLogDB.Record("warning", target, account.Id, (ulong)(hb?.roomInstance?.roomId ?? 0), block.GameSessionId, 0, category, reason, note);
Console.WriteLine($"[moderation] {account.Id} warned {target}: {category} \"{reason}\"{(note.Length > 0 ? $" (note: {note})" : "")}");
return Ok(new
{
success = true,
error = "",
value = (string?)null
});
}
/// <summary>
/// The vmod menu's kick (modKick / roomModKick) - the "restrict from room" that follows a
/// warning. Fields are read by name from form, query or JSON (the exact names the 2025
/// client sends are logged the first times so they can be pinned): the player, the game
/// session, a duration in seconds, a category and a message.
///
/// What it does:
/// - the player is removed from the instance (ModerationKick frame + presence cleared)
/// - they are kept out of THAT INSTANCE for ConfigCampus.RoomKickInstanceBlockSeconds
/// (8 h): the matchmaker never pools them back into it and a join-player / invite
/// into it is refused. Any other instance of the room is still open to them.
///
/// A kick is NOT a room ban. It used to be stored as a permanent room ban ("Kicked by X"),
/// so a moderator's kick in ^RecCenter locked the player out of the whole map until
/// somebody found and lifted it. Banning is the separate room ban route
/// (POST rooms/{id}/bans), which the room's owners lift from Room Settings.
///
/// Who: staff (developer / moderator flag), or a moderator-or-better on the room the
/// player is in. Room owners and staff cannot be kicked by non-staff.
/// </summary>
[HttpPost("/api/PlayerReporting/v1/modKick")]
[HttpPost("/api/PlayerReporting/v1/roomModKick")]
public IActionResult ModKick()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var f = RoomModeration.Fields(Request);
Console.WriteLine($"[modkick] {Request.Path} by {account.Id}: {RoomModeration.Dump(f)}");
ulong target = RoomModeration.ULong(f, "PlayerId", "KickedPlayerId", "TargetPlayerId", "AccountId", "Id", "PlayerIds");
if (target == 0)
return Ok(new { success = false, error = "No player given" });
if (target == account.Id)
return Ok(new { success = false, error = "You cannot kick yourself" });
long session = RoomModeration.Long(f, "GameSessionId", "RoomInstanceId", "SessionId");
long seconds = RoomModeration.Long(f, "Duration", "DurationSeconds", "TimeoutDuration", "TimeoutSeconds");
if (seconds <= 0) seconds = RoomModeration.Long(f, "DurationMinutes", "Minutes") * 60;
var category = RoomModeration.Category(f, "ReportCategory", "Category");
string message = RoomModeration.Str(f, "Message", "DisplayReason", "Reason", "KickMessage") ?? "";
string note = RoomModeration.Str(f, "ModeratorNote", "Note") ?? "";
// Which room: where the player is now, else the instance named, else a RoomId field.
var hb = HeartbeatDB.GetPlayerHeartbeat(target);
var inst = hb?.roomInstance ?? (session != 0 ? HeartbeatDB.FindInstanceById(session) : null);
ulong roomId = inst != null ? (ulong)inst.roomId : RoomModeration.ULong(f, "RoomId");
if (session == 0 && inst != null) session = inst.roomInstanceId;
var room = roomId != 0 ? RoomDB.GetRoom(roomId) : null;
bool staff = PlayerDB.CheckDevFlag(account.Id) || PlayerDB.CheckModFlag(account.Id);
bool roomMod = room != null && RoomDB.CanModerateRoom(room, account.Id);
if (!staff && !roomMod)
{
Console.WriteLine($"[modkick] REFUSED {account.Id} -> {target}: not staff and not a moderator of room {roomId}");
return StatusCode(403, JsonConvert.SerializeObject(new { success = false, error = "You cannot moderate this room" }));
}
if (!PlayerDB.CheckDevFlag(account.Id) && PlayerDB.CheckDevFlag(target))
return Ok(new { success = false, error = "You cannot kick a staff member" });
if (!staff && room != null && (room.CreatorAccountId == target || RoomDB.IsCoOwner(room, target)))
return Ok(new { success = false, error = "You cannot kick an owner of this room" });
string shown = message.Length > 0 ? message : (room != null ? $"You have been removed from {room.Name}." : "You have been removed from the room.");
// 1. out of the instance now (the ModerationKick frame their client leaves on, and
// their presence cleared). Nothing account-wide is stored: a room kick is not a
// timeout, whatever duration field the client may send.
RoomModeration.RemoveFromRoom(target, roomId, session, shown, account.Id, isBan: false, isHostKick: !staff);
// 2. and out of THAT instance for the next 8 hours. Other instances of the room stay
// open, and nothing is written to the room's ban list.
int blockSeconds = Math.Max(60, ConfigCampus.RoomKickInstanceBlockSeconds);
if (inst != null && room != null && room.CreatorAccountId != target)
InstanceBlocks.Add(target, inst, account.Id, TimeSpan.FromSeconds(blockSeconds), message.Length > 0 ? message : $"Kicked by {account.Username}");
else if (inst == null)
Console.WriteLine($"[modkick] {target} is not in a live instance - removed only, nothing to block");
ModerationLogDB.Record("modkick", target, account.Id, roomId, session, blockSeconds, category, message, note);
return Ok(new { success = true, error = "" });
}
// Vote to kick (form: PlayerId, Response, Reason, GameSessionId). Was an ack that did
// nothing, so a vote never reached anyone. Now: the caller and the target must both be
// in that instance; everyone else there gets the MessageReceived type-5 (VoteToKick)
// frame their client raises the prompt from; each Response is tallied per
// (instance, target), and once more than half of the OTHER players have said yes the
// target is removed and kept out of that INSTANCE for ConfigCampus.VoteKickBanSeconds.
// Answers the {success,error} envelope.
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, (Dictionary<ulong, bool> votes, DateTime started)> _voteKicks = new();
[HttpPost("/api/PlayerReporting/v3/voteToKick")]
[HttpPost("/api/PlayerReporting/v2/voteToKick")]
public IActionResult VoteToKick()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var f = RoomModeration.Fields(Request);
ulong target = RoomModeration.ULong(f, "PlayerId", "playerId", "TargetPlayerId", "Id");
long session = RoomModeration.Long(f, "GameSessionId", "gameSessionId", "RoomInstanceId");
string responseRaw = RoomModeration.Str(f, "Response", "response", "Vote") ?? "";
string reason = RoomModeration.Str(f, "Reason", "reason") ?? "";
if (target == 0) return BadRequest(new { success = false, error = "PlayerId is required" });
var myHb = HeartbeatDB.GetPlayerHeartbeat(account.Id);
if (session == 0) session = myHb?.roomInstance?.roomInstanceId ?? 0;
if (session == 0) return BadRequest(new { success = false, error = "GameSessionId is required" });
if (myHb?.roomInstance?.roomInstanceId != session)
return StatusCode(403, JsonConvert.SerializeObject(new { success = false, error = "You are not in that game session!" }));
var targetHb = HeartbeatDB.GetPlayerHeartbeat(target);
if (targetHb?.roomInstance?.roomInstanceId != session)
return StatusCode(403, JsonConvert.SerializeObject(new { success = false, error = "That player is not in that game session!" }));
var room = RoomDB.GetRoom((ulong)myHb.roomInstance.roomId);
if (RoomDB.CanModerateRoom(room, target) && !RoomDB.CanModerateRoom(room, account.Id))
return Ok(new { success = false, error = "You cannot vote to kick a moderator of this room" });
// Everyone in the instance except the caller.
var audience = RoomPermissions.PlayersInRoom((ulong)myHb.roomInstance.roomId)
.Where(pid => pid != account.Id && HeartbeatDB.GetPlayerHeartbeat(pid)?.roomInstance?.roomInstanceId == session)
.ToList();
string key = $"{session}|{target}";
var entry = _voteKicks.GetOrAdd(key, _ => (new Dictionary<ulong, bool>(), DateTime.UtcNow));
if ((DateTime.UtcNow - entry.started).TotalMinutes > 3) { entry = (new Dictionary<ulong, bool>(), DateTime.UtcNow); _voteKicks[key] = entry; }
bool newVote = entry.votes.Count == 0;
bool yes = responseRaw.Length == 0 || responseRaw.Equals("true", StringComparison.OrdinalIgnoreCase) || responseRaw == "1" || responseRaw.Equals("yes", StringComparison.OrdinalIgnoreCase);
lock (entry.votes) entry.votes[account.Id] = yes;
// A NEW vote is put to the room; a later Response is just tallied.
if (newVote)
{
// The frame the client raises its "Vote kick X?" prompt from (reference: the
// vote-to-kick Message). Two things it reads literally:
// * FromPlayerId is the player being VOTED ON - the prompt is about whoever this
// names; the caller is named nowhere on the frame (and needn't be).
// * Data is the REASON as plain text ("Abusing bugs or exploits") - it's a string
// on the wire like every Message's Data, and the prompt prints it verbatim. It
// used to be an escaped {PlayerId, Response, GameSessionId, Reason} object, so
// players saw that JSON where the reason belongs.
string data = reason;
foreach (var pid in audience)
{
var msg = new MessageData
{
Id = (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
FromPlayerId = target, ToPlayerId = pid, SentTime = DateTime.UtcNow,
Type = WebsocketEvents.MessageType.VoteToKick, Data = data
};
Notifications.FireAndForget(Notifications.SendToPlayer(pid, JsonConvert.SerializeObject(WebsocketEvents.CreateMessageResponse(msg))), "vote-kick");
}
Console.WriteLine($"[votekick] {account.Id} called a vote on {target} in instance {session} ({audience.Count} asked){(reason.Length > 0 ? $" - {reason}" : "")}");
}
// Tally: yes votes among everyone other than the target.
int voters = Math.Max(1, audience.Count(p => p != target) + 1); // + the caller
int yesVotes; lock (entry.votes) yesVotes = entry.votes.Where(kv => kv.Key != target && kv.Value).Count();
Console.WriteLine($"[votekick] instance {session} target {target}: {yesVotes}/{voters} yes");
if (yesVotes > voters * ConfigCampus.VoteKickMajority || (voters == 1 && yesVotes >= 1 && audience.Count(p => p != target) == 0))
{
_voteKicks.TryRemove(key, out _);
var block = new Moderation_Detail.ModerationBlockDetails
{
ReportCategory = Moderation_Detail.ReportCategory.VoteKick, Duration = ConfigCampus.VoteKickBanSeconds, GameSessionId = session,
IsHostKick = false, Message = reason.Length > 0 ? reason : "The room voted to remove you.", PlayerIdReporter = account.Id,
IsBan = false, VoteKickReason = reason, TimeoutStartedAt = DateTime.UtcNow
};
try { Notifications.SendToPlayer(target, JsonConvert.SerializeObject(WebsocketEvents.CreateBan(block))); } catch { }
{
HeartbeatDB.RoomInstance? dorm = null;
try { dorm = Sessions.CreateDorm(target, PlayerDB.GetAccountById(target)?.Username ?? "player"); } catch { }
HeartbeatDB.UpdatePlayerHeartbeat(target, dorm, online: true);
}
if (room != null && room.CreatorAccountId != target)
InstanceBlocks.Add(target, myHb.roomInstance, account.Id, TimeSpan.FromSeconds(Math.Max(60, ConfigCampus.VoteKickBanSeconds)), "Vote kicked" + (reason.Length > 0 ? $": {reason}" : ""));
ModerationLogDB.Record("votekick", target, account.Id, room?.RoomId ?? 0, session, ConfigCampus.VoteKickBanSeconds, Moderation_Detail.ReportCategory.VoteKick, reason);
Console.WriteLine($"[votekick] {target} was vote-kicked from instance {session}");
}
return Ok(new { success = true, error = "" });
}
// In-game bug report ("Report a bug" on the watch posts to /ado/bug - Azure DevOps on the
// real service). Was a 404. Whatever the client sends - form, JSON or multipart with
// screenshots / logs - is kept and shown on the admin panel's Reports tab.
[HttpPost("/ado/bug")]
[HttpPost("/ado/bug/{**rest}")]
[HttpPost("/ado/file")]
[HttpPost("/ado/file/{**rest}")]
[HttpPost("/api/bugreport/v1")]
public async Task<IActionResult> BugReport(string? rest = null)
{
var (account, _) = AuthHelper.ValidateToken(HttpContext);
var r = new BugReportDB.Report { AccountId = account?.Id ?? 0, Username = account?.Username ?? "" };
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
try
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var kv in form) fields[kv.Key] = string.Join(",", kv.Value.ToArray());
string dir = Path.Combine(Environment.CurrentDirectory, "Data", "BugReports");
Directory.CreateDirectory(dir);
foreach (var file in form.Files)
{
if (file.Length <= 0 || file.Length > 25 * 1024 * 1024) continue;
string safe = System.Text.RegularExpressions.Regex.Replace(Path.GetFileName(file.FileName ?? "file"), @"[^A-Za-z0-9_.-]", "_");
string name = $"{DateTime.UtcNow:yyyyMMdd-HHmmss}-{account?.Id ?? 0}-{safe}";
using var fs = System.IO.File.Create(Path.Combine(dir, name));
await file.CopyToAsync(fs);
r.Files.Add(name);
}
}
else
{
foreach (var kv in RoomModeration.Fields(Request)) fields[kv.Key] = kv.Value;
}
}
catch (Exception ex) { Console.WriteLine($"[bug] could not read report body: {ex.Message}"); }
string Pick(params string[] names) { foreach (var n in names) if (fields.TryGetValue(n, out var v) && !string.IsNullOrWhiteSpace(v)) return v; return ""; }
r.Title = Pick("title", "Title", "summary", "Summary", "subject");
r.Description = Pick("description", "Description", "body", "Body", "details", "Details", "text", "message", "Message", "repro", "ReproSteps");
r.Category = Pick("category", "Category", "type", "Type", "area", "Area", "bugType");
r.Room = Pick("roomName", "RoomName", "room", "Room", "roomId", "RoomId");
r.Platform = Pick("platform", "Platform", "device", "Device", "buildVersion", "appVersion");
r.Raw = string.Join("\n", fields.Select(kv => $"{kv.Key}={(kv.Value.Length > 4000 ? kv.Value[..4000] + "…" : kv.Value)}"));
if (r.Title.Length == 0 && r.Description.Length > 0) r.Title = r.Description.Length > 80 ? r.Description[..80] : r.Description;
var hb = account != null ? HeartbeatDB.GetPlayerHeartbeat(account.Id) : null;
if (r.Room.Length == 0 && hb?.roomInstance != null) r.Room = $"{hb.roomInstance.name} ({hb.roomInstance.roomId})";
BugReportDB.Add(r);
Console.WriteLine($"[bug] #{r.Id} from {r.Username} ({r.AccountId}): '{r.Title}' fields={fields.Count} files={r.Files.Count}");
return Ok(new { success = true, error = "", value = r.Id, id = r.Id });
}
/// <summary>
/// Eject players from one live instance (JSON {GameSessionId, PlayerIds}). Room moderators
/// or staff. Only players actually in that instance are touched; nothing is stored and
/// nothing stops them rejoining - that is what modKick is for. Answers {success,error}.
/// Was an ack that did nothing.
/// </summary>
[HttpPost("/api/PlayerReporting/v1/instantKick")]
public IActionResult InstantKick()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var f = RoomModeration.Fields(Request);
long session = RoomModeration.Long(f, "GameSessionId", "RoomInstanceId");
var ids = RoomModeration.ULongs(f, "PlayerIds", "PlayerId", "Ids");
if (session == 0 || ids.Count == 0)
return BadRequest(new { success = false, error = "GameSessionId and PlayerIds are required" });
var inst = HeartbeatDB.FindInstanceById(session);
if (inst == null)
return NotFound(new { success = false, error = "No such game session" });
var room = RoomDB.GetRoom((ulong)inst.roomId);
if (!RoomDB.CanModerateRoom(room, account.Id))
return StatusCode(403, JsonConvert.SerializeObject(new { success = false, error = "You cannot moderate this room" }));
int kicked = 0;
foreach (var pid in ids)
{
if (pid == account.Id) continue;
if (RoomDB.CanModerateRoom(room, pid) && !PlayerDB.CheckDevFlag(account.Id)) continue;
var hb = HeartbeatDB.GetPlayerHeartbeat(pid);
if (hb?.roomInstance?.roomInstanceId != session) continue;
RoomModeration.RemoveFromRoom(pid, (ulong)inst.roomId, session, $"You have been removed from {room?.Name ?? "the room"}.", account.Id, isBan: false, isHostKick: true);
ModerationLogDB.Record("kick", pid, account.Id, (ulong)inst.roomId, session, 0, Moderation_Detail.ReportCategory.Moderator, "");
kicked++;
}
Console.WriteLine($"[instantkick] {account.Id} kicked {kicked}/{ids.Count} from instance {session}");
return Ok(new { success = true, error = "" });
}
[HttpGet("/api/banappeal/generateCode")]
public IActionResult GenerateBanAppealCode()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(new
{
success = false,
error = "",
value = "Make a ticket in https://discord.gg/xH8MMwK5Bk"
});
}
[HttpGet("/api/outfits/me")]
[HttpGet("/outfits/me")]
public IActionResult GetMyOutfits()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var player = PlayerDB.GetFullAccount(account.Id);
// Was: no null check, then player.player_Extra.avatar - NRE for an
// account with no row.
if (player?.player_Extra == null)
{
Console.WriteLine($"[/outfits/me] no player/player_Extra row for {account.Id}");
return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 400 };
}
if (string.IsNullOrEmpty(player.player_Extra.avatar))
{
return Ok(new
{
LegacyData = new
{
SelectionsV1 = (string?)null,
SelectionsV2 = (string?)null,
FaceFeatures = (string?)null,
SkinColor = (string?)null,
HairColor = (string?)null
},
Selections = Array.Empty<object>(),
DataVersion = 9,
CustomizationSettings = (string?)null,
ThumbnailFileName = (string?)null,
Name = (string?)null,
Accessibility = 0,
Slot = 0
});
}
// REVERTED 2026-08-23 to the original Ok(...) form. Changing this - along
// with wrapping the stored avatar in a LegacyData envelope - stopped avatar
// items loading entirely and broke the watch's Customize tab. The client
// parse errors those changes removed were less harmful than the breakage
// they caused, so the original behaviour stands until there is a way to
// verify a replacement against the real client before shipping it.
// Was: Ok(<string>). ASP.NET's StringOutputFormatter writes a raw string as
// Content-Type: text/plain, so these JSON payloads were served mislabelled
// and the client would not parse them. Verified live: /outfits/me returned
// text/plain while /api/avatar/v2 returned application/json. Same bytes,
// correct content type.
return new ContentResult { Content = player.player_Extra.avatar, ContentType = "application/json", StatusCode = 200 };
}
[HttpPut("/api/outfits/me")]
[HttpPut("/outfits/me")]
public async Task<IActionResult> SetMyOutfit()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string jsonData = "";
try
{
using var reader = new StreamReader(HttpContext.Request.Body);
jsonData = await reader.ReadToEndAsync();
}
catch
{
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 400
};
}
if (string.IsNullOrWhiteSpace(jsonData))
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 400
};
try
{
Newtonsoft.Json.Linq.JToken.Parse(jsonData);
}
catch
{
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 400
};
}
// The newer client saves a WARDROBE PRESET by PUTting here with Slot > 0; the outfit
// being WORN is Slot 0 (or no Slot at all). A preset save must NOT overwrite the worn
// avatar - which is exactly what happened before, because Slot was ignored and every
// save clobbered player_Extra.avatar. So Slot > 0 is upserted into the saved wardrobe
// (the same store /api/avatar/v3/saved and /outfits/me/saved read) and returns here;
// Slot 0 / absent falls through to the unchanged worn-avatar path below.
int savedSlot = 0;
try
{
var jo = Newtonsoft.Json.Linq.JObject.Parse(jsonData);
if (jo.TryGetValue("Slot", StringComparison.OrdinalIgnoreCase, out var slotTok))
savedSlot = slotTok.Type == Newtonsoft.Json.Linq.JTokenType.Integer ? slotTok.Value<int>() : 0;
}
catch { /* not an object / no Slot -> treat as worn (slot 0) */ }
if (savedSlot > 0)
{
try
{
var preset = JsonConvert.DeserializeObject<avatar_data_saved>(jsonData) ?? new avatar_data_saved();
preset.Slot = (ulong)savedSlot;
// Keep the raw document the client sent, so a preset round-trips without losing
// any field our structured type doesn't model.
if (string.IsNullOrEmpty(preset.Avatar)) preset.Avatar = jsonData;
PlayerDB.SetAvatarSaved(account.Id, new List<avatar_data_saved> { preset });
}
catch (Exception ex)
{
Console.WriteLine($"[/outfits/me] preset save (slot {savedSlot}) failed: {ex.Message}");
return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 400 };
}
return new ContentResult
{
Content = JsonConvert.SerializeObject(new { Success = true, Error = (string?)null, error_id = (string?)null }),
ContentType = "application/json",
StatusCode = 200
};
}
var player = PlayerDB.GetFullAccount(account.Id);
if (player == null)
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 401
};
try
{
PlayerDB.set_player_avatar(jsonData, player.playerid);
}
catch (Exception ex)
{
Console.WriteLine($"[Avatar Set Error] {ex}");
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 400
};
}
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 200
};
}
// The caller's saved-outfit wardrobe. Was `ConfigCampus.Bracket` - an economy config
// object, not a list of outfits - so the newer wardrobe screen showed nothing no matter
// what you saved, which read as "saving outfits doesn't work". Returns the real saved
// outfits (the same store PUT /outfits/me slot>0 and /api/avatar/v4/saved/set write to,
// and /api/avatar/v3/saved reads); "[]" for a player who has saved none.
[HttpGet("/api/outfits/me/saved")]
[HttpGet("/outfits/me/saved")]
public ContentResult GetMySavedOutfits()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return new ContentResult()
{
Content = PlayerDB.GetAvatarSaved(account.Id),
ContentType = "application/json",
StatusCode = 200
};
}
// Several players' WORN outfits at once, keyed by account id - the call the newer client
// makes to dress a whole room (and to render other people's avatars, e.g. on a profile).
// Ported from RecFlare's POST /outfits/bulk; it was missing, so in the newer client every
// OTHER player rendered with no/default avatar. Body is { AccountIds: [..] }; the answer is
// { OutfitsByAccountId: { "<id>": <worn outfit>, ... } }. A player with no saved avatar is
// simply ABSENT from the map (a map says "no outfit" by not having the key) rather than
// carrying a null. Worn outfit = player_Extra.avatar, handed back exactly as it was saved.
[HttpPost("/outfits/bulk")]
public async Task<ContentResult> GetOutfitsBulk()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string body;
using (var reader = new StreamReader(HttpContext.Request.Body))
body = await reader.ReadToEndAsync();
List<ulong> ids = new();
try
{
var jo = Newtonsoft.Json.Linq.JObject.Parse(body);
if (jo.TryGetValue("AccountIds", StringComparison.OrdinalIgnoreCase, out var arr)
&& arr is Newtonsoft.Json.Linq.JArray jarr)
{
foreach (var t in jarr)
if (t.Type == Newtonsoft.Json.Linq.JTokenType.Integer)
ids.Add(t.Value<ulong>());
}
}
catch
{
return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 400 };
}
// One round trip, so cap the list (a room never holds this many). Distinct ids only.
var distinct = ids.Distinct().Take(99).ToList();
var map = new Dictionary<string, object>();
foreach (var pid in distinct)
{
var p = PlayerDB.GetFullAccount(pid);
var worn = p?.player_Extra?.avatar;
if (string.IsNullOrEmpty(worn)) continue; // absent from the map, never null
try { map[pid.ToString()] = Newtonsoft.Json.Linq.JToken.Parse(worn); }
catch { /* stored avatar isn't valid JSON - skip rather than break the whole map */ }
}
return new ContentResult
{
Content = JsonConvert.SerializeObject(new { OutfitsByAccountId = map }),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/sampling")]
public IActionResult Sampling()
{
// Validate the user token
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var samplingData = new List<object>
{
new { eventType = "SimilarEntitiesContext", shouldBeSampled = false },
new { eventType = "OnlineRankingContext", shouldBeSampled = false },
new { eventType = "ugc_makerpen_modify_object", shouldBeSampled = false },
new { eventType = "heartbeat", shouldBeSampled = false },
new { eventType = "edible_bite", shouldBeSampled = false },
new { eventType = "impression", shouldBeSampled = false },
new { eventType = "downloadable_cache_loaded", shouldBeSampled = false },
new { eventType = "ugc_makerpen_selectionChanged", shouldBeSampled = false },
new { eventType = "hand_gesture_use", shouldBeSampled = false },
new { eventType = "ugc_makerpen_open_menu", shouldBeSampled = false },
new { eventType = "load_outfit_failed", shouldBeSampled = false },
new { eventType = "subroom_stats", shouldBeSampled = false },
new { eventType = "button_press", shouldBeSampled = false },
new { eventType = "click", shouldBeSampled = false },
new { eventType = "emote_use", shouldBeSampled = false },
new { eventType = "watch_stats", shouldBeSampled = false },
new { eventType = "edible_consume", shouldBeSampled = false },
new { eventType = "ugc_makerpen_menu_item_selected", shouldBeSampled = false },
new { eventType = "drinkable_drink", shouldBeSampled = false },
new { eventType = "sessionMetadata", shouldBeSampled = false },
new { eventType = "stacked_UI_flow_change", shouldBeSampled = false },
new { eventType = "food_spawned", shouldBeSampled = false },
new { eventType = "Announcement_Shown", shouldBeSampled = false },
new { eventType = "ugc_makerpen_connect", shouldBeSampled = false },
new { eventType = "mtm_round_stats", shouldBeSampled = false },
new { eventType = "RRUI_route_change", shouldBeSampled = false },
new { eventType = "drinkable_consume", shouldBeSampled = false },
new { eventType = "makerpen_session_detailed", shouldBeSampled = false },
new { eventType = "store_search", shouldBeSampled = false },
new { eventType = "ugc_makerpen_create_object", shouldBeSampled = false },
new { eventType = "ugc_makerpen_changed_setting", shouldBeSampled = false },
new { eventType = "ugc_room_save_stats_detailed", shouldBeSampled = false },
new { eventType = "downloadable_item_loaded", shouldBeSampled = false },
new { eventType = "ugc_makerpen_configure_object", shouldBeSampled = false },
new { eventType = "ugc_makerpen_search", shouldBeSampled = false },
new { eventType = "ugc_makerpen_acm_movement_check", shouldBeSampled = false },
new { eventType = "eligible_for_matchmaking_toast_event", shouldBeSampled = false },
new { eventType = "ineligible_for_matchmaking_toast_event", shouldBeSampled = false },
new { eventType = "external_room_progression_shown", shouldBeSampled = false },
new { eventType = "time_to_content_load", shouldBeSampled = false },
new { eventType = "time_to_first_card_loaded", shouldBeSampled = false },
new { eventType = "avatar_outfit_changed", shouldBeSampled = false },
new { eventType = "DataAPIQuery", shouldBeSampled = false },
new { eventType = "RoomElasticsearchQuery", shouldBeSampled = false },
new { eventType = "ugc_makerpen_favorite_object", shouldBeSampled = false },
new { eventType = "mlm_quest_step_start", shouldBeSampled = false },
new { eventType = "mlm_acquire_egg", shouldBeSampled = false },
new { eventType = "mlm_monster_added", shouldBeSampled = false },
new { eventType = "mlm_monster_need_met", shouldBeSampled = false },
new { eventType = "mlm_monster_lvl_up", shouldBeSampled = false },
new { eventType = "mlm_travel_companion_change", shouldBeSampled = false },
new { eventType = "mlm_monster_rename", shouldBeSampled = false },
new { eventType = "mlm_left_evergrown", shouldBeSampled = false },
new { eventType = "mlm_crafting_complete", shouldBeSampled = false },
new { eventType = "mlm_sanctuary_lvl_up", shouldBeSampled = false },
new { eventType = "mlm_monster_light_lvl_up", shouldBeSampled = false },
new { eventType = "mlm_daily_check_in", shouldBeSampled = false },
new { eventType = "mlm_add_guaranteed_item", shouldBeSampled = false },
new { eventType = "RoomAutocompleteElasticsearchQuery", shouldBeSampled = false },
new { eventType = "mlm_player_capacity_reached", shouldBeSampled = false },
new { eventType = "mlm_tag_failure", shouldBeSampled = false },
new { eventType = "mlm_npc_nearby", shouldBeSampled = false },
new { eventType = "mlm_conversation_start", shouldBeSampled = false },
new { eventType = "autocomplete_impression", shouldBeSampled = false },
new { eventType = "rtb_player_stats", shouldBeSampled = false },
new { eventType = "rtb_challenge_stats", shouldBeSampled = false },
new { eventType = "rtb_public_event_stats", shouldBeSampled = false },
new { eventType = "ugc_makerpen_container_rename", shouldBeSampled = false },
new { eventType = "rtb_feedback", shouldBeSampled = false },
new { eventType = "contextual_features", shouldBeSampled = false },
new { eventType = "RoomSearchRequest", shouldBeSampled = false },
new { eventType = "StatsigParameterDesync", shouldBeSampled = false },
new { eventType = "reccon_candy_stats", shouldBeSampled = false },
new { eventType = "reccon_roomstats", shouldBeSampled = false },
new { eventType = "reccon_trivia_stats", shouldBeSampled = false },
new { eventType = "reccon_fashion_stats", shouldBeSampled = false },
new { eventType = "mlm_missing_replicators", shouldBeSampled = false },
new { eventType = "engagementTitlescreenJumpRightInStatsigTimeout", shouldBeSampled = false },
new { eventType = "engagementTitlescreenJumpRightInOnlineFriendClick", shouldBeSampled = false },
new { eventType = "engagementTitlescreenJumpRightInOnlineFriendsPagination", shouldBeSampled = false },
new { eventType = "engagementTitlescreenJumpRightInOnlineFriendsJoinFriendClick", shouldBeSampled = false },
new { eventType = "engagementTitlescreenJumpRightInOnlineFriendsViewAll", shouldBeSampled = false },
new { eventType = "smart_circuit_makerpen_drag_released", shouldBeSampled = false },
new { eventType = "ugc_room_event", shouldBeSampled = false },
new { eventType = "CustomAvatarItemSearchRequest", shouldBeSampled = false },
new { eventType = "perf_metrics", shouldBeSampled = false },
new { eventType = "whalegame_boatmaterialreset", shouldBeSampled = false },
new { eventType = "engagementTitlescreenJumpRightInOnlineFriendsViewMore", shouldBeSampled = false },
new { eventType = "ValentinesDay2025", shouldBeSampled = false },
new { eventType = "orientation_purchase_flow_steps", shouldBeSampled = false },
new { eventType = "ai_error", shouldBeSampled = false },
new { eventType = "ai_mistake", shouldBeSampled = false },
new { eventType = "ai_missing_functionality", shouldBeSampled = false },
new { eventType = "ai_response_error", shouldBeSampled = false },
new { eventType = "ai_text_bug", shouldBeSampled = false },
new { eventType = "socialcreation1_robotparts", shouldBeSampled = false },
new { eventType = "socialcreation1_robotactivated", shouldBeSampled = false },
new { eventType = "ai_conversation", shouldBeSampled = false },
new { eventType = "ai_expense", shouldBeSampled = false },
new { eventType = "crossed_bridge", shouldBeSampled = false },
new { eventType = "completed_task_normally", shouldBeSampled = false },
new { eventType = "bridge_dropped_time", shouldBeSampled = false },
new { eventType = "Player_pressed_button", shouldBeSampled = false },
new { eventType = "bridge_dropped_due_to_time", shouldBeSampled = false },
new { eventType = "Players_at_finishline", shouldBeSampled = false },
new { eventType = "Players_at_timeout", shouldBeSampled = false },
new { eventType = "Social_creation_2_events", shouldBeSampled = false },
new { eventType = "socail_creation_2_completed_task_normally", shouldBeSampled = false },
new { eventType = "Socialcreation2_Players_at_finishline", shouldBeSampled = false },
new { eventType = "Socialcreation2_completed_task_normally", shouldBeSampled = false },
new { eventType = "Socialcreation2_Placed_object", shouldBeSampled = false },
new { eventType = "Socialcreation2_Players_at_timeout", shouldBeSampled = false },
new { eventType = "Socialcreation2_crossed_bridge", shouldBeSampled = false },
new { eventType = "Socialcreation2_bridge_dropped_due_to_time", shouldBeSampled = false },
new { eventType = "Socialcreation2_Player_pressed_button", shouldBeSampled = false },
new { eventType = "widget_watch_state_change", shouldBeSampled = false },
new { eventType = "ListFilteredEntities", shouldBeSampled = false },
new { eventType = "roomie_ai_session", shouldBeSampled = false },
new { eventType = "roomie_ai_response", shouldBeSampled = false },
new { eventType = "roomie_ai_conversation_history", shouldBeSampled = false },
new { eventType = "ai_session", shouldBeSampled = false },
new { eventType = "ai_conversation_history", shouldBeSampled = false },
new { eventType = "ai_response", shouldBeSampled = false },
new { eventType = "roomie_ai_expense", shouldBeSampled = false },
new { eventType = "roomie_ai_error", shouldBeSampled = false },
new { eventType = "roomie_ai_response_error", shouldBeSampled = false },
new { eventType = "avatar_item_consumable", shouldBeSampled = false },
new { eventType = "ham_reached", shouldBeSampled = false },
new { eventType = "prompt_submitted", shouldBeSampled = false },
new { eventType = "llm_response_received", shouldBeSampled = false },
new { eventType = "AccountsToGenericCandidatesContext", shouldBeSampled = false },
new { eventType = "RoomsToGenericSimilarEntitiesContext", shouldBeSampled = false },
new { eventType = "Rooms_searchContext", shouldBeSampled = false },
new { eventType = "QueryRecommendation", shouldBeSampled = false },
new { eventType = "Generic_searchContext", shouldBeSampled = false },
new { eventType = "avatar_effect_grenade", shouldBeSampled = false },
new { eventType = "game_ai_session", shouldBeSampled = false },
new { eventType = "game_ai_conversation_history", shouldBeSampled = false },
new { eventType = "game_ai_response", shouldBeSampled = false },
new { eventType = "game_ai_response_error", shouldBeSampled = false },
new { eventType = "roomie_avatar_equip", shouldBeSampled = false },
new { eventType = "game_ai_error", shouldBeSampled = false },
new { eventType = "roomie_ai_stuck_in_text", shouldBeSampled = false },
new { eventType = "roomie_ai_restart", shouldBeSampled = false },
new { eventType = "gift_button_clicked", shouldBeSampled = false },
new { eventType = "gift_button_consume_clicked", shouldBeSampled = false },
new { eventType = "rma_booth_entered", shouldBeSampled = false },
new { eventType = "rma_rate_button_pushed ", shouldBeSampled = false },
new { eventType = "rma_rating_completion", shouldBeSampled = false },
new { eventType = "sj_rate_button_pushed", shouldBeSampled = false },
new { eventType = "sj_rating_completion", shouldBeSampled = false },
new { eventType = "avatar_glitch_effect_grenade", shouldBeSampled = false },
new { eventType = "game_ai_expense", shouldBeSampled = false },
new { eventType = "roomie_interaction", shouldBeSampled = false },
new { eventType = "roomie_ai_energy_updated", shouldBeSampled = false },
new { eventType = "Ticket consumed", shouldBeSampled = false }
};
return Ok(samplingData);
}
[HttpGet("/api/consumables/v1/all")]
public async Task<IActionResult> GetUnlockableConsumables()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string path = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "items", "AllUnlockableConsumables.json");
string content = await System.IO.File.ReadAllTextAsync(path);
// Was: Ok(<string>). ASP.NET's StringOutputFormatter writes a raw string as
// Content-Type: text/plain, so these JSON payloads were served mislabelled
// and the client would not parse them. Verified live: /outfits/me returned
// text/plain while /api/avatar/v2 returned application/json. Same bytes,
// correct content type.
return new ContentResult { Content = content, ContentType = "application/json", StatusCode = 200 };
}
[HttpPost("/statsigUserProperties")]
public IActionResult GetStatsigUserProperties()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(new
{
success = ConfigCampus.StatsigEnabled
});
}
[HttpGet("/api/referee/files")]
public IActionResult GetRefereeFiles()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(ConfigCampus.Bracket);
}
[HttpPost("/api/PlayerReporting/v1/referee")] // we hate referee so false it to hell
public IActionResult RefereeCheck()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok("false");
}
[HttpGet("/api/ugcPurchasables/v1/items/room/{roomId}")]
public IActionResult RefereeCheck(ulong roomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(ConfigCampus.Bracket);
}
[HttpPost("/lists/contextualfeatures")]
public IActionResult ContextualFeatures()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(new
{
success = true,
error_id = (string?)null,
error = (string?)null
});
}
// The limited-time XP events running now. An empty LIST, not a 204: the client logged
// "Response was empty" for the bodiless answer on every boot (Player.log) - it reads a
// body here, and [] is "no event".
[HttpGet("/api/progressionEvents/active")]
public IActionResult GetActiveProgressionEvents()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Content("[]", "application/json");
}
// Maker AI free trial: the 20250718 client reads an OBJECT (the reference's bare `false`
// was a 2023 shape - "expected:'{', actual:'false'" in Player.log). Not eligible either way.
[HttpGet("/api/makerai/checkfreetrialeligibility")]
public IActionResult CheckFreeTrialEligibility()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Content("{\"IsEligible\":false,\"Eligible\":false,\"Success\":true,\"Error\":null}", "application/json");
}
// The friends the caller and another player have in common - what the client shows on
// someone else's profile. Ported from RecFlare's /relationships/mutualfriends; the C#
// handler was a stub that returned ConfigCampus.Bracket (nonsense on the profile panel).
//
// Answers account cards (AccountId, Username, DisplayName, ProfileImage), ascending by
// account id, capped at 100. Only real friendships on BOTH sides count. Degenerate cases
// (no id, id <= 0, or the caller asking for mutuals with themselves) return [] rather than
// an error - this feeds a profile panel. Mutual ids with no account row are dropped, so
// the list can be shorter than the raw intersection. ProfileImage is "" (never null).
[HttpGet("/api/relationships/mutualfriends")]
public IActionResult GetMutualFriends([FromQuery(Name = "id")] long id = 0)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
if (id <= 0 || (ulong)id == account.Id)
return Ok(Array.Empty<object>());
ulong otherId = (ulong)id;
HashSet<ulong> FriendIds(ulong who) => FriendsDB.GetRelationships(who)
.Where(r => r.RelationshipType == RelationshipType.Friend)
.Select(r => r.PlayerID)
.ToHashSet();
var mine = FriendIds(account.Id);
var theirs = FriendIds(otherId);
mine.IntersectWith(theirs);
mine.Remove(account.Id);
mine.Remove(otherId);
// The element type is the client's RELATIONSHIP DTO, not an account card:
// BaseAccountModel.MutualFriendList is List<LANKKABFAEB>, the same type its
// get_Relationship() returns - {PlayerID, RelationshipType, Favorited, Ignored, Muted},
// exactly one row of /api/relationships/v2/get. The reference server (and this handler
// until now) answered account cards, which deserialise into rows with PlayerID 0 - so
// "friends in common" on another player's profile came up empty even when the server
// had found ten. The card fields are kept alongside: unknown keys cost nothing, and
// anything that did read them keeps working.
var cards = mine
.OrderBy(x => x)
.Take(100)
.Select(fid => PlayerDB.GetFullAccount(fid)?.player)
.Where(p => p != null)
.Select(p => new
{
PlayerID = p!.Id,
RelationshipType = (int)RelationshipType.Friend,
Favorited = 0,
Ignored = 0,
Muted = 0,
AccountId = p.Id,
Username = p.Username ?? "",
DisplayName = p.DisplayName ?? "",
ProfileImage = p.profileImage ?? ""
})
.ToList();
return Ok(cards);
}
[HttpGet("/api/CampusCard/v1/SignUpBonus")]
public IActionResult GetRRPlusSignUpBonus()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(new
{
RRPlusSignUpBonusId = 3,
MinFreeItemsPrice = 6000,
MaxFreeItemsPrice = 10000
});
}
// Store category carousel -> name needles (reference lists.app.ts STORE_ROW_RULES). A slug
// here draws store items whose FriendlyName contains ANY needle; null/empty = the whole
// store (itemsforyou); skinsitems is present so it resolves to an (empty) skins row rather
// than falling through to the file lookup. The catalog records no real slot, so name match
// is the crude stand-in the reference uses too.
// The wardrobe slots (RecRoom.Avatars.OutfitType) each category row sells - the reference's
// CUSTOM_ITEM_ROW_OUTFIT_TYPES. Used for every Watch row whose slot is known
// (StoreGenerator.OutfitTypeOf: custom items, and legacy items with a downloadable twin);
// the name needles below only sort what no record slots.
private static readonly Dictionary<string, int[]> StoreRowSlots = new(StringComparer.OrdinalIgnoreCase)
{
["headwearitems"] = new[] { 0 }, // Hat
["topsitems"] = new[] { 101, 104 }, // Shirt, TeamJersey
["handsitems"] = new[] { 200, 203 }, // Wrist, TeamWrist
["hairitems"] = new[] { 2 }, // Hair
["facialhairitems"] = new[] { 20 }, // Beard
["waistitems"] = new[] { 102 }, // Waist
["accessoriesitems"]= new[] { 3, 10, 103 }, // Ear, Eye, Neck
["footwearitems"] = new[] { 301 }, // Feet
["bottomsitems"] = new[] { 300 }, // Legs
["shoulderitems"] = new[] { 100 }, // Shoulder
};
private static readonly Dictionary<string, string[]?> StoreRowRules = new(StringComparer.OrdinalIgnoreCase)
{
["headwearitems"] = new[] { "hat" },
["topsitems"] = new[] { "shirt", "jacket", "dress" },
["handsitems"] = new[] { "glove", "hand", "wrist" },
["hairitems"] = new[] { "hair" },
["facialhairitems"] = new[] { "beard", "mustache" },
["waistitems"] = new[] { "belt" },
["accessoriesitems"] = new[] { "earrings", "hearing aids" },
["footwearitems"] = new[] { "shoes", "sneakers", "sandals", "boots" },
["bottomsitems"] = new[] { "pants", "shorts" },
["shoulderitems"] = new[] { "quiver", "backpack", "sword" },
["skinsitems"] = System.Array.Empty<string>(), // no storefront carries skins yet -> empty
["itemsforyou"] = null, // whole store, no name filter
};
[HttpGet("/lists/algorithmiclists/{algorithmicList}")]
public async Task<IActionResult> GetAlgorithmicList(string algorithmicList, [FromQuery] int type)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var safeFileName = Path.GetFileName(algorithmicList);
// The store's carousels are algorithmic-list rows too, but they name PURCHASABLE ITEMS
// (not rooms), and there is no live "what's new in the store" feed - so RecFlare serves
// them from a fixed, hand-picked id list (STATIC_ROW_ENTITIES). Without this the store's
// "New" (newitems) and Featured "Medieval" (summerpartycarousel) carousels came back
// empty and the store landing looked broken. The ids resolve against the storefront
// catalogs the client then fetches. Keyed on the lowercased slug, like the reference.
var storeCarousel = safeFileName.ToLowerInvariant();
// "New" / "Hot" / the featured carousel: a draw from the generated Watch store rather
// than a hand-picked id list. The old fixed ids (257, 192, 641, 657) were
// PurchasableItemIds from ANOTHER capture - in ours they either named nothing or
// named a different item, which is one way a click showed the wrong thing. Nothing
// here ranks items yet, so it is a random draw of sellable rows - honestly arbitrary,
// like the reference, instead of pretending to be a ranking.
if (storeCarousel == "newitems" || storeCarousel == "hotitems" || storeCarousel == "summerpartycarousel")
{
var all = (Campus_live_rewrite.api.server.StoreGenerator.MergedWatchStore()["StoreItems"] as JArray ?? new JArray())
.Where(it => ((int?)it["GiftDrop"]?["Rarity"] ?? 0) != -1 && !string.IsNullOrEmpty((string?)it["GiftDrop"]?["AvatarItemDesc"]))
.Select(it => ((int?)it["PurchasableItemId"])?.ToString())
.Where(s => !string.IsNullOrEmpty(s)).Select(s => s!).ToList();
// STABLE within the hour. A fresh Random per request meant the row the client drew
// the board from and the row it re-fetched when a tile was clicked were two different
// draws - so tile N now named a different item and the click opened "a random item".
// Seeded on the hour and the row name, the same request gets the same list until the
// hour rolls over, which is also what the board's "Items change in..." timer is for.
var rng0 = new Random(HashCode.Combine(storeCarousel, DateTime.UtcNow.Year, DateTime.UtcNow.DayOfYear, DateTime.UtcNow.Hour));
var draw = all.OrderBy(_ => rng0.Next()).Take(24).ToList();
string pfx = type == 5 ? "0." : "";
return Ok(new
{
Type = type,
Entities = draw.Select(id => new { Id = pfx + id, Context = (string?)null }).ToArray()
});
}
// Store CATEGORY carousels, generated from our storefront by NAME - the reference's
// lists.app.ts STORE_ROW_RULES, ported. The reference's StoreCategories page names a
// slug per wardrobe slot (headwearitems, topsitems, ..., skinsitems); each draws a
// random handful of matching store items so those carousels populate instead of showing
// empty. Ids are our store's PurchasableItemIds, which the client resolves against the
// storefront it fetches. Generic rows (type=5) need the "0." PurchasableItem prefix;
// the store's own rows are type=4 and use bare ids. skinsitems stays empty until a
// storefront carries skins - exactly as the reference notes for its own skins row.
if (StoreRowRules.TryGetValue(storeCarousel, out var needles))
{
var picks = new List<string>();
if (storeCarousel == "skinsitems")
{
// Every skin row in the Watch store - the captured ones and the ones generated
// from Equipments.json (StoreGenerator), stable per hour like the other rows.
var rngS = new Random(HashCode.Combine(storeCarousel, DateTime.UtcNow.Year, DateTime.UtcNow.DayOfYear, DateTime.UtcNow.Hour));
picks = Campus_live_rewrite.api.server.StoreGenerator.SkinPurchasableIds()
.OrderBy(_ => rngS.Next()).Take(60).Select(i => i.ToString()).ToList();
}
else
{
// From the GENERATED Watch store, so every id handed out here is one the
// client's loaded storefront can resolve to the same item.
{
var storeItems = Campus_live_rewrite.api.server.StoreGenerator.MergedWatchStore()["StoreItems"] as JArray ?? new JArray();
StoreRowSlots.TryGetValue(storeCarousel, out var slots);
var matched = storeItems.Where(it =>
{
var gd = it["GiftDrop"];
if (gd == null) return false;
if (((int?)gd["Rarity"] ?? 0) == -1) return false; // unsellable tier
if (!string.IsNullOrEmpty((string?)gd["EquipmentPrefabName"])) return false; // skins have their own row
if (needles == null || needles.Length == 0) return true; // whole store (itemsforyou)
// The real slot when a record carries one; the name only for the rest.
int slot = Campus_live_rewrite.api.server.StoreGenerator.OutfitTypeOf(it);
if (slot >= 0) return slots != null && slots.Contains(slot);
string name = (((string?)gd["FriendlyName"]) ?? "").ToLowerInvariant();
return needles.Any(n => name.Contains(n));
})
.Select(it => ((int?)it["PurchasableItemId"])?.ToString())
.Where(s => !string.IsNullOrEmpty(s))
.Select(s => s!)
.ToList();
// Same stable-per-hour seed as the featured rows, for the same reason: a
// re-fetch during a click must return the list the board was drawn from.
var rng = new Random(HashCode.Combine(storeCarousel, DateTime.UtcNow.Year, DateTime.UtcNow.DayOfYear, DateTime.UtcNow.Hour));
picks = matched.OrderBy(_ => rng.Next()).Take(50).ToList(); // reference: ORDER BY RANDOM() LIMIT 50
}
}
string idPrefix = type == 5 ? "0." : "";
return Ok(new
{
Type = type,
Entities = picks.Select(pid => new { Id = idPrefix + pid, Context = (string?)null }).ToArray()
});
}
// Room rows (type 1 = Rooms): built from this server's rooms - foryou, HotList,
// staffpicks, new, recentlyupdated, inkshowcase and the genre rows. These answered
// from files that mostly didn't exist, which is why the Play tab's carousels were blank.
if (type == 1 && Campus_live_rewrite.api.server.DiscoveryLists.Handles(safeFileName))
{
var ids = Campus_live_rewrite.api.server.DiscoveryLists.RoomIds(safeFileName);
return Ok(new
{
Type = type,
Entities = ids.Select(id => new { Id = id, Context = (string?)null }).ToArray()
});
}
string path = Path.Combine(
Environment.CurrentDirectory,
"Data", "APIS", "AlgorithmicLists",
$"{safeFileName}_{type}.json"
);
if (!System.IO.File.Exists(path))
return Ok(new
{
Type = type,
Entities = Array.Empty<object>()
});
try
{
var jsonText = await System.IO.File.ReadAllTextAsync(path);
var node = JsonNode.Parse(jsonText);
return Ok(new
{
Type = type,
Entities = node as JsonArray ?? new JsonArray()
});
}
catch (System.Text.Json.JsonException)
{
return Ok(new
{
Type = type,
Entities = Array.Empty<object>()
});
}
}
// The room genre tags a room can be filed under (wire-shapes: curatedlists-lookup-and-
// pagesource-names: name=RoomGenreTags&type=5, ItemIds are the tag names verbatim). Was a
// placeholder with no ids, so room settings offered no genre.
private static readonly string[] RoomGenreTags =
{ "Hangout", "Battle", "Quests", "Roleplay", "Horror", "Casual", "Explore", "Art", "Sports", "Music", "Parkour", "Comedy" };
private static object CuratedListObj(long listId, ulong creator, string name, int type, IEnumerable<string> ids, DateTime? created = null, int? accessibility = null)
{
var o = new JObject
{
["ListId"] = listId,
["CreatorAccountId"] = (long)creator,
["Name"] = name,
["Description"] = null,
["ImageName"] = "",
["Type"] = type,
["ItemIds"] = new JArray(ids),
["CreatedAt"] = (created ?? new DateTime(2025, 7, 18, 0, 0, 0, DateTimeKind.Utc)).ToString("o"),
};
if (accessibility.HasValue) o["Accessibility"] = accessibility.Value;
return o;
}
[HttpGet("/lists/curatedlists")]
public async Task<IActionResult> GetCuratedlists([FromQuery] ulong creatorAccountId, [FromQuery] ulong type, [FromQuery] string name)
{
Console.WriteLine($"[lists] curatedlists{Request.QueryString}");
name ??= "";
// A player's OWN list (Saved for Later) - a real, stable ListId whether or not it has
// been created yet, so the client's save has something to save into. Was ListId 0,
// which the client reads as "No such list" and never writes to.
if (name.StartsWith("__") || CuratedListDB.Get(creatorAccountId, (int)type, name) != null)
{
var mine = CuratedListDB.Get(creatorAccountId, (int)type, name);
return new ContentResult
{
Content = JsonConvert.SerializeObject(mine != null
? CuratedListObj(mine.ListId, mine.CreatorAccountId, mine.Name, mine.Type, mine.ItemIds, mine.CreatedAt, mine.Accessibility)
: CuratedListObj(CuratedListDB.IdFor(creatorAccountId, (int)type, name), creatorAccountId, name, (int)type, Array.Empty<string>(), null, 0)),
ContentType = "application/json",
StatusCode = 200
};
}
if (string.Equals(name, "RoomGenreTags", StringComparison.OrdinalIgnoreCase))
return new ContentResult
{
Content = JsonConvert.SerializeObject(CuratedListObj(1, 1, "RoomGenreTags", 5, RoomGenreTags)),
ContentType = "application/json",
StatusCode = 200
};
string path = Path.Combine(
Environment.CurrentDirectory,
"Data", "APIS", "CuratedLists",
$"{creatorAccountId}_{type}_{name}.json"
);
if (System.IO.File.Exists(path))
return PhysicalFile(path, "application/json");
return new ContentResult
{
Content = JsonConvert.SerializeObject(CuratedListObj(0, creatorAccountId, name, (int)type, Array.Empty<string>())),
ContentType = "application/json",
StatusCode = 200
};
}
// Save a room for later (reference: PUT /curatedlists/{name}/items/{itemId}/createlistifneeded).
// The owner is the TOKEN's account; `type` and `accessibility` (query or form) apply only
// when the list is created. Answers the list as it now stands, without Accessibility.
[HttpPut("/lists/curatedlists/{name}/items/{itemId}/createlistifneeded")]
[HttpPost("/lists/curatedlists/{name}/items/{itemId}/createlistifneeded")]
public IActionResult SaveToCuratedList(string name, string itemId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
string Q(string k) { string v = Request.Query[k].ToString(); if (string.IsNullOrEmpty(v) && Request.HasFormContentType) v = Request.Form[k].ToString(); return v; }
int type = int.TryParse(Q("type"), out var t) ? t : 1;
int acc = int.TryParse(Q("accessibility"), out var a) ? a : 0;
var list = CuratedListDB.AddItem(account.Id, type, name, acc, itemId);
Console.WriteLine($"[lists] {account.Id} saved {itemId} into '{name}' ({list.ItemIds.Count} item(s))");
return new ContentResult
{
Content = JsonConvert.SerializeObject(CuratedListObj(list.ListId, list.CreatorAccountId, list.Name, list.Type, list.ItemIds, list.CreatedAt)),
ContentType = "application/json",
StatusCode = 200
};
}
// Un-save (the spelling is a guess at the reference's inverse; any other write under
// /lists/curatedlists is logged by the catch-all below so it can be named).
[HttpDelete("/lists/curatedlists/{name}/items/{itemId}")]
[HttpDelete("/lists/curatedlists/{name}/items/{itemId}/createlistifneeded")]
public IActionResult RemoveFromCuratedList(string name, string itemId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
int type = int.TryParse(Request.Query["type"], out var t) ? t : 1;
var list = CuratedListDB.RemoveItem(account.Id, type, name, itemId);
if (list == null) return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 404 };
Console.WriteLine($"[lists] {account.Id} removed {itemId} from '{name}'");
return new ContentResult
{
Content = JsonConvert.SerializeObject(CuratedListObj(list.ListId, list.CreatorAccountId, list.Name, list.Type, list.ItemIds, list.CreatedAt)),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPut("/lists/curatedlists/{**rest}")]
[HttpPost("/lists/curatedlists/{**rest}")]
[HttpDelete("/lists/curatedlists/{**rest}")]
public IActionResult UnknownCuratedListWrite(string rest)
{
string body = "";
try { if (Request.HasFormContentType) body = string.Join("&", Request.Form.Select(kv => kv.Key + "=" + kv.Value)); } catch { }
Console.WriteLine($"[lists] UNHANDLED {Request.Method} /lists/curatedlists/{rest}{Request.QueryString} body='{body}'");
return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 404 };
} // Bulk curated-list lookup (the client repeats ?id=). The store landing (StoreCategories)
// has a "store-featured" section sourced from a CuratedList, and it fetches the list here;
// this route was MISSING, so the section 404'd and the store landing had nothing to draw.
// Matches RecFlare: one canned list whatever is asked for, shaped the way the client parses
// it - ItemIds are STRINGS, Description may be null, ImageName MUST be a string. Answers 200
// even for an unknown id (a 404 shows as a row that failed to load).
[HttpGet("/lists/curatedlists/bulk")]
public IActionResult GetCuratedListsBulk()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
Console.WriteLine($"[lists] curatedlists/bulk{Request.QueryString}");
// The Play/Watch pages ask for live Rec Room's playlists by id ("Fun with Friends",
// "New Ways to Play"). Each is answered as a ROOM list built here: a playlist id the
// page names maps to one of the DiscoveryLists rows, anything else to the hot list.
// Was a placeholder list of two room ids that don't exist.
var lists = new List<object>();
foreach (var raw in Request.Query["id"])
{
if (!long.TryParse(raw, out var listId)) continue;
var owned = CuratedListDB.GetById(listId);
if (owned != null)
{
lists.Add(CuratedListObj(owned.ListId, owned.CreatorAccountId, owned.Name, owned.Type, owned.ItemIds, owned.CreatedAt));
continue;
}
string row = listId switch
{
7732248245192312892 => "hangout_algoendpoint", // Fun with Friends
640498224290317839 => "new", // New Ways to Play
_ => "hotlist",
};
string name = listId switch
{
7732248245192312892 => "Fun with Friends",
640498224290317839 => "New Ways to Play",
_ => "Trending",
};
lists.Add(new
{
ListId = listId,
CreatorAccountId = 1L,
Name = name,
Description = (string?)null,
ImageName = "",
Type = 1, // ListEntityType: 0 Accounts, 1 Rooms, 2 Inventions, 3 CustomAvatarItems, 4 PurchasableItems, 5 Generic
ItemIds = Campus_live_rewrite.api.server.DiscoveryLists.RoomIds(row).ToArray(),
CreatedAt = "2025-07-18T00:00:00Z"
});
}
return Ok(lists);
}
// The charades activity's word lists - Icebreakers, Charades, ... - one file per list
// in Data/APIS/Charades/<list>.json ([{Id, Difficulty, EN_US}]). ^3DCharades and
// ^Legacy3DCharades ask for /words/Charades when a round starts; when that file was
// missing this answered [] and the cards fell back to the client's built-in words.
// Charades.json is the full 1.3k-word bank; CharadesApril.json replaces it on April 1st
// (UTC), and a list name with no file of its own gets the Charades bank rather than
// nothing (mirrors the reference server, where the name isn't matched on at all).
[HttpGet("/api/activities/charades/v1/words/{list}")]
public IActionResult GetCharadesWords(string list, ulong RoomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string dir = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "Charades");
string safe = System.Text.RegularExpressions.Regex.Replace(list ?? "", @"[^A-Za-z0-9_-]", "");
string path = Path.Combine(dir, safe + ".json");
if (safe.Length == 0 || !System.IO.File.Exists(path))
path = Path.Combine(dir, "Charades.json");
if (safe.Equals("Charades", StringComparison.OrdinalIgnoreCase))
{
var now = DateTime.UtcNow;
string april = Path.Combine(dir, "CharadesApril.json");
if (now.Month == 4 && now.Day == 1 && System.IO.File.Exists(april)) path = april;
}
if (!System.IO.File.Exists(path))
return Ok(ConfigCampus.Bracket);
return PhysicalFile(path, "application/json");
}
[HttpGet("/aimoderation/roomie/enabledForPlayer")]
public IActionResult IsRoomieEnabledForPlayer()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// Was: Ok(<string>). ASP.NET's StringOutputFormatter writes a raw string as
// Content-Type: text/plain, so these JSON payloads were served mislabelled
// and the client would not parse them. Verified live: /outfits/me returned
// text/plain while /api/avatar/v2 returned application/json. Same bytes,
// correct content type.
return new ContentResult { Content = "true", ContentType = "application/json", StatusCode = 200 };
}
[HttpPost("/aimoderation/roomie/conversation")]
public IActionResult SaveRoomieConversation([FromForm] string conversation)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
if (string.IsNullOrEmpty(conversation))
return BadRequest();
string pattern = @"(?<=\d{2}:\d{2}:\d{2}\s)(USER|AI)(?=\s\(|:)";
string processedConversation = Regex.Replace(conversation ?? "", pattern, m =>
{
return m.Value == "USER" ? account.Username : "Roomie";
});
_ = Task.Run(() => Webhook.Send(
account.Id,
"roomie",
"Roomie Info",
$"User: @{account.Username} ({account.Id})\n\nLog: {processedConversation}",
5898927,
null
));
return Ok();
}
[HttpGet("/api/players/v1/playerPhotoTaggingSetting")]
public IActionResult GetPlayerPhotoTaggingSetting()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(PlayerDB.GetPlayerPhotoTaggingSetting(account.Id));
}
[HttpPut("/api/players/v1/playerPhotoTaggingSetting")]
public IActionResult SetPlayerPhotoTaggingSetting([FromBody] SetPlayerPhotoTaggingSettingRequest request)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(PlayerDB.SetPlayerPhotoTaggingSetting(account.Id, request.Setting));
}
[HttpGet("/api/keepsakes/globalconfig")]
public IActionResult GetKeepsakeGlobalConfig()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(new
{
KeepsakeFeatureEnabled = true,
KeepsakeRoomLimit = 10,
SocialXpBoostEnabled = false
});
}
// The pending quick-play action (join a friend / deep link), read-and-clear. Nothing
// queues one, so all three fields are null - the reference shape; a 204 read as
// "Response was empty".
[HttpGet("/api/quickPlay/v1/getandclear")]
public IActionResult GetAndClearQuickPlay()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Content("{\"RoomName\":null,\"ActionCode\":null,\"TargetPlayerId\":null}", "application/json");
}
[HttpGet("/api/keepsakes/rooms/{roomId}")]
public IActionResult GetKeepsakesForRoom(ulong roomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return NoContent();
}
[HttpPost("/api/messages/v1/friendOnlineStatus")]
public IActionResult GetFriendOnlineCount()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// Real count: the caller's accepted friends that currently hold a live notification
// websocket. Was int.MaxValue as a joke, which surfaced verbatim in the client's
// "Failed to load room" dialog as "You have 2147483647 friends online."
return Ok(new
{
success = true,
value = new
{
FriendsOnlineCount = FriendsDB.CountOnlineFriends(account.Id)
}
});
}
[HttpGet("/api/purchasableXpBoosts/activations")]
public IActionResult GetPurchasableXpBoosts()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(new
{
Results = Array.Empty<object>(),
TotalResults = 0
});
}
[HttpPost("/api/consumables/v1/updateActive")]
public IActionResult UpdateActiveConsumable()
{
return NoContent();
}
// A player's inventions as another player sees them (the profile's Inventions tab):
// PUBLISHED ones only; the creator's own private saves stay in /inventions/v2/mine.
[HttpGet("/api/inventions/v1/fromcreators")]
public IActionResult InventionsFromCreator([FromQuery] ulong id, [FromQuery] int skip, [FromQuery] int take)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
bool self = id == account.Id;
var list = InventionDB.GetByCreator(id)
.Where(i => self || i.IsPublished)
.Select(Campus_live_rewrite.Controllers.InventionController.InventionDto).ToList();
if (skip > 0) list = list.Skip(skip).ToList();
if (take > 0) list = list.Take(take).ToList();
return new ContentResult { Content = JsonConvert.SerializeObject(list), ContentType = "application/json", StatusCode = 200 };
}
[HttpGet("/sections/bulk")]
public IActionResult GetSectionsBulk([FromQuery] List<string> id)
{
var filePath = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "WatchPages", "bulk.json");
if (!System.IO.File.Exists(filePath))
return NotFound();
var jsonString = System.IO.File.ReadAllText(filePath);
var allSections = System.Text.Json.JsonSerializer.Deserialize<List<Dictionary<string, object>>>(jsonString);
if (id == null || !id.Any())
return Ok(new List<object>());
var filtered = allSections.Where(s => s.ContainsKey("id") && id.Contains(s["id"]?.ToString())).ToList();
return Ok(filtered);
}
}
}
@@ -0,0 +1,546 @@
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server;
using Campus_live_rewrite.api.server.Classes;
using Campus_live_rewrite.api.server.Classes.db_class;
namespace Campus_live_rewrite.Controllers
{
[ApiController]
[Route("/")]
public class AccountController : ControllerBase
{
[HttpGet("acc/account/bulk")]
public IActionResult GetAccountsBulk([FromQuery] List<ulong> id)
{
// Was: Ok(<string>). ASP.NET's StringOutputFormatter writes a raw string as
// Content-Type: text/plain, so these JSON payloads were served mislabelled
// and the client would not parse them. Verified live: /outfits/me returned
// text/plain while /api/avatar/v2 returned application/json. Same bytes,
// correct content type.
return new ContentResult { Content = PlayerDB.GetAccountsBulk(id), ContentType = "application/json", StatusCode = 200 };
}
// Self-service username set, used during first-run account creation
// onboarding. Flips HasChosenUsername so the client's onboarding flow
// considers this step done.
[HttpPut("acc/account/me/username")]
public ContentResult PutMyUsername([FromForm] string username)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// Same rules as every other name path (NameRules): shape, length, swear filter,
// reserved / staff look-alikes, case-insensitive uniqueness. This path used to accept
// any string at all.
if (PlayerDB.HasActiveModerationBlock(account.Id))
return Utils.Error("You cannot change your name at this time.");
if (PlayerDB.IsNameLocked(account.Id))
return Utils.Error("Your name has been locked by a moderator and cannot be changed.");
string? nameErr = NameRules.CheckUsername(username, account.Id, out username);
if (nameErr != null)
{
Console.WriteLine($"[names] {account.Id} first-run username refused: {nameErr}");
return new ContentResult
{
Content = JsonConvert.SerializeObject(new { success = false, error = nameErr, value = (string?)null }),
ContentType = "application/json",
StatusCode = 200
};
}
var player = PlayerDB.GetFullAccount(account.Id);
if (player == null)
{
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
success = false,
error = "Player not found",
value = (string?)null
}),
ContentType = "application/json",
StatusCode = 200
};
}
player.player.Username = username;
player.player.DisplayName = username;
var col = PlayerDB.PlayerDBFile.GetCollection<player_data>("players");
col.Update(player);
Notifications.FireAndForget(Notifications.PushAccountUpdate(account.Id), $"account-update {account.Id}");
// Set the account creation flag
SettingsDB.SetPlayerSetting("Recroom.AccountCreation.HasChosenUsername", "True", account.Id);
// Push a live self-refresh so the new @name/display shows in-game without a relog,
// exactly like the website edit and the Accounts/account/me/username path do.
Notifications.FireAndForget(Notifications.PushAccountUpdate(account.Id), "acc username refresh");
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
success = true,
error = "",
value = (string?)null
}),
ContentType = "application/json",
StatusCode = 200
};
}
// Self-service password set for onboarding. Password is hashed
// before storage — never stored in plaintext.
[HttpPut("acc/account/me/password")]
public ContentResult PutMyPassword([FromForm] string password)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
if (string.IsNullOrWhiteSpace(password) || password.Length < 4)
{
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
success = false,
error = "Password must be at least 4 characters!",
value = (string?)null
}),
ContentType = "application/json",
StatusCode = 200
};
}
var player = PlayerDB.GetFullAccount(account.Id);
if (player == null)
{
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
success = false,
error = "Player not found",
value = (string?)null
}),
ContentType = "application/json",
StatusCode = 200
};
}
player.password = ClientSecurity.HashPassword(password);
var col = PlayerDB.PlayerDBFile.GetCollection<player_data>("players");
col.Update(player);
Notifications.FireAndForget(Notifications.PushAccountUpdate(account.Id), $"account-update {account.Id}");
// Set the account creation flag
SettingsDB.SetPlayerSetting("Recroom.AccountCreation.HasCreatedPassword", "True", account.Id);
// Check if username has also been set
var hasUsername = SettingsDB.GetPlayerSettingValue("Recroom.AccountCreation.HasChosenUsername", account.Id) == "True";
if (hasUsername)
{
SettingsDB.SetPlayerSetting("Recroom.AccountCreation.HasFinished", "True", account.Id);
}
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
success = true,
error = "",
value = (string?)null
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPut("acc/account/me/profileimage")]
public ContentResult PutMyProfileImage([FromForm] string imageName)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
if (string.IsNullOrWhiteSpace(imageName))
{
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
success = false,
error = "Image name cannot be empty!",
value = (string?)null
}),
ContentType = "application/json",
StatusCode = 200
};
}
var player = PlayerDB.GetFullAccount(account.Id);
if (player == null)
{
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
success = false,
error = "Player not found",
value = (string?)null
}),
ContentType = "application/json",
StatusCode = 200
};
}
player.player.profileImage = imageName;
var col = PlayerDB.PlayerDBFile.GetCollection<player_data>("players");
col.Update(player);
Notifications.FireAndForget(Notifications.PushAccountUpdate(account.Id), $"account-update {account.Id}");
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
success = true,
error = "",
value = (string?)null
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPut("acc/account/me/bannerimage")]
public ContentResult PutMyBannerImage([FromForm] string imageName)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
if (string.IsNullOrWhiteSpace(imageName))
{
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
success = false,
error = "Image name cannot be empty!",
value = (string?)null
}),
ContentType = "application/json",
StatusCode = 200
};
}
var player = PlayerDB.GetFullAccount(account.Id);
if (player == null)
{
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
success = false,
error = "Player not found",
value = (string?)null
}),
ContentType = "application/json",
StatusCode = 200
};
}
player.player.bannerImage = imageName;
var col = PlayerDB.PlayerDBFile.GetCollection<player_data>("players");
col.Update(player);
Notifications.FireAndForget(Notifications.PushAccountUpdate(account.Id), $"account-update {account.Id}");
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
success = true,
error = "",
value = (string?)null
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPut("acc/account/me/bio")]
public ContentResult PutMyBio([FromForm] string? bio)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
bio = System.Net.WebUtility.UrlDecode(bio ?? "");
if (bio.Length > 255)
{
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
success = false,
error = "Your bio must not exceed 255 characters!",
value = (string?)null
}),
ContentType = "application/json",
StatusCode = 200
};
}
var player = PlayerDB.GetFullAccount(account.Id);
if (player == null)
{
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
success = false,
error = "Player not found",
value = (string?)null
}),
ContentType = "application/json",
StatusCode = 200
};
}
player.player_Extra.Bio = bio;
var col = PlayerDB.PlayerDBFile.GetCollection<player_data>("players");
col.Update(player);
Notifications.FireAndForget(Notifications.PushAccountUpdate(account.Id), $"account-update {account.Id}");
Notifications.FireAndForget(Notifications.PushAccountUpdate(account.Id), "acc bio refresh");
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
success = true,
error = "",
value = (string?)null
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPut("acc/account/me/personalpronouns")]
public ContentResult PutMyPronouns([FromForm] int pronounFlags)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var player = PlayerDB.GetFullAccount(account.Id);
if (player == null)
{
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
success = false,
error = "Player not found",
value = (string?)null
}),
ContentType = "application/json",
StatusCode = 200
};
}
player.player.pronounFlags = pronounFlags;
var col = PlayerDB.PlayerDBFile.GetCollection<player_data>("players");
col.Update(player);
Notifications.FireAndForget(Notifications.PushAccountUpdate(account.Id), $"account-update {account.Id}");
Notifications.FireAndForget(Notifications.PushAccountUpdate(account.Id), "acc pronouns refresh");
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
success = true,
error = "",
value = (string?)null
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPut("acc/account/me/identityflags")]
public ContentResult PutMyIdentityFlags([FromForm] int identityFlags)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var player = PlayerDB.GetFullAccount(account.Id);
if (player == null)
{
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
success = false,
error = "Player not found",
value = (string?)null
}),
ContentType = "application/json",
StatusCode = 200
};
}
player.player.identityFlags = identityFlags;
var col = PlayerDB.PlayerDBFile.GetCollection<player_data>("players");
col.Update(player);
Notifications.FireAndForget(Notifications.PushAccountUpdate(account.Id), $"account-update {account.Id}");
Notifications.FireAndForget(Notifications.PushAccountUpdate(account.Id), "acc identity refresh");
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
success = true,
error = "",
value = (string?)null
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("acc/account/{accountId}/bio")]
public IActionResult GetAccountBio(ulong accountId)
{
var player = PlayerDB.GetFullAccount(accountId);
return Ok(new
{
AccountId = accountId,
Bio = player?.player_Extra?.Bio ?? ""
});
}
[HttpGet("acc/account/me")]
public IActionResult GetAccountMe()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var accountData = PlayerDB.GetAccountMeSafe(account.Id);
return accountData != null ? Ok(accountData) : NotFound();
}
[HttpPost("acc/account/me/email")]
[HttpPost("/Accounts/account/me/email")] // the 2025 client's path (was a 404)
[HttpPut("/Accounts/account/me/email")]
public IActionResult AccAccountMeEmail()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// Was a no-op that just returned success, so a linked email was thrown away. Store it
// on the account (same field the website signup writes and account/me reads back) and
// push a live self-refresh so it shows in-game immediately, like every other edit.
string email = (Request.Form["email"].ToString() ?? "").Trim();
if (email.Length > 0 &&
(email.Length > 254 ||
!System.Text.RegularExpressions.Regex.IsMatch(email, @"^[^@\s]+@[^@\s]+\.[^@\s]+$")))
{
return Ok(new { success = false, error = "Enter a valid email address." });
}
var player = PlayerDB.GetFullAccount(account.Id);
if (player?.player != null)
{
player.player.Email = email.Length == 0 ? null : email;
PlayerDB.PlayerDBFile.GetCollection<player_data>("players").Update(player);
}
Notifications.FireAndForget(Notifications.RefreshAccount(account.Id), "acc email refresh");
return Ok(new { success = true });
}
[HttpPost("acc/account/me/skip-orientation")]
public ContentResult SkipOrientation()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// Set tutorial as complete to skip orientation
SettingsDB.SetPlayerSetting("TUTORIAL_COMPLETE_MASK", "4294967295", account.Id);
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
success = true,
error = "",
value = (string?)null
}),
ContentType = "application/json",
StatusCode = 200
};
}
// The client derives role-badge checks from the service map's Auth base,
// so it calls /auth/role/... — keep both paths registered.
[HttpGet("role/moderator/{playerId}")]
[HttpGet("/auth/role/moderator/{playerId}")]
public IActionResult CheckModerator(ulong playerId)
{
var player = PlayerDB.GetFullAccount(playerId);
if (player == null)
return NotFound();
var isModerator = PlayerDB.CheckModFlag(playerId);
return Ok(isModerator.ToString().ToLower());
}
[HttpGet("role/developer/{playerId}")]
[HttpGet("/auth/role/developer/{playerId}")]
public IActionResult CheckDeveloper(ulong playerId)
{
var player = PlayerDB.GetFullAccount(playerId);
if (player == null)
return NotFound();
var isDeveloper = PlayerDB.CheckDevFlag(playerId);
return Ok(isDeveloper.ToString().ToLower());
}
// BULK query form: /Auth/role/developer?id=6&id=10&... — the client fetches this for
// player lists (dev badges) and it was 404ing (only the /{playerId} path form existed).
// Returns a JSON array of the requested ids that ARE developers.
[HttpGet("role/developer")]
[HttpGet("/auth/role/developer")]
public IActionResult CheckDevelopersBulk()
{
var ids = Request.Query["id"].Select(s => ulong.TryParse(s, out var v) ? v : 0UL).Where(v => v != 0).Distinct();
var devs = ids.Where(id => PlayerDB.GetFullAccount(id) != null && PlayerDB.CheckDevFlag(id)).ToList();
return Ok(devs);
}
[HttpGet("role/moderator")]
[HttpGet("/auth/role/moderator")]
public IActionResult CheckModeratorsBulk()
{
var ids = Request.Query["id"].Select(s => ulong.TryParse(s, out var v) ? v : 0UL).Where(v => v != 0).Distinct();
var mods = ids.Where(id => PlayerDB.GetFullAccount(id) != null && PlayerDB.CheckModFlag(id)).ToList();
return Ok(mods);
}
}
}
@@ -0,0 +1,650 @@
using System.Text.RegularExpressions;
using System.Xml.Linq;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Campus_live_rewrite.api;
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server;
using Campus_live_rewrite.api.server.Classes;
using Campus_live_rewrite.api.server.Classes.db_class;
using static Campus_live_rewrite.Controllers.AccountsController;
namespace Campus_live_rewrite.Controllers
{
[Route("Accounts")]
public class AccountsController : ControllerBase
{
[HttpGet("account/bulk")]
public ContentResult AccountBulk([FromQuery] List<ulong> id)
{
return new ContentResult()
{
Content = PlayerDB.GetAccountsBulk(id),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("account/me")]
public IActionResult AccountMe()
{
string authtoken = HttpContext.Request.Headers["Authorization"].ToString();
if (string.IsNullOrEmpty(authtoken))
{
authtoken = HttpContext.Request.Cookies["Authorization"];
}
if (string.IsNullOrEmpty(authtoken))
{
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 401
};
}
string playerdata = ClientSecurity.DecodeToken(authtoken);
if (playerdata == null)
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 401
};
if (playerdata.Contains("error_token"))
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 401
};
player_info? account = JsonConvert.DeserializeObject<player_info>(playerdata);
var me = PlayerDB.GetAccountMeSafe(account.Id);
// Return the account's REAL email (GetAccountMeSafe already fills it, "" when unset).
// This used to be hardcoded to "example@example.com", which hid the linked email and
// overwrote what the player entered.
return Ok(me);
}
// The client GETs /Accounts/account/{id} whenever a profile is opened. Only
// account/bulk existed, so this 404'd and the profile panel rendered blank.
// Reuse GetAccountsBulk so the object is byte-for-byte the shape the client
// already accepts from bulk - it returns a JSON ARRAY, so unwrap the one entry.
//
// The ":long" constraint keeps this from shadowing account/me, account/bulk and
// account/search (literal segments win over parameters, but be explicit).
[HttpGet("account/{id:long}")]
public ContentResult AccountById(long id)
{
if (id < 0)
return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 404 };
string json = PlayerDB.GetAccountsBulk(new List<ulong> { (ulong)id });
try
{
var arr = Newtonsoft.Json.Linq.JArray.Parse(json);
if (arr.Count == 0)
return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 404 };
return new ContentResult
{
Content = arr[0].ToString(Newtonsoft.Json.Formatting.None),
ContentType = "application/json",
StatusCode = 200
};
}
catch
{
return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 404 };
}
}
// Prefix/contains search on username + display name. Returns the SAME account DTO shape as
// /account/bulk and /account/{id} (account_data_web) - not the raw internal account_data,
// which can emit null username/displayName/profileImage and lacks bannerImage/displayEmoji,
// so the client's strict parser rejected it and the People search came back EMPTY even when
// a match existed (e.g. "coach"). Route the matches through GetAccountsBulk so the shape is
// byte-identical to what the client already accepts from bulk.
[HttpGet("account/search")]
public IActionResult AccountSearch(string name)
{
var matches = PlayerDB.Search(name);
var ids = matches.Select(a => a.accountId).Distinct().ToList();
if (ids.Count == 0)
return new ContentResult { Content = "[]", ContentType = "application/json", StatusCode = 200 };
return new ContentResult
{
Content = PlayerDB.GetAccountsBulk(ids),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("account/{id}/bio")]
public IActionResult GetBioForId(ulong id)
{
player_data? FullAccount = PlayerDB.GetFullAccount(id);
string bio = FullAccount?.player_Extra?.Bio ?? "";
return Ok(new
{
accountId = id,
bio = bio
});
}
[HttpGet("accountprivacysettings/{id}")]
public ContentResult accountprivacysettings_for_userid(ulong id)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
player_data? FullAccount = PlayerDB.GetFullAccount(id);
bool IsRecentRoomHistoryVisible = FullAccount?.player_Extra?.IsRecentRoomHistoryVisible ?? false;
var AccountPrivacySettings = JsonConvert.SerializeObject(new
{
accountId = id,
isRecentHistoryVisible = IsRecentRoomHistoryVisible
}); // Btw, the room history API can return [] if IsRecentRoomHistoryVisible is false like the Rec Room API does. - e12354
return new ContentResult()
{
Content = AccountPrivacySettings,
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("parentalcontrol/me")]
public ContentResult ParentalControlMe()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
accountId = account.Id,
disallowInAppPurchases = false,
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPut("account/me/username")]
public async Task<ContentResult> ChangeUsername([FromForm] string username)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
if (PlayerDB.HasActiveModerationBlock(account.Id))
{
return Utils.Error("You cannot change your name at this time.");
}
if (PlayerDB.IsNameLocked(account.Id))
{
Console.WriteLine($"[names] {account.Id} name change refused: name lock");
return Utils.Error("Your name has been locked by a moderator and cannot be changed.");
}
string? nameErr = NameRules.CheckUsername(username, account.Id, out username);
if (nameErr != null)
{
Console.WriteLine($"[names] {account.Id} @name change refused: {nameErr}");
return Utils.Error(nameErr);
}
bool usercheck = await Task.Run(() => PlayerDB.SetPlayerUsername(username, account.Id));
if (!usercheck)
{
return Utils.Error("This username is already in use!");
}
await Task.Run(() => PlayerDB.SetDisplayName(username, account.Id));
try
{
// Notify ONLY the owner. This used to also SendToAll an "AccountUpdate"
// carrying this player's own account_data_me, which every other client
// applied over its own state - error-kicking the whole server to their
// dorms (and letting banned players rejoin). See Notifications.RefreshAccount.
// PushAccountUpdate (AccountUpdate), NOT RefreshAccount (SelfAccountUpdate): the
// self-update does NOT redraw the profile/name, which is why an in-game edit used
// to need a relog. AccountUpdate is the redraw the WEBSITE edit sends, so this now
// updates the @name/display name in real time in-game, exactly like the website.
await Notifications.PushAccountUpdate(account.Id);
}
catch (Exception ex)
{
Console.WriteLine($"Failed to send notification: {ex.Message}");
}
_ = Task.Run(() =>
{
try
{
Webhook.Send(
account.Id,
"prod",
"Server info",
$"({account.Id}) @{account.Username} has changed their @ name to @{username}",
5898927
);
}
catch (Exception ex)
{
Console.WriteLine($"[Webhook] Failed to send username change webhook: {ex.Message}");
}
});
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
success = true,
error = "",
value = (string?)null
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPut("account/me/bio")]
public async Task<ContentResult> ChangeBio([FromForm] string bio)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
if (bio.Length > 255)
{
return Utils.Error("Your bio must not exceed 255 characters!");
}
bool CheckSwears = !string.IsNullOrEmpty(bio) && Sanitize.ContainsSwears(bio);
if (CheckSwears)
{
return Utils.Error("Your bio contains swear words!");
}
string shortBio = bio.Length > 60 ? bio.Substring(0, 60) + "..." : bio;
await Task.Run(() => PlayerDB.SetPlayerBio(bio, account.Id));
_ = Task.Run(() =>
{
try
{
Webhook.Send(
account.Id,
"prod",
"Server info",
$"({account.Id}) @{account.Username} has changed their bio! " + $"Preview: \"{shortBio}\"",
5898927
);
}
catch (Exception ex)
{
Console.WriteLine($"[Webhook] Failed to send bio change webhook: {ex.Message}");
}
});
try
{
// Notify ONLY the owner. This used to also SendToAll an "AccountUpdate"
// carrying this player's own account_data_me, which every other client
// applied over its own state - error-kicking the whole server to their
// dorms (and letting banned players rejoin). See Notifications.RefreshAccount.
// PushAccountUpdate (AccountUpdate), NOT RefreshAccount (SelfAccountUpdate): the
// self-update does NOT redraw the profile/name, which is why an in-game edit used
// to need a relog. AccountUpdate is the redraw the WEBSITE edit sends, so this now
// updates the @name/display name in real time in-game, exactly like the website.
await Notifications.PushAccountUpdate(account.Id);
}
catch (Exception ex)
{
Console.WriteLine($"[Notifications] Failed to send bio update: {ex.Message}");
}
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
success = true,
error = "",
value = (string?)null
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPut("account/me/displayname")]
public async Task<ContentResult> ChangeDisplayName()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string NewDisplayName = HttpContext.Request.Form["displayName"].ToString();
if (PlayerDB.HasActiveModerationBlock(account.Id))
{
return Utils.Error("You cannot change your name at this time.");
}
if (PlayerDB.IsNameLocked(account.Id))
{
Console.WriteLine($"[names] {account.Id} name change refused: name lock");
return Utils.Error("Your name has been locked by a moderator and cannot be changed.");
}
string? dnErr = NameRules.CheckDisplayName(NewDisplayName, account.Id, out NewDisplayName);
if (dnErr != null)
{
Console.WriteLine($"[names] {account.Id} display name refused: {dnErr}");
return Utils.Error(dnErr);
}
await Task.Run(() => PlayerDB.SetDisplayName(NewDisplayName, account.Id));
try
{
// Notify ONLY the owner. This used to also SendToAll an "AccountUpdate"
// carrying this player's own account_data_me, which every other client
// applied over its own state - error-kicking the whole server to their
// dorms (and letting banned players rejoin). See Notifications.RefreshAccount.
// PushAccountUpdate (AccountUpdate), NOT RefreshAccount (SelfAccountUpdate): the
// self-update does NOT redraw the profile/name, which is why an in-game edit used
// to need a relog. AccountUpdate is the redraw the WEBSITE edit sends, so this now
// updates the @name/display name in real time in-game, exactly like the website.
await Notifications.PushAccountUpdate(account.Id);
}
catch (Exception ex)
{
Console.WriteLine($"Failed to send notification: {ex.Message}");
}
_ = Task.Run(() =>
{
try
{
Webhook.Send(
account.Id,
"prod",
"Server info",
$"({account.Id}) @{account.Username} has changed their Display Name to \"{NewDisplayName}\"",
5898927
);
}
catch (Exception ex)
{
Console.WriteLine($"[Webhook] Failed to send Display Name change webhook: {ex.Message}");
}
});
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
success = true,
error = "",
value = (string?)null
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("emojiConfig/whitelistedEmojis")]
public ContentResult GetWhitelistedEmojis()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return new ContentResult()
{
Content = JsonConvert.SerializeObject(Emojis.emojis),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPut("account/me/emoji")]
public async Task<ContentResult> ChangeEmoji()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string displayEmoji = HttpContext.Request.Form["displayEmoji"].ToString();
displayEmoji = Uri.UnescapeDataString(displayEmoji ?? "");
var whitelistedEmojis = Emojis.emojis;
if (!whitelistedEmojis.Contains(displayEmoji))
{
return Utils.Error("Emoji not allowed!");
}
await Task.Run(() => PlayerDB.SetPlayerEmoji(displayEmoji, account.Id));
try
{
// Notify ONLY the owner. This used to also SendToAll an "AccountUpdate"
// carrying this player's own account_data_me, which every other client
// applied over its own state - error-kicking the whole server to their
// dorms (and letting banned players rejoin). See Notifications.RefreshAccount.
// PushAccountUpdate (AccountUpdate), NOT RefreshAccount (SelfAccountUpdate): the
// self-update does NOT redraw the profile/name, which is why an in-game edit used
// to need a relog. AccountUpdate is the redraw the WEBSITE edit sends, so this now
// updates the @name/display name in real time in-game, exactly like the website.
await Notifications.PushAccountUpdate(account.Id);
}
catch (Exception ex)
{
Console.WriteLine($"Failed to send notification: {ex.Message}");
}
_ = Task.Run(() =>
{
try
{
Webhook.Send(
account.Id,
"prod",
"Server info",
$"({account.Id}) @{account.Username} has changed their Display Emoji to \"{displayEmoji}\"",
5898927
);
}
catch (Exception ex)
{
Console.WriteLine($"[Webhook] Failed to send Display Emoji change webhook: {ex.Message}");
}
});
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
success = true,
error = "",
value = (string?)null
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPut("account/me/personalpronouns")]
public async Task<ContentResult> ChangePersonalPronouns([FromForm] int pronounFlags)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
await Task.Run(() => PlayerDB.SetPlayerPronounFlags(pronounFlags, account.Id));
try
{
// Notify ONLY the owner. This used to also SendToAll an "AccountUpdate"
// carrying this player's own account_data_me, which every other client
// applied over its own state - error-kicking the whole server to their
// dorms (and letting banned players rejoin). See Notifications.RefreshAccount.
// PushAccountUpdate (AccountUpdate), NOT RefreshAccount (SelfAccountUpdate): the
// self-update does NOT redraw the profile/name, which is why an in-game edit used
// to need a relog. AccountUpdate is the redraw the WEBSITE edit sends, so this now
// updates the @name/display name in real time in-game, exactly like the website.
await Notifications.PushAccountUpdate(account.Id);
}
catch (Exception ex)
{
Console.WriteLine($"Failed to send notification: {ex.Message}");
}
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
success = true,
error = "",
value = (string?)null
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPut("account/me/identityflags")]
public async Task<ContentResult> ChangeIdentityFlags([FromForm] int identityFlags)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
await Task.Run(() => PlayerDB.SetPlayerIdentityFlags(identityFlags, account.Id));
// Real-time redraw (same as the website), not the self-only update that needed a relog.
await Notifications.PushAccountUpdate(account.Id);
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
success = true,
error = "",
value = (string?)null
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPut("account/me/profileimage")]
public async Task<IActionResult> ChangeProfileImage([FromForm] string imageName)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
if (string.IsNullOrEmpty(imageName))
{
return Ok(new
{
success = false,
error = "Image name cannot be empty!",
value = (string?)null
});
}
await Task.Run(() => PlayerDB.SetPlayerImageName(imageName, account.Id));
// Refresh the client so the new picture shows WITHOUT a relog - but DELAYED.
//
// An IMMEDIATE SelfAccountUpdate here caused "Dropping to dormroom due to error
// [385120]": the client's image loader keys in-flight loads by image id and completes
// one TaskCompletionSource per id. Answering this request already starts a load of the
// new image; a simultaneous push starts a SECOND load of the same id and double-
// completes that promise (InvalidOperationException: Cannot complete an already
// completed promise). Leaving it out avoided the crash but meant the picture only
// appeared after a relog.
//
// Delaying the push past that first load removes the race: by the time it fires the
// load is done (its promise cleared), so the refresh is a clean single reload and the
// picture updates live. Fire-and-forget so the HTTP 200 returns immediately.
ulong pid = account.Id;
Notifications.FireAndForget(Task.Run(async () =>
{
await Task.Delay(3000);
await Notifications.PushAccountUpdate(pid); // AccountUpdate redraws the profile (owner + everyone)
}), "profile image live refresh");
return Ok(new
{
success = true,
error = "",
value = (string?)null
});
}
[HttpPut("account/me/bannerimage")]
public async Task<IActionResult> ChangeBannerImage([FromForm] string imageName)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
if (string.IsNullOrEmpty(imageName))
{
return Ok(new
{
success = false,
error = "Image name cannot be empty!",
value = (string?)null
});
}
await Task.Run(() => PlayerDB.SetPlayerBanner(imageName, account.Id));
// Delayed refresh so the new banner shows without a relog. See ChangeProfileImage above
// for why it must be delayed: an immediate SelfAccountUpdate double-completes the
// client's in-flight image-load promise and drops it to the dorm ([385120]). Firing it
// after that load finishes makes it a clean single reload.
ulong pid = account.Id;
Notifications.FireAndForget(Task.Run(async () =>
{
await Task.Delay(3000);
await Notifications.PushAccountUpdate(pid); // AccountUpdate redraws the profile (owner + everyone)
}), "banner image live refresh");
return Ok(new
{
success = true,
error = "",
value = (string?)null
});
}
[HttpPost("account/me/phone")]
public IActionResult AddPhoneNumber([FromForm] string phone)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string phoneNumber = phone.Substring(2);
Console.WriteLine($"[Phone] Player {account.Id} is trying to add phone number: {phoneNumber}. Full phone: {phone}");
return Ok(new
{
success = false,
error = "This ain't implemented yet.",
value = (string?)null
});
}
[HttpGet("namegen/options")]
public IActionResult NameGenOptions()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(new
{
nouns = Utils.Nouns,
adjectives = Utils.Adjectives,
});
}
}
}
@@ -0,0 +1,3351 @@
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 = @"[^-]";
private static string SafeDecrypt(string v)
{
try { return Campus_live_rewrite.api.server.Utils.DecryptAes(v); }
catch { return "(unreadable)"; }
}
/// <summary>Checkbox/switch values arrive as "1", "true" or "on" depending on the form.</summary>
private static bool Truthy(string? v)
{
if (string.IsNullOrWhiteSpace(v)) return false;
v = v.Trim();
return v == "1"
|| string.Equals(v, "true", StringComparison.OrdinalIgnoreCase)
|| string.Equals(v, "on", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Lift a ban: clears the account block and, by default, every device/IP ban that
/// originated from this account.
///
/// Clearing only the account block would leave their device blacklisted, so the
/// player still could not log in and the unban would look broken.
/// </summary>
[HttpPost("/admin/api/unban_player")]
public ContentResult AdminUnbanPlayer()
{
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
if (error != null) return error;
string rawId = Request.Form["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}." });
bool clearedAccount = PlayerDB.ClearModerationBlock(playerId);
// Default ON: an unban that leaves the device blocked is not an unban.
bool liftDeviceIp = Request.Form["liftDeviceIp"].Count == 0 || Truthy(Request.Form["liftDeviceIp"]);
int lifted = liftDeviceIp ? BanDB.UnbanAllForPlayer(playerId) : 0;
Console.WriteLine($"[admin] {account.Username} ({account.Id}) unbanned {playerId} " +
$"(account block cleared: {clearedAccount}, device/IP entries lifted: {lifted})");
string msg = clearedAccount ? $"@{acc.player.Username} unbanned." : $"@{acc.player.Username} had no account ban.";
if (lifted > 0) msg += $" Lifted {lifted} device/IP ban(s).";
return Json(new { success = true, message = msg, clearedAccount, liftedDeviceIpBans = lifted });
}
/// <summary>
/// Reads a player's editable profile fields for the admin panel's Edit Player form.
/// </summary>
[HttpGet("/admin/api/get_profile")]
public ContentResult AdminGetProfile()
{
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
if (error != null) return error;
string rawId = 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}." });
return Json(new
{
success = true,
playerId,
username = acc.player.Username,
displayName = acc.player.DisplayName,
profileImage = acc.player.profileImage,
bannerImage = acc.player.bannerImage,
// Strings, not numbers: a 17-digit SteamID64 exceeds JavaScript's 53-bit
// integer precision and would be silently rounded on the way into the form.
// An account may hold several, so this is a list; empty means not linked.
steamIds = PlayerDB.GetPlatformIds(playerId, Platforms.Steam).Select(i => i.ToString()).ToArray(),
discordId = (acc.player_Extra?.DiscordId ?? 0).ToString(),
discordUsername = acc.player_Extra?.DiscordUsername ?? "",
nameLocked = acc.player_Extra?.NameLocked == true
});
}
/// <summary>
/// Name lock: while on, the player cannot change their own @name or display name anywhere
/// (in-game, website, first-run onboarding). Admin edits (edit_player) are not gated, so a
/// moderator can still set the name they want and then lock it.
/// </summary>
[HttpPost("/admin/api/set_name_lock")]
public ContentResult AdminSetNameLock()
{
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
if (error != null) return error;
string rawId = Request.Form["playerId"].ToString();
if (!ulong.TryParse(rawId, out ulong playerId))
return Json(new { success = false, message = "Invalid player id." });
bool locked = string.Equals(Request.Form["locked"].ToString(), "true", StringComparison.OrdinalIgnoreCase)
|| Request.Form["locked"].ToString() == "1";
var acc = PlayerDB.GetFullAccount(playerId);
if (acc?.player == null)
return Json(new { success = false, message = $"No player with id {playerId}." });
if (!PlayerDB.SetNameLock(playerId, locked))
return Json(new { success = false, message = "Could not update the player." });
Console.WriteLine($"[admin] {account.Id} set name lock {(locked ? "ON" : "off")} for {playerId} (@{acc.player.Username})");
return Json(new
{
success = true,
nameLocked = locked,
message = locked ? $"@{acc.player.Username} can no longer change their @ name or display name."
: $"@{acc.player.Username} can change their names again."
});
}
/// <summary>
/// Edits another player's username / display name / profile picture / banner.
///
/// Every field is optional - blank means "leave it alone" - so one form can change
/// any combination without clobbering the rest.
///
/// Validation mirrors the self-service endpoints in AccountsController (length,
/// swears, spaces, non-ASCII, username uniqueness) so an admin cannot create a
/// name the player themselves would be refused, and so a duplicate @name cannot
/// be forced onto an account.
///
/// The one rule NOT carried over is the "you are banned, you cannot rename" check:
/// renaming a banned player is a normal moderation action.
///
/// Deliberately does NOT push a notification. That is what caused
/// "Dropping to dormroom due to error [385120]" - answering the request AND pushing
/// an account update makes the client load the same image twice and complete one
/// promise twice. See ChangeProfileImage in AccountsController.
/// </summary>
[HttpPost("/admin/api/edit_player")]
public async Task<ContentResult> AdminEditPlayer()
{
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
if (error != null) return error;
string rawId = Request.Form["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}." });
string username = (Request.Form["username"].ToString() ?? "").Trim();
string displayName = (Request.Form["displayName"].ToString() ?? "").Trim();
string profileImage = (Request.Form["profileImage"].ToString() ?? "").Trim();
string bannerImage = (Request.Form["bannerImage"].ToString() ?? "").Trim();
string steamId = (Request.Form["steamId"].ToString() ?? "").Trim();
string discordId = (Request.Form["discordId"].ToString() ?? "").Trim();
var changed = new List<string>();
// Advisory lines appended to the success message. Nothing here blocks a save.
var notes = new List<string>();
// ---- VALIDATE EVERYTHING FIRST -------------------------------------
// Nothing is written until every field passes. Validating as we went meant a
// failure halfway through left earlier fields already applied - the @name
// changed, then the display name was rejected, and the caller was told the
// edit failed while half of it had actually happened.
bool wantUsername = !string.IsNullOrWhiteSpace(username) && username != acc.player.Username;
bool wantDisplay = !string.IsNullOrWhiteSpace(displayName) && displayName != acc.player.DisplayName;
if (wantUsername)
{
// Admins may set single-character @names. The self-service endpoint keeps
// its 3-character minimum - short names are a thing to hand out, not to
// let players claim for themselves.
if (username.Length < 1)
return Json(new { success = false, message = "@ Name cannot be empty." });
if (username.Length > 50)
return Json(new { success = false, message = "@ Name must not exceed 50 characters." });
if (username.Contains(" "))
return Json(new { success = false, message = "@ Name must not contain spaces." });
if (System.Text.RegularExpressions.Regex.IsMatch(username, NonAsciiPattern))
return Json(new { success = false, message = "@ Name contains non-ASCII characters." });
}
if (wantDisplay)
{
// Cap is 50 here, not the 15 the self-service endpoint uses. Accounts the
// SERVER generates already exceed 15 ("EndlessCheetah8397" is 18), so a
// 15-char limit would stop an admin restoring a name the server itself
// created. Admins are trusted; the 15-char rule stays for players.
if (displayName.Length > 50)
return Json(new { success = false, message = "Display Name must not exceed 50 characters." });
if (System.Text.RegularExpressions.Regex.IsMatch(displayName, NonAsciiPattern))
return Json(new { success = false, message = "Display Name contains non-ASCII characters." });
}
// ---- platform links -------------------------------------------------
// Blank leaves the link alone; "0" (or "none"/"clear") removes it.
//
// Both are checked for uniqueness BEFORE anything is written. A Steam id is
// how a sign-in resolves to an account (PlayerDB.GetLogins looks up by
// platform + platformId), so the same id on two accounts makes the target of
// a Steam login whichever row LiteDB happens to return first. Refusing the
// duplicate is the only way to keep that lookup deterministic.
var currentSteam = PlayerDB.GetPlatformIds(playerId, Platforms.Steam);
ulong currentDiscord = acc.player_Extra?.DiscordId ?? 0;
// Steam accepts a LIST - one account may hold several Steam ids, and the same
// id may sit on several accounts. Separate with commas, spaces or newlines.
//
// Sharing an id across accounts is allowed on purpose, so there is no
// uniqueness check here. It is still surfaced in the response, because
// PlayerDB.GetLogins resolves a sign-in by (platform, platformId) and returns
// every account holding it - worth seeing, not worth blocking.
bool wantSteam = false;
var newSteam = new List<ulong>();
if (!string.IsNullOrWhiteSpace(steamId))
{
if (!IsClearKeyword(steamId))
{
foreach (var part in steamId.Split(new[] { ',', ';', ' ', '\t', '\r', '\n' },
StringSplitOptions.RemoveEmptyEntries))
{
if (!ulong.TryParse(part.Trim(), out ulong parsed) || parsed == 0)
return Json(new { success = false, message = $"'{part.Trim()}' is not a valid Steam ID. Use 17-digit SteamID64 numbers separated by commas, or 0 to unlink all." });
if (!newSteam.Contains(parsed)) newSteam.Add(parsed);
}
}
wantSteam = !newSteam.SequenceEqual(currentSteam);
}
bool wantDiscord = false;
ulong newDiscord = 0;
if (!string.IsNullOrWhiteSpace(discordId))
{
if (IsClearKeyword(discordId)) newDiscord = 0;
else if (!ulong.TryParse(discordId, out newDiscord))
return Json(new { success = false, message = "Discord ID must be a number (the 17-19 digit user id), or 0 to unlink." });
wantDiscord = newDiscord != currentDiscord;
if (wantDiscord && newDiscord != 0)
{
ulong owner = PlayerDB.FindPlayerByDiscordId(newDiscord, playerId);
if (owner != 0)
return Json(new { success = false, message = $"Discord ID {newDiscord} is already linked to player {owner}. Unlink it there first." });
}
}
// ---- APPLY ---------------------------------------------------------
// Username first: it is the only field that can still fail (uniqueness is only
// knowable at write time), so a clash aborts before anything else is touched.
if (wantUsername)
{
bool ok = await Task.Run(() => PlayerDB.SetPlayerUsername(username, playerId));
if (!ok)
return Json(new { success = false, message = $"The @name '{username}' is already in use." });
changed.Add($"@name -> {username}");
}
if (wantDisplay)
{
await Task.Run(() => PlayerDB.SetDisplayName(displayName, playerId));
changed.Add($"display name -> {displayName}");
}
// ---- profile picture / banner --------------------------------------
if (!string.IsNullOrWhiteSpace(profileImage) && profileImage != acc.player.profileImage)
{
await Task.Run(() => PlayerDB.SetPlayerImageName(profileImage, playerId));
changed.Add($"profile picture -> {profileImage}");
}
if (!string.IsNullOrWhiteSpace(bannerImage))
{
await Task.Run(() => PlayerDB.SetPlayerBanner(bannerImage, playerId));
changed.Add($"banner -> {bannerImage}");
}
if (wantSteam)
{
await Task.Run(() => PlayerDB.SetPlatformIds(playerId, Platforms.Steam, newSteam));
changed.Add(newSteam.Count == 0
? "Steam IDs unlinked"
: $"Steam IDs -> {string.Join(", ", newSteam)}");
// Not a refusal - just say which ids this account now shares, and with whom.
foreach (var id in newSteam)
{
var others = PlayerDB.FindPlayersByPlatformId(Platforms.Steam, id, playerId);
if (others.Count > 0)
notes.Add($"Steam ID {id} is also on player{(others.Count > 1 ? "s" : "")} {string.Join(", ", others)}.");
}
}
if (wantDiscord)
{
await Task.Run(() => PlayerDB.SetDiscordId(playerId, newDiscord));
changed.Add(newDiscord == 0 ? "Discord ID unlinked" : $"Discord ID -> {newDiscord}");
}
if (changed.Count == 0)
return Json(new { success = false, message = "Nothing to change - every field was blank or unchanged." });
// Live push: refresh the affected player's OWN client so a new @name / display name /
// profile picture / banner / identity shows without a relog.
//
// The 385120 dorm-drop only happens on a SELF-service image change, where the client
// both answers its own change request (load #1) AND gets this push (load #2) - the same
// promise completed twice. An ADMIN edit is different: the target's client never made a
// request, so this push is a SINGLE load and is safe even for a picture/banner change.
PushAccountRefresh(playerId, "edit");
// The player themselves updates instantly via the refresh above. This client has NO
// handler to change ANOTHER player's name in real time (the old "AccountUpdate"
// broadcast applied the payload to the RECEIVER's own account and mass-kicked everyone
// - see Notifications.RefreshAccount). As a SAFE best-effort, broadcast a reputation
// update for the renamed player: it's a proven SendToAll event and nudges other
// clients to re-read that player, so a re-rendered nameplate / opened profile can pick
// up the new name. Existing in-room nametags still refresh on the next room join.
bool nameChanged = changed.Any(c => c.StartsWith("@name") || c.StartsWith("display name"));
if (nameChanged)
PushEvent(playerId, Campus_live_rewrite.api.WebsocketEvents.CreateResponseReputationUpdate(playerId), "name broadcast", toAll: true);
Console.WriteLine($"[admin] {account.Username} ({account.Id}) edited player {playerId}: {string.Join("; ", changed)}");
var after = PlayerDB.GetFullAccount(playerId);
string message = "Updated " + string.Join("; ", changed) + ".";
if (notes.Count > 0) message += " " + string.Join(" ", notes);
return Json(new
{
success = true,
message,
username = after?.player?.Username,
displayName = after?.player?.DisplayName,
profileImage = after?.player?.profileImage,
steamIds = PlayerDB.GetPlatformIds(playerId, Platforms.Steam).Select(i => i.ToString()).ToArray(),
discordId = (after?.player_Extra?.DiscordId ?? 0).ToString()
});
}
/// <summary>
/// Resets another player's login password from the admin panel.
///
/// Kept as its own endpoint (not folded into edit_player) so a blank password box on
/// the profile Save can never touch the password, and so the action is deliberate and
/// separately audited. Mirrors the self-service /Auth/account/me/changepassword rules
/// (min 4, max 64) so an admin cannot set a password the player could not. The plaintext
/// is hashed by PlayerDB.SetPlayerPassword - it is never stored or logged in the clear.
/// </summary>
[HttpPost("/admin/api/reset_password")]
public async Task<ContentResult> AdminResetPassword()
{
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
if (error != null) return error;
string rawId = Request.Form["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}." });
string newPassword = Request.Form["newPassword"].ToString() ?? "";
if (string.IsNullOrWhiteSpace(newPassword) || newPassword.Length < 4)
return Json(new { success = false, message = "Password must be at least 4 characters." });
if (newPassword.Length > 64)
return Json(new { success = false, message = "Password must be fewer than 64 characters." });
await Task.Run(() => PlayerDB.SetPlayerPassword(newPassword, playerId, log: false));
// Live push: sign the player out so their old session ends and they must re-login with
// the new password (existing JWTs stay valid until expiry otherwise). No-op if offline.
PushEvent(playerId, Campus_live_rewrite.api.WebsocketEvents.CreateLogout(playerId), "password logout");
// Log WHO did it and to WHOM - never the password itself.
Console.WriteLine($"[admin] {account.Username} ({account.Id}) reset the password for @{acc.player.Username} ({playerId}).");
return Json(new { success = true, message = $"Password reset for @{acc.player.Username} (id {playerId}). They were signed out and must log in with the new password." });
}
// Expire EVERY in-game bearer token at once - the response to a leaked token/password.
// Bumps the game-token revoke epoch (ClientSecurity), so any game token minted before now
// is rejected on its next request and the player is bounced to login. Also broadcasts a
// logout to everyone currently connected so it takes effect immediately rather than on
// their next call. WEBSITE and ADMIN sessions are unaffected (the epoch is game-scoped).
[HttpPost("/admin/api/revoke_game_tokens")]
public ContentResult AdminRevokeGameTokens()
{
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
if (error != null) return error;
long epoch = ClientSecurity.RevokeAllGameTokens();
// Kick everyone connected right now (best-effort; a no-op for anyone offline, and the
// epoch already covers them on their next request either way).
int kicked = 0;
try
{
foreach (var pid in CampusLiveSource.Controllers.NotificationsController.WebSockets.Keys.ToList())
{
PushEvent(pid, Campus_live_rewrite.api.WebsocketEvents.CreateLogout(pid), "revoke-all logout");
kicked++;
}
}
catch (Exception ex) { Console.WriteLine($"[admin] revoke_game_tokens broadcast error: {ex.Message}"); }
Console.WriteLine($"[admin] {account.Username} ({account.Id}) EXPIRED ALL in-game tokens (epoch {epoch}); kicked {kicked} connected player(s).");
return new ContentResult
{
Content = Newtonsoft.Json.JsonConvert.SerializeObject(new
{
success = true,
epoch,
kicked,
message = $"All in-game sign-ins expired. {kicked} connected player(s) were signed out; everyone else is signed out on their next request. Website and admin logins are unaffected."
}),
ContentType = "application/json",
StatusCode = 200
};
}
/// <summary>"0", "none", "clear" and "unlink" all mean "remove this link".</summary>
private static bool IsClearKeyword(string s) =>
s == "0" ||
s.Equals("none", StringComparison.OrdinalIgnoreCase) ||
s.Equals("clear", StringComparison.OrdinalIgnoreCase) ||
s.Equals("unlink", StringComparison.OrdinalIgnoreCase);
// ---- live push helpers -----------------------------------------------------------
// Push an admin change to the affected player's game client so it shows without a relog.
// All fire-and-forget and a no-op if the player has no open socket, so they never block
// or fail an admin action.
// "Re-read your account" - updates username, display name, profile picture, banner and
// identity (account_data_me). Safe EXCEPT when the profile image itself just changed:
// answering the change AND pushing makes the client load the new image twice and drop to
// the dorm ([385120]). Callers must not use this on a profile-image change.
private static void PushAccountRefresh(ulong playerId, string what)
=> Notifications.FireAndForget(Notifications.RefreshAccount(playerId), $"admin {what} refresh");
// Serialize + send any websocket event object. toAll broadcasts to every connected client
// (only for events the client has a per-player handler for - Level, Reputation - never for
// account_data_me, which other clients apply to THEMSELVES and get kicked to the dorm).
private static void PushEvent(ulong playerId, object ev, string what, bool toAll = false)
{
string json = Newtonsoft.Json.JsonConvert.SerializeObject(ev);
Notifications.FireAndForget(
toAll ? Notifications.SendToAll(json) : Notifications.SendToPlayer(playerId, json),
$"admin {what} push");
}
/// <summary>Everything currently banned for a player - shown by the admin panel.</summary>
// ---- Steam allowlist ------------------------------------------------------
//
// Data/steam_allowlist.txt gates STEAM logins only (AuthController). Quest/Meta
// and every other platform ignore it. An empty list means the check is OFF.
[HttpGet("/admin/api/steam_allowlist")]
public ContentResult AdminGetSteamAllowlist()
{
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
if (error != null) return error;
var ids = SteamAllowlist.All().OrderBy(i => i).ToList();
// Show who each id belongs to where we can, so the panel is not a wall of
// 17-digit numbers with no way to tell whose is whose.
var rows = ids.Select(id =>
{
var owners = PlayerDB.FindPlayersByPlatformId(Platforms.Steam, id);
string? name = null;
if (owners.Count > 0)
name = PlayerDB.GetAccountById(owners[0])?.Username;
return new
{
steamId = id.ToString(),
accountId = owners.Count > 0 ? owners[0] : 0,
username = name,
linkedAccounts = owners.Count
};
}).ToList();
return Json(new
{
success = true,
active = SteamAllowlist.IsActive,
count = ids.Count,
entries = rows
});
}
[HttpPost("/admin/api/steam_allowlist/add")]
public ContentResult AdminAddSteamAllowlist([FromForm] string steamId)
{
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
if (error != null) return error;
if (!ulong.TryParse((steamId ?? "").Trim(), out ulong id) || id == 0)
return Json(new { success = false, message = "Enter a valid SteamID64 (17 digits)." });
bool added = SteamAllowlist.Add(id);
Console.WriteLine($"[admin] {account.Username} ({account.Id}) "
+ (added ? "added" : "re-added") + $" SteamID {id} to the allowlist");
return Json(new
{
success = true,
message = added ? $"SteamID {id} added." : $"SteamID {id} was already on the list.",
active = SteamAllowlist.IsActive,
count = SteamAllowlist.All().Count
});
}
[HttpPost("/admin/api/steam_allowlist/remove")]
public ContentResult AdminRemoveSteamAllowlist([FromForm] string steamId)
{
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
if (error != null) return error;
if (!ulong.TryParse((steamId ?? "").Trim(), out ulong id) || id == 0)
return Json(new { success = false, message = "Enter a valid SteamID64." });
bool removed = SteamAllowlist.Remove(id);
Console.WriteLine($"[admin] {account.Username} ({account.Id}) removed SteamID {id} from the allowlist");
// Removing the last entry turns the whole check off - that is a big enough
// behaviour change that the panel should say so out loud.
bool nowOff = !SteamAllowlist.IsActive;
return Json(new
{
success = removed,
message = removed
? (nowOff
? $"SteamID {id} removed. The list is now EMPTY - every Steam user can connect."
: $"SteamID {id} removed.")
: $"SteamID {id} was not on the list.",
active = SteamAllowlist.IsActive,
count = SteamAllowlist.All().Count
});
}
[HttpGet("/admin/api/get_bans")]
public ContentResult AdminGetBans()
{
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
if (error != null) return error;
string rawId = 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 block = acc.player_Extra?.ModerationBlockDetails;
bool accountBanned = PlayerDB.HasActiveModerationBlock(playerId);
string? deviceId = PlayerDB.GetDeviceId(playerId);
// Decrypt IP entries for display. An admin managing IP bans has to be able to
// see WHICH address is blocked - otherwise an over-broad ban (shared house,
// CGNAT, mobile carrier) is invisible and impossible to reason about.
var entries = BanDB.ForPlayer(playerId).Select(b => new
{
b.Type,
Value = b.Type == BanDB.Ip ? SafeDecrypt(b.Value) : b.Value,
b.Reason,
b.CreatedAt,
b.ExpiresAt,
Permanent = b.ExpiresAt == 0
}).ToList();
return Json(new
{
success = true,
playerId,
username = acc.player.Username,
accountBanned,
banMessage = block?.Message,
deviceId,
deviceBanned = BanDB.IsDeviceBanned(deviceId),
knownIps = (PlayerDB.GetDecryptedIpAddresses(playerId) ?? new List<string>()).Count,
entries
});
}
[HttpPost("/admin/api/kick_player")]
public ContentResult KickPlayer()
{
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
if (error != null)
return error;
string playerid = HttpContext.Request.Form["playerId"].ToString();
if (!ulong.TryParse(playerid, out ulong playerId))
{
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
success = false,
message = "Invalid player ID"
}),
ContentType = "application/json",
StatusCode = 200
};
}
if (playerId == account.Id)
{
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
success = false,
message = "You can't kick yourself!"
}),
ContentType = "application/json",
StatusCode = 200
};
}
/*
if (player_db_file.CheckDevFlag(playerId))
{
return new ContentResult()
{
Content = "{\"success\":false,\"message\":\"You can't kick developers silly!\"}",
ContentType = "application/json",
StatusCode = 200
};
}*/
PlayerDB.Set_moderation_block(
playerId,
ReportCategory.Moderator,
duration: 1,
gameSessionId: -1,
isHostKick: true,
message: "You have been kicked.",
playerIdReporter: null,
isBan: false,
bannedByPlayerId: account.Id
);
var acc = PlayerDB.GetFullAccount(playerId);
Webhook.Send(
account.Id,
"modacc",
"Reporting info - Player",
$"({account.Id}) @{account.Username} [DEV] kicked player ({playerId}) @{acc.player.Username} {acc.player.DisplayName}",
5898927
);
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
success = true,
message = "Kicked player!"
}),
ContentType = "application/json"
};
}
[HttpGet("/admin/api/account/bulk")]
public ContentResult AccountBulk([FromQuery] List<ulong> id)
{
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
if (error != null)
return error;
return new ContentResult()
{
Content = PlayerDB.GetAccountsBulkAdmin(id),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPost("/admin/api/send_annoucement")]
public ContentResult AdminAPISendAnnoucement([FromForm] string message, [FromForm] ulong playerId)
{
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
if (error != null)
return error;
var response = WebsocketEvents.createResponse_msg(message, playerId);
Notifications.SendToAll(JsonConvert.SerializeObject(response));
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
success = true,
message = "sent!"
}),
ContentType = "application/json"
};
}
[HttpPost("/admin/api/{roomId}/{subroomId}/set_room_datablob")]
public ContentResult set_room_datablob([FromForm] string dataBlob, ulong roomId, ulong subroomId)
{
/*var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;*/
try
{
var e = RoomDB.SetRoomSubroomRoomFile(roomId, subroomId, dataBlob, "", 1, currentplatform: Platforms.All);
}
catch (Exception ex)
{
Console.WriteLine(ex);
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
success = false,
message = "unknown error!"
}),
ContentType = "application/json"
};
}
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
success = true,
message = "success!"
}),
ContentType = "application/json"
};
}
// =====================================================================================
// REPORTS and STAFF ROLES
//
// Neither existed. Player reports had nowhere to go (the client's
// /api/PlayerReporting/v3/create route was not implemented, see APIController) and there
// was no way at all to make someone a moderator - DevFlag.mod was in the enum, and
// PlayerDB.CheckModFlag read it, but nothing could ever SET it. That is why assigning a
// mod from the admin panel did nothing: there was no endpoint behind the button.
// =====================================================================================
/// <summary>
/// The report queue. Open reports by default, newest first, each enriched with the two
/// players' names and the reported player's prior-report count so a repeat offender is
/// obvious without opening every row.
/// </summary>
[HttpGet("/admin/api/reports")]
public ContentResult AdminReports()
{
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
if (error != null) return error;
bool openOnly = !Truthy(Request.Query["all"]);
int skip = int.TryParse(Request.Query["skip"], out var sk) ? sk : 0;
int take = int.TryParse(Request.Query["take"], out var tk) ? tk : 100;
ulong playerId = ulong.TryParse(Request.Query["playerId"], out var pid) ? pid : 0;
string NameOf(ulong id)
{
if (id == 0) return "";
var a = PlayerDB.GetFullAccount(id);
if (a?.player == null) return $"#{id}";
return string.IsNullOrWhiteSpace(a.player.DisplayName) ? a.player.Username ?? $"#{id}" : a.player.DisplayName;
}
var rows = Campus_live_rewrite.api.dbs.ReportsDB.List(openOnly, skip, take, playerId)
.Select(r => new
{
r.Id,
r.ReporterPlayerId,
ReporterName = NameOf(r.ReporterPlayerId),
r.ReportedPlayerId,
ReportedName = NameOf(r.ReportedPlayerId),
r.ReportCategory,
CategoryName = !string.IsNullOrEmpty(r.Reason) ? r.Reason
: Enum.IsDefined(typeof(Moderation_Detail.ReportCategory), r.ReportCategory)
? ((Moderation_Detail.ReportCategory)r.ReportCategory).ToString()
: $"Unknown({r.ReportCategory})",
r.Details,
// website reports: where it came from and a link to the reported thing
Source = r.Source ?? "game",
r.Subject,
r.SubjectId,
r.SubjectLink,
r.RoomId,
RoomName = r.RoomId != null ? (RoomDB.GetRoom(r.RoomId.Value)?.Name ?? "") : "",
r.RoomInstanceType,
r.HeightReporter,
r.HeightReported,
r.CreatedAt,
r.Resolved,
r.Action,
r.ResolvedByPlayerId,
ResolvedByName = NameOf(r.ResolvedByPlayerId),
r.ResolvedAt,
r.ModeratorNote,
// How many times this player has been reported, ever - the number that tells
// you whether one complaint is a pattern.
PriorReports = Campus_live_rewrite.api.dbs.ReportsDB.CountAgainst(r.ReportedPlayerId),
ReportedIsBanned = PlayerDB.Get_moderation_block(r.ReportedPlayerId) != null
})
.ToList();
return Json(new { success = true, openCount = Campus_live_rewrite.api.dbs.ReportsDB.CountOpen(), reports = rows });
}
/// <summary>
/// Act on one report. `action` is dismiss | warn | ban.
///
/// A ban goes through PlayerDB.Set_moderation_block - the SAME path /admin/api/ban_player
/// uses - rather than a second ban mechanism living behind the report queue. Acting on a
/// player also resolves their other open reports, so a pile-on does not leave nine rows
/// describing the thing you just dealt with.
/// </summary>
[HttpPost("/admin/api/report_action")]
public ContentResult AdminReportAction()
{
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
if (error != null) return error;
if (!long.TryParse(Request.Form["reportId"].ToString(), out long reportId))
return Json(new { success = false, message = "Invalid report id." });
var report = Campus_live_rewrite.api.dbs.ReportsDB.Get(reportId);
if (report == null)
return Json(new { success = false, message = $"No report #{reportId}." });
string action = (Request.Form["action"].ToString() ?? "").Trim().ToLowerInvariant();
string note = (Request.Form["note"].ToString() ?? "").Trim();
ulong target = report.ReportedPlayerId;
switch (action)
{
case "dismiss":
Campus_live_rewrite.api.dbs.ReportsDB.Resolve(reportId, account.Id, "dismissed", note);
return Json(new { success = true, message = $"Report #{reportId} dismissed." });
case "warn":
{
// A warning is delivered as a zero-duration moderation block: the client shows
// the block screen with the message and then lets them straight back in. That
// is how the real service words a warning, and it reuses the one delivery path.
string wmsg = string.IsNullOrWhiteSpace(note) ? "You have received a warning from a moderator." : note;
PlayerDB.Set_moderation_block(target, (Moderation_Detail.ReportCategory)report.ReportCategory,
0, gameSessionId: -1, isHostKick: false, message: wmsg,
playerIdReporter: report.ReporterPlayerId, isBan: false, bannedByPlayerId: account.Id);
int n = Campus_live_rewrite.api.dbs.ReportsDB.ResolveAllAgainst(target, account.Id, "warned", note);
return Json(new { success = true, message = $"Warned {target}. {n} report(s) closed." });
}
case "ban":
{
if (PlayerDB.CheckDevFlag(target))
return Json(new { success = false, message = "Cannot ban this player." });
if (!int.TryParse(Request.Form["duration"].ToString(), out int duration) || duration < 0)
duration = 0; // 0 = permanent, same as ban_player
string bmsg = string.IsNullOrWhiteSpace(note) ? "Banned following a player report." : note;
var category = (Moderation_Detail.ReportCategory)report.ReportCategory;
PlayerDB.Set_moderation_block(target, category, duration,
gameSessionId: -1, isHostKick: false, message: bmsg,
playerIdReporter: report.ReporterPlayerId, isBan: true, bannedByPlayerId: account.Id);
int n = Campus_live_rewrite.api.dbs.ReportsDB.ResolveAllAgainst(target, account.Id, "banned", note);
Console.WriteLine($"[report] {account.Id} banned {target} from report #{reportId} ({duration} min)");
return Json(new { success = true, message = $"Banned {target}. {n} report(s) closed." });
}
default:
return Json(new { success = false, message = $"Unknown action '{action}'. Use dismiss, warn or ban." });
}
}
/// <summary>
/// Staff: everyone holding a moderator / developer / screenshare flag. This is the "vmod
/// list" - there was no way to see or edit it.
/// </summary>
[HttpGet("/admin/api/staff")]
public ContentResult AdminStaff()
{
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
if (error != null) return error;
var col = PlayerDB.PlayerDBFile.GetCollection<player_data>("players");
var staff = col.FindAll()
.Where(p => p.dev_Flag != DevFlag.none && p.player != null)
.OrderBy(p => p.player!.Id)
.Select(p => new
{
// camelCase: the panel reads p.accountId / p.username / p.isModerator
accountId = p.player!.Id,
username = p.player.Username,
displayName = p.player.DisplayName,
isModerator = (p.dev_Flag & DevFlag.mod) == DevFlag.mod,
isDeveloper = (p.dev_Flag & DevFlag.dev) == DevFlag.dev,
isScreenshare = (p.dev_Flag & DevFlag.screenshare) == DevFlag.screenshare,
isSupport = (p.dev_Flag & DevFlag.support) == DevFlag.support,
flags = (int)p.dev_Flag
})
.ToList();
return Json(new { success = true, staff });
}
/// <summary>
/// Grant or revoke staff flags. Send only the ones you mean to change - an absent field is
/// left alone, so ticking "moderator" cannot silently strip someone's dev flag.
///
/// Developer is deliberately harder to hand out than moderator: it is the flag
/// AuthHelper.ValidateTokenDev gates this whole admin panel on, so granting it hands over
/// the panel itself. Revoking your OWN developer flag is refused - locking yourself out of
/// the admin panel with a mis-click is not a recoverable mistake from inside the panel.
/// </summary>
[HttpPost("/admin/api/set_roles")]
public ContentResult AdminSetRoles()
{
var (account, error) = AuthHelper.ValidateTokenDev(HttpContext);
if (error != null) return error;
if (!ulong.TryParse(Request.Form["playerId"].ToString(), out ulong playerId) || playerId == 0)
return Json(new { success = false, message = "Invalid player id." });
var col = PlayerDB.PlayerDBFile.GetCollection<player_data>("players");
var rec = col.FindOne(x => x.id == playerId);
if (rec?.player == null)
return Json(new { success = false, message = $"No player with id {playerId}." });
var before = rec.dev_Flag;
var flags = rec.dev_Flag;
var changed = new List<string>();
// An absent form field means "leave it"; only a present one flips a bit.
void Apply(string field, DevFlag bit, string label)
{
string raw = Request.Form[field].ToString();
if (string.IsNullOrEmpty(raw)) return;
bool want = Truthy(raw);
bool has = (flags & bit) == bit;
if (want == has) return;
if (bit == DevFlag.dev && !want && playerId == account.Id)
{
changed.Add("developer NOT removed (you cannot revoke your own admin access)");
return;
}
flags = want ? (flags | bit) : (flags & ~bit);
changed.Add($"{label} {(want ? "granted" : "removed")}");
}
Apply("moderator", DevFlag.mod, "moderator");
Apply("developer", DevFlag.dev, "developer");
Apply("screenshare", DevFlag.screenshare, "screenshare");
Apply("support", DevFlag.support, "support");
if (flags == before)
return Json(new { success = true, message = changed.Count > 0 ? string.Join("; ", changed) : "Nothing to change.", flags = (int)flags });
rec.dev_Flag = flags;
col.Update(rec);
// The roles are baked into the JWT at mint time (ClientSecurity), so a player holding
// a live token keeps the old set until it is re-issued. Say so rather than letting a
// moderator wonder why their new powers have not appeared.
Console.WriteLine($"[roles] {account.Id} changed {playerId}: {before} -> {flags} ({string.Join("; ", changed)})");
// Take effect NOW: the player's client is told to refresh its login (frame 5,
// RefreshLogin) so it re-mints a token carrying the new roles - the moderator menu
// (warn / kick / room ban) and their vmod tag appear without a relog. Everyone else's
// view of their tag reads /Auth/role/moderator, which is the DB - already current.
try
{
Notifications.FireAndForget(Notifications.SendToPlayer(playerId, JsonConvert.SerializeObject(new WebsocketEvents.Reponse
{
Id = ((int)WebsocketEvents.ResponseResults.RefreshLogin).ToString(),
Msg = new { Reason = "roles changed" }
})), "roles refresh-login");
Notifications.FireAndForget(Notifications.RefreshAccount(playerId), "roles account refresh");
}
catch { }
return Json(new
{
success = true,
message = string.Join("; ", changed) + ". Applied; their client has been told to refresh its login (a relog also does it).",
flags = (int)flags
});
}
string FormatBytes(long bytes)
{
string[] sizes = { "B", "KB", "MB", "GB", "TB" };
double len = bytes;
int order = 0;
while (len >= 1024 && order < sizes.Length - 1)
{
order++;
len /= 1024;
}
return $"{len:0.0} {sizes[order]}";
}
}
}
@@ -0,0 +1,1039 @@
using System.Text;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api;
using Campus_live_rewrite.api.server;
using Campus_live_rewrite.api.server.Classes;
using Campus_live_rewrite.api.server.Classes.db_class;
using Newtonsoft.Json.Linq;
using Discord.Net;
using System.Security.Principal;
namespace Campus_live_rewrite.Controllers
{
public class AuthController : ControllerBase
{
// Translate an account's stored DevFlag bitmask into the JWT `role` strings the client
// uses to decide dev / moderator / screenshare permissions. The DB is updated correctly
// by the admin panel (via PlayerDB.SetPlayerDev / SetRoleFlag), but the login path
// below was HARDCODING roles = ["gameclient"] and dropping every flag on the floor - so
// toggling "dev" in the admin panel changed the DB but the client's next token still
// said only gameclient, and the player wasn't dev in-game.
//
// Every player still gets "gameclient" as the base role; dev/mod/screenshare are added
// on top when the corresponding flag bit is set. Owner is treated as full dev+mod for
// parity with how the 2023 server did it.
private static List<string> RolesForAccount(player_data account)
{
var roles = new List<string> { "gameclient" };
if (account == null) return roles;
var f = account.dev_Flag;
bool isDev = (f & DevFlag.dev) == DevFlag.dev;
bool isMod = (f & DevFlag.mod) == DevFlag.mod;
bool isScreenshare = (f & DevFlag.screenshare) == DevFlag.screenshare;
bool isOwner = (f & DevFlag.owner) == DevFlag.owner;
if (isDev || isOwner) roles.Add("developer");
if (isMod || isOwner || isDev) roles.Add("moderator");
if (isScreenshare || isDev || isOwner) roles.Add("screenshare");
return roles;
}
[HttpGet("/Auth/eac/challenge")]
public ContentResult EACChallenge()
{
return new ContentResult
{
Content = JsonConvert.SerializeObject("test"),
ContentType = "application/json",
StatusCode = 200
};
}
// GET or POST: older clients GET this; the 2025 build POSTs it with a deviceId/platformAuth
// form body that is read by nothing here (reference: recflare auth). Same answer either way.
[HttpGet("/Auth/cachedlogin/forplatformid/{platform}/{platform_id}")]
[HttpPost("/Auth/cachedlogin/forplatformid/{platform}/{platform_id}")]
public ContentResult CachedLogins(int platform, ulong platform_id)
{
Platforms platformEnum;
if (Enum.IsDefined(typeof(Platforms), platform))
{
platformEnum = (Platforms)platform;
}
else
{
platformEnum = Platforms.All;
}
List<AccountLogin>? accounts = null;
PlayerDB.GetLogins(platformEnum, platform_id, out accounts);
accounts ??= new List<AccountLogin>();
// Admin test switch: an allowlisted IP is shown an EMPTY picker so its client walks the
// new-player path (create_account -> Orientation) even though this Steam / device
// already has accounts. See ConfigCampus.AdminTestNewAccountFlow.
string pickerIp = Campus_live_rewrite.api.server.LoginGuard.ClientIp(HttpContext);
if (ConfigCampus.AdminTestNewAccountFlow && AdminProtectionMiddleware.IsAllowlistedIp(pickerIp))
{
Console.WriteLine($"[cachedlogin] {platformEnum} {platform_id} from admin IP {pickerIp}: TEST switch on - empty picker, client will create_account");
return new ContentResult { Content = "[]", ContentType = "application/json", StatusCode = 200 };
}
// The Quest no-Meta build sends the sentinel id 1 for EVERY headset (see
// PlatformIdentity), so anything it matched belongs to some OTHER player - handing it
// back would cached-log this headset into their account with no password. Drop the
// matches and fall through to the password-login entry below. Nothing should ever be
// bound to the sentinel in the first place (AccountLock refuses to record it); this is
// the belt-and-braces half, and it also cleans up after any binding made before that.
if (PlatformIdentity.IsSentinel(platformEnum, platform_id) && accounts.Count > 0)
{
Console.WriteLine($"[cachedlogin] Quest sentinel id {platform_id} matched {accounts.Count} "
+ $"account(s) - ignoring them, sending to password login screen");
accounts.Clear();
}
// Quest "remember me": with no verifiable platform identity, offer the accounts this
// HEADSET (its deviceId, sent in the form body) has already signed in to with a password.
// See ConfigCampus.QuestDeviceCachedLogin; the cached_login grant re-checks the binding.
if (ConfigCampus.QuestDeviceCachedLogin && accounts.Count == 0 &&
PlatformIdentity.IsSentinel(platformEnum, platform_id))
{
string? devId = null;
try { if (HttpContext.Request.HasFormContentType) devId = HttpContext.Request.Form["deviceId"]; } catch { }
var remembered = PlayerDB.GetDeviceLogins(devId, Platforms.Oculus, platform_id);
if (remembered.Count > 0)
{
Console.WriteLine($"[cachedlogin] Quest device '{devId}' remembered {remembered.Count} account(s) - offering cached login");
accounts = remembered;
}
}
// A Steam id should map to ONE account. If a stray duplicate exists (two website accounts
// both recorded the same Steam id, since the account-lock is per-DEVICE not per-Steam), the
// client's cached-login picker could land on the WRONG one - that's the "logs into a random
// account" bug. Fix: for a NORMAL player, offer only the most-recently-used account for this
// Steam id. Owner/test accounts (Coach/Kyle/@Importer) intentionally share one Steam, so if
// any exempt account is present we keep the full list (the deliberate account picker).
// A Steam id may now carry the game-made + website pair (ConfigCampus.MaxAccountsPerDevice),
// and the picker shows both so the player chooses; only a stray surplus beyond that is
// trimmed to the most-recent ones.
int pickerCap = Math.Max(1, ConfigCampus.MaxAccountsPerDevice);
if (accounts.Count > pickerCap && platform_id != 0)
{
bool anyExempt = accounts.Any(a => AccountLock.IsExempt(a.accountId));
if (!anyExempt)
{
accounts = accounts.OrderByDescending(a => a.lastLoginTime).Take(pickerCap).ToList();
Console.WriteLine($"[cachedlogin] Steam {platform_id} had more than {pickerCap} accounts; offering the most-recent {pickerCap} (ids {string.Join(",", accounts.Select(a => a.accountId))}) to avoid a random login");
}
}
if (accounts != null && accounts.Count > 0)
{
// A linked account: platform ownership is the credential, so cached-log-in with no
// prompt (requirePassword defaults false).
return new ContentResult()
{
Content = JsonConvert.SerializeObject(accounts),
ContentType = "application/json",
StatusCode = 200
};
}
// No account linked to this platform id yet. Hand back one FAKE cached-login entry with
// requirePassword=true. That sends the client to the USERNAME/PASSWORD login screen -
// where the player types the credentials they made on the website - instead of an empty
// picker, which falls through to create_account (refused) and shows an error. Mirrors
// the reference's fake cached-login trick.
//
// STEAM is gated on the allowlist (or the allowlist being off); non-allowlisted ids get
// [] and are refused at the token endpoint anyway.
//
// QUEST/OCULUS is NOT gated - a headset player needs no platform verification to reach
// the login screen. That matches how the rest of the server already treats Oculus
// (SteamAllowlist.ShouldCheck is Steam-only, so the token endpoint never gates a
// headset either). It grants nothing on its own: the entry carries accountId 0 and
// requirePassword=true, so the player still has to type website credentials, and the
// ban gates + LoginGuard lockout on /Auth/connect/token all still apply.
bool steamId = (platformEnum == Platforms.Steam || platformEnum == Platforms.All) && platform_id != 0;
bool questId = platformEnum == Platforms.Oculus;
// In-game account creation ON: a platform id with no account gets an EMPTY picker, which
// is what makes the client run its new-player flow (create_account -> Orientation:
// choose a name, make a password). A returning website player still logs in through
// the picker's + / "log in with existing account" (grant_type=password). The fake
// "Sign in" tile below is the website-only mode.
if (ConfigCampus.InGameAccountCreation && (questId || steamId))
{
Console.WriteLine($"[cachedlogin] {platformEnum} {platform_id}: no account - empty picker (in-game creation on)");
return new ContentResult { Content = "[]", ContentType = "application/json", StatusCode = 200 };
}
if (questId && !ConfigCampus.QuestLoginPlaceholder)
{
// No fake tile (see ConfigCampus.QuestLoginPlaceholder): an empty picker, and the
// player adds their account with + . Everything a returning headset needs is above
// (the device-remembered login), so this is only ever the very first launch.
Console.WriteLine($"[cachedlogin] Oculus {platform_id}: no remembered account - empty picker (player logs in via +)");
return new ContentResult()
{
Content = "[]",
ContentType = "application/json",
StatusCode = 200
};
}
if (questId || (steamId && SteamAllowlist.IsAllowed(platform_id)))
{
Platforms fakePlatform = questId ? Platforms.Oculus : Platforms.Steam;
var fake = new List<AccountLogin>
{
new AccountLogin
{
createdAt = DateTime.UtcNow,
lastLoginTime = DateTime.UtcNow,
accountId = PlatformIdentity.LoginPlaceholderAccountId,
platform = fakePlatform,
platformId = platform_id.ToString(),
requirePassword = true
}
};
Console.WriteLine($"[cachedlogin] {fakePlatform} {platform_id} allowed, no account - sending to password login screen");
return new ContentResult()
{
Content = JsonConvert.SerializeObject(fake),
ContentType = "application/json",
StatusCode = 200
};
}
Webhook.Send(1,
"mod",
"Reporing Info - No Cached Logins",
$"A cached login was attempted for platform {platformEnum} with platform ID {platform_id}, but no matching accounts were found.",
5898927
);
return new ContentResult()
{
Content = "[]",
ContentType = "application/json",
StatusCode = 200
};
}
private enum OculusProof { NotOculus, Verified, Sentinel, Unconfigured, Rejected }
// What a login's platform_auth proves for a Meta/Oculus client (reference: recflare auth
// verifyPlatformProof). The sentinel id the sideload build sends is refused before anything
// is asked of Meta, so nothing downstream can treat it as an identity. Steam is left on the
// server's existing (currently disabled) ticket path and is not touched here.
private static async Task<(OculusProof status, ulong verifiedId, string reason)> VerifyOculusProof(
Platforms platform, ulong platformId, string? platformAuth)
{
if (platform != Platforms.Oculus) return (OculusProof.NotOculus, 0, "");
if (PlatformIdentity.IsSentinel(platform, platformId))
return (OculusProof.Sentinel, 0, "sideload sentinel id is never an identity");
string secret = ConfigCampus.MetaAppSecret;
if (string.IsNullOrEmpty(secret)) return (OculusProof.Unconfigured, 0, "META_APP_SECRET not set");
var r = await MetaNonce.VerifyAsync(platformAuth, platformId, secret);
return r.Ok ? (OculusProof.Verified, r.UserId, "") : (OculusProof.Rejected, 0, r.Reason);
}
private static ContentResult GrantError(int status, string error, string description) => new()
{
Content = JsonConvert.SerializeObject(new { error, error_description = description }),
ContentType = "application/json",
StatusCode = status
};
[HttpPost("/Auth/connect/token")]
public async Task<ContentResult> ConnectToken(int platform, ulong platform_id)
{
// The token endpoint is the GAME CLIENT only (BestHTTP). Reject anything else up
// front - BEFORE any body parsing - so a REST tool / HTTP debugger cannot reach the
// grant handlers to farm bearer tokens. This check used to sit AFTER an
// int.Parse(Form["platform"]) that threw on a minimal body and was swallowed by a
// catch, so a hand-crafted request skipped straight past it - which is exactly how a
// token got minted from a REST client. NB: User-Agent is client-set and spoofable, so
// this is defence-in-depth ON TOP OF credentials + the per-IP auth rate limit
// (TrafficGuardMiddleware), not a standalone gate.
string reqUa = HttpContext.Request.Headers["User-Agent"].ToString();
if (ConfigCampus.RequireGameUserAgent && reqUa != "BestHTTP")
{
Console.WriteLine($"[connect/token] refused non-game User-Agent '{reqUa}'");
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
error = "invalid_client",
error_description = "This endpoint is for the Campus game client only."
}),
ContentType = "application/json",
StatusCode = 403
};
}
// NOTE: the raw request body is NOT read/logged here any more. It used to be buffered
// in full and Console.WriteLine'd every call, which (a) printed every login's PLAINTEXT
// password into the journal and (b) pulled an unbounded body into memory. Form parsing
// below reads the body on its own; Kestrel now caps the body size globally.
string token = null;
string? temp2 = HttpContext.Request.Headers["Authorization"];
string? Ip = HttpContext.Request.Headers["CF-Connecting-IP"].FirstOrDefault();
Guid myuuid = Guid.NewGuid();
string guid = myuuid.ToString();
string? grant_type = "";
try
{
grant_type = HttpContext.Request.Form["grant_type"];
}
catch
{
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 400
};
}
ulong accid = 0;
string? device_id = "";
string? username = "";
string? password = "";
string? platform_auth = HttpContext.Request.Form["platform_auth"];
string? user_agent = "";
string? ver = "";
Platforms platformparsed = Platforms.All;
try
{
device_id = HttpContext.Request.Form["device_id"];
user_agent = HttpContext.Request.Headers["User-Agent"];
platform = int.Parse(HttpContext.Request.Form["platform"]);
platform_id = ulong.Parse(HttpContext.Request.Form["platform_id"]);
ver = HttpContext.Request.Form["ver"];
platform_auth = HttpContext.Request.Form["platform_auth"];
platformparsed = Enum.IsDefined(typeof(Platforms), platform) ? (Platforms)platform : Platforms.All;
// ---- device / IP ban gate ----------------------------------------
// Checked HERE, before any account is looked up or created, so a banned
// machine cannot simply register a fresh account and walk back in. An
// account ban alone never achieved that.
var deviceBan = Campus_live_rewrite.api.dbs.BanDB.GetDeviceBan(device_id);
if (deviceBan != null)
{
Console.WriteLine($"[ban] refused login: device '{device_id}' is banned " +
$"(from account {deviceBan.PlayerId}, reason: {deviceBan.Reason})");
return BannedLoginResponse(deviceBan.Reason, "device");
}
var ipBan = Campus_live_rewrite.api.dbs.BanDB.GetIpBan(Ip);
if (ipBan != null)
{
Console.WriteLine($"[ban] refused login: IP is banned " +
$"(from account {ipBan.PlayerId}, reason: {ipBan.Reason})");
return BannedLoginResponse(ipBan.Reason, "ip");
}
// ---- Steam allowlist ---------------------------------------------
// STEAM ONLY. Quest/Meta (Oculus) and every other platform skip this
// entirely - ShouldCheck returns false for them, so a headset player is
// never gated here.
//
// Sits with the ban gates, before any account lookup or creation, so a
// non-listed Steam user cannot register an account on the way past.
//
// Off by default: an absent or empty Data/steam_allowlist.txt means
// "allow everyone". An allowlist that locked the server the instant the
// file appeared would be a foot-gun.
if (SteamAllowlist.ShouldCheck(platformparsed, platform_id)
&& !SteamAllowlist.IsAllowed(platform_id))
{
// NOT a brute-force failure, and it must not be recorded as one.
//
// This used to call LoginGuard.RecordFailure, which is the 3-strikes-per-IP
// brute-force counter. But an unlisted Steam id is a POLICY refusal against a
// verified identity - there is no secret being guessed, and the client retries
// on its own, so one un-allowlisted player burned all three strikes in seconds
// and locked the whole ADDRESS out of /Auth/connect/token for five minutes.
// Everyone behind that address - a household, a CGNAT range, allowlisted
// players signing in to their own accounts - then got "Unable to login" too.
// That is the half of the error-3 reports that came from people who were on
// the allowlist and had done nothing wrong.
//
// The allowlist still refuses the login; it just stops manufacturing lockouts
// for bystanders. Password guessing (below) is what feeds the lockout.
Console.WriteLine($"[steam-allowlist] refused login: SteamID {platform_id} is not on the allowlist");
return BannedLoginResponse(
"This Steam account is not on the Campus allowlist. " +
"Ask an admin to add your SteamID64.", "steam-allowlist");
}
// Only parse account_id for grants that actually carry one. create_account has
// no account yet, and the password grant identifies the account by username -
// parsing a missing account_id there would throw and skip the checks below.
if (grant_type != "create_account" && grant_type != "password")
{
ulong.TryParse(HttpContext.Request.Form["account_id"], out accid);
}
// (User-Agent is already enforced at the very top of this method, before any
// parsing that could throw and skip it - see the RequireGameUserAgent gate.)
}
catch (Exception ex)
{
Console.WriteLine(ex.ToString());
}
Console.WriteLine($"{grant_type} : {accid}");
// The in-game "Enter Account details" login. Accounts are created on the WEBSITE
// (SiteController /api/site/register); the game signs in to them by username +
// password here. On success the account is bound 1:1 to this Steam id and device
// (AccountLock) - that binding is what "locks the account to your Steam". After the
// first login this account also appears in the platform's cached-login list, so
// later launches sign in automatically.
if (grant_type == "password")
{
string clientIp = Campus_live_rewrite.api.server.LoginGuard.ClientIp(HttpContext);
string? Username = HttpContext.Request.Form["username"];
string? Pass = HttpContext.Request.Form["password"];
// Reject absurdly long credential fields up front (a bot posting garbage), before any
// lookup. Counts as a failed attempt so it feeds the lockout too.
if ((Username?.Length ?? 0) > ConfigCampus.MaxUsernameLength ||
(Pass?.Length ?? 0) > ConfigCampus.MaxPasswordLength)
{
Campus_live_rewrite.api.server.LoginGuard.RecordFailure(clientIp);
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new { access_token = (string?)null, error = "invalid_grant", error_description = "Invalid username or password.", key = (string?)null, refresh_token = (string?)null }),
ContentType = "application/json",
StatusCode = 200
};
}
var loginResult = PlayerDB.GetAccountPassword((Username ?? "").Trim(), Pass, isAdminPanel: false);
if (!loginResult.success)
{
// Wrong username/password: count it toward the 3-strikes IP lockout.
Campus_live_rewrite.api.server.LoginGuard.RecordFailure(clientIp);
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
access_token = (string?)null,
error = "invalid_grant",
error_description = loginResult.error ?? "Incorrect username or password.",
key = (string?)null,
refresh_token = (string?)null
}),
ContentType = "application/json",
StatusCode = 200
};
}
// Correct credentials: clear this IP's failure counter / lockout.
Campus_live_rewrite.api.server.LoginGuard.RecordSuccess(clientIp);
ulong loggedInId = loginResult.accid;
if (PlayerDB.HasActiveModerationBlock(loggedInId))
{
return BannedLoginResponse("This account is currently suspended.", "account");
}
// A password login has already proven who it is, so it is never gated on the platform
// proof - but the platform id it RECORDS becomes a password-free way into the account
// (cached login), so for Oculus only a Meta-verified id is linked. Unverified (or
// unconfigured, or the sideload sentinel) links nothing: the login still succeeds, the
// headset just types its password each launch. Reference: recflare auth, password grant.
ulong linkPlatformId = platform_id;
if (platformparsed == Platforms.Oculus)
{
var (st, vid, why) = await VerifyOculusProof(platformparsed, platform_id, platform_auth);
if (st == OculusProof.Verified)
{
linkPlatformId = vid;
Console.WriteLine($"[meta] password login for account {loggedInId}: nonce verified, linking Meta id {vid}");
}
else
{
linkPlatformId = 0;
Console.WriteLine($"[meta] password login for account {loggedInId}: not linking headset ({st}: {why})");
}
}
// Enforce + apply the one-account-per-Steam / one-account-per-device lock.
if (!AccountLock.TryEnforceAndBind(loggedInId, platformparsed, linkPlatformId, device_id, out string lockErr))
{
return BannedLoginResponse(lockErr, "account-lock");
}
string pw_request_url = $"{Request.Scheme}://{Request.Host}";
List<string> pw_roles = RolesForAccount(PlayerDB.GetFullAccount(loggedInId));
string? pw_token = ClientSecurity.CreateToken(loggedInId, pw_roles, pw_request_url, platformparsed);
if (string.IsNullOrEmpty(pw_token))
goto token_err;
PlayerDB.SetLastLoginTime(loggedInId);
PlayerDB.AddIpAddress(loggedInId, Ip);
PlayerDB.ResetCheerCreditIfNewDay(loggedInId);
Webhook.Send(loggedInId, "prod", "Matchmaking info",
$"({loggedInId}) signed in with credentials on **{platformparsed}**", 5898927);
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
access_token = pw_token,
expires_in = ConfigCampus.GameTokenHours * 3600,
token_type = "Bearer",
refresh_token = guid,
scope = "offline_access rn.accounts rn.accounts.gc rn.api rn.auth rn.auth.gc rn.bugreporting rn.cards rn.chat rn.clubs rn.cms rn.commerce rn.data rn.data.gc rn.datacollection rn.datacollection.gc rn.discovery rn.gamelogs.gc rn.leaderboard rn.link rn.lists rn.match.read rn.match.write rn.moderation rn.notify rn.platformnotifications rn.playersettings rn.roomcomments rn.rooms rn.storage rn.strings rn.studio.gc",
key = ""
}),
ContentType = "application/json",
StatusCode = 200
};
}
if (grant_type == "cached_login")
{
ulong steamidticket = 0;
Console.WriteLine("account cached");
// TEMPORARILY DISABLED STEAM AUTH CHECKS FOR DEBUGGING
Console.WriteLine("[STEAM AUTH TEMPORARILY DISABLED]");
/*
if (!string.IsNullOrEmpty(platform_auth))
{
string unescaped = System.Net.WebUtility.UrlDecode(platform_auth);
Console.WriteLine("[PLATFORM AUTH (Decoded)]");
Console.WriteLine(unescaped);
var platformAuthJson = JsonConvert.DeserializeObject<Dictionary<string, string>>(unescaped);
if (platformAuthJson != null && platformAuthJson.TryGetValue("Ticket", out string ticket))
{
Console.WriteLine("[Parsed Ticket]");
Console.WriteLine(ticket);
using (var client = new HttpClient())
{
var url = $"https://api.steampowered.com/ISteamUserAuth/AuthenticateUserTicket/v1?key={ConfigCampus.SteamAPIKey}&appid=471710&ticket={ticket}";
var response = await client.GetAsync(url);
string result = await response.Content.ReadAsStringAsync();
Console.WriteLine("[Steam Auth Response]");
Console.WriteLine(result);
var steamAuthResponse = JsonConvert.DeserializeObject<SteamAuthResponse>(result);
if (steamAuthResponse != null && steamAuthResponse.response != null && steamAuthResponse.response.paramsData != null)
{
if (ulong.TryParse(steamAuthResponse.response.paramsData.steamid, out ulong steamIdFromTicket))
{
steamidticket = steamIdFromTicket;
Console.WriteLine("[Parsed SteamID from Ticket]");
Console.WriteLine(steamidticket);
}
else
{
return new ContentResult()
{
Content = "{\"access_token\":\"" + "\",\"error_description\":\"Cannot verify steam ticket\",\"error\":\"steam_ticket\",\"refresh_token\":\"" + "\",\"key\":\"\"}",
ContentType = "application/json",
StatusCode = 200
};
}
}
else
{
return new ContentResult()
{
Content = "{\"access_token\":\"" + "\",\"error_description\":\"Cannot verify steam ticket\",\"error\":\"steam_ticket\",\"refresh_token\":\"" + "\",\"key\":\"\"}",
ContentType = "application/json",
StatusCode = 200
};
}
}
}
}
*/
// Platform ownership is the ONLY credential a cached login has, so (1) for Oculus the
// proof must verify with Meta, and (2) the account handed back must already be LINKED
// to that identity - read from the same table the picker lists from, so an entry the
// picker offered is always redeemable and nothing else is. Without (2), anyone could
// post grant_type=cached_login&account_id=X and receive a token for X.
// Reference: recflare auth, cached_login grant.
{
var (st, vid, why) = await VerifyOculusProof(platformparsed, platform_id, platform_auth);
if (st == OculusProof.Unconfigured)
{
Console.WriteLine("[meta] refusing a cached Quest login: no app secret configured");
return GrantError(500, "server_error", "Meta platform verification is not configured");
}
bool deviceCached = false;
if (st == OculusProof.Sentinel && ConfigCampus.QuestDeviceCachedLogin &&
PlayerDB.IsDeviceBoundTo(accid, device_id, Platforms.Oculus))
{
// Quest "remember me": this headset already signed in to this account with a
// password (the device lock bound it then). Device ownership is the credential.
deviceCached = true;
Console.WriteLine($"[cachedlogin] Quest device '{device_id}' cached login into account {accid}");
}
else if (st == OculusProof.Sentinel || st == OculusProof.Rejected)
{
Console.WriteLine($"[meta] cached login refused for account {accid}: {why}");
return GrantError(400, "invalid_grant", "invalid or missing platform_auth");
}
if (st == OculusProof.Verified) platform_id = vid;
if (!deviceCached)
{
PlayerDB.GetLogins(platformparsed, platform_id, out var linked);
bool isLinked = platform_id != 0 && linked != null && linked.Any(l => l.accountId == accid);
if (!isLinked)
{
Console.WriteLine($"[cachedlogin] refused: account {accid} is not linked to {platformparsed} id {platform_id}");
return GrantError(400, "invalid_grant", "account is not linked to this platform identity");
}
}
}
var accountData = PlayerDB.GetFullAccount(accid);
// Enforce + apply the one-account-per-Steam / one-account-per-device lock. The
// cached list only offers accounts already bound to this Steam id, so this
// mainly stamps/validates the device binding and blocks a second account being
// signed in on the same machine. Exempt accounts (Coach/Kyle) pass through.
if (!AccountLock.TryEnforceAndBind(accid, platformparsed, platform_id, device_id, out string cachedLockErr))
{
return BannedLoginResponse(cachedLockErr, "account-lock");
}
string request_url = $"{Request.Scheme}://{Request.Host}";
// Roles derived from the account's DevFlag so admin-panel toggles actually
// reach the client on next login (see RolesForAccount at top of this file).
List<string> roles = RolesForAccount(accountData);
token = ClientSecurity.CreateToken(accid, roles, request_url, platformparsed);
if (token == null)
goto token_err;
if (string.IsNullOrEmpty(token))
{
goto token_err;
}
Webhook.Send(accid,
"prod",
"Matchmaking info",
$"({accid}) is logging in to **{ver}** on the platform **{platformparsed.ToString()}**",
5898927
);
PlayerDB.SetPlayerDeviceId(device_id, accid);
PlayerDB.SetLastLoginTime(accid);
PlayerDB.AddIpAddress(accid, Ip);
PlayerDB.ResetCheerCreditIfNewDay(accid);
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
access_token = token,
expires_in = ConfigCampus.GameTokenHours * 3600,
token_type = "Bearer",
refresh_token = guid,
scope = "offline_access rn.accounts rn.accounts.gc rn.api rn.auth rn.auth.gc rn.bugreporting rn.cards rn.chat rn.clubs rn.cms rn.commerce rn.data rn.data.gc rn.datacollection rn.datacollection.gc rn.discovery rn.gamelogs.gc rn.leaderboard rn.link rn.lists rn.match.read rn.match.write rn.moderation rn.notify rn.platformnotifications rn.playersettings rn.roomcomments rn.rooms rn.storage rn.strings rn.studio.gc",
key = ""
}),
ContentType = "application/json",
StatusCode = 200
};
}
if (grant_type == "refresh_token")
{
// Console.WriteLine("refresh_token");
// token = ClientSecurity.RefreshToken(temp2, accid, platform, platform_id, device_id);
// if (token == null)
// goto token_err;
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 200
};
}
bool questCreate = grant_type == "create_account"
&& (ConfigCampus.InGameAccountCreation
|| (platformparsed == Platforms.Oculus && ConfigCampus.QuestInGameAccountCreation));
if (grant_type == "create_account" && !questCreate)
{
// ACCOUNT CREATION IS WEBSITE-ONLY NOW (except a headset's first launch - see
// ConfigCampus.QuestInGameAccountCreation and the questCreate block below).
//
// The game used to auto-create an account on first launch. It no longer does:
// players make a Campus account on the website (choose @username, display name,
// password), then sign in from the game with those credentials (grant_type=
// password). Refusing here is what makes the client fall through to the "Enter
// Account details" login instead of minting a throwaway account on first boot.
Console.WriteLine("[create_account] REFUSED - account creation is website-only");
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
error = "account_creation_disabled",
error_description = "Accounts can't be created in-game. Make yours at gameauth.ddns.net, "
+ "then come back and LOG IN with that username and password."
}),
ContentType = "application/json",
StatusCode = 403
};
}
if (questCreate)
{
Console.WriteLine($"[create_account] {platformparsed} (platform id {platform_id}, device '{device_id}') creating an account in-game");
// Same caps as the website signup form (ConfigCampus.SignupsPer*): a headset is not
// a way around them.
string signupIp = Campus_live_rewrite.api.server.LoginGuard.ClientIp(HttpContext);
var limit = PlayerDB.CheckSignupLimit(signupIp, out DateTime nextAllowed);
if (limit != PlayerDB.SignupLimit.None)
{
// NOT a 429. On a headset, create_account is the client's FIRST move whenever
// its device is not yet bound - it fires before the player is ever offered
// "log in with an existing account". A 429 here therefore did not just refuse
// a new account: it showed Code 3 and dead-ended the flow, so a RETURNING
// player on a new or reinstalled headset could not reach their own account
// either. That is the other half of "can't log in to their own accounts".
//
// The 403 account_creation_disabled shape is the one the client already
// understands as "not here - go and log in": it falls through to the
// credentials screen (the same response the website-only mode uses). A
// returning player logs in; a genuinely new player on a capped address is
// told where to make their account instead of being shown an error code.
Console.WriteLine($"[create_account] refused ({limit}) IP {signupIp} - next allowed {nextAllowed:u} - sending the client to the login screen");
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
error = "account_creation_disabled",
error_description = limit == PlayerDB.SignupLimit.Global
? "Lots of accounts are being created right now. If you already have a Campus account, log in with it below - otherwise please try again in a little while."
: "Several accounts were recently made from your network. If you already have a Campus account, log in with it below - otherwise make one at gameauth.ddns.net and come back."
}),
ContentType = "application/json",
StatusCode = 403
};
}
// ---- one account per device -------------------------------------------------
// A device that already belongs to an account cannot register another. Playing
// on a second machine is fine - signing in there adds that device to the
// account (PlayerDB.SetPlayerDeviceId) - but it cannot be used to make a NEW
// one. This is the anti-alt rule; without it a single device could create
// unlimited accounts, which is what the code did before.
//
// A missing device id is rejected outright rather than waved through: an
// empty id is not bindable, not bannable, and would be the obvious way around
// this check.
//
// Worth knowing: device_id is supplied by the CLIENT, so it is spoofable by a
// modified one. This raises the cost of alt farming, it does not make it
// impossible - especially with the patch gate currently disabled.
if (string.IsNullOrWhiteSpace(device_id))
{
Console.WriteLine("[create_account] REFUSED - no device id supplied");
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
error = "invalid_request",
error_description = "A device id is required to create an account."
}),
ContentType = "application/json",
StatusCode = 400
};
}
// An admin-allowlisted IP may create more accounts on its own device, to test the
// in-game creation flow (ConfigCampus.AdminRateLimitEnabled = false).
bool adminExempt = AdminProtectionMiddleware.IsLimitExempt(signupIp);
if (adminExempt) Console.WriteLine($"[create_account] IP {signupIp} is admin-allowlisted - limits waived");
if (!adminExempt && !PlayerDB.CanCreateAccountOnDevice(device_id, out ulong existingAcc))
{
Console.WriteLine($"[create_account] REFUSED - device '{device_id}' already "
+ $"has its game-made account ({existingAcc}) or is full");
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
error = "device_already_registered",
error_description = "This device already has a Campus account made in-game. "
+ "Log in with it, or add the account you made on the website."
}),
ContentType = "application/json",
StatusCode = 403
};
}
// Create new account
try
{
account_data newAccount;
// The no-Meta / fallback headset sends the shared sentinel id - store NO platform
// id for it (see PlatformIdentity); the device binding below is what remembers it.
ulong storedPlatformId = PlatformIdentity.IsSentinel(platformparsed, platform_id) ? 0 : platform_id;
bool success = PlayerDB.CreateAccount(platformparsed, storedPlatformId, device_id, 0, out newAccount);
if (!success || newAccount == null)
{
Console.WriteLine("Failed to create account");
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
error = "account_creation_failed",
error_description = "Failed to create account"
}),
ContentType = "application/json",
StatusCode = 200
};
}
ulong newAccountId = newAccount.accountId;
Console.WriteLine($"[create_account] created account {newAccountId} @{newAccount.username} from IP {signupIp}");
PlayerDB.RecordSignup(signupIp, newAccountId);
// A password the client posted becomes the login credential (so the account can
// also be used on the website / another device). Without one, the headset's
// device-remembered login is how they get back in.
string? createPass = HttpContext.Request.HasFormContentType ? HttpContext.Request.Form["password"].ToString() : null;
if (!string.IsNullOrEmpty(createPass) && createPass.Length >= 6 && createPass.Length <= 64)
PlayerDB.SetPlayerPassword(createPass, newAccountId, false);
// Bind the device to the new account (one account per device, remembered login).
PlayerDB.BindDevice(device_id, platformparsed, newAccountId);
// The client's own identity flow (Orientation: choose username, create
// password, code of conduct) runs for this account - see NewPlayerBootstrap.
Campus_live_rewrite.api.server.NewPlayerBootstrap.Start(newAccountId, platformparsed, string.IsNullOrEmpty(createPass));
Notifications.FireAndForget(Notifications.PushAccountUpdate(newAccountId), "new-account push");
// Generate token for the new account
string request_url = $"{Request.Scheme}://{Request.Host}";
List<string> roles = new List<string> { "gameclient" };
token = ClientSecurity.CreateToken(newAccountId, roles, request_url, platformparsed);
if (string.IsNullOrEmpty(token))
{
Console.WriteLine("Failed to generate token for new account");
goto token_err;
}
Console.WriteLine($"Account created successfully: {newAccount.username}");
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
access_token = token,
expires_in = ConfigCampus.GameTokenHours * 3600,
token_type = "Bearer",
refresh_token = guid,
scope = "offline_access rn.accounts rn.accounts.gc rn.api rn.auth rn.auth.gc rn.bugreporting rn.cards rn.chat rn.clubs rn.cms rn.commerce rn.data rn.data.gc rn.datacollection rn.datacollection.gc rn.discovery rn.gamelogs.gc rn.leaderboard rn.link rn.lists rn.match.read rn.match.write rn.moderation rn.notify rn.platformnotifications rn.playersettings rn.roomcomments rn.rooms rn.storage rn.strings rn.studio.gc",
key = ""
}),
ContentType = "application/json",
StatusCode = 200
};
}
catch (Exception ex)
{
Console.WriteLine($"Error creating account: {ex.ToString()}");
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
error = "account_creation_failed",
error_description = "Failed to create account"
}),
ContentType = "application/json",
StatusCode = 200
};
}
}
token_err:
// A grant this server does not accept. OAuth2 says invalid_grant is a 400 with a JSON
// error body, and that is what the client expects: a 400 here makes it fall through to
// its next login option (the reference Quest trace is "400 /connect/token" for the
// refused platform login, then "200" for username/password). The previous empty 401
// reads as "session dead" to an OAuth client rather than "try another grant".
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
error = "invalid_grant",
error_description = "This login method is not accepted. Sign in with your Campus username and password."
}),
ContentType = "application/json",
StatusCode = 400
};
}
// Quest/Oculus login step. After cached-login the headset client GETs this and expects a
// JSON STRING nonce back (there is no RecNet "Nonce" DTO in the client metadata, so an
// object would fail to deserialise). The client literal is "oculus/nonce", appended to the
// Auth base URL from the nameserver -> /Auth/oculus/nonce. This server does not verify
// Oculus proofs against Meta, so the nonce carries no meaning beyond letting the client's
// flow continue to /Auth/connect/token, where the real credential check happens. Without
// this route the headset saw a 404 here and stopped.
[HttpGet("/Auth/oculus/nonce")]
[HttpPost("/Auth/oculus/nonce")]
public ContentResult OculusNonce()
{
string nonce = Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N");
Console.WriteLine("[oculus] nonce issued to headset client");
return new ContentResult()
{
Content = JsonConvert.SerializeObject(nonce),
ContentType = "application/json",
StatusCode = 200
};
}
// Client literal "cachedlogin/migrate" - no handler existed. Ack envelope, same shape the
// gap stubs use, so a migrate call from a headset is a no-op rather than a 404.
[HttpPost("/Auth/cachedlogin/migrate")]
public IActionResult CachedLoginMigrate()
=> Ok(new { error = "", success = true, value = (object?)null });
[HttpPost("/Auth/cachedlogin/forplatformids")]
public IActionResult GetCachedLoginForPlatformIds()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(ConfigCampus.Bracket);
}
[HttpDelete("/Auth/cachedlogin/current")]
public ContentResult DeleteCurrentCachedLogin()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
success = false,
error = "This ain't implemented yet.",
value = (string?)null
}),
ContentType = "application/json",
StatusCode = 200
};
}
// NOTE: the single dev/moderator checks (/Auth/role/developer/{id} + /moderator/{id}) live in
// AccountController now (with [HttpGet] + a NotFound for unknown ids + the bulk ?id= form).
// The duplicate [Route] handlers that were here matched ALL verbs on the same path, which is a
// latent ambiguous-route hazard - removed so there's exactly one handler per route.
[Route("account/me/remoteauth")]
public ContentResult RemoteAuth([FromForm] string code)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
success = false,
error = "This ain't implemented yet.",
value = (string?)null
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPost("/Auth/account/me/changepassword")]
public ContentResult ChangePassword([FromForm] string newPassword)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
if (string.IsNullOrEmpty(newPassword))
{
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
success = false,
error = "Password cannot be empty!",
value = (string?)null
}),
ContentType = "application/json",
StatusCode = 200
};
}
if (newPassword.Length > 64)
{
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
success = false,
error = "Password must be fewer than 64 characters.",
value = (string?)null
}),
ContentType = "application/json",
StatusCode = 200
};
}
PlayerDB.SetPlayerPassword(newPassword, account.Id, log: false);
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
success = true,
error = "",
value = (string?)null
}),
ContentType = "application/json",
StatusCode = 200
};
}
/// <summary>
/// Refusal returned to a login from a banned device or IP. 401 so the client
/// treats it as a failed sign-in rather than a transport error.
/// </summary>
private static ContentResult BannedLoginResponse(string? reason, string kind)
{
return new ContentResult()
{
Content = Newtonsoft.Json.JsonConvert.SerializeObject(new
{
error = "access_denied",
error_description = string.IsNullOrWhiteSpace(reason)
? $"This {kind} is banned."
: reason
}),
ContentType = "application/json",
StatusCode = 401
};
}
}
}
@@ -0,0 +1,305 @@
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server;
using Campus_live_rewrite.api.server.Classes;
using Campus_live_rewrite.api.server.Classes.db_class;
using System.Net.Http.Headers;
using System.Text.Json;
namespace Campus_live_rewrite.Controllers
{
// Auth endpoints for the CampusPatch2025 Launcher.
//
// These live alongside the existing DiscordController (web-admin OAuth flow). The launcher
// uses a different callback strategy than the web admin: it spins up a short-lived HTTP
// listener on 127.0.0.1:<random port>, opens the browser at
// /api/auth/discord/login?state=launcher:<port>
// and expects the browser to be redirected to
// http://127.0.0.1:<port>/campus-auth?token=<jwt>
// after Discord OAuth completes. That JWT is the launcher's session credential; the launcher
// saves it locally and uses it as a Bearer for /api/auth/me + /api/auth/link/steam etc.
//
// Non-OAuth flows: launcher can also register with username+password (creates account with
// no Steam yet) or log in with existing credentials. Same JWT is returned in all three cases.
[ApiController]
[Route("api/auth")]
public class AuthLauncherController : ControllerBase
{
// Discord OAuth app is shared with DiscordController — same Client Id/Secret/Redirect. The
// callback discriminates admin-panel vs launcher by inspecting the `state` param format:
// "admin" => (default, existing) redirect to /admin/home/v4
// "launcher:<port>" => (new) redirect browser to http://127.0.0.1:<port>/campus-auth?token=<jwt>
private const string ClientId = "1537203658725265488";
private const string ClientSecret = "6JOoRs9-b-B8m8y5t9TnhURs6dOO-P-2";
private const string RedirectUri = "https://gameauth.ddns.net/api/auth/discord/login";
// ---- Discord OAuth — combined start + callback ------------------------------------------
// GET /api/auth/discord/login
// Discord's registered redirect URI is now this same endpoint (see RedirectUri
// constant above), so the browser makes TWO trips through here per login:
// 1) Initial hit from the launcher's "Log in with Discord" button. `code` is
// absent → we 302 the browser to Discord's OAuth authorize page.
// 2) Discord redirects the user back with ?code=<X>&state=<Y>. `code` is present
// → we exchange the code for an access token, fetch the Discord user, find or
// auto-create the linked Campus account, mint a JWT, and either:
// - state == "launcher:<port>" → redirect to http://127.0.0.1:<port>/campus-auth?token=<jwt>
// - state == "admin" (default) → set cookie + redirect to /admin/home/v4
// Doing both phases in one route matches whatever redirect URI is on the Discord
// dashboard without needing a separate /callback endpoint.
[HttpGet("discord/login")]
public async Task<IActionResult> DiscordLoginLauncher([FromQuery] string? code = null,
[FromQuery] string? state = null)
{
// Phase 1: no code yet → kick off OAuth by 302-ing to Discord.
if (string.IsNullOrEmpty(code))
{
string stateOut = string.IsNullOrEmpty(state) ? "admin" : state;
var url =
"https://discord.com/api/oauth2/authorize"
+ $"?client_id={ClientId}"
+ $"&redirect_uri={Uri.EscapeDataString(RedirectUri)}"
+ "&response_type=code"
+ "&scope=identify email"
+ $"&state={Uri.EscapeDataString(stateOut)}";
return Redirect(url);
}
// Phase 2: Discord redirected the user back here with a code. Exchange it.
using var http = new HttpClient();
var tokenResponse = await http.PostAsync(
"https://discord.com/api/oauth2/token",
new FormUrlEncodedContent(new Dictionary<string, string>
{
["client_id"] = ClientId,
["client_secret"] = ClientSecret,
["grant_type"] = "authorization_code",
["code"] = code,
["redirect_uri"] = RedirectUri
})
);
if (!tokenResponse.IsSuccessStatusCode)
return Unauthorized("OAuth token exchange failed.");
var tokenBody = await tokenResponse.Content.ReadAsStringAsync();
var accessTok = System.Text.Json.JsonDocument.Parse(tokenBody)
.RootElement.GetProperty("access_token").GetString();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessTok);
var userBody = await http.GetStringAsync("https://discord.com/api/users/@me");
var user = System.Text.Json.JsonDocument.Parse(userBody).RootElement;
string discordIdStr = user.GetProperty("id").GetString();
string discordName = user.GetProperty("username").GetString();
ulong discordId = ulong.Parse(discordIdStr);
// Find existing Campus account for this Discord id, or auto-create one.
var accountIds = PlayerDB.GetAccountIdsByDiscordId(discordId);
ulong accId;
if (accountIds == null || !accountIds.Any())
{
if (!PlayerDB.CreateAccount(Platforms.RecNet, 0UL, "", discordId, out var newAcc))
return StatusCode(500, "Failed to create Discord-linked account.");
accId = newAcc.accountId;
Console.WriteLine($"[DiscordAuth/launcher] Auto-created account_id={accId} for discord_id={discordId} ({discordName})");
}
else
{
accId = accountIds.First();
Console.WriteLine($"[DiscordAuth/launcher] Existing account_id={accId} for discord_id={discordId} ({discordName})");
}
string jwt = ClientSecurity.RefreshToken_Web(accId);
// Launcher flow: bounce to the launcher's local http listener with the JWT.
if (!string.IsNullOrEmpty(state) && state.StartsWith("launcher:", StringComparison.Ordinal))
{
string portStr = state.Substring("launcher:".Length);
if (int.TryParse(portStr, out int port) && port >= 1024 && port <= 65535)
{
string dest = $"http://127.0.0.1:{port}/campus-auth?token={Uri.EscapeDataString(jwt)}&account_id={accId}";
Console.WriteLine($"[DiscordAuth/launcher] redirecting to {dest}");
return Redirect(dest);
}
}
// Admin panel flow (default): cookie + redirect to dashboard.
Response.Cookies.Append("Authorization", jwt, new Microsoft.AspNetCore.Http.CookieOptions
{
Expires = DateTime.Now.AddHours(24),
HttpOnly = true,
Secure = true,
SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Lax,
Path = "/"
});
return Redirect("/admin/home/v4");
}
// ---- Register: username + password ------------------------------------------------------
// POST /api/auth/register {username, password}
// Creates a PlayerDB account with no Steam / Discord link yet. Both are added later:
// Steam via /api/auth/link/steam when the launcher extracts the local Steam ID, Discord
// via /api/auth/link/discord if the user then chooses to Discord-link. Returns a JWT.
[HttpPost("register")]
public IActionResult Register([FromBody] AuthCreds body)
{
if (body == null || string.IsNullOrWhiteSpace(body.username) || string.IsNullOrWhiteSpace(body.password))
return BadRequest(new { success = false, error = "username and password required" });
// Reject if username already taken. The DB reserves username via PlayerDB.SetPlayerUsername
// but there's no atomic create-with-username helper, so we check-then-create with a note
// that racing two identical registers is possible (rare; second one gets rejected on next
// rename check).
var existing = PlayerDB.GetFullAccountByName(body.username);
if (existing != null)
return Conflict(new { success = false, error = "username already taken" });
// Create account with Platforms.RecNet (no Steam yet) and blank device id. Discord id 0
// means "not Discord-linked". Link steps happen separately.
if (!PlayerDB.CreateAccount(Platforms.RecNet, 0UL, "", 0UL, out var newAcc))
return StatusCode(500, new { success = false, error = "account creation failed" });
PlayerDB.SetPlayerUsername(body.username, newAcc.accountId);
PlayerDB.SetPlayerPassword(body.password, newAcc.accountId, log: true);
string jwt = ClientSecurity.RefreshToken_Web(newAcc.accountId);
return Ok(new { success = true, token = jwt, account_id = newAcc.accountId });
}
// ---- Login: username + password ---------------------------------------------------------
// POST /api/auth/login {username, password}
// Delegates to PlayerDB.GetAccountPassword (same helper the web admin login uses).
// Returns a JWT and a minimal account snapshot. Launcher usually calls /api/auth/me
// right after for the fuller view including ban state, dev/mod flags, pfp, etc.
[HttpPost("login")]
public IActionResult Login([FromBody] AuthCreds body)
{
if (body == null || string.IsNullOrWhiteSpace(body.username) || string.IsNullOrWhiteSpace(body.password))
return BadRequest(new { success = false, error = "username and password required" });
var result = PlayerDB.GetAccountPassword(body.username, body.password, isAdminPanel: false);
if (!result.success)
return Unauthorized(new { success = false, error = result.error });
string jwt = ClientSecurity.RefreshToken_Web(result.accid);
return Ok(new { success = true, token = jwt, account_id = result.accid });
}
// ---- Logout -----------------------------------------------------------------------------
// POST /api/auth/logout
// JWTs are stateless (HMACSHA256, no server-side session store), so logout is a no-op
// server-side today. The launcher wipes its local JWT on 401 anyway. This endpoint just
// returns 200 so the launcher has a well-known URL to POST to for symmetry.
[HttpPost("logout")]
public IActionResult Logout() => Ok(new { success = true });
// ---- Me ---------------------------------------------------------------------------------
// GET /api/auth/me
// Returns the full account snapshot the launcher UI needs: display name, pfp, discord +
// steam ids, ban status + expiry, dev/mod flags. Launcher polls this at startup and on
// every panel refresh. 401 tells the launcher to wipe its local JWT and show
// "Login time expired, please log back in".
[HttpGet("me")]
public IActionResult Me()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return Unauthorized(new { success = false, error = "invalid or expired token" });
var full = PlayerDB.GetFullAccount(account.Id);
if (full == null) return NotFound(new { success = false, error = "account not found" });
// Ban info: HasActiveModerationBlock is the source of truth. Get_moderation_block
// returns a JSON string of the current block; we parse it lightly to extract end time
// + reason. If no active ban, everything stays null.
bool banned = PlayerDB.HasActiveModerationBlock(account.Id);
long? banEndsAt = null;
string? banReason = null;
if (banned)
{
try
{
var blockJson = PlayerDB.Get_moderation_block(account.Id);
if (!string.IsNullOrEmpty(blockJson) && blockJson != "null")
{
var doc = System.Text.Json.JsonDocument.Parse(blockJson);
var root = doc.RootElement;
if (root.TryGetProperty("EndTime", out var e) && e.TryGetInt64(out var eSec)) banEndsAt = eSec;
else if (root.TryGetProperty("ends_at", out var e2) && e2.TryGetInt64(out var e2Sec)) banEndsAt = e2Sec;
if (root.TryGetProperty("Reason", out var r) && r.ValueKind == System.Text.Json.JsonValueKind.String) banReason = r.GetString();
else if (root.TryGetProperty("reason", out var r2) && r2.ValueKind == System.Text.Json.JsonValueKind.String) banReason = r2.GetString();
}
}
catch { /* leave ban fields null on parse error */ }
}
// Platform ids: pull first Steam / Oculus ids off PlatformIds list.
string? steamId = null, metaId = null;
if (full.player?.PlatformIds != null)
{
foreach (var p in full.player.PlatformIds)
{
if (p == null) continue;
if (p.Platform == Platforms.Steam && steamId == null) steamId = p.PlatformId.ToString();
if (p.Platform == Platforms.Oculus && metaId == null) metaId = p.PlatformId.ToString();
}
}
return Ok(new
{
success = true,
id = full.playerid,
username = full.player?.Username,
display_name = full.player?.DisplayName,
profile_image = full.player?.profileImage,
bio = full.player_Extra?.Bio,
discord_id = full.player_Extra?.DiscordId == 0 ? (ulong?)null : full.player_Extra?.DiscordId,
// Discord username isn't stored today. If it were captured during Discord OAuth
// callback (and persisted onto player_Extra), it would surface here. For now null.
discord_username = (string?)null,
steam_id = steamId,
meta_id = metaId,
banned = banned,
ban_ends_at = banEndsAt,
ban_reason = banReason,
is_dev = (full.dev_Flag & DevFlag.dev) == DevFlag.dev,
is_mod = (full.dev_Flag & DevFlag.mod) == DevFlag.mod,
});
}
// ---- Link Steam --------------------------------------------------------------------------
// POST /api/auth/link/steam {steam_id}
// Called by the launcher after Discord/register login once it extracts the local Steam
// ID (via steam_api). Behaviour matrix:
// - account has NO Steam yet AND no other account owns this steam_id → add it, 200 ok
// - account has THIS steam_id already → 200 no-op
// - account has a DIFFERENT steam_id (user changed Steam accounts) → swap to new one
// - a DIFFERENT account owns this steam_id → 409 conflict, caller decides (probably
// "This Steam account is linked to a different Campus account, log in there instead")
[HttpPost("link/steam")]
public IActionResult LinkSteam([FromBody] SteamLinkBody body)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return Unauthorized(new { success = false, error = "invalid or expired token" });
if (body == null || body.steam_id == 0) return BadRequest(new { success = false, error = "steam_id required" });
// Check if the steam_id is already claimed by another account.
PlayerDB.GetLogins(Platforms.Steam, body.steam_id, out var owners);
if (owners != null)
{
foreach (var o in owners)
{
if (o.accountId != account.Id)
return Conflict(new { success = false, error = "steam_id already linked to a different Campus account", other_account_id = o.accountId });
}
}
// Safe to add or swap. SetPlayerSteamId replaces the account's Steam entry (or adds if
// missing) — see PlayerDB.SetPlayerSteamId.
bool ok = PlayerDB.SetPlayerSteamId(body.steam_id, account.Id);
return Ok(new { success = ok, steam_id = body.steam_id });
}
// Body shapes.
public class AuthCreds { public string? username { get; set; } public string? password { get; set; } }
public class SteamLinkBody { public ulong steam_id { get; set; } }
}
}
@@ -0,0 +1,151 @@
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server;
namespace Campus_live_rewrite.Controllers
{
public class BotController : Controller
{
[HttpPost("/api/bot/player/level_up/{discorduserid}")]
public async Task<ContentResult> LevelUp(ulong discorduserid)
{
string botkey = Request.Headers["Bot-Key"].ToString();
var newLevel = Request.Form["new_level"].ToString();
if (botkey != ConfigCampus.Campus_bot_key)
{
return new ContentResult // The people tryna find this api wont get in
{
Content = "",
ContentType = "application/json",
StatusCode = 404
};
}
// Was: int.Parse(newLevel) - FormatException (500) when the new_level form
// field is missing or non-numeric. And account.player.Id was dereferenced
// unguarded even though the line below already uses account?. - an unknown
// Discord id therefore threw instead of reporting not-found.
if (!int.TryParse(newLevel, out int parsedLevel))
{
return new ContentResult
{
Content = JsonConvert.SerializeObject(new { success = false, message = "new_level must be an integer" }),
ContentType = "application/json",
StatusCode = 400
};
}
var account = PlayerDB.GetPlayerByDiscordId(discorduserid);
if (account?.player == null)
{
return new ContentResult
{
Content = JsonConvert.SerializeObject(new { success = false, message = $"no account for discord id {discorduserid}" }),
ContentType = "application/json",
StatusCode = 404
};
}
await PlayerDB.SetPlayerLevel(parsedLevel, account.player.Id);
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
success = true,
message = $"Leveled up user {account?.player.Username} to level {newLevel}"
}),
ContentType = "application/json",
StatusCode = 200
};
}
[Route("/api/bot/create")]
public ContentResult CreateAccViaBot()
{
string key = HttpContext.Request.Headers["Key"].ToString();
string steamid = HttpContext.Request.Form["steam_id"].ToString();
string discordUserId = HttpContext.Request.Form["discord_user_id"].ToString();
ulong steamid2 = ulong.Parse(steamid);
if (key == ConfigCampus.Campus_bot_key)
{
var blockedIds = new HashSet<string>
{
"1176571557414445137",
"1193883666946998383",
"1344328341951615106"
};
if (PlayerDB.GetAccountCountByPlatformId(steamid2) >= 1)
{
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
success = false,
value = "You already have a account!"
}),
ContentType = "application/json",
StatusCode = 200
};
}
if (blockedIds.Contains(discordUserId))
{
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
success = false,
value = "You require manual verification. Make a ticket in https://discord.gg/xH8MMwK5Bk on Discord"
}),
ContentType = "application/json",
StatusCode = 200
};
}
var blockedSteamIds = new HashSet<string>
{
"76561199485696854"
};
if (blockedSteamIds.Contains(steamid))
{
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
success = false,
value = "You require manual verification. Make a ticket in https://discord.gg/xH8MMwK5Bk on Discord"
}),
ContentType = "application/json",
StatusCode = 200
};
}
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
eeee = discordUserId, // todo
}),
ContentType = "application/json",
StatusCode = 200
};
}
else
{
return new ContentResult
{
Content = "",
ContentType = "application/json",
StatusCode = 404
};
}
}
}
}
@@ -0,0 +1,559 @@
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Campus_live_rewrite.api.server;
using System.Net.Security;
namespace Campus_live_rewrite.Controllers
{
[ApiController]
public class CDNController : Controller
{
// Reused HttpClient for CDN passthrough. .NET 8 on Linux was failing the TLS handshake
// to cdn.rec.net with "SSL connection could not be established" even though curl handshakes
// fine from the same box — most likely a chain-verification quirk. Trust everything and
// move on; we're only fetching public asset blobs, not credentials.
private static readonly HttpClient s_cdnClient = new HttpClient(new SocketsHttpHandler
{
SslOptions = new SslClientAuthenticationOptions
{
// curl handshakes fine from the same box; .NET's default cert chain check trips
// on cdn.rec.net specifically. Accept whatever the server presents — we're only
// fetching public asset blobs, not credentials.
RemoteCertificateValidationCallback = (_, _, _, _) => true
},
AutomaticDecompression = System.Net.DecompressionMethods.All,
ConnectTimeout = TimeSpan.FromSeconds(10)
})
{
Timeout = TimeSpan.FromSeconds(20)
};
// Loading screens.
//
// Drop any images into Data/Images/LoadingScreenTips/ and they are used - no
// JSON editing required. Each file becomes one loading screen, and ImageController
// already searches that folder, so /img/<filename> resolves with no extra wiring.
//
// Titles/messages are optional: add an entry to Data/APIS/LoadingScreenTipData.json
// whose "ImageName" matches the filename (with or without extension) and its Title,
// Message, PlatformMask and RoomNames are applied to that image.
//
// If the folder is empty or missing, the JSON is served as-is, so the stock
// behaviour is unchanged.
[HttpGet("/cdn/config/LoadingScreenTipData")]
public IActionResult LoadingScreenTipData()
{
string filePath = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "LoadingScreenTipData.json");
string tipsDir = Path.Combine(Environment.CurrentDirectory, "Data", "Images", "LoadingScreenTips");
// Existing entries, keyed by ImageName so custom images can inherit their text.
var overrides = new Dictionary<string, Newtonsoft.Json.Linq.JObject>(StringComparer.OrdinalIgnoreCase);
Newtonsoft.Json.Linq.JArray? configured = null;
if (System.IO.File.Exists(filePath))
{
try
{
configured = Newtonsoft.Json.Linq.JArray.Parse(System.IO.File.ReadAllText(filePath));
foreach (var entry in configured.OfType<Newtonsoft.Json.Linq.JObject>())
{
string? nm = entry["ImageName"]?.ToString();
if (string.IsNullOrWhiteSpace(nm)) continue;
overrides[nm!] = entry;
overrides[Path.GetFileNameWithoutExtension(nm)!] = entry;
}
}
catch (Exception ex)
{
Console.WriteLine($"[LoadingScreens] {Path.GetFileName(filePath)} is not valid JSON: {ex.Message}");
}
}
string[] images = Array.Empty<string>();
try
{
if (Directory.Exists(tipsDir))
{
images = Directory.EnumerateFiles(tipsDir)
.Where(f =>
{
string e = Path.GetExtension(f).ToLowerInvariant();
return e == ".png" || e == ".jpg" || e == ".jpeg" || e == ".webp" || e == "";
})
.OrderBy(f => Path.GetFileName(f), StringComparer.OrdinalIgnoreCase)
.ToArray();
}
}
catch (Exception ex)
{
Console.WriteLine($"[LoadingScreens] could not read {tipsDir}: {ex.Message}");
}
// Nothing dropped in - keep the previous behaviour exactly.
if (images.Length == 0)
{
if (!System.IO.File.Exists(filePath))
return Content("[]", "application/json");
return PhysicalFile(filePath, "application/json");
}
var result = new Newtonsoft.Json.Linq.JArray();
foreach (var img in images)
{
string fileName = Path.GetFileName(img);
string bare = Path.GetFileNameWithoutExtension(img);
if (overrides.TryGetValue(fileName, out var match) || overrides.TryGetValue(bare, out match))
{
var copy = (Newtonsoft.Json.Linq.JObject)match.DeepClone();
copy["ImageName"] = fileName; // must match what /img/<name> will serve
result.Add(copy);
}
else
{
result.Add(new Newtonsoft.Json.Linq.JObject
{
["ImageName"] = fileName,
["Message"] = "",
["PlatformMask"] = -1,
["RoomNames"] = new Newtonsoft.Json.Linq.JArray(),
["Title"] = ""
});
}
}
Console.WriteLine($"[LoadingScreens] serving {result.Count} custom loading screen(s) from Data/Images/LoadingScreenTips");
return Content(result.ToString(Newtonsoft.Json.Formatting.None), "application/json");
}
[HttpGet("/iam/me/channels/{type}")]
public IActionResult GetIamChannel(string type)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string basePath = Path.Combine(Environment.CurrentDirectory, "Data", "APIS");
switch (type.ToLower())
{
case "announcements":
string announcementsPath = Path.Combine(basePath, "IamAnnouncements.json");
if (System.IO.File.Exists(announcementsPath))
return PhysicalFile(announcementsPath, "application/json");
break;
case "justintimetutorials":
return Ok(ConfigCampus.Bracket);
}
return Ok(ConfigCampus.Bracket);
}
[HttpGet("/sections/pagesource/{type}")]
public IActionResult GetWatchPages(string type)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string path = Path.Combine(
Environment.CurrentDirectory,
"Data",
"APIS",
"WatchPages",
$"{type}.json"
);
if (!System.IO.File.Exists(path))
return Ok(ConfigCampus.Bracket);
// The Play page was captured from live Rec Room and names ITS banner room and ITS
// featured creator by id; neither exists here, so the banner was blank and the
// Featured Creator tile pointed at an empty profile. Both ids are swapped for local
// ones on the way out (DiscoveryLists): the first admin-featured room, and the local
// creator with the most public rooms. The file is left alone.
if (type.Equals("PlayHighlight", StringComparison.OrdinalIgnoreCase))
{
string json = System.IO.File.ReadAllText(path);
ulong banner = Campus_live_rewrite.api.server.DiscoveryLists.BannerRoom();
ulong creator = Campus_live_rewrite.api.server.DiscoveryLists.FeaturedCreator();
if (banner != 0) json = json.Replace("2477031627165896495", banner.ToString());
if (creator != 0) json = json.Replace("\"730029\"", "\"" + creator + "\"");
return new ContentResult { Content = json, ContentType = "application/json", StatusCode = 200 };
}
return PhysicalFile(path, "application/json");
}
[Route("/cdn/room/{*room_path}")]
public async Task<IActionResult> CDNRoom(string room_path)
{
// 1. SANITIZE: confine to RoomBlobs. The route is catch-all, so room_path can be
// "../../../../etc/passwd" - Path.Combine happily walks out of the folder and the
// file was then served. ResolveUnder returns null the moment the canonical path
// leaves RoomBlobs. (The old code computed Path.GetFileName but never used it.)
string blobRoot = Path.Combine(Environment.CurrentDirectory, "Data", "CDN", "RoomBlobs");
string? localPath = Campus_live_rewrite.api.server.PathSafety.ResolveUnder(blobRoot, room_path);
if (localPath == null)
{
Console.WriteLine($"[cdn/room] REFUSED path traversal attempt: {room_path}");
return NotFound();
}
string directory = Path.GetDirectoryName(localPath)!;
try
{
if (!Directory.Exists(directory))
Directory.CreateDirectory(directory);
// 2. DOWNLOAD IF MISSING. Try cdn.rec.net first, then img.rec.net as a
// fallback (Rec Room historically served the same room blobs from both).
//
// A ZERO-BYTE cache entry counts as a MISS, not a hit. This used to be a
// plain File.Exists check, and that is how the cache poisoned itself:
// FileMode.Create truncates the file the moment the stream opens, so a
// download that threw partway (reset connection, upstream 404 body, etc.)
// left a 0-byte file behind - and File.Exists then returned true forever,
// so it was served empty and NEVER retried. 148 of 151 files in RoomBlobs
// and all 731 in DataBlobs are in exactly that state. Treating empty as a
// miss lets the cache heal itself the moment an upstream works again.
var cached = new FileInfo(localPath);
if (!cached.Exists || cached.Length == 0)
{
string[] upstreams = {
$"https://cdn.rec.net/room/{room_path}",
$"https://img.rec.net/room/{room_path}",
};
HttpResponseMessage? good = null;
foreach (var url in upstreams)
{
try
{
var resp = await s_cdnClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
if (resp.IsSuccessStatusCode) { good = resp; break; }
Console.WriteLine($"[cdn/room] upstream {url} -> HTTP {(int)resp.StatusCode}");
resp.Dispose();
}
catch (Exception uex)
{
Console.WriteLine($"[cdn/room] upstream {url} FAILED: {uex.GetType().Name}: {uex.Message}");
}
}
if (good == null) return NotFound();
// Download to a TEMP file and only commit it on success, so a failed
// or empty transfer can never leave a 0-byte file at localPath again.
string tmpPath = localPath + ".part";
long written;
try
{
using (good)
await using (var remoteStream = await good.Content.ReadAsStreamAsync())
await using (var fileStream = new FileStream(tmpPath, FileMode.Create, FileAccess.Write, FileShare.None))
{
await remoteStream.CopyToAsync(fileStream);
}
written = new FileInfo(tmpPath).Length;
}
catch (Exception dex)
{
try { System.IO.File.Delete(tmpPath); } catch { }
Console.WriteLine($"[cdn/room] download of {room_path} FAILED: {dex.GetType().Name}: {dex.Message}");
return NotFound();
}
if (written == 0)
{
// An empty upstream body is not a cacheable result - storing it is
// what broke this cache in the first place.
try { System.IO.File.Delete(tmpPath); } catch { }
Console.WriteLine($"[cdn/room] upstream returned 0 bytes for {room_path} - not caching");
return NotFound();
}
System.IO.File.Move(tmpPath, localPath, overwrite: true);
Console.WriteLine($"[cdn/room] cached {room_path} ({written} bytes)");
}
await ApplyBlobHeaders(localPath);
// 5. RETURN AS STREAM
var fs = new FileStream(localPath, FileMode.Open, FileAccess.Read, FileShare.Read);
// enableRangeProcessing allows standard 206 Partial Content responses (crucial for large room files)
return File(fs, "application/octet-stream", enableRangeProcessing: true);
}
catch (Exception ex)
{
Console.WriteLine($"CDN Error: {ex.Message}");
return NotFound();
}
}
// Azure-Blob-shaped response headers for CDN blobs (rooms AND avatar bundles). The client's
// blob loader (its HTTP client + BestHTTP disk cache) was written against cdn.rec.net, which
// is Azure Blob behind a CDN: it expects ETag / Last-Modified / Content-MD5 and the x-ms-*
// set on every blob. The room route has mimicked these since rooms first loaded; the avatar
// route used to send only Cache-Control, which is the one difference between a room blob
// that loads and an avatar bundle whose load dies in a NullReferenceException right after
// the response arrives.
private async Task ApplyBlobHeaders(string localPath)
{
// 3. GATHER FILE METADATA & DYNAMIC DATA
var fileInfo = new FileInfo(localPath);
var now = DateTimeOffset.UtcNow;
// Compute actual MD5 hash dynamically
string md5Base64;
using (var md5 = System.Security.Cryptography.MD5.Create())
{
using var readStream = System.IO.File.OpenRead(localPath);
byte[] hash = await md5.ComputeHashAsync(readStream);
md5Base64 = Convert.ToBase64String(hash);
}
// Generate fake Azure/Cloudflare IDs
string requestId = Guid.NewGuid().ToString();
// Mimic Azure Ref format: YYYYMMDDTHHMMSSZ-[randomhex]
string azureRef = $"{now:yyyyMMddTHHmmssZ}-{Guid.NewGuid().ToString("N").Substring(0, 32)}";
// Generate ETag based on the file's LastWriteTime to act as a unique version identifier
string eTag = $"\"0x{fileInfo.LastWriteTimeUtc.Ticks:X}\"";
// 4. APPLY DYNAMIC HEADERS
// Note: Content-Length, Content-Type, and Accept-Ranges are handled automatically by the File() return method.
Response.Headers["Date"] = now.ToString("R"); // RFC 1123 format (e.g., Thu, 12 Mar 2026 16:06:27 GMT)
Response.Headers["Cache-Control"] = "public, max-age=31536000";
Response.Headers["Last-Modified"] = fileInfo.LastWriteTimeUtc.ToString("R");
Response.Headers["ETag"] = eTag;
// Mock Azure Blob Storage Headers
Response.Headers["x-ms-request-id"] = requestId;
Response.Headers["x-ms-version"] = "2021-04-10";
Response.Headers["x-ms-version-id"] = fileInfo.LastWriteTimeUtc.ToString("o"); // ISO 8601 format
Response.Headers["x-ms-is-current-version"] = "true";
Response.Headers["x-ms-creation-time"] = fileInfo.CreationTimeUtc.ToString("R");
Response.Headers["x-ms-blob-content-md5"] = md5Base64;
Response.Headers["x-ms-lease-status"] = "unlocked";
Response.Headers["x-ms-lease-state"] = "available";
Response.Headers["x-ms-blob-type"] = "BlockBlob";
Response.Headers["x-ms-server-encrypted"] = "true";
Response.Headers["x-ms-last-access-time"] = now.ToString("R");
// CORS and CDN Mock Headers
Response.Headers["Access-Control-Expose-Headers"] = "x-ms-request-id,Server,x-ms-version,x-ms-version-id,x-ms-is-current-version,Content-Type,Cache-Control,ETag,Last-Modified,x-ms-creation-time,x-ms-blob-content-md5,x-ms-lease-status,x-ms-lease-state,x-ms-blob-type,x-ms-server-encrypted,Accept-Ranges,x-ms-last-access-time,Content-Length,Date,Transfer-Encoding";
Response.Headers["Access-Control-Allow-Origin"] = "*";
Response.Headers["x-azure-ref"] = azureRef;
Response.Headers["x-fd-int-roxy-purgeid"] = "0";
Response.Headers["X-Cache-Info"] = "L2_T2";
Response.Headers["X-Cache"] = "TCP_REMOTE_HIT"; // Tells the client it successfully hit the CDN cache
}
// Avatar bundles are answered the way the reference CDN (cdn.recflare.net, Cloudflare in
// front of R2) answers them, and NOTHING more: Content-Type octet-stream, Content-Length,
// Accept-Ranges, Cache-Control, an ETag that is the quoted md5 hex of the file, and CORS *.
// The Azure-blob mimicry (ApplyBlobHeaders: x-ms-*, Last-Modified, X-Cache...) stays on the
// room route, where it is known to work; on avatar bundles it is the one remaining
// difference from a server where the same bytes load, so it is gone here.
private IActionResult ServeAvatarBundle(string path)
{
var fi = new FileInfo(path);
string md5Hex;
using (var md5 = System.Security.Cryptography.MD5.Create())
using (var rs = System.IO.File.OpenRead(path))
md5Hex = Convert.ToHexString(md5.ComputeHash(rs)).ToLowerInvariant();
string etag = $"\"{md5Hex}\"";
Response.Headers["Cache-Control"] = "public, max-age=2592000";
Response.Headers["Access-Control-Allow-Origin"] = "*";
Response.Headers["ETag"] = etag;
// A conditional GET that matches gets the 304 the reference gives (no body).
if (Request.Headers.TryGetValue("If-None-Match", out var inm) && inm.ToString().Split(',').Select(x => x.Trim()).Contains(etag))
return StatusCode(304);
var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
return File(fs, "application/octet-stream", enableRangeProcessing: true);
}
// Avatar item asset bundles. The client loads each avatar item's 3D mesh from
// /cdn/avatar/<hash>.assetbundle; without it the item can't equip or render (the wardrobe
// tile still shows from metadata, but putting it on fails with "Unable to load asset bundle
// ... HTTP Error 404"). Served LOCAL-FIRST from Data/CDN/AvatarBlobs so bundles dropped
// there (sourced from a Rec Room Addressables cache / extraction) are used; upstream is
// attempted as a best-effort fallback (Rec Room's CDN currently 404s old bundles, so it
// usually won't help, but it heals automatically if an upstream ever serves them).
[Route("/cdn/avatar/{*avatar_path}")]
public async Task<IActionResult> CDNAvatar(string avatar_path)
{
string root = Path.Combine(Environment.CurrentDirectory, "Data", "CDN", "AvatarBlobs");
string? localPath = Campus_live_rewrite.api.server.PathSafety.ResolveUnder(root, avatar_path);
if (localPath == null)
{
Console.WriteLine($"[cdn/avatar] REFUSED path traversal attempt: {avatar_path}");
return NotFound();
}
try
{
string directory = Path.GetDirectoryName(localPath)!;
if (!Directory.Exists(directory)) Directory.CreateDirectory(directory);
var cached = new FileInfo(localPath);
if (!cached.Exists || cached.Length == 0)
{
// No flat copy: serve it out of an avatar-pack sub-folder if one holds it (the
// same lookup BundleHashes stamps the item definition from).
string? packed = Campus_live_rewrite.api.server.BundleHashes.Resolve(Path.GetFileName(avatar_path));
if (packed != null && new FileInfo(packed).Length > 0)
return ServeAvatarBundle(packed);
// The bundle CDN base the 2023 service map used ("https://cdn.rec.net/cdn") +
// /avatar/<hash>. Best-effort; a soft-404 there just means we can't heal this one.
string[] upstreams = {
$"https://cdn.rec.net/cdn/avatar/{avatar_path}",
$"https://cdn.rec.net/avatar/{avatar_path}",
};
HttpResponseMessage? good = null;
foreach (var url in upstreams)
{
try
{
var resp = await s_cdnClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
// Only accept a real bundle - cdn.rec.net serves a 200/404 HTML page for
// unknown paths, which must NOT be cached as a bundle.
var ct = resp.Content.Headers.ContentType?.MediaType ?? "";
if (resp.IsSuccessStatusCode && !ct.Contains("html", StringComparison.OrdinalIgnoreCase))
{ good = resp; break; }
resp.Dispose();
}
catch (Exception uex) { Console.WriteLine($"[cdn/avatar] upstream {url} FAILED: {uex.Message}"); }
}
if (good == null)
{
Console.WriteLine($"[cdn/avatar] MISS (no local bundle, upstream unavailable): {avatar_path}");
return NotFound();
}
string tmp = localPath + ".part"; long written;
try
{
using (good)
await using (var rs = await good.Content.ReadAsStreamAsync())
await using (var fsw = new FileStream(tmp, FileMode.Create, FileAccess.Write, FileShare.None))
await rs.CopyToAsync(fsw);
written = new FileInfo(tmp).Length;
}
catch (Exception dex) { try { System.IO.File.Delete(tmp); } catch { } Console.WriteLine($"[cdn/avatar] download {avatar_path} FAILED: {dex.Message}"); return NotFound(); }
if (written == 0) { try { System.IO.File.Delete(tmp); } catch { } return NotFound(); }
System.IO.File.Move(tmp, localPath, overwrite: true);
Console.WriteLine($"[cdn/avatar] cached {avatar_path} ({written} bytes)");
}
return ServeAvatarBundle(localPath);
}
catch (Exception ex)
{
Console.WriteLine($"[cdn/avatar] error: {ex.Message}");
return NotFound();
}
}
// Invention blobs (the .inv a v9 save wrote to Data/CDN/InventionBlobs, advertised to the
// client as inventionDataFilename). There was NO route for this: every spawn asked
// /cdn/invention/<name>.inv and got a 404 - 2068 of them in the API log, never one 200 -
// so no invention ever loaded, however well the publish/buy side worked.
[Route("/cdn/invention/{*inv_path}")]
public async Task<IActionResult> CDNInvention(string inv_path)
{
string root = Campus_live_rewrite.api.server.Images.InvSavePath;
string? localPath = Campus_live_rewrite.api.server.PathSafety.ResolveUnder(root, inv_path);
if (localPath == null)
{
Console.WriteLine($"[cdn/invention] REFUSED path traversal attempt: {inv_path}");
return NotFound();
}
try
{
var fi = new FileInfo(localPath);
if (!fi.Exists || fi.Length == 0)
{
Console.WriteLine($"[cdn/invention] MISS: {inv_path}");
return NotFound();
}
await ApplyBlobHeaders(localPath);
var fs = new FileStream(localPath, FileMode.Open, FileAccess.Read, FileShare.Read);
return File(fs, "application/octet-stream", enableRangeProcessing: true);
}
catch (Exception ex)
{
Console.WriteLine($"[cdn/invention] error: {ex.Message}");
return NotFound();
}
}
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, DateTime> _dataMisses = new();
[Route("/cdn/data/{*room_path}")]
public async Task<IActionResult> CDNData(string room_path)
{
try
{
// Confine to DataBlobs - same catch-all traversal hole as /cdn/room.
string dataRoot = Path.Combine(Environment.CurrentDirectory, "Data", "CDN", "DataBlobs");
string? localPath = Campus_live_rewrite.api.server.PathSafety.ResolveUnder(dataRoot, room_path);
if (localPath == null)
{
Console.WriteLine($"[cdn/data] REFUSED path traversal attempt: {room_path}");
return NotFound();
}
string directory = Path.GetDirectoryName(localPath);
if (!Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
if (System.IO.File.Exists(localPath))
{
return File(await System.IO.File.ReadAllBytesAsync(localPath), "application/octet-stream");
}
// Negative cache: a holotar the upstream CDN no longer has (17 of them, 942 misses in
// three days) was re-fetched from BOTH upstreams on every room load, and each room
// load waited on the pair of round-trips. A miss is remembered for an hour.
if (_dataMisses.TryGetValue(room_path, out var missedAt) && DateTime.UtcNow - missedAt < TimeSpan.FromHours(1))
return NotFound("");
string[] upstreams = {
$"https://cdn.rec.net/data/{room_path}",
$"https://img.rec.net/data/{room_path}",
};
foreach (var url in upstreams)
{
try
{
var resp = await s_cdnClient.GetAsync(url);
if (!resp.IsSuccessStatusCode)
{
Console.WriteLine($"[cdn/data] upstream {url} -> HTTP {(int)resp.StatusCode}");
continue;
}
byte[] data = await resp.Content.ReadAsByteArrayAsync();
await System.IO.File.WriteAllBytesAsync(localPath, data);
Console.WriteLine($"[cdn/data] cached {room_path} ({data.Length} bytes) from {url}");
return File(data, "application/octet-stream");
}
catch (Exception uex)
{
Console.WriteLine($"[cdn/data] upstream {url} FAILED: {uex.GetType().Name}: {uex.Message}");
}
}
_dataMisses[room_path] = DateTime.UtcNow;
return NotFound("");
}
catch (Exception ex)
{
Console.WriteLine(ex.ToString());
return NotFound();
}
}
}
}
@@ -0,0 +1,10 @@
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
namespace Campus_live_rewrite.Controllers
{
public class CampusDllController : Controller
{
}
}
@@ -0,0 +1,27 @@
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
namespace Campus_live_rewrite.Controllers
{
// The "Campus Net" web portal (/campus_net/*: login, home, online_players, account) was a
// legacy fourth web surface and has been REMOVED - only three surfaces exist now: the game
// client, the website (gameauth.ddns.net), and the admin panel. Its login mint was already
// commented out; its pages are no longer routed.
//
// The one endpoint kept here is the GLOBAL photo feed, which is a general game/website API
// and was never Campus-Net-specific - it just happened to live in this controller.
public class CampusNetController : Controller
{
[HttpGet("/api/images/v3/feed/global")]
public ContentResult GetGlobalFeedImages()
{
var images = ImageMetadataDB.GetGlobalImages();
return new ContentResult
{
Content = JsonConvert.SerializeObject(images),
ContentType = "application/json",
StatusCode = 200
};
}
}
}
@@ -0,0 +1,704 @@
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server;
using Campus_live_rewrite.api.server.Classes;
using Campus_live_rewrite.api.server.Classes.db_class;
using CampusLiveSource.api.admin;
using static Campus_live_rewrite.api.server.Classes.db_class.ChatDBClasses;
namespace Campus_live_rewrite.Controllers
{
/// <summary>
/// The chat service (chat.rec.net): DMs, group chats and party threads. Contracts follow the
/// reference chat app (apps/chat) - a reverse-engineered protocol, so the shapes matter:
///
/// GET thread the caller's threads, each with its latestMessage
/// POST thread create with ids + messageContents -> { chatThread, chatResult }
/// POST thread/withmembers FETCH-OR-CREATE the thread with exactly these members -> thread + messages
/// GET thread/{id} thread + a page of messages (object)
/// GET thread/{id}/message a page of messages (bare array)
/// POST thread/{id} | /message send -> { ChatMessage, ChatResult, chatResult, chatThread }
/// POST thread/{id}/member/{pid} add someone to a group (bare ChatResult)
/// POST thread/{id}/leave | rename | snooze | favorite | read | message/{mid}/read (bare ChatResult)
/// GET/POST thread/party the caller's party thread / create one
///
/// Read pointer, snooze and favorite are PER MEMBER. Every message (and every join/leave/rename
/// notice) is pushed to the members as ChatMessageReceived over the hub, which is what draws
/// the in-room bubble and updates an open conversation.
/// </summary>
[Route("chat")]
public class ChatController : ControllerBase
{
// ---------------- helpers ----------------
private static ContentResult Json(object o, int status = 200) => new()
{
Content = JsonConvert.SerializeObject(o),
ContentType = "application/json",
StatusCode = status
};
private static ContentResult Raw(string body, int status = 200) => new() { Content = body, ContentType = "application/json", StatusCode = status };
private static ContentResult Result(ChatResults r) => Raw(((int)r).ToString());
// The ChatResult values beyond the local enum that the client branches on.
private const int ResultCannotMessageDueToRemotePrivacy = 16;
private const int ResultCannotAddDueToPrivacy = 18;
private string? Field(string name)
{
if (Request.HasFormContentType)
foreach (var k in Request.Form.Keys)
if (string.Equals(k, name, StringComparison.OrdinalIgnoreCase)) return Request.Form[k].ToString();
foreach (var k in Request.Query.Keys)
if (string.Equals(k, name, StringComparison.OrdinalIgnoreCase)) return Request.Query[k].ToString();
return null;
}
private List<ulong> Ids(string name = "ids")
{
var raw = new List<string>();
if (Request.HasFormContentType)
foreach (var k in Request.Form.Keys)
if (string.Equals(k, name, StringComparison.OrdinalIgnoreCase)) raw.AddRange(Request.Form[k].ToArray()!);
foreach (var k in Request.Query.Keys)
if (string.Equals(k, name, StringComparison.OrdinalIgnoreCase)) raw.AddRange(Request.Query[k].ToArray()!);
return raw.SelectMany(v => (v ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries))
.Select(v => ulong.TryParse(v.Trim(), out var id) ? id : 0UL)
.Where(v => v != 0).Distinct().ToList();
}
private static bool FormBool(string? v) =>
v != null && (v.Equals("true", StringComparison.OrdinalIgnoreCase) || v == "1" || v.Equals("yes", StringComparison.OrdinalIgnoreCase));
// The raw body (any content type), for logging what an unknown call actually sends.
private async Task<string> RawBody(int max = 2048)
{
try
{
Request.EnableBuffering();
Request.Body.Position = 0;
using var sr = new StreamReader(Request.Body, leaveOpen: true);
string body = await sr.ReadToEndAsync();
Request.Body.Position = 0;
return body.Length > max ? body.Substring(0, max) + "..." : body;
}
catch { return "(unreadable)"; }
}
private int Count(string name, int fallback, int cap = 100)
{
if (int.TryParse(Field(name), out var n) && n > 0) return Math.Min(n, cap);
return fallback;
}
private static object ThreadListDto(ChatThread t, ulong viewer)
{
var st = ChatDB.StateFor(t, viewer);
var latest = t.Messages != null && t.Messages.Count > 0 ? t.Messages.OrderByDescending(m => m.ChatMessageId).First() : null;
return new
{
latestMessage = latest != null ? ChatDB.MessageDto(latest) : null,
chatThreadId = t.ChatThreadId,
playerIds = t.PlayerIds.OrderBy(x => x).ToList(),
lastReadMessageId = st.LastReadMessageId,
chatThreadName = t.ChatThreadName ?? "",
chatThreadType = t.ChatThreadType,
snoozedUntil = st.SnoozedUntil,
isFavorited = st.IsFavorited
};
}
private static object ThreadWithMessagesDto(ChatThread t, ulong viewer, int count)
{
var st = ChatDB.StateFor(t, viewer);
return new
{
chatThreadId = t.ChatThreadId,
playerIds = t.PlayerIds.OrderBy(x => x).ToList(),
lastReadMessageId = st.LastReadMessageId,
chatThreadName = t.ChatThreadName ?? "",
chatThreadType = t.ChatThreadType,
snoozedUntil = st.SnoozedUntil,
isFavorited = st.IsFavorited,
messages = (t.Messages ?? new()).OrderByDescending(m => m.ChatMessageId).Take(count).Select(ChatDB.MessageDto).ToList()
};
}
private static object SentMessageDto(ChatMessage m) => new
{
ChatMessageId = m.ChatMessageId,
ChatThreadId = m.ChatThreadId,
SenderPlayerId = m.SenderPlayerId,
TimeSent = m.TimeSent.ToUniversalTime(),
Contents = m.Contents,
ModerationState = m.ModerationState
};
// The stored envelope, with profanity masked in a Text message when the server is set to.
private static string CleanContents(string contents)
{
if (ConfigCampus.AllowSwearsInChatThreads) return contents;
try
{
var mj = JsonConvert.DeserializeObject<MessageJson>(contents);
if (mj != null && mj.Type == MessageContentType.Text && mj.Data != null)
{
mj.Data = Sanitize.SanitizeMessage(mj.Data);
return JsonConvert.SerializeObject(mj);
}
}
catch { /* not an envelope - store as sent */ }
return contents;
}
// ---------------- privacy ----------------
private const string DmPrivacyKey = "directMessagePrivacySetting";
private const string GroupPrivacyKey = "groupChatPrivacySetting";
private static ChatPrivacy? ParseChatPrivacy(string? v)
{
if (string.IsNullOrWhiteSpace(v)) return null;
if (Enum.TryParse<ChatPrivacy>(v.Trim(), ignoreCase: true, out var byName)) return byName;
if (int.TryParse(v.Trim(), out var n) && Enum.IsDefined(typeof(ChatPrivacy), n)) return (ChatPrivacy)n;
return null;
}
private static ChatPrivacySettings ReadChatPrivacy(ulong playerId)
{
var dm = ParseChatPrivacy(SettingsDB.GetPlayerSettingValue(DmPrivacyKey, playerId)) ?? ChatPrivacy.Friends;
var grp = ParseChatPrivacy(SettingsDB.GetPlayerSettingValue(GroupPrivacyKey, playerId)) ?? ChatPrivacy.Friends;
return new ChatPrivacySettings { playerId = playerId, directMessagePrivacySetting = dm, groupChatPrivacySetting = grp };
}
private static bool AreFriends(ulong a, ulong b)
{
if (a == b) return true;
try
{
return (FriendsDB.GetRelationships(a) ?? new List<RelationshipDetail>())
.Any(r => r.PlayerID == b && r.RelationshipType == RelationshipType.Friend);
}
catch { return false; }
}
private static bool IsFavoriteOf(ulong owner, ulong other)
{
try
{
return (FriendsDB.GetRelationships(owner) ?? new List<RelationshipDetail>())
.Any(r => r.PlayerID == other && r.RelationshipType == RelationshipType.Friend
&& (r.Favorited == ReciprocalStatus.Local || r.Favorited == ReciprocalStatus.Mutual));
}
catch { return false; }
}
/// <summary>May `sender` open a DM (group=false) with / add to a group (group=true) `target`, per the target's privacy setting? Devs always may.</summary>
private static bool PrivacyAllows(ulong sender, ulong target, bool group)
{
if (sender == target) return true;
if (PlayerDB.CheckDevFlag(sender)) return true;
var p = ReadChatPrivacy(target);
var setting = group ? p.groupChatPrivacySetting : p.directMessagePrivacySetting;
return setting switch
{
ChatPrivacy.NoOne => false,
ChatPrivacy.Favorites => IsFavoriteOf(target, sender),
_ => AreFriends(sender, target),
};
}
[HttpGet("thread/chatPrivacySetting")]
public IActionResult GetChatPrivacySetting()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
return Ok(ReadChatPrivacy(account.Id));
}
// One field per call, by name ("Favorites") or ordinal; an absent field is left as it was.
[HttpPut("thread/chatPrivacySetting")]
public IActionResult SetChatPrivacySetting()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var dm = ParseChatPrivacy(Field(DmPrivacyKey));
var grp = ParseChatPrivacy(Field(GroupPrivacyKey));
if (dm != null) SettingsDB.SetPlayerSetting(DmPrivacyKey, ((int)dm.Value).ToString(), account.Id);
if (grp != null) SettingsDB.SetPlayerSetting(GroupPrivacyKey, ((int)grp.Value).ToString(), account.Id);
if (dm != null || grp != null)
Console.WriteLine($"[chat] {account.Id} set privacy dm={dm?.ToString() ?? "(unchanged)"} group={grp?.ToString() ?? "(unchanged)"}");
return Ok(ReadChatPrivacy(account.Id));
}
// Asked before the client opens a new DM (receivingPlayerId=...). Bare ChatResult.
[HttpGet("thread/checkCanSendDirectMessageWithPrivacySetting")]
public IActionResult CheckCanSendDirectMessage()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
ulong target = ulong.TryParse(Field("receivingPlayerId") ?? Field("playerId"), out var t) ? t : 0;
if (target == 0) return Result(ChatResults.Success);
return Raw(PrivacyAllows(account.Id, target, group: false) ? "0" : ResultCannotMessageDueToRemotePrivacy.ToString());
}
// How long a party invite link lives (bare single-key object, reference value).
[HttpGet("settings/partyinvite")]
public IActionResult PartyInviteSettings() => Json(new { InviteLinkLifetimeInMinutes = 60 });
// ---------------- listing ----------------
[HttpGet("thread")]
public ContentResult GetThreads([FromQuery] int maxCount = 50, int mode = 0)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var threads = ChatDB.GetThreadsForPlayer(account.Id, Math.Clamp(maxCount, 1, 200));
return Json(threads.Select(t => ThreadListDto(t, account.Id)).ToList());
}
// The caller's party thread, or JSON null when they're not in one.
[HttpGet("thread/party")]
public ContentResult GetPartyThread([FromQuery] int maxCount = 1, [FromQuery] int mode = 0)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var party = ChatDB.GetPartyThreadFor(account.Id);
if (party == null) return Raw("null");
return Json(PartyThreadDto(party, account.Id));
}
// A party's chat membership follows the party itself: the party forms between the clients
// over Photon (a shared PlayerPartyID), then each member's client simply opens the party
// thread BY ID (GET /chat/thread/{id}) - it never asks to be added. So a party thread admits
// any player who opens it: they're put on it (leaving any other party thread first) and the
// members are told. Without this the second player got a 404, the Party tab listed no
// members and party chat never opened.
private static ChatThread AdmitToParty(ChatThread thread, ulong playerId)
{
if (thread.ChatThreadType != ChatDB.ThreadTypeParty || thread.PlayerIds.Contains(playerId)) return thread;
var old = ChatDB.GetPartyThreadFor(playerId);
if (old != null && old.ChatThreadId != thread.ChatThreadId)
{
ChatDB.SystemNotice(old.ChatThreadId, $"Player <@U{playerId}> left");
ChatDB.RemoveMember(old.ChatThreadId, playerId);
}
ChatDB.AddMember(thread.ChatThreadId, playerId);
ChatDB.SystemNotice(thread.ChatThreadId, $"Player <@U{playerId}> joined the party");
Console.WriteLine($"[chat] {playerId} joined party thread {thread.ChatThreadId} (opened it)");
return ChatDB.GetThread(thread.ChatThreadId)!;
}
// Party thread DTO: the list shape (latestMessage) AND a messages page in one object, so
// whichever way the client parses a thread it finds what it needs.
private static object PartyThreadDto(ChatThread t, ulong viewer)
{
var st = ChatDB.StateFor(t, viewer);
var ordered = (t.Messages ?? new()).OrderByDescending(m => m.ChatMessageId).ToList();
return new
{
latestMessage = ordered.Count > 0 ? ChatDB.MessageDto(ordered[0]) : null,
chatThreadId = t.ChatThreadId,
playerIds = t.PlayerIds.OrderBy(x => x).ToList(),
lastReadMessageId = st.LastReadMessageId,
chatThreadName = t.ChatThreadName ?? "",
chatThreadType = t.ChatThreadType,
snoozedUntil = st.SnoozedUntil,
isFavorited = st.IsFavorited,
messages = ordered.Take(50).Select(ChatDB.MessageDto).ToList()
};
}
// POST /chat/thread/party - "make me a party thread" (the client sends NO body: the thread
// id it gets back is the party id it then shares with the players it parties up with over
// Photon). Answers the THREAD itself; the create envelope this used to answer was not what
// the client parses, so every Join Party click re-posted and no party ever formed. A caller
// already in a party thread gets that one back. Optional ids seed members, optional
// messageContents posts a first message.
[HttpPost("thread/party")]
public async Task<ContentResult> CreatePartyThread()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
Console.WriteLine($"[chat] {account.Id} POST thread/party ct='{Request.ContentType}' fields=[{(Request.HasFormContentType ? string.Join(",", Request.Form.Keys) : "")}] query={Request.QueryString} body={await RawBody()} headers=[{string.Join("; ", Request.Headers.Where(h => !string.Equals(h.Key, "Authorization", StringComparison.OrdinalIgnoreCase)).Select(h => h.Key + "=" + h.Value))}]");
var party = ChatDB.GetPartyThreadFor(account.Id);
if (party == null)
{
var members = new List<ulong> { account.Id };
members.AddRange(Ids().Where(id => id != account.Id));
party = ChatDB.CreateThread(members, account.Id, "", ChatDB.ThreadTypeParty);
Console.WriteLine($"[chat] {account.Id} created party thread {party.ChatThreadId} with [{string.Join(",", members)}]");
}
ChatMessage? first = null;
string? contents = Field("messageContents");
if (!string.IsNullOrWhiteSpace(contents))
{
first = ChatDB.AddMessage(party.ChatThreadId, (int)account.Id, CleanContents(contents));
party = ChatDB.GetThread(party.ChatThreadId)!;
if (first != null) ChatDB.Push(party, first);
}
// The client's create-party call is its CreateThread(messageContents) with nothing to
// post (BestHTTP sends an empty form for that). It only proceeds on chatResult 0, so an
// empty first message must NOT report InvalidArguments here (the reference's rule for a
// plain /thread create) - that "1" is what left every Join Party click re-creating.
return Json(new
{
ChatMessage = first != null ? SentMessageDto(first) : null,
ChatResult = 0,
chatResult = 0,
chatThread = PartyThreadDto(party, account.Id)
});
}
// Join another player's party thread (the "Join Party" side): /chat/thread/party/{threadId}
// and /chat/thread/party/join?... are both accepted. Adds the caller, tells the members.
// `thread/{id}/joinpartychat` is the spelling THIS client uses (it posted it 148 times and
// got a 404 every time, which is why the party list flickered between 0 and 2 people and
// the thread filled with joined/left notices - each refusal made the client retry from
// scratch). The other two spellings are kept for older clients.
[HttpPost("thread/{threadId:long}/joinpartychat")]
[HttpPut("thread/{threadId:long}/joinpartychat")]
[HttpPost("thread/party/{threadId:long}")]
[HttpPut("thread/party/{threadId:long}")]
public ContentResult JoinPartyThread(long threadId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var party = ChatDB.GetThread(threadId);
if (party == null || party.ChatThreadType != ChatDB.ThreadTypeParty) return Result(ChatResults.ThreadNotFound);
if (!party.PlayerIds.Contains(account.Id))
{
// Leave any previous party first - one party at a time.
var old = ChatDB.GetPartyThreadFor(account.Id);
if (old != null && old.ChatThreadId != threadId) { ChatDB.SystemNotice(old.ChatThreadId, $"Player <@U{account.Id}> left"); ChatDB.RemoveMember(old.ChatThreadId, account.Id); }
ChatDB.AddMember(threadId, account.Id);
// One notice per real join. A client that re-posts the same join (reconnect, room
// change, a retry after a 404) lands in the `already a member` branch above and
// says nothing - that repetition is what filled the thread with joined/left pairs.
ChatDB.SystemNotice(threadId, $"Player <@U{account.Id}> joined the party");
Console.WriteLine($"[chat] {account.Id} joined party thread {threadId}");
}
return Json(PartyThreadDto(ChatDB.GetThread(threadId)!, account.Id));
}
// Leave the party (drop out of the party thread).
[HttpPost("thread/party/leave")]
[HttpDelete("thread/party")]
public ContentResult LeavePartyThread()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var party = ChatDB.GetPartyThreadFor(account.Id);
if (party == null) return Result(ChatResults.MembershipNotFound);
ChatDB.SystemNotice(party.ChatThreadId, $"Player <@U{account.Id}> left");
ChatDB.RemoveMember(party.ChatThreadId, account.Id);
Console.WriteLine($"[chat] {account.Id} left party thread {party.ChatThreadId}");
return Result(ChatResults.Success);
}
// ---------------- creating / opening ----------------
// POST /chat/thread: ids (repeated or comma-joined, the caller is added) + messageContents.
// Reuses the thread with exactly these members if one exists (one conversation per
// pair/group) and posts the first message. Answers the reference's CreateThreadResponse.
[HttpPost("thread")]
public ContentResult CreateThread()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var members = Ids().Where(id => id != account.Id).ToList();
if (members.Count == 0 || members.Count + 1 > ChatDB.MaxThreadMembers) return Raw("", 400);
bool group = members.Count > 1;
var blocked = members.Where(m => !PrivacyAllows(account.Id, m, group)).ToList();
if (blocked.Count == members.Count)
return Json(new { ChatMessage = (object?)null, ChatResult = group ? ResultCannotAddDueToPrivacy : ResultCannotMessageDueToRemotePrivacy,
chatResult = group ? ResultCannotAddDueToPrivacy : ResultCannotMessageDueToRemotePrivacy, chatThread = (object?)null });
members = members.Except(blocked).ToList();
var all = new List<ulong> { account.Id }; all.AddRange(members);
var thread = ChatDB.GetOrCreateThreadWithMembers(all, account.Id);
ChatMessage? first = null;
string? contents = Field("messageContents");
if (!string.IsNullOrWhiteSpace(contents))
{
first = ChatDB.AddMessage(thread.ChatThreadId, (int)account.Id, CleanContents(contents));
thread = ChatDB.GetThread(thread.ChatThreadId)!;
if (first != null) ChatDB.Push(thread, first);
}
int result = first != null ? (blocked.Count > 0 ? 17 : 0) : 1;
Console.WriteLine($"[chat] {account.Id} opened thread {thread.ChatThreadId} with [{string.Join(",", members)}] (msg: {(first != null ? "yes" : "no")})");
return Json(new { ChatMessage = first != null ? SentMessageDto(first) : null, ChatResult = result, chatResult = result, chatThread = ThreadListDto(thread, account.Id) });
}
// POST /chat/thread/withmembers: the client's GetChatBetweenPlayers - FETCH-OR-CREATE the
// thread with exactly these members and answer it WITH its messages (an object, not the
// create envelope). Minting a fresh thread on every call is what produced a new empty
// conversation each time you opened someone's DM.
[HttpPost("thread/withmembers")]
public ContentResult ThreadWithMembers()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var members = Ids().Where(id => id != account.Id).ToList();
if (members.Count == 0 || members.Count + 1 > ChatDB.MaxThreadMembers) return Raw("", 400);
var all = new List<ulong> { account.Id }; all.AddRange(members);
var existing = ChatDB.FindThreadWithExactMembers(all);
if (existing == null)
{
bool group = members.Count > 1;
if (members.Any(m => !PrivacyAllows(account.Id, m, group)))
return Raw((group ? ResultCannotAddDueToPrivacy : ResultCannotMessageDueToRemotePrivacy).ToString());
existing = ChatDB.CreateThread(all, account.Id);
var notice = existing.Messages.LastOrDefault();
if (notice != null) ChatDB.Push(existing, notice);
Console.WriteLine($"[chat] {account.Id} opened thread {existing.ChatThreadId} with [{string.Join(",", members)}]");
}
int count = Count("messageCount", 50);
return Json(ThreadWithMessagesDto(existing, account.Id, count));
}
[HttpGet("thread/{id:long}")]
public ContentResult GetThreadById(long id, [FromQuery] int maxCount = 50, [FromQuery] int messageCount = 0, [FromQuery] int mode = 0)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var thread = ChatDB.GetThread(id);
if (thread != null) thread = AdmitToParty(thread, account.Id);
if (thread == null || !thread.PlayerIds.Contains(account.Id)) return Raw("", 404);
int count = messageCount > 0 ? Math.Min(messageCount, 100) : Math.Clamp(maxCount, 1, 100);
if (thread.ChatThreadType == ChatDB.ThreadTypeParty) return Json(PartyThreadDto(thread, account.Id));
return Json(ThreadWithMessagesDto(thread, account.Id, count));
}
// A page of messages - a bare array. Latest / newer-than / older-than a reference id.
[HttpGet("thread/{id:long}/message")]
public ContentResult GetThreadMessages(long id, [FromQuery] int messageCount = 50, [FromQuery] int mode = 0, [FromQuery] long referenceMessageId = 0)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var thread = ChatDB.GetThread(id);
if (thread != null) thread = AdmitToParty(thread, account.Id);
if (thread == null || !thread.PlayerIds.Contains(account.Id)) return Raw("", 404);
int count = Math.Clamp(messageCount, 1, 200);
IEnumerable<ChatMessage> messages = thread.Messages;
messages = (QueryMode)mode switch
{
QueryMode.NewerThan => messages.Where(m => m.ChatMessageId > referenceMessageId).OrderBy(m => m.ChatMessageId).Take(count).OrderByDescending(m => m.ChatMessageId),
QueryMode.OlderThan => messages.Where(m => m.ChatMessageId < referenceMessageId).OrderByDescending(m => m.ChatMessageId).Take(count),
_ => messages.OrderByDescending(m => m.ChatMessageId).Take(count),
};
return Json(messages.Select(ChatDB.MessageDto).ToList());
}
// ---------------- sending ----------------
[HttpPost("thread/{id:long}")]
[HttpPost("thread/{id:long}/message")]
public async Task<ContentResult> SendMessageToThread(long id)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var thread = ChatDB.GetThread(id);
if (thread != null) thread = AdmitToParty(thread, account.Id);
if (thread == null || !thread.PlayerIds.Contains(account.Id))
return Json(new { ChatMessage = (object?)null, ChatResult = 3, chatResult = 3, chatThread = (object?)null });
// Members can be added in the same call (ids=...): a group grows as you message it.
var extra = Ids().Where(pid => !thread.PlayerIds.Contains(pid)).ToList();
foreach (var pid in extra)
{
if (!PrivacyAllows(account.Id, pid, group: true)) continue;
ChatDB.AddMember(id, pid);
ChatDB.SystemNotice(id, $"Player <@U{account.Id}> added <@U{pid}>");
}
if (extra.Count > 0) thread = ChatDB.GetThread(id)!;
string? contents = Field("messageContents");
if (string.IsNullOrWhiteSpace(contents))
return Json(new { ChatMessage = (object?)null, ChatResult = 1, chatResult = 1, chatThread = ThreadWithMessagesDto(thread, account.Id, Count("messageCount", 50)) });
string actualMessage = "";
try { actualMessage = JObject.Parse(contents)?["Data"]?.ToString() ?? ""; } catch { }
var msg = ChatDB.AddMessage(id, (int)account.Id, CleanContents(contents));
if (msg == null)
return Json(new { ChatMessage = (object?)null, ChatResult = 5, chatResult = 5, chatThread = (object?)null });
thread = ChatDB.GetThread(id)!;
// Everyone on the thread, sender included (the client doesn't fold its own send in).
await Notifications.SendToPlayers(thread.PlayerIds.ToList(), JsonConvert.SerializeObject(new { Id = "ChatMessageReceived", Msg = ChatDB.MessageDto(msg) }));
// Dev chat commands ("<=>cmd") answered by Coach (1) when a dev talks to Coach.
if (actualMessage.StartsWith("<=>") && PlayerDB.CheckDevFlag(account.Id) && thread.PlayerIds.Contains(1))
{
_ = Task.Run(async () =>
{
try
{
await Task.Delay(250);
string? result = DevChatCommands.ParseCommand(actualMessage.Substring(3), "ChatThread", account.Id);
if (string.IsNullOrWhiteSpace(result)) return;
var bot = ChatDB.AddMessage(id, 1, JsonConvert.SerializeObject(new { Type = 0, Version = 2, Data = "<=>" + result }));
if (bot != null) ChatDB.Push(ChatDB.GetThread(id)!, bot);
}
catch (Exception ex) { Console.WriteLine($"DevChat error: {ex}"); }
});
}
return Json(new
{
ChatMessage = SentMessageDto(msg),
ChatResult = 0,
chatResult = 0,
chatThread = ThreadWithMessagesDto(thread, account.Id, Count("messageCount", 50))
});
}
// ---------------- membership ----------------
// Add a player to a thread you're on. Bare ChatResult: 0 ok, 3 not a member, 4 already on it, 18 their privacy forbids it.
[HttpPost("thread/{id:long}/member/{playerId:long}")]
[HttpPut("thread/{id:long}/member/{playerId:long}")]
public ContentResult AddMember(long id, long playerId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var thread = ChatDB.GetThread(id);
if (thread == null || !thread.PlayerIds.Contains(account.Id)) return Result(ChatResults.MembershipNotFound);
ulong pid = (ulong)playerId;
if (thread.PlayerIds.Contains(pid)) return Result(ChatResults.PlayerAlreadyOnThread);
if (!PrivacyAllows(account.Id, pid, group: true)) return Raw(ResultCannotAddDueToPrivacy.ToString());
if (thread.PlayerIds.Count + 1 > ChatDB.MaxThreadMembers) return Result(ChatResults.ThreadTooLarge);
ChatDB.AddMember(id, pid);
ChatDB.SystemNotice(id, $"Player <@U{account.Id}> added <@U{pid}>");
Console.WriteLine($"[chat] {account.Id} added {pid} to thread {id}");
return Result(ChatResults.Success);
}
// Remove a player (yourself, or anyone if you started the thread).
[HttpDelete("thread/{id:long}/member/{playerId:long}")]
public ContentResult RemoveMember(long id, long playerId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var thread = ChatDB.GetThread(id);
if (thread == null || !thread.PlayerIds.Contains(account.Id)) return Result(ChatResults.MembershipNotFound);
ulong pid = (ulong)playerId;
if (!thread.PlayerIds.Contains(pid)) return Result(ChatResults.MembershipNotFound);
if (pid != account.Id && thread.CreatorPlayerId != account.Id) return Raw("9"); // InsufficientPermission
ChatDB.SystemNotice(id, pid == account.Id ? $"Player <@U{pid}> left" : $"Player <@U{account.Id}> removed <@U{pid}>");
ChatDB.RemoveMember(id, pid);
return Result(ChatResults.Success);
}
// Leave: notice first (the leaver still gets the push that tells their client the thread
// is gone), then drop the membership. The thread survives for the others.
[HttpPost("thread/{id:long}/leave")]
[HttpDelete("thread/{id:long}/leave")]
public ContentResult LeaveThread(long id)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var thread = ChatDB.GetThread(id);
if (thread == null || !thread.PlayerIds.Contains(account.Id)) return Result(ChatResults.MembershipNotFound);
var notice = ChatDB.AddMessage(id, ChatDB.SystemSenderId,
JsonConvert.SerializeObject(new MessageJson { Type = MessageContentType.Text, Version = 1, Data = $"Player <@U{account.Id}> left" }));
if (notice != null)
{
var t = ChatDB.GetThread(id)!;
ChatDB.Push(t, notice);
ChatDB.Push(t, notice, "PlayerLeftChat");
}
ChatDB.RemoveMember(id, account.Id);
Console.WriteLine($"[chat] {account.Id} left thread {id}");
return Result(ChatResults.Success);
}
[HttpPost("thread/{id:long}/rename")]
[HttpPut("thread/{id:long}/rename")]
public ContentResult RenameThread(long id)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var thread = ChatDB.GetThread(id);
if (thread == null || !thread.PlayerIds.Contains(account.Id)) return Result(ChatResults.MembershipNotFound);
string name = Uri.UnescapeDataString(Field("name") ?? "").Trim();
if (name.Length > 128) name = name.Substring(0, 128);
ChatDB.SetName(id, name);
ChatDB.SystemNotice(id, $"Player <@U{account.Id}> renamed the chat");
return Result(ChatResults.Success);
}
// ---------------- per-member state ----------------
[HttpPost("thread/{id:long}/snooze")]
[HttpPut("thread/{id:long}/snooze")]
public ContentResult SnoozeThread(long id)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
if (!ChatDB.IsMember(id, account.Id)) return Result(ChatResults.MembershipNotFound);
bool on = FormBool(Field("snooze"));
ChatDB.UpdateState(id, account.Id, s => s.SnoozedUntil = on ? new DateTime(9999, 12, 31, 23, 59, 59, DateTimeKind.Utc) : null);
return Result(ChatResults.Success);
}
[HttpPost("thread/{id:long}/favorite")]
[HttpPut("thread/{id:long}/favorite")]
public ContentResult FavoriteThread(long id)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
if (!ChatDB.IsMember(id, account.Id)) return Result(ChatResults.MembershipNotFound);
bool on = FormBool(Field("favorite"));
ChatDB.UpdateState(id, account.Id, s => s.IsFavorited = on);
return Result(ChatResults.Success);
}
[HttpPost("thread/{id:long}/read")]
[HttpPut("thread/{id:long}/read")]
public ContentResult MarkThreadRead(long id)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
{ var t = ChatDB.GetThread(id); if (t != null) AdmitToParty(t, account.Id); }
if (!ChatDB.IsMember(id, account.Id)) return Raw("", 404);
ChatDB.MarkRead(id, account.Id);
return Result(ChatResults.Success);
}
[HttpPost("thread/{id:long}/message/{messageId:long}/read")]
[HttpPut("thread/{id:long}/message/{messageId:long}/read")]
public ContentResult MarkMessageRead(long id, long messageId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
{ var t = ChatDB.GetThread(id); if (t != null) AdmitToParty(t, account.Id); }
if (!ChatDB.IsMember(id, account.Id)) return Raw("", 404);
ChatDB.MarkRead(id, account.Id, messageId);
return Result(ChatResults.Success);
}
// ---------------- anything else the client asks the chat service ----------------
// Logged so a route this build calls that isn't implemented yet names itself (method,
// path, query, form keys) instead of vanishing as a silent 404.
[Route("{*rest}")]
[AcceptVerbs("GET", "POST", "PUT", "DELETE", "PATCH")]
public async Task<ContentResult> Unhandled(string? rest)
{
string fields = Request.HasFormContentType ? string.Join(",", Request.Form.Keys) : "";
Console.WriteLine($"[chat] UNHANDLED {Request.Method} /chat/{rest}{Request.QueryString} ct='{Request.ContentType}' fields=[{fields}] body={await RawBody()}");
return Raw("", 404);
}
}
}
@@ -0,0 +1,1047 @@
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Campus_live_rewrite.api;
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server;
using Campus_live_rewrite.api.server.Classes;
namespace Campus_live_rewrite.Controllers
{
[Route("clubs")]
public class ClubsController : ControllerBase
{
// subscription/mine/member
// club/categoryTags // the tags for clubs // todo: get club tags from rec.net
// club/home/me // return 404 if you don't have a club home set, i think this return just "1" room id
// announcements/v2/mine/unread
// announcements/v2/subscription/mine/unread
// /clubs/club/mine/created
// Club category tags for the Clubs browse/filter UI AND the "Create a Club" screen's
// "Primary Club Tag" dropdown. An empty array left that dropdown empty, which disables
// the CREATE button (the client requires a tag). The fixed set matches the RecFlare
// reference (clubs.app.ts /club/categoryTags).
public static readonly string[] CategoryTags =
{ "Social", "Creative", "Competitive", "Casual", "Entertainment" };
[HttpGet("club/categoryTags")]
public ContentResult ClubCategoryTags() => new ContentResult
{
Content = JsonConvert.SerializeObject(CategoryTags),
ContentType = "application/json",
StatusCode = 200
};
// ── club DTO builders (match the RecFlare wire shapes) ──────────────────────────────
private static ContentResult ClubJson(object o, int status = 200) => new ContentResult
{
Content = JsonConvert.SerializeObject(o),
ContentType = "application/json",
StatusCode = status
};
private static object ClubError(string message) => new { error = message, success = false, value = (object?)null };
private static object ClubDtoObj(ClubDB.Club c) => new
{
c.ClubId,
c.Name,
c.Description,
c.Category,
c.Visibility,
c.Joinability,
c.AllowJuniors,
c.MainImageName,
c.ClubType,
ClubhouseRoomId = c.ClubhouseRoomId,
CreatorAccountId = c.CreatorAccountId,
c.IsRRO,
c.MinLevel,
c.State,
MemberCount = ClubDB.MemberCount(c.ClubId),
};
private static object ClubPermObj(int clubId, int tier) => new
{
ClubId = clubId,
Type = tier,
ApproveMember = tier >= ClubDB.TierModerator,
BanUnban = tier >= ClubDB.TierModerator,
CreateEvent = tier >= ClubDB.TierCoOwner,
EditDetails = tier >= ClubDB.TierCoOwner,
EditPermissionSettings = tier >= ClubDB.TierCoOwner,
PostAnnouncement = tier >= ClubDB.TierModerator,
};
private static object ClubMemberObj(ClubDB.ClubMember m) => new
{
m.ClubMemberId,
m.ClubId,
AccountId = m.AccountId,
MembershipType = m.MembershipType,
CreatedAt = m.CreatedAt,
};
// A club gallery image as a whole SavedImage record (the client deserializes each entry
// into an object; a bare name fails its parser). Reads the real row when the name is a
// known image, else synthesizes a minimal record around the name.
private static object ClubImageObj(string name)
{
var img = ImageMetadataDB.GetImageByName(name);
if (img != null)
return new
{
Id = img.Id,
Type = (int)img.Type,
Accessibility = (int)img.Accessibility,
AccessibilityLocked = img.AccessibilityLocked,
ImageName = img.ImageName,
Description = img.Description,
PlayerId = img.PlayerId,
TaggedPlayerIds = img.TaggedPlayerIds ?? new List<ulong>(),
RoomId = (int?)img.RoomId,
PlayerEventId = (ulong?)img.PlayerEventId,
CreatedAt = img.CreatedAt,
CheerCount = img.CheerCount,
CommentCount = img.CommentCount,
};
return new
{
Id = 0,
Type = 3,
Accessibility = 1,
AccessibilityLocked = false,
ImageName = name,
Description = (string?)null,
PlayerId = 0UL,
TaggedPlayerIds = new List<ulong>(),
RoomId = (int?)null,
PlayerEventId = (ulong?)null,
CreatedAt = DateTime.UtcNow,
CheerCount = 0,
CommentCount = 0,
};
}
private static object ClubDetailsObj(ClubDB.Club c, ulong viewer) => new
{
AdditionalImages = (c.AdditionalImageNames ?? new List<string>()).Select(ClubImageObj).ToList(),
Club = ClubDtoObj(c),
ClubId = c.ClubId,
CoownerPermissions = ClubPermObj(c.ClubId, ClubDB.TierCoOwner),
CustomTags = c.CustomTags ?? new List<string>(),
MemberPermissions = ClubPermObj(c.ClubId, ClubDB.TierMember),
ModeratorPermissions = ClubPermObj(c.ClubId, ClubDB.TierModerator),
MyMembershipType = ClubDB.MyMembership(c.ClubId, viewer),
};
// Accept an enum arriving as a name or an ordinal (the client sends either).
private static int ParseEnum(string? raw, Dictionary<string, int> names, int fallback)
{
if (string.IsNullOrWhiteSpace(raw)) return fallback;
raw = raw.Trim();
if (int.TryParse(raw, out var n)) return n;
foreach (var kv in names) if (string.Equals(kv.Key, raw, StringComparison.OrdinalIgnoreCase)) return kv.Value;
return fallback;
}
private static bool ParseBool(string? raw, bool fallback)
{
if (string.IsNullOrWhiteSpace(raw)) return fallback;
raw = raw.Trim();
if (bool.TryParse(raw, out var b)) return b;
return raw == "1" || raw.Equals("yes", StringComparison.OrdinalIgnoreCase);
}
// POST /clubs/club/create - the "Create a Club" screen. Form: name, description,
// category, and optionally visibility/joinability/allowJuniors/minLevel. Returns the
// { error, success, value } envelope carrying the new club's full details.
[HttpPost("club/create")]
public ContentResult CreateClub()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
string Field(string name) => Request.HasFormContentType ? Request.Form[name].ToString() : "";
string clubName = Field("name")?.Trim() ?? "";
string description = Field("description") ?? "";
if (string.IsNullOrWhiteSpace(clubName))
return ClubJson(ClubError("You must enter a name for your club."), 400);
if (clubName.Length > ClubDB.MaxNameLength)
return ClubJson(ClubError($"Club names can be at most {ClubDB.MaxNameLength} characters."), 400);
if ((description ?? "").Length > ClubDB.MaxDescriptionLength)
return ClubJson(ClubError($"Club descriptions can be at most {ClubDB.MaxDescriptionLength} characters."), 400);
int visibility = ParseEnum(Field("visibility"),
new() { ["Private"] = 0, ["Public"] = 1 }, 1);
int joinability = ParseEnum(Field("joinability"),
new() { ["Open"] = 0, ["InviteOnly"] = 1, ["AskToJoin"] = 2 }, 0);
bool allowJuniors = ParseBool(Field("allowJuniors"), true);
int minLevel = int.TryParse(Field("minLevel"), out var ml) ? ml : 0;
string category = Field("category")?.Trim() ?? "";
var club = ClubDB.CreateClub(account.Id, clubName, description, category,
visibility, joinability, allowJuniors, minLevel);
Console.WriteLine($"[clubs] '{club.Name}' (#{club.ClubId}) created by {account.Id}");
return ClubJson(new { error = "", success = true, value = ClubDetailsObj(club, account.Id) });
}
// GET /clubs/club/{clubId} - the bare club DTO (404 if none). A creator auto-club id
// (5000000+) resolves too - the profile's subscription club 404'd here before.
[HttpGet("club/{clubId:int}")]
public ContentResult GetClub(int clubId)
{
var club = ClubDB.GetById(clubId) ?? (ClubDB.IsCreatorClub(clubId) ? ClubDB.CreatorClub(ClubDB.CreatorOfClub(clubId)) : null);
if (club == null) return ClubJson(new { error = "No such club.", success = false, value = (object?)null }, 404);
return ClubJson(ClubDtoObj(club));
}
// ---- HOME CLUB (the club whose clubhouse the player spawns into) ----
// GET 404s when none is set - that is what the client expects for "no home club"; it
// errors on an empty object. PUT/DELETE answer the envelope.
[HttpGet("club/home/me")]
public ContentResult GetHomeClub()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
int id = ClubDB.GetHomeClubId(account.Id);
var club = id == 0 ? null : ClubDB.GetById(id);
if (club == null) return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 404 };
return ClubJson(ClubDtoObj(club));
}
[HttpPut("club/home/me")]
[HttpPost("club/home/me")]
public ContentResult SetHomeClub()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var f = Campus_live_rewrite.api.server.RoomModeration.Fields(Request);
int clubId = (int)Campus_live_rewrite.api.server.RoomModeration.Long(f, "clubId", "ClubId", "id");
if (clubId <= 0) return ClubJson(new { error = "Invalid clubId.", success = false, value = (object?)null }, 400);
var club = ClubDB.GetById(clubId);
if (club == null) return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 404 };
if (ClubDB.MyMembership(clubId, account.Id) < 30 && club.CreatorAccountId != account.Id)
return ClubJson(new { error = "You are not a member of that club.", success = false, value = (object?)null }, 403);
ClubDB.SetHomeClub(account.Id, clubId);
return ClubJson(new { error = "", success = true, value = ClubDtoObj(club) });
}
[HttpDelete("club/home/me")]
public ContentResult ClearHomeClub()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
ClubDB.ClearHomeClub(account.Id);
return ClubJson(new { error = "", success = true, value = (object?)null });
}
// GET /clubs/account/{accountId}/clubs - the clubs an account belongs to or created
// (public ones only for someone else's profile). Bare array of club DTOs.
[HttpGet("account/{accountId:long}/clubs")]
[HttpGet("club/account/{accountId:long}/clubs")]
public ContentResult ClubsOfAccount(long accountId)
{
var (account, _) = AuthHelper.ValidateToken(HttpContext);
ulong who = (ulong)Math.Max(0, accountId);
var clubs = ClubDB.GetByMember(who).Concat(ClubDB.GetByCreator(who))
.GroupBy(c => c.ClubId).Select(g => g.First())
.Where(c => account?.Id == who || c.Visibility == 1 || ClubDB.MyMembership(c.ClubId, account?.Id ?? 0) >= 30)
.ToList();
return ClubJson(clubs.Select(ClubDtoObj).ToList());
}
// GET /clubs/club/{clubId}/details - the full club-screen payload (NOT enveloped).
[HttpGet("club/{clubId:int}/details")]
public ContentResult GetClubDetails(int clubId)
{
var club = ClubDB.GetById(clubId);
if (club == null) return ClubJson(new { error = "No such club.", success = false, value = (object?)null }, 404);
var (account, _) = AuthHelper.ValidateToken(HttpContext);
ulong viewer = account?.Id ?? 0;
return ClubJson(ClubDetailsObj(club, viewer));
}
// GET /clubs/club/{clubId}/members - the members list envelope (empty for unknown club).
[HttpGet("club/{clubId:int}/members")]
public ContentResult GetClubMembers(int clubId)
{
int? tier = int.TryParse(Request.Query["membershipType"].ToString(), out var t) ? t : (int?)null;
string? sortBy = Request.Query["sortBy"].ToString();
var members = ClubDB.GetMembers(clubId, tier, string.IsNullOrEmpty(sortBy) ? null : sortBy)
.Select(ClubMemberObj).ToList();
return ClubJson(new { error = "", success = true, value = members });
}
// PUT /clubs/club/{clubId}/modifydetails (and /modify) — edit a club (co-owner+). The
// create flow's step-4 (privacy / member-access / custom tags) PUTs here; it was missing,
// so it showed "Failed to Modify Club Details". Form fields (all optional, absent = keep):
// name, description, category, visibility, joinability, allowJuniors, minLevel, customTags.
// Answers the details envelope.
[HttpPut("club/{clubId:int}/modifydetails")]
[HttpPut("club/{clubId:int}/modify")]
public ContentResult ModifyClub(int clubId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var club = ClubDB.GetById(clubId);
if (club == null) return ClubJson(new { error = "No such club.", success = false, value = (object?)null }, 404);
if (ClubDB.MyMembership(clubId, account.Id) < ClubDB.TierCoOwner)
return ClubJson(ClubError("Insufficient permissions."), 403);
bool Has(string k) => Request.HasFormContentType && Request.Form.ContainsKey(k);
string F(string k) => Request.HasFormContentType ? Request.Form[k].ToString() : "";
string? name = Has("name") ? F("name") : null;
string? desc = Has("description") ? F("description") : null;
string? cat = Has("category") ? F("category") : null;
int? vis = Has("visibility") ? ParseEnum(F("visibility"), new() { ["Private"] = 0, ["Public"] = 1 }, club.Visibility) : (int?)null;
int? join = Has("joinability") ? ParseEnum(F("joinability"), new() { ["Open"] = 0, ["InviteOnly"] = 1, ["AskToJoin"] = 2 }, club.Joinability) : (int?)null;
bool? aj = Has("allowJuniors") ? ParseBool(F("allowJuniors"), club.AllowJuniors) : (bool?)null;
int? ml = (Has("minLevel") && int.TryParse(F("minLevel"), out var mlv)) ? mlv : (int?)null;
List<string>? tags = null;
if (Has("customTags"))
tags = Request.Form["customTags"]
.SelectMany(s => (s ?? "").Split(','))
.Select(t => t.Trim()).Where(t => t.Length > 0)
.Distinct().Take(3).ToList();
var updated = ClubDB.UpdateDetails(clubId, name, desc, cat, vis, join, aj, ml, tags);
Console.WriteLine($"[clubs] {account.Id} modified club {clubId}");
return ClubJson(new { error = "", success = true, value = ClubDetailsObj(updated!, account.Id) });
}
// ---- browse / profile ------------------------------------------------------------
//
// GET /clubs/club/search?sort=0&category=Social&count=6 - the Clubs page's rows, one
// request per category. This 404'd (46 times in the log), which is why nobody could see
// anyone's club: the browse page was empty. Reference: public, non-subscription clubs,
// { Clubs, ContinuationToken, TotalClubs }.
[HttpGet("club/search")]
public ContentResult SearchClubs([FromQuery] string? category, [FromQuery] string? query, [FromQuery] string? sort, [FromQuery] int count = 30)
{
if (count <= 0 || count > 100) count = 30;
var (page, total) = ClubDB.Search(category, query, sort, count);
return ClubJson(new
{
Clubs = page.Select(ClubDtoObj).ToList(),
ContinuationToken = (string?)null,
TotalClubs = total
});
}
// GET /clubs/club/account/{accountId}/created - the clubs card on a PROFILE (404'd). The
// public clubs that account created, as a bare array like club/mine/created; the owner
// looking at their own profile sees private ones too.
[HttpGet("club/account/{accountId:long}/created")]
public ContentResult ClubsCreatedBy(long accountId)
{
var (account, _) = AuthHelper.ValidateToken(HttpContext);
var clubs = (account != null && (long)account.Id == accountId)
? ClubDB.GetByCreator((ulong)accountId)
: ClubDB.PublicByCreator((ulong)accountId);
return ClubJson(clubs.Select(ClubDtoObj).ToList());
}
// GET /clubs/club/{clubId}/members/{membershipType} - one tier of the member list (the
// client asks /members/0 for the pending requests it can approve). Same envelope.
[HttpGet("club/{clubId:int}/members/{membershipType:int}")]
public ContentResult GetClubMembersOfTier(int clubId, int membershipType)
{
var members = ClubDB.GetMembers(clubId, membershipType == 0 ? ClubDB.TierPendingRequest : membershipType, null)
.Select(ClubMemberObj).ToList();
return ClubJson(new { error = "", success = true, value = members });
}
// ---- membership --------------------------------------------------------------------
private ContentResult DetailsEnvelope(int clubId, ulong viewer)
{
var club = ClubDB.GetById(clubId);
if (club == null) return ClubJson(new { error = "No such club.", success = false, value = (object?)null }, 404);
return ClubJson(new { error = "", success = true, value = ClubDetailsObj(club, viewer) });
}
// PUT /clubs/club/{id}/members/requesttojoin - the JOIN button. Open club -> member,
// AskToJoin -> pending, InviteOnly -> refused. Answers the details envelope so the client
// reads its new MyMembershipType.
[HttpPut("club/{clubId:int}/members/requesttojoin")]
[HttpPost("club/{clubId:int}/members/requesttojoin")]
public ContentResult RequestToJoin(int clubId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
if (ClubDB.IsCreatorClub(clubId) || ClubDB.GetById(clubId) == null)
return ClubJson(new { error = "No such club.", success = false, value = (object?)null }, 404);
int tier = ClubDB.RequestJoin(clubId, account.Id);
Console.WriteLine($"[clubs] {account.Id} asked to join club {clubId} -> tier {tier}");
return DetailsEnvelope(clubId, account.Id);
}
// POST /clubs/club/{id}/join and /leave - the bare-club forms of the same two actions.
[HttpPost("club/{clubId:int}/join")]
public ContentResult JoinClub(int clubId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var club = ClubDB.IsCreatorClub(clubId) ? null : ClubDB.GetById(clubId);
if (club == null) return ClubJson(new { error = "No such club.", success = false, value = (object?)null }, 404);
ClubDB.RequestJoin(clubId, account.Id);
return ClubJson(ClubDtoObj(ClubDB.GetById(clubId)!));
}
[HttpPost("club/{clubId:int}/leave")]
public ContentResult LeaveClubBare(int clubId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var club = ClubDB.IsCreatorClub(clubId) ? null : ClubDB.GetById(clubId);
if (club == null) return ClubJson(new { error = "No such club.", success = false, value = (object?)null }, 404);
if (club.CreatorAccountId == account.Id)
return ClubJson(ClubError("You created this club - delete it instead of leaving."), 403);
ClubDB.Leave(clubId, account.Id);
return ClubJson(ClubDtoObj(ClubDB.GetById(clubId)!));
}
// POST/PUT /clubs/club/{id}/members/leave - leave (or withdraw a request); details envelope.
[HttpPost("club/{clubId:int}/members/leave")]
[HttpPut("club/{clubId:int}/members/leave")]
public ContentResult LeaveClub(int clubId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var club = ClubDB.IsCreatorClub(clubId) ? null : ClubDB.GetById(clubId);
if (club == null) return ClubJson(new { error = "No such club.", success = false, value = (object?)null }, 404);
if (club.CreatorAccountId == account.Id)
return ClubJson(ClubError("You created this club - delete it instead of leaving."), 403);
ClubDB.Leave(clubId, account.Id);
Console.WriteLine($"[clubs] {account.Id} left club {clubId}");
return DetailsEnvelope(clubId, account.Id);
}
// PUT /clubs/club/{id}/members/invite - a co-owner adds / promotes / demotes / bans someone
// (form: accountId, membershipType; absent tier = Member). Also how a pending request is
// approved (membershipType=10) or denied (membershipType=0). Never the creator.
[HttpPut("club/{clubId:int}/members/invite")]
[HttpPost("club/{clubId:int}/members/invite")]
[HttpPut("club/{clubId:int}/members/modify")]
[HttpPost("club/{clubId:int}/members/modify")]
public ContentResult InviteMember(int clubId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var club = ClubDB.IsCreatorClub(clubId) ? null : ClubDB.GetById(clubId);
if (club == null) return ClubJson(new { error = "No such club.", success = false, value = (object?)null }, 404);
int me = ClubDB.MyMembership(clubId, account.Id);
if (me < ClubDB.TierModerator) return ClubJson(ClubError("Insufficient permissions."), 403);
string F(string k) => Request.HasFormContentType ? Request.Form[k].ToString() : Request.Query[k].ToString();
if (!ulong.TryParse(F("accountId"), out var target) || target == 0)
return ClubJson(ClubError("Invalid accountId."), 400);
int tier = int.TryParse(F("membershipType"), out var t) ? t : ClubDB.TierMember;
// A moderator may approve/deny/ban members; only a co-owner hands out staff tiers.
if (tier >= ClubDB.TierModerator && me < ClubDB.TierCoOwner)
return ClubJson(ClubError("Insufficient permissions."), 403);
if (target == club.CreatorAccountId)
return ClubJson(ClubError("The club's creator can't be changed."), 400);
if (!ClubDB.SetTier(clubId, target, tier))
return ClubJson(ClubError("Invalid membership type."), 400);
Console.WriteLine($"[clubs] {account.Id} set {target} to tier {tier} in club {clubId}");
return DetailsEnvelope(clubId, account.Id);
}
// DELETE /clubs/club/{id}/members/{accountId} - kick (moderator+, never the creator).
[HttpDelete("club/{clubId:int}/members/{accountId:long}")]
public ContentResult KickMember(int clubId, long accountId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var club = ClubDB.IsCreatorClub(clubId) ? null : ClubDB.GetById(clubId);
if (club == null) return ClubJson(new { error = "No such club.", success = false, value = (object?)null }, 404);
if (ClubDB.MyMembership(clubId, account.Id) < ClubDB.TierModerator) return ClubJson(ClubError("Insufficient permissions."), 403);
if ((ulong)accountId == club.CreatorAccountId) return ClubJson(ClubError("The club's creator can't be removed."), 400);
ClubDB.SetTier(clubId, (ulong)accountId, ClubDB.TierNone);
return DetailsEnvelope(clubId, account.Id);
}
// PUT /clubs/club/{id}/clubhouse - point the club at its clubhouse room (form roomId; 0 clears).
[HttpPut("club/{clubId:int}/clubhouse")]
public ContentResult SetClubhouse(int clubId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
if (ClubDB.MyMembership(clubId, account.Id) < ClubDB.TierCoOwner) return ClubJson(ClubError("Insufficient permissions."), 403);
string raw = Request.HasFormContentType ? Request.Form["roomId"].ToString() : Request.Query["roomId"].ToString();
int? roomId = int.TryParse(raw, out var r) && r > 0 ? r : (int?)null;
var club = ClubDB.SetClubhouse(clubId, roomId);
if (club == null) return ClubJson(new { error = "No such club.", success = false, value = (object?)null }, 404);
return ClubJson(new { error = "", success = true, value = ClubDetailsObj(club, account.Id) });
}
// PUT /clubs/club/{id}/minlevel - the club's minimum level (form minLevel).
[HttpPut("club/{clubId:int}/minlevel")]
public ContentResult SetMinLevel(int clubId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
if (ClubDB.MyMembership(clubId, account.Id) < ClubDB.TierCoOwner) return ClubJson(ClubError("Insufficient permissions."), 403);
string raw = Request.HasFormContentType ? Request.Form["minLevel"].ToString() : Request.Query["minLevel"].ToString();
int ml = int.TryParse(raw, out var v) ? Math.Max(0, v) : 0;
var updated = ClubDB.UpdateDetails(clubId, null, null, null, null, null, null, ml, null);
if (updated == null) return ClubJson(new { error = "No such club.", success = false, value = (object?)null }, 404);
return ClubJson(new { error = "", success = true, value = ClubDetailsObj(updated, account.Id) });
}
// DELETE /clubs/club/{id} - the creator deletes the club (memberships and announcements go with it).
[HttpDelete("club/{clubId:int}")]
public ContentResult DeleteClub(int clubId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var club = ClubDB.IsCreatorClub(clubId) ? null : ClubDB.GetById(clubId);
if (club == null) return ClubJson(new { error = "No such club.", success = false, value = (object?)null }, 404);
if (club.CreatorAccountId != account.Id) return ClubJson(ClubError("Only the club's creator can delete it."), 403);
ClubDB.Delete(clubId);
Console.WriteLine($"[clubs] {account.Id} deleted club {clubId} ('{club.Name}')");
return ClubJson(new { error = "", success = true, value = (object?)null });
}
// GET /clubs/club/{clubId}/hasDisabledClubChat - nothing disables club chat yet.
[HttpGet("club/{clubId:int}/hasDisabledClubChat")]
public ContentResult ClubChatDisabled(int clubId) => ClubJson(false);
// viewer / tier: who is looking, so their own posts (and a co-owner's board) carry the
// edit / delete flags the client shows the buttons from.
private static object AnnouncementObj(ClubDB.ClubAnnouncement a, ulong viewer = 0, int viewerTier = 0)
{
bool mine = viewer != 0 && a.AccountId == viewer;
bool canEdit = mine || viewerTier >= ClubDB.TierCoOwner;
return new
{
a.AnnouncementId,
a.ClubId,
a.AccountId,
CreatorAccountId = a.AccountId,
PlayerId = a.AccountId,
Title = a.Title ?? "",
Body = a.Body ?? "",
ImageName = a.ImageName ?? "",
Meta = a.Meta ?? "",
CreatedAt = a.CreatedAt.ToUniversalTime().ToString("o"),
ModifiedAt = (a.ModifiedAt ?? a.CreatedAt).ToUniversalTime().ToString("o"),
IsMine = mine,
CanEdit = canEdit,
CanDelete = canEdit,
IsEditable = canEdit,
};
}
// GET /clubs/announcements/club/{clubId} - the noticeboard, newest first, in the
// reference envelope. Reading it marks everything read for the viewer (that is what
// clears the unread badge the two /v2/.../unread lists drive). Works for creator
// (subscription) clubs too - a creator's posts reach every subscriber this way.
[HttpGet("announcements/club/{clubId:int}")]
public ContentResult ClubAnnouncements(int clubId)
{
var (account, _) = AuthHelper.ValidateToken(HttpContext);
ulong viewer = account?.Id ?? 0;
var list = ClubDB.GetAnnouncements(clubId);
int lastRead = ClubDB.LastReadAnnouncementId(clubId, viewer);
if (list.Count > 0 && viewer != 0) ClubDB.MarkAnnouncementsRead(clubId, viewer, list[0].AnnouncementId);
return ClubJson(new
{
error = "",
success = true,
value = new
{
Announcements = list.Select(a => AnnouncementObj(a, viewer, ClubDB.MyMembership(clubId, viewer))).ToList(),
ClubId = clubId,
LastAnnouncementId = list.Count > 0 ? list[0].AnnouncementId : (int?)null,
LastReadAnnouncementId = lastRead,
}
});
}
// POST /clubs/announcements/club/{clubId} - post to the noticeboard. Form fields title /
// body / imageName / meta (case-insensitive). Co-owner or above; for a creator club that
// is the creator. The envelope's value is the new announcement's id (reference).
[HttpPost("announcements/club/{clubId:int}")]
public ContentResult PostClubAnnouncement(int clubId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var club = ClubDB.GetById(clubId);
if (club == null) return ClubJson(ClubError("No such club."), 404);
if (ClubDB.MyMembership(clubId, account.Id) < ClubDB.TierCoOwner)
return ClubJson(ClubError("Insufficient permissions."), 403);
string? Field(string name)
{
if (!Request.HasFormContentType) return null;
foreach (var k in Request.Form.Keys)
if (string.Equals(k, name, StringComparison.OrdinalIgnoreCase)) return Request.Form[k].ToString();
return null;
}
var a = ClubDB.PostAnnouncement(clubId, account.Id, Field("title"), Field("body"), Field("imageName"), Field("meta"));
Console.WriteLine($"[clubs] {account.Id} posted announcement {a.AnnouncementId} to club {clubId} ('{a.Title}')");
// Tell the members it's there: a creator club's subscribers, a regular club's members.
try
{
var members = ClubDB.GetMembers(clubId, null, null).Select(m => m.AccountId).Where(id => id != account.Id).Distinct().ToList();
foreach (var m in members)
Notifications.FireAndForget(Notifications.SendToPlayer(m, JsonConvert.SerializeObject(new WebsocketEvents.Reponse
{
Id = "ClubAnnouncementReceived",
Msg = new { ClubId = clubId, AnnouncementId = a.AnnouncementId, ClubName = club.Name, Title = a.Title }
})), "club-announcement");
}
catch (Exception ex) { Console.WriteLine($"[clubs] announcement push failed: {ex.Message}"); }
return ClubJson(new { error = "", success = true, value = a.AnnouncementId });
}
// GET /clubs/announcements/club/{clubId}/{announcementId} - one announcement (the client
// opens it on click). Was 404.
[HttpGet("announcements/club/{clubId:int}/{announcementId:int}")]
public ContentResult GetClubAnnouncement(int clubId, int announcementId)
{
var (account, _) = AuthHelper.ValidateToken(HttpContext);
var a = ClubDB.GetAnnouncements(clubId).FirstOrDefault(x => x.AnnouncementId == announcementId);
if (a == null) return ClubJson(ClubError("No such announcement."), 404);
return ClubJson(new { error = "", success = true, value = AnnouncementObj(a, account?.Id ?? 0, ClubDB.MyMembership(clubId, account?.Id ?? 0)) });
}
// PUT/POST /clubs/announcements/club/{clubId}/{announcementId} - edit (the poster, or a
// co-owner+ / the creator). Form fields title / body / imageName / meta; only the ones
// sent change. Was 404, so the edit button had nothing to save to.
[HttpPut("announcements/club/{clubId:int}/{announcementId:int}")]
[HttpPost("announcements/club/{clubId:int}/{announcementId:int}")]
[HttpPost("announcements/club/{clubId:int}/{announcementId:int}/edit")]
public ContentResult EditClubAnnouncement(int clubId, int announcementId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var existing = ClubDB.GetAnnouncements(clubId).FirstOrDefault(a => a.AnnouncementId == announcementId);
if (existing == null) return ClubJson(ClubError("No such announcement."), 404);
if (existing.AccountId != account.Id && ClubDB.MyMembership(clubId, account.Id) < ClubDB.TierCoOwner && !PlayerDB.CheckDevFlag(account.Id))
return ClubJson(ClubError("Insufficient permissions."), 403);
var f = Campus_live_rewrite.api.server.RoomModeration.Fields(Request);
string? title = Campus_live_rewrite.api.server.RoomModeration.Str(f, "title", "Title");
string? body = Campus_live_rewrite.api.server.RoomModeration.Str(f, "body", "Body", "text", "Text");
string? image = Campus_live_rewrite.api.server.RoomModeration.Str(f, "imageName", "ImageName");
string? meta = Campus_live_rewrite.api.server.RoomModeration.Str(f, "meta", "Meta");
var updated = ClubDB.EditAnnouncement(announcementId, title, body, image, meta);
if (updated == null) return ClubJson(ClubError("No such announcement."), 404);
Console.WriteLine($"[clubs] {account.Id} edited announcement {announcementId} in club {clubId}");
return ClubJson(new { error = "", success = true, value = AnnouncementObj(updated, account.Id, ClubDB.MyMembership(clubId, account.Id)) });
}
// DELETE /clubs/announcements/club/{clubId}/{announcementId} - co-owner+ (or the poster).
// POST /clubs/announcements/club/{clubId}/{announcementId}/read - the reader saw it (the
// unread badge). Idempotent; answers the envelope.
[HttpPost("announcements/club/{clubId:int}/{announcementId:int}/read")]
[HttpPut("announcements/club/{clubId:int}/{announcementId:int}/read")]
public ContentResult MarkAnnouncementRead(int clubId, int announcementId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
ClubDB.MarkAnnouncementsRead(clubId, account.Id, announcementId);
return ClubJson(new { error = "", success = true, value = (object?)null });
}
[HttpDelete("announcements/club/{clubId:int}/{announcementId:int}")]
public ContentResult DeleteClubAnnouncement(int clubId, int announcementId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var existing = ClubDB.GetAnnouncements(clubId).FirstOrDefault(a => a.AnnouncementId == announcementId);
if (existing == null) return ClubJson(ClubError("No such announcement."), 404);
if (existing.AccountId != account.Id && ClubDB.MyMembership(clubId, account.Id) < ClubDB.TierCoOwner)
return ClubJson(ClubError("Insufficient permissions."), 403);
ClubDB.DeleteAnnouncement(announcementId);
return ClubJson(new { error = "", success = true, value = announcementId });
}
// Read the form field "imageName" (case-insensitive), or "".
private string FormImageName()
{
if (!Request.HasFormContentType) return "";
foreach (var k in Request.Form.Keys)
if (string.Equals(k, "imageName", StringComparison.OrdinalIgnoreCase))
return Request.Form[k].ToString().Trim();
return "";
}
// GET /clubs/club/{clubId}/mainimage - the reference 404s here but the client asks for
// it, so answer the details envelope (MainImageName rides on value.Club).
[HttpGet("club/{clubId:int}/mainimage")]
public ContentResult GetClubMainImage(int clubId)
{
var club = ClubDB.GetById(clubId);
if (club == null) return ClubJson(new { error = "No such club.", success = false, value = (object?)null }, 404);
var (account, _) = AuthHelper.ValidateToken(HttpContext);
return ClubJson(new { error = "", success = true, value = ClubDetailsObj(club, account?.Id ?? 0) });
}
// PUT /clubs/club/{clubId}/mainimage - set the club's main image from an uploaded
// image name (the "Take a club photo" step). Co-owner or above. Answers the details
// envelope. Was missing -> the create flow showed "Failed to Modify Club Image".
[HttpPut("club/{clubId:int}/mainimage")]
public ContentResult SetClubMainImage(int clubId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var club = ClubDB.GetById(clubId);
if (club == null) return ClubJson(new { error = "No such club.", success = false, value = (object?)null }, 404);
if (ClubDB.MyMembership(clubId, account.Id) < ClubDB.TierCoOwner)
return ClubJson(ClubError("Insufficient permissions."), 403);
string name = FormImageName();
if (string.IsNullOrEmpty(name)) return ClubJson(ClubError("imageName is required."), 400);
var updated = ClubDB.SetMainImage(clubId, name);
return ClubJson(new { error = "", success = true, value = ClubDetailsObj(updated!, account.Id) });
}
// PUT/DELETE /clubs/club/{clubId}/additionalimage/{index} - one gallery image by 0-based
// position (PUT sets/appends; DELETE removes and shifts up). Co-owner or above.
[HttpPut("club/{clubId:int}/additionalimage/{index:int}")]
public ContentResult SetClubGalleryImage(int clubId, int index)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var club = ClubDB.GetById(clubId);
if (club == null) return ClubJson(new { error = "No such club.", success = false, value = (object?)null }, 404);
if (ClubDB.MyMembership(clubId, account.Id) < ClubDB.TierCoOwner)
return ClubJson(ClubError("Insufficient permissions."), 403);
string name = FormImageName();
if (string.IsNullOrEmpty(name)) return ClubJson(ClubError("imageName is required."), 400);
var updated = ClubDB.SetAdditionalImage(clubId, index, name);
return ClubJson(new { error = "", success = true, value = ClubDetailsObj(updated!, account.Id) });
}
[HttpDelete("club/{clubId:int}/additionalimage/{index:int}")]
public ContentResult DeleteClubGalleryImage(int clubId, int index)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var club = ClubDB.GetById(clubId);
if (club == null) return ClubJson(new { error = "No such club.", success = false, value = (object?)null }, 404);
if (ClubDB.MyMembership(clubId, account.Id) < ClubDB.TierCoOwner)
return ClubJson(ClubError("Insufficient permissions."), 403);
var updated = ClubDB.RemoveAdditionalImage(clubId, index);
return ClubJson(new { error = "", success = true, value = ClubDetailsObj(updated!, account.Id) });
}
[HttpGet("club/mine/created")]
public ContentResult MyClubs()
{
// Signed-out -> [] (never error), as the reference does. Otherwise the caller's
// created clubs as a bare array of club DTOs.
var (account, error) = AuthHelper.ValidateToken(HttpContext);
var clubs = (error != null || account == null)
? new List<ClubDB.Club>()
: ClubDB.GetByCreator(account.Id);
return ClubJson(clubs.Select(ClubDtoObj).ToList());
}
// GetMyMembershipClubs (the client calls /clubs/club/mine/member). This MUST return
// [] with 200 - NEVER 404. The People/social panel loads this right after the
// relationship list, and the RecFlare note is explicit: "an error there breaks the
// screen." A 404 here threw inside the client's social load and blanked the WHOLE
// panel - friends list, player cards and profiles (own + others). Confirmed from the
// client's Player.log: HTTP 404 thrown inside the relationship-list callback, and this
// endpoint 404'd on every refresh right after /api/relationships/v2/get. No club system
// yet, so the list is empty. Deliberately NOT auth-gated (return [] even signed out, as
// the reference does) so it can never 401/404 the screen.
[HttpGet("club/mine/member")]
public ContentResult MyMemberClubs()
{
// Signed-out -> [] (never error/404 - this loads with the social panel). Otherwise
// the clubs the caller belongs to, as a bare array of club DTOs.
var (account, error) = AuthHelper.ValidateToken(HttpContext);
var clubs = (error != null || account == null)
? new List<ClubDB.Club>()
: ClubDB.GetByMember(account.Id);
return ClubJson(clubs.Select(ClubDtoObj).ToList());
}
// The client calls /clubs/subscription/mine/member (no auth). Empty array = no club
// subscription memberships; the client chokes on null and errors on a 404 - same
// screen-breaking class as club/mine/member above.
// The 2025 client's "who am I subscribed to" call (it no longer uses my/subscriptions).
// Answered [] until now, so the client believed it was subscribed to nobody - the button
// never showed "Subscribed" even after a successful subscribe. The shape is the creator
// auto-club object captured from live RecNet traffic (see the comment at the bottom of
// this file): one club per creator the caller subscribes to, clubType 1, creatorAccountId
// = the creator. Club ids are the same synthetic ones subscription/details hands out.
[HttpGet("subscription/mine/member")]
public ContentResult MySubscriptionMemberships()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var clubs = new List<object>();
foreach (var creator in PlayerDB.GetSubscribedToIds(account.Id).Distinct())
{
var acc = PlayerDB.GetAccountById(creator);
if (acc == null) continue;
clubs.Add(new
{
clubId = CreatorClubId(creator),
name = acc.Username ?? creator.ToString(),
category = "CreatorAutoTag",
mainImageName = "DefaultClubImage2k.jpg",
description = (string?)null,
creatorAccountId = creator,
state = 0,
memberCount = CountSubscribers(creator),
isRRO = false,
allowJuniors = true,
minLevel = 0,
visibility = 1,
joinability = 0,
clubhouseRoomId = (long?)null,
clubType = 1,
createdAt = (PlayerDB.GetFullAccount(creator)?.player?.created_at ?? DateTime.UtcNow).ToString("o"),
clubChatEnabled = false
});
}
return new ContentResult
{
Content = JsonConvert.SerializeObject(clubs),
ContentType = "application/json",
StatusCode = 200
};
}
// The caller's regular clubs whose noticeboard has posts they haven't read.
[HttpGet("announcements/v2/mine/unread")]
public ContentResult UnreadAnnoucements()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var clubs = ClubDB.GetByMember(account.Id)
.Where(c => ClubDB.HasUnreadAnnouncements(c.ClubId, account.Id))
.Select(ClubDtoObj).ToList();
return ClubJson(clubs);
}
// The creators the caller subscribes to whose noticeboard has unread posts - the same
// club shape /subscription/mine/member hands out.
[HttpGet("announcements/v2/subscription/mine/unread")]
public ContentResult UnreadAnnoucements2()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var clubs = new List<object>();
foreach (var creator in PlayerDB.GetSubscribedToIds(account.Id).Distinct())
{
int clubId = (int)CreatorClubId(creator);
if (!ClubDB.HasUnreadAnnouncements(clubId, account.Id)) continue;
var club = ClubDB.CreatorClub(creator);
if (club != null) clubs.Add(ClubDtoObj(club));
}
return ClubJson(clubs);
}
// The profile panel asks for /api/subscriptions/v1/subscriberCount/{id}, which
// had no route at all and 404'd. Same number the clubs route below serves, just
// at the path the client actually calls (absolute route - not under /clubs).
[HttpGet("/api/subscriptions/v1/subscriberCount/{PlayerId}")]
public ContentResult SubscriberCountApi(ulong PlayerId) => SubscriberCount(PlayerId);
// Every account has a creator club on the real RecNet (research/RecNet API.postman:
// /subscription/details/:id -> {clubId: <nonzero>, accountId, subscriberCount}). The
// client enables its Subscribe button off that clubId; answering 0 (as this did, and as
// the reference still does) reads as "this player has no club" and the button never
// fires. The club id is synthetic and stable - derived from the account id - since no
// club record is needed for anything but this handshake.
public static long CreatorClubId(ulong accountId) => 5_000_000L + (long)accountId;
// The real count, from the subscription lists themselves rather than the cached
// sub_count (which drifted whenever a subscribe raced an unsubscribe).
private static int CountSubscribers(ulong accountId) => PlayerDB.GetSubscriberIds(accountId).Count;
[HttpGet("subscription/subscriberCount/{PlayerId}")]
public ContentResult SubscriberCount(ulong PlayerId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return new ContentResult()
{
Content = CountSubscribers(PlayerId).ToString(),
ContentType = "application/json",
StatusCode = 200
};
}
// Postman: GET /subscription/my/subscribers -> bare array of account ids.
[HttpGet("subscription/my/subscribers")]
public ContentResult MySubscribers()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return new ContentResult()
{
Content = JsonConvert.SerializeObject(PlayerDB.GetSubscriberIds(account.Id)),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPost("subscription/{PlayerId}")]
public async Task<ContentResult> SubscribeToPlayer(ulong PlayerId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
await Task.Run(() => PlayerDB.AddSubscription(account.Id, PlayerId));
Console.WriteLine($"[subscription] {account.Id} subscribed to {PlayerId}");
// Website tabs of both parties refresh (subscriber count / Subscribed state).
SiteNotifications.Notify(account.Id);
SiteNotifications.Notify(PlayerId);
try
{
var selfUpdate = JsonConvert.SerializeObject(new WebsocketEvents.Reponse
{
Id = "CreatorClubSubscriptionUpdate",
Msg = new { creatorAccountId = PlayerId, clubId = CreatorClubId(PlayerId), membershipType = 10 }
});
var notifySelfTask = Notifications.SendToPlayer(account.Id, selfUpdate);
await Task.WhenAll(notifySelfTask);
}
catch (Exception ex)
{
Console.WriteLine($"Failed to send notification: {ex.Message}");
}
return new ContentResult()
{
// Postman: {value: <clubId>, success, errorId, error}
Content = JsonConvert.SerializeObject(new
{
value = CreatorClubId(PlayerId),
success = true,
errorId = (string?)null,
error = (string?)null
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpDelete("subscription/{PlayerId}")]
public async Task<ContentResult> UnsubscribeToPlayer(ulong PlayerId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
await Task.Run(() => PlayerDB.RemoveSubscription(account.Id, PlayerId));
Console.WriteLine($"[subscription] {account.Id} unsubscribed from {PlayerId}");
SiteNotifications.Notify(account.Id);
SiteNotifications.Notify(PlayerId);
try
{
var selfUpdate = JsonConvert.SerializeObject(new WebsocketEvents.Reponse
{
Id = "CreatorClubSubscriptionUpdate",
Msg = new { creatorAccountId = PlayerId, clubId = CreatorClubId(PlayerId), membershipType = 0 }
});
var notifySelfTask = Notifications.SendToPlayer(account.Id, selfUpdate);
await Task.WhenAll(notifySelfTask);
}
catch (Exception ex)
{
Console.WriteLine($"Failed to send notification: {ex.Message}");
}
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
success = true,
errorId = (string?)null,
error = (string?)null
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("subscription/my/subscriptions")]
public ContentResult MySubscriptions()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var subs = PlayerDB.GetSubscribedToIds(account.Id);
return new ContentResult()
{
Content = JsonConvert.SerializeObject(subs),
ContentType = "application/json",
StatusCode = 200
};
}
// The profile panel fetches /clubs/subscription/details/{accountId} - for the caller's
// OWN profile too. It was only documented as the comment below and 404'd, which broke
// the OWN profile specifically (other players' profiles rendered, your own didn't).
// RecFlare returns a zeroed object; there's no subscription-club system here, so clubId
// 0. Deliberately NOT auth-gated - a 404/401 here blanks the panel.
// https://clubs.rec.net/subscription/details/1503269750 returns { clubId, accountId, subscriberCount }
[HttpGet("subscription/details/{accountId}")]
public ContentResult SubscriptionDetails(ulong accountId) => new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
clubId = CreatorClubId(accountId),
accountId = accountId,
subscriberCount = CountSubscribers(accountId)
}),
ContentType = "application/json",
StatusCode = 200
};
// https://clubs.rec.net/club/2065378865047881941 return
/*
{
"clubId": 2065378865047881941,
"name": "33c7nhjv7txs1dsitn4v92pb5",
"category": "CreatorAutoTag",
"mainImageName": "DefaultClubImage2k.jpg",
"description": null,
"creatorAccountId": 1503269750,
"state": 0,
"memberCount": 0,
"isRRO": false,
"allowJuniors": true,
"minLevel": 0,
"visibility": 1,
"joinability": 0,
"clubhouseRoomId": null,
"clubType": 1,
"createdAt": "2025-08-03T13:18:44.3295131Z",
"clubChatEnabled": false
}
*/
}
}
@@ -0,0 +1,66 @@
using Microsoft.AspNetCore.Mvc;
using Campus_live_rewrite.api.server;
namespace Campus_live_rewrite.Controllers
{
[Route("Commerce")]
public class CommerceController : Controller
{
[HttpGet("purchasecampaign/allcurrent/v2")]
public IActionResult GetAllCurrentPurchaseCampaignsV2()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(ConfigCampus.Bracket);
}
[HttpPost("purchase/v1/cleanuppending")]
public IActionResult CleanUpPendingPurchases()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(ConfigCampus.Bracket);
}
[HttpGet("api/catalog/v1/all")]
public async Task<IActionResult> GetAllCatalogItemsV1(bool onlyAvailableSkus = true)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string path = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "AllCatalog.json");
string content = await System.IO.File.ReadAllTextAsync(path);
// Was: Ok(<string>). ASP.NET's StringOutputFormatter writes a raw string as
// Content-Type: text/plain, so these JSON payloads were served mislabelled
// and the client would not parse them. Verified live: /outfits/me returned
// text/plain while /api/avatar/v2 returned application/json. Same bytes,
// correct content type.
return new ContentResult { Content = content, ContentType = "application/json", StatusCode = 200 };
}
[HttpGet("reminder/currentTokenBundles/v2")]
public IActionResult GetCurrentTokenBundlesV2()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(ConfigCampus.Bracket);
}
[HttpGet("purchaseRestriction/isplayerrestricted")]
public IActionResult IsPlayerRestricted()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Content("false", "application/json");
}
}
}
@@ -0,0 +1,184 @@
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Campus_live_rewrite.api.server;
namespace Campus_live_rewrite.Controllers
{
/// <summary>
/// In-game custom clothing creation (the costume maker) - reference: api/routes/avatar.ts.
///
/// POST /api/customAvatarItems/v1 multipart: metadata (JSON) + thumbnailImage + design (PNG)
/// PUT /api/customAvatarItems/v1/{id} JSON: Name / Description / Price / Accessibility (null = keep)
/// DELETE /api/customAvatarItems/v1/{id}
///
/// All three answer the item in the client's { Value, Success, Error, error_id } envelope. The
/// two PNGs land in Data/Images/custom_shirt_imgs (served by /img/&lt;name&gt;), the record is
/// indexed by CustomItemsCache under its creator and persisted to CreatedUGC.json - so it shows
/// in the creator's wardrobe (/econ/customAvatarItems/v1/owned), profile (fromCreator) and
/// renders through the base item + design texture like every other design-based UGC shirt.
/// The metadata's key case is whatever the client sends ("name" lower-case, the rest Pascal).
/// </summary>
[ApiController]
public class CustomAvatarItemCreationController : ControllerBase
{
private static readonly string ImgDir = Path.Combine(Environment.CurrentDirectory, "Data", "Images", "custom_shirt_imgs");
private static ContentResult Envelope(object? value, bool success, string? error, int status = 200) => new()
{
Content = JsonConvert.SerializeObject(new { Value = value, Success = success, Error = error, error_id = (string?)null }),
ContentType = "application/json",
StatusCode = status
};
private static JToken? Get(JObject o, string name) =>
o.Properties().FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase))?.Value;
/// <summary>OutfitType of the base item, learned from any catalog item built on the same base.</summary>
private static int OutfitTypeForBase(int baseAvatarItemId)
{
foreach (var it in CustomItemsCache.AllRecs)
if (it.BaseAvatarItemId == baseAvatarItemId && it.OutfitType >= 0)
return it.OutfitType;
return 0;
}
[HttpPost("/api/customAvatarItems/v1")]
[RequestSizeLimit(32 * 1024 * 1024)]
public async Task<ContentResult> Create()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
if (!Request.HasFormContentType)
return Envelope(null, false, "multipart form expected", 400);
var form = await Request.ReadFormAsync();
Console.WriteLine($"[ugc-create] {account.Id} fields=[{string.Join(",", form.Keys)}] files=[{string.Join(",", form.Files.Select(f => $"{f.Name}:{f.FileName}:{f.Length}"))}]");
string metaRaw = form["metadata"].ToString();
if (string.IsNullOrWhiteSpace(metaRaw)) return Envelope(null, false, "metadata is required", 400);
JObject meta;
try { meta = JObject.Parse(metaRaw); }
catch { return Envelope(null, false, "metadata is not valid JSON", 400); }
string name = ((string?)Get(meta, "Name") ?? "").Trim();
if (name.Length == 0) return Envelope(null, false, "Name is required", 400);
int baseId = (int?)Get(meta, "BaseAvatarItemId") ?? 0;
if (baseId == 0) return Envelope(null, false, "BaseAvatarItemId is required", 400);
string color = (string?)Get(meta, "BaseAvatarItemColor") ?? "#F55C1A";
// The two uploads: named parts first, else by position (thumb, design).
var thumb = form.Files.GetFile("thumbnailImage") ?? form.Files.GetFile("thumbnail") ?? (form.Files.Count > 0 ? form.Files[0] : null);
var design = form.Files.GetFile("design") ?? form.Files.GetFile("designImage") ?? (form.Files.Count > 1 ? form.Files[1] : null);
if (design == null && thumb == null) return Envelope(null, false, "design and thumbnailImage are required", 400);
design ??= thumb; thumb ??= design;
// Both images must be the game's own encodes (see UploadGuard) before anything is
// written.
byte[] thumbBytes, designBytes;
using (var ms = new MemoryStream()) { await thumb!.CopyToAsync(ms); thumbBytes = ms.ToArray(); }
using (var ms = new MemoryStream()) { await design!.CopyToAsync(ms); designBytes = ms.ToArray(); }
var refused = UploadGuard.Gate(HttpContext, account, UploadGuard.Kind.Image, thumbBytes, "customAvatarItems/v1:thumbnail")
?? (ReferenceEquals(thumb, design) ? null : UploadGuard.Gate(HttpContext, account, UploadGuard.Kind.Image, designBytes, "customAvatarItems/v1:design"));
if (refused != null) return refused;
string id = Guid.NewGuid().ToString();
string thumbName = $"{id}-thumb.png", designName = $"{id}-design.png";
try
{
Directory.CreateDirectory(ImgDir);
await System.IO.File.WriteAllBytesAsync(Path.Combine(ImgDir, thumbName), thumbBytes);
await System.IO.File.WriteAllBytesAsync(Path.Combine(ImgDir, designName), designBytes);
}
catch (Exception ex)
{
Console.WriteLine($"[ugc-create] {account.Id}: saving images failed - {ex.Message}");
return Envelope(null, false, "could not store the images", 500);
}
string now = DateTime.UtcNow.ToString("o");
var item = new JObject
{
["Accessibility"] = (int?)Get(meta, "Accessibility") ?? 0,
["BaseAvatarItemColor"] = color,
["BaseAvatarItemId"] = baseId,
["CreatedAt"] = now,
["CreatorAccountId"] = account.Id,
["CurrentSaves"] = new JArray(),
["CustomAvatarItemId"] = id,
["CustomBadgeMetadata"] = null,
["Description"] = (string?)Get(meta, "Description") ?? "",
["DesignFilename"] = designName,
["ForceCannotPublish"] = false,
["IsFeatured"] = false,
["IsRecRoomApproved"] = false,
["ModifiedAt"] = now,
["Name"] = name,
["OutfitType"] = (int?)Get(meta, "OutfitType") ?? OutfitTypeForBase(baseId),
["PreviewOrientation"] = (int?)Get(meta, "PreviewOrientation") ?? 0,
["Price"] = (int?)Get(meta, "Price") ?? 0,
["RankedEntityId"] = id,
["RankingContext"] = null,
["Tags"] = new JArray(),
["ThumbnailImageFilename"] = thumbName,
["PurchaseInfo"] = null
};
CustomItemsCache.AddCreated(item);
Console.WriteLine($"[ugc-create] {account.Id} created '{name}' ({id}) on base {baseId} {color}, accessibility {(int?)item["Accessibility"]}, price {(int?)item["Price"]}");
return Envelope(item, true, null);
}
[HttpPut("/api/customAvatarItems/v1/{id}")]
public async Task<ContentResult> Edit(string id)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var item = CustomItemsCache.Get(id);
if (item == null) return Envelope(null, false, "No such item", 404);
if ((ulong?)item["CreatorAccountId"] != account.Id) return Envelope(null, false, "Not your item", 403);
JObject body;
try
{
using var sr = new StreamReader(Request.Body);
string raw = await sr.ReadToEndAsync();
body = string.IsNullOrWhiteSpace(raw) ? new JObject() : JObject.Parse(raw);
// Some client builds PUT the edit as a form instead of JSON.
if (body.Count == 0 && Request.HasFormContentType)
foreach (var kv in Request.Form) body[kv.Key] = kv.Value.ToString();
}
catch { return Envelope(null, false, "A JSON body is required", 400); }
void Set(string field, Func<JToken, JToken?> conv)
{
var v = Get(body, field);
if (v == null || v.Type == JTokenType.Null) return;
var c = conv(v); if (c != null) item[field] = c;
}
Set("Name", v => { var s = ((string?)v)?.Trim(); return string.IsNullOrEmpty(s) ? null : s; });
Set("Description", v => (string?)v ?? "");
Set("Price", v => int.TryParse(v.ToString(), out var n) ? n : null);
Set("Accessibility", v => int.TryParse(v.ToString(), out var n) ? n : null);
item["ModifiedAt"] = DateTime.UtcNow.ToString("o");
CustomItemsCache.CreatedChanged(item); // Get() hands out a copy; the edit has to be stored back
Console.WriteLine($"[ugc-create] {account.Id} edited '{(string?)item["Name"]}' ({id}): accessibility {(int?)item["Accessibility"]}, price {(int?)item["Price"]}");
return Envelope(item, true, null);
}
[HttpDelete("/api/customAvatarItems/v1/{id}")]
public ContentResult Delete(string id)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var item = CustomItemsCache.Get(id);
if (item == null) return Envelope(null, false, "No such item", 404);
if ((ulong?)item["CreatorAccountId"] != account.Id) return Envelope(null, false, "Not your item", 403);
CustomItemsCache.Remove(id);
foreach (var f in new[] { (string?)item["ThumbnailImageFilename"], (string?)item["DesignFilename"] })
if (!string.IsNullOrEmpty(f)) { try { System.IO.File.Delete(Path.Combine(ImgDir, f!)); } catch { } }
Console.WriteLine($"[ugc-create] {account.Id} deleted '{(string?)item["Name"]}' ({id})");
return Envelope(item, true, null);
}
}
}
@@ -0,0 +1,31 @@
using System.Text;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Campus_live_rewrite.api.server;
namespace Campus_live_rewrite.Controllers
{
[Route("/data/")]
public class DataController : ControllerBase
{
[HttpPost("event")]
public IActionResult CollectEvent()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok();
}
[HttpPost("heartbeat")]
public IActionResult CollectHeartbeat()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok();
}
}
}
@@ -0,0 +1,167 @@
using Microsoft.AspNetCore.Mvc;
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server;
using System.Net.Http.Headers;
using System.Text.Json;
namespace Campus_live_rewrite.Controllers
{
[ApiController]
[Route("api/auth")]
public class DiscordController : Controller
{
// ClientId + RedirectUri are shared with the website "Link Discord" flow (SiteController
// builds the same authorize URL and reuses this SAME callback, branching on state="link:").
// ClientSecret stays private - only the token exchange here needs it.
internal const string ClientId = "1537203658725265488";
private const string ClientSecret = "6JOoRs9-b-B8m8y5t9TnhURs6dOO-P-2";
internal const string RedirectUri = "https://gameauth.ddns.net/api/auth/discord/callback";
[HttpGet("discord")]
public IActionResult DiscordLogin()
{
var url =
"https://discord.com/api/oauth2/authorize" +
$"?client_id={ClientId}" +
$"&redirect_uri={Uri.EscapeDataString(RedirectUri)}" +
"&response_type=code" +
"&scope=identify email";
return Redirect(url);
}
[HttpGet("discord/callback")]
public async Task<IActionResult> DiscordCallback([FromQuery] string code, [FromQuery] string? state = null)
{
if (string.IsNullOrEmpty(code))
return BadRequest("Missing OAuth code.");
using var http = new HttpClient();
// Exchange code for access token
var tokenResponse = await http.PostAsync(
"https://discord.com/api/oauth2/token",
new FormUrlEncodedContent(new Dictionary<string, string>
{
["client_id"] = ClientId,
["client_secret"] = ClientSecret,
["grant_type"] = "authorization_code",
["code"] = code,
["redirect_uri"] = RedirectUri
})
);
if (!tokenResponse.IsSuccessStatusCode)
return Unauthorized("OAuth token exchange failed.");
var tokenJson = await tokenResponse.Content.ReadAsStringAsync();
var token = JsonDocument.Parse(tokenJson)
.RootElement.GetProperty("access_token").GetString();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var userJson = await http.GetStringAsync("https://discord.com/api/users/@me");
var user = JsonDocument.Parse(userJson).RootElement;
string discordId = user.GetProperty("id").GetString();
string username = user.GetProperty("username").GetString();
// Email may be null
string? email = null;
if (user.TryGetProperty("email", out var emailProp))
email = emailProp.GetString();
Console.WriteLine($"Discord Login: {username} ({discordId}) | Email: {email ?? "N/A"}");
// ---- Discord LINK flow (website Settings > Discord "Link Discord") --------------
// state "link:<code>" means the user started this from Settings while signed in on the
// website. This is COMPLETELY separate from the admin login below: resolve the code to
// their Campus account, attach the Discord id, and send them back to /settings. It
// NEVER issues an admin cookie, NEVER redirects to /admin, and NEVER auto-creates an
// account - so linking Discord can't accidentally log anyone into the admin panel.
if (!string.IsNullOrEmpty(state) && state.StartsWith("link:", StringComparison.Ordinal))
{
string linkCode = state.Substring("link:".Length);
if (!DiscordLinkCodes.TryResolve(linkCode, out ulong linkAccId))
return Redirect("/settings?discord=expired");
if (!ulong.TryParse(discordId, out ulong linkDid))
return Redirect("/settings?discord=error");
// One Discord may own only one Campus account.
ulong otherAcc = PlayerDB.FindPlayerByDiscordId(linkDid, linkAccId);
if (otherAcc != 0)
return Redirect("/settings?discord=inuse");
PlayerDB.SetDiscordLink(linkAccId, linkDid, username);
DiscordLinkCodes.Consume(linkCode);
Console.WriteLine($"[DiscordLink] account {linkAccId} linked to discord {linkDid} ({username}) via OAuth");
return Redirect("/settings?discord=linked");
}
// 🔐 Lookup account (auto-create if this Discord id has never been seen)
ulong discordIdUlong = ulong.Parse(discordId);
var accountIds = PlayerDB.GetAccountIdsByDiscordId(discordIdUlong);
ulong resolvedAccId;
if (accountIds == null || !accountIds.Any())
{
Console.WriteLine($"[DiscordAuth] No account linked to discord_id={discordIdUlong} ({username}) — auto-creating.");
if (!PlayerDB.CreateAccount(Campus_live_rewrite.api.server.Classes.db_class.Platforms.RecNet, 0UL, "", discordIdUlong, out var newAcc))
{
Console.WriteLine($"[DiscordAuth] CreateAccount FAILED for discord_id={discordIdUlong}");
return StatusCode(500, "Failed to create Discord-linked account.");
}
Console.WriteLine($"[DiscordAuth] Auto-created account_id={newAcc.accountId} for discord_id={discordIdUlong}");
resolvedAccId = newAcc.accountId;
}
else
{
resolvedAccId = accountIds.First();
Console.WriteLine($"[DiscordAuth] Existing account_id={resolvedAccId} for discord_id={discordIdUlong}");
}
var account = PlayerDB.GetFullAccount(resolvedAccId);
if (account is null)
{
Console.WriteLine($"[DiscordAuth] GetFullAccount returned null for accountId={resolvedAccId} (discord_id={discordIdUlong})");
return Unauthorized("Account not found.");
}
// ✅ Issue JWT
string jwtToken = ClientSecurity.RefreshToken_Web(account.playerid);
// SameSite=Lax (not Strict): a cookie set on a cross-site redirect from Discord is
// still allowed AND is sent on the follow-up top-level GET (the /admin/home/v4
// redirect). With Strict, the browser refuses to send it on that navigation, so the
// admin page bounces you right back to the login form — which was the symptom.
Response.Cookies.Append("Authorization", jwtToken, new Microsoft.AspNetCore.Http.CookieOptions
{
Expires = DateTime.Now.AddHours(24),
HttpOnly = true,
Secure = true,
SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Lax,
Path = "/"
});
// Discriminate based on `state`. Web-admin login (default / "admin") stays on the
// original path — cookie is set on gameauth.ddns.net and we redirect to the admin
// dashboard. Launcher OAuth (state="launcher:<port>") skips the cookie and instead
// redirects the browser to the launcher's local http listener with the JWT in the
// query string; the launcher picks it up there and closes the browser tab.
if (!string.IsNullOrEmpty(state) && state.StartsWith("launcher:", StringComparison.Ordinal))
{
string portStr = state.Substring("launcher:".Length);
if (int.TryParse(portStr, out int port) && port >= 1024 && port <= 65535)
{
string dest = $"http://127.0.0.1:{port}/campus-auth?token={Uri.EscapeDataString(jwtToken)}&account_id={account.playerid}";
Console.WriteLine($"[DiscordAuth] launcher flow — redirecting to {dest}");
return Redirect(dest);
}
Console.WriteLine($"[DiscordAuth] launcher flow — malformed port in state='{state}', falling through to admin redirect");
}
Console.WriteLine($"[DiscordAuth] Issued admin JWT for account_id={account.playerid}, redirecting to /admin/home/v4");
return Redirect("/admin/home/v4");
}
}
}
@@ -0,0 +1,190 @@
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Campus_live_rewrite.api.server;
using Campus_live_rewrite.api.server.Classes;
using Campus_live_rewrite.api.dbs;
namespace Campus_live_rewrite.Controllers
{
[ApiController]
public class EconController : ControllerBase
{
[HttpGet("/econ/customAvatarItems/v1/owned")]
public IActionResult OwnedCustomAvatarItems([FromQuery] int skip = 0, [FromQuery] int take = 100)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// PAGINATED. The client downloads its locker in pages of 100 (skip/take) and keeps asking
// until it has TotalResults. Ignoring skip/take handed EVERY page the whole 3k-item dev
// locker (7.5 MB per request) - the headset timed out, retried a few times and gave up
// with "Failed to download unlocked avatar items", so the imported items never showed.
if (take > 1000) take = 1000;
if (take <= 0) take = 100;
if (skip < 0) skip = 0;
// The caller's OWN custom items (the ones they created), from the in-memory UGC index.
// Was streaming the ENTIRE catalog file - fine when empty, an 87MB response now. Items
// a player equips but didn't create still resolve via /api/customAvatarItems/v1/bulk.
//
// DEV LOCKER: a dev owns every IMPORTED UGC item (the ones whose assets we host, so they
// actually render) on top of their own creations - so imported clothing shows up in the
// wardrobe and equips like a normal item. Non-devs still only own what they created.
// Compact records; only the requested page is turned into JSON objects.
var mine = CustomItemsCache.ByCreatorRecs(account.Id);
// Bought / gifted UGC items.
{
var seenB = new HashSet<string>(mine.Select(x => x.Id), StringComparer.OrdinalIgnoreCase);
foreach (var caid in PlayerDB.OwnedCustomItems(account.Id))
{
var r = CustomItemsCache.GetRec(caid);
if (r != null && seenB.Add(r.Id)) mine.Add(r);
}
}
// The dev locker used to add every IMPORTED first-party item here as well. Off by
// default now: those records render only through a downloaded asset bundle, and the
// 20250718 PC client fails that download for every bundle we serve (the wardrobe
// tile shows, equipping dies in a NullReferenceException). The reference server has
// no such locker - its wardrobe is the LEGACY catalog (/api/avatar/v4/items), which the
// PC client renders from its own built-in assets, and a dev already owns all of that.
// So a dev's "Winter Scarf" that works is the legacy one; the imported twin only ever
// produced the broken duplicate tile. ConfigCampus.DevLockerImportedUgc turns it back on.
if (ConfigCampus.DevLockerImportedUgc && PlayerDB.CheckDevFlag(account.Id))
{
var seen = new HashSet<string>(mine.Select(x => x.Id), StringComparer.OrdinalIgnoreCase);
foreach (var it in CustomItemsCache.ImportedRecs)
if (seen.Add(it.Id)) mine.Add(it);
}
var page = mine.Skip(skip).Take(take).Select(r => r.ToJObject()).ToList();
if (skip == 0) Console.WriteLine($"[ugc] {account.Id} owns {mine.Count} custom item(s) (page size {take})");
return new ContentResult
{
Content = new Newtonsoft.Json.Linq.JObject
{
["Results"] = new Newtonsoft.Json.Linq.JArray(page),
["TotalResults"] = mine.Count
}.ToString(Newtonsoft.Json.Formatting.None),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/econ/roomInventory/room/{roomId}")]
public IActionResult GetRoomInventoryForRoom(ulong roomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(ConfigCampus.Bracket);
}
[HttpGet("/econ/roomInventory/room/{roomId}/player")]
public IActionResult GetRoomInventoryForPlayer(ulong roomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(ConfigCampus.Bracket);
}
[HttpGet("/econ/roomInventoryItemTags/room/{roomId}")]
public IActionResult GetRoomInventoryItemTagsForRoom(ulong roomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(ConfigCampus.Bracket);
}
[HttpGet("/econ/roomEconConfig/{roomId}")]
public IActionResult GetRoomEconConfig(ulong roomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(new
{
RoomId = roomId,
EnableSortingTabs = false
});
}
[HttpGet("/econ/roomOffer/room/{roomId}")]
public IActionResult GetRoomOffer(ulong roomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(ConfigCampus.Bracket);
}
[HttpGet("/econ/roomOffer/room/{roomId}/purchaseCounts")]
public IActionResult GetRoomOfferPurchaseCounts(ulong roomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(new
{
value = Array.Empty<object>(),
success = true
});
}
[HttpGet("/econ/roomGiftDropShops/room/{roomId}")]
public IActionResult GetRoomGiftDropShops(ulong roomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(ConfigCampus.Bracket);
}
[HttpPost("/econ/roomInventory/v2/award")]
public IActionResult AwardRoomInventory([FromBody] List<AwardRoomInventoryRequest> request)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(new
{
Result = 0,
PlayerRoomInventoryItem = new
{
AccountId = account.Id,
RoomInventoryItemId = request.FirstOrDefault().RoomInventoryItemOriginId,
RoomId = request.FirstOrDefault().RoomId,
RoomInventoryItemOriginId = request.FirstOrDefault().RoomInventoryItemOriginId,
Count = 0,
ConcurrencyCode = request.FirstOrDefault().ConcurrencyCodes.NewConcurrencyCode,
FirstReceivedAt = DateTime.UtcNow,
ModifiedAt = DateTime.UtcNow,
QuantityAwarded = request.FirstOrDefault().Quantity
}
});
}
public class AwardRoomInventoryRequest
{
public ConcurrencyCodes ConcurrencyCodes { get; set; }
public int Quantity { get; set; }
public ulong RoomId { get; set; }
public string RoomInventoryItemOriginId { get; set; }
}
public class ConcurrencyCodes
{
public string? CurrentConcurrencyCode { get; set; }
public string? NewConcurrencyCode { get; set; }
}
}
}
@@ -0,0 +1,320 @@
using Microsoft.AspNetCore.Mvc;
using Campus_live_rewrite.api.dbs;
namespace Campus_live_rewrite.Controllers
{
/// <summary>
/// Safe stubs for endpoints the 2025 client calls that had NO handler (live 404s), found by
/// diffing the client's full endpoint list against the server's routes (2026-09-10). Each
/// returns the most conservative valid shape for its kind - an empty list, a scalar, or the
/// { error, success, value } ack envelope the rest of this codebase uses - so a missing panel
/// loads empty instead of erroring. Handlers here are intentionally param-less (no [FromBody])
/// so a non-JSON/empty body can't 415/400 under [ApiController]. Ones that need REAL behaviour
/// later (purchases, invention publishing, room-setting writes) are grouped + noted.
/// </summary>
[ApiController]
public class GapEndpointsController : ControllerBase
{
private static ContentResult Json(string s) => new() { Content = s, ContentType = "application/json", StatusCode = 200 };
private static ContentResult EmptyArray() => Json("[]");
private IActionResult Ack() => Ok(new { error = "", success = true, value = (object?)null });
// ---------------- Room Settings tabs (Game Roles / Moderation / Instances / Earnings) --
// These back the "This Room → Room Settings" tabs and were live 404s. No custom role /
// ban / earnings-split storage yet, so they answer the conservative empty shape so the
// tab loads instead of erroring.
// NOTE: rooms/{id}/roles + roles/{pid}/invite are now REAL (persist + return roles) in
// RoomController - removed the empty-array/ack stubs here to avoid duplicate routes.
// Earnings distribution: an object (not a list), so the Earnings tab can render "distribute
// equally = off, no splits, 100% unallocated" rather than showing "Unable to retrieve…".
[HttpGet("/api/roomEarningsDistributions/v1/earningsDistribution/{roomId}")]
public ContentResult RoomEarningsDistribution(long roomId) => Json(
"{\"RoomId\":" + roomId + ",\"DistributeEarningsEqually\":false,\"Distributions\":[],\"EarningsDistributions\":[],\"TotalPercentage\":0}");
// Keepsake categories — the client parses this as a RESULT SET object, not a list, and
// chokes on an array. Empty catalog.
[HttpGet("/api/keepsakes/categories")] public ContentResult KeepsakeCategories() => Json("{\"Results\":[],\"TotalResults\":0}");
// ---------------- Rooms discovery lists (empty carousels are safe) ----------------
[HttpGet("/Room_server/rooms/contestwinners")] public ContentResult RoomsContestWinners() => EmptyArray();
[HttpGet("/Room_server/rooms/curated_playlists")] public ContentResult RoomsCuratedPlaylists() => EmptyArray();
[HttpGet("/Room_server/rooms/recommendations")] public ContentResult RoomsRecommendations() => EmptyArray();
[HttpGet("/Room_server/rooms/topcreators")] public ContentResult RoomsTopCreators() => EmptyArray();
[HttpGet("/Room_server/rooms/magic_door")] public ContentResult RoomsMagicDoor() => EmptyArray();
[HttpGet("/Room_server/rooms/rro_ids")] public ContentResult RoomsRroIds() => EmptyArray();
[HttpGet("/Room_server/rooms/{roomId}/promo_images")] public ContentResult RoomPromoImages(string roomId) => EmptyArray();
[HttpGet("/Room_server/rooms/{roomId}/promo_external")] public ContentResult RoomPromoExternal(string roomId) => Json("{}");
// ---------------- Room setting writes (ack; real persistence is TODO) ----------------
[HttpPut("/Room_server/rooms/{roomId}/allow_new_users")] [HttpPost("/Room_server/rooms/{roomId}/allow_new_users")]
public IActionResult RoomAllowNewUsers(string roomId) => Ack();
[HttpPut("/Room_server/rooms/{roomId}/automute")] [HttpPost("/Room_server/rooms/{roomId}/automute")]
public IActionResult RoomAutomute(string roomId) => Ack();
[HttpPut("/Room_server/rooms/{roomId}/max_player_calculation_mode")] [HttpPost("/Room_server/rooms/{roomId}/max_player_calculation_mode")]
public IActionResult RoomMaxPlayerCalc(string roomId) => Ack();
[HttpPut("/Room_server/rooms/{roomId}/voice_chatencryption")] [HttpPost("/Room_server/rooms/{roomId}/voice_chatencryption")]
public IActionResult RoomVoiceEncryption(string roomId) => Ack();
[HttpPost("/Room_server/rooms/{roomId}/requestManualModeration")]
public IActionResult RoomRequestManualMod(string roomId) => Ack();
[HttpPost("/Room_server/rooms/{roomId}/bans/import")]
public IActionResult RoomBansImport(string roomId) => Ack();
// ---------------- Account ----------------
[HttpGet("/Accounts/account/me/haspassword")] public ContentResult HasPassword() => Json("true");
[HttpPost("/Accounts/account/me/createlogintoken")]
public IActionResult CreateLoginToken() => Ok(new { token = System.Guid.NewGuid().ToString("N"), success = true });
[HttpPost("/Accounts/account/recoverpassword")] public IActionResult RecoverPassword() => Ack();
[HttpPut("/Accounts/accountprivacysettings/recenthistoryvisibility")]
[HttpPost("/Accounts/accountprivacysettings/recenthistoryvisibility")]
public IActionResult SetRecentHistoryVisibility() => Ack();
// ---------------- Objectives / consumables / images ----------------
[HttpPost("/api/objectives/v1/completegroup")]
public IActionResult CompleteObjectivesGroup() => Ok(new { group = 0, isCompleted = true, clearedAt = System.DateTime.UtcNow.ToString("o") });
[HttpPost("/api/consumables/v1/transfer")] public IActionResult ConsumablesTransfer() => Ack();
[HttpGet("/api/images/v1/listsaved")] public ContentResult ImagesListSaved() => EmptyArray();
// ---- photo edits: were acks that did nothing, so a deleted photo came back and a photo
// made private stayed public. The field names the client uses were never captured, so
// every plausible spelling is read (form, query or JSON) and the raw keys are logged the
// first few times so the real ones name themselves.
private static int _imgLogged;
private async Task<(int id, string? accessibility, string? description, List<int> ids)> ReadImageEdit()
{
var keys = new List<string>();
string Get(params string[] names)
{
foreach (var n in names)
{
string v = Request.HasFormContentType ? Request.Form[n].ToString() : "";
if (string.IsNullOrEmpty(v)) v = Request.Query[n].ToString();
if (!string.IsNullOrEmpty(v)) return v;
}
return "";
}
Newtonsoft.Json.Linq.JObject? body = null;
if (!Request.HasFormContentType)
{
try
{
using var r = new StreamReader(Request.Body);
string raw = await r.ReadToEndAsync();
if (!string.IsNullOrWhiteSpace(raw)) body = Newtonsoft.Json.Linq.JObject.Parse(raw);
}
catch { }
}
string J(params string[] names)
{
if (body == null) return "";
foreach (var n in names)
{
var p = body.Properties().FirstOrDefault(x => string.Equals(x.Name, n, StringComparison.OrdinalIgnoreCase));
if (p != null && p.Value.Type != Newtonsoft.Json.Linq.JTokenType.Null) return p.Value.Type == Newtonsoft.Json.Linq.JTokenType.Array ? string.Join(",", p.Value) : p.Value.ToString();
}
return "";
}
if (Request.HasFormContentType) keys.AddRange(Request.Form.Keys);
if (body != null) keys.AddRange(body.Properties().Select(p => p.Name));
if (_imgLogged++ < 5) Console.WriteLine($"[images] {Request.Path} fields=[{string.Join(",", keys)}]");
string idRaw = Get("id", "imageId", "savedImageId", "SavedImageId", "ImageId", "Id");
if (idRaw == "") idRaw = J("id", "imageId", "savedImageId", "ImageId");
string idsRaw = Get("ids", "imageIds", "savedImageIds", "SavedImageIds");
if (idsRaw == "") idsRaw = J("ids", "imageIds", "savedImageIds", "SavedImageIds");
var ids = idsRaw.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(x => int.TryParse(x.Trim(), out var n) ? n : 0).Where(n => n != 0).ToList();
int id = int.TryParse(idRaw, out var one) ? one : (ids.Count == 1 ? ids[0] : 0);
if (id != 0 && !ids.Contains(id)) ids.Add(id);
// resolve ImageName: the 2025 client names the photo by its IMAGE NAME, not its id
// (deletesaved / modifyaccessibility / modifydescription all sent fields=[ImageName]
// and "deleted 0 of 0" every time).
string namesRaw = Get("ImageName", "imageName", "ImageNames", "imageNames", "name"); if (namesRaw == "") namesRaw = J("ImageName", "imageName", "ImageNames", "imageNames", "name");
foreach (var nm in namesRaw.Split(',', StringSplitOptions.RemoveEmptyEntries))
{
var rec = ImageMetadataDB.GetByName(nm.Trim());
if (rec != null && !ids.Contains(rec.Id)) ids.Add(rec.Id);
}
if (id == 0 && ids.Count == 1) id = ids[0];
string acc = Get("accessibility", "Accessibility", "imageAccessibility"); if (acc == "") acc = J("accessibility", "imageAccessibility");
string desc = Get("description", "Description"); if (desc == "") desc = J("description");
return (id, acc == "" ? null : acc, desc == "" ? null : desc, ids);
}
[HttpPost("/api/images/v1/deletesaved")]
[HttpPost("/api/images/v2/deletesaved")]
[HttpDelete("/api/images/v1/deletesaved")]
public async Task<IActionResult> ImagesDeleteSaved()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var (_, _, _, ids) = await ReadImageEdit();
int done = 0;
foreach (var id in ids)
{
var img = ImageMetadataDB.GetById(id);
if (img == null) continue;
if (img.PlayerId != account.Id && !PlayerDB.CheckDevFlag(account.Id)) continue;
if (ImageMetadataDB.DeleteImage(id) != null) done++;
}
Console.WriteLine($"[images] {account.Id} deleted {done} of {ids.Count} photo(s)");
return Ack();
}
[HttpPost("/api/images/v1/modifyaccessibility")]
[HttpPost("/api/images/v2/modifyaccessibility")]
[HttpPut("/api/images/v2/modifyaccessibility")]
public async Task<IActionResult> ImagesModifyAccessibility()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var (_, accRaw, _, ids) = await ReadImageEdit();
ImageMetadataDB.ImageAccessibility acc;
if (int.TryParse(accRaw, out var ai)) acc = ai == 0 ? ImageMetadataDB.ImageAccessibility.Private : ImageMetadataDB.ImageAccessibility.Public;
else if (string.Equals(accRaw, "Private", StringComparison.OrdinalIgnoreCase)) acc = ImageMetadataDB.ImageAccessibility.Private;
else if (string.Equals(accRaw, "Public", StringComparison.OrdinalIgnoreCase)) acc = ImageMetadataDB.ImageAccessibility.Public;
else { Console.WriteLine($"[images] {account.Id} modifyaccessibility with no readable accessibility ('{accRaw}')"); return Ack(); }
int done = 0;
foreach (var id in ids)
{
var img = ImageMetadataDB.GetById(id);
if (img == null || (img.PlayerId != account.Id && !PlayerDB.CheckDevFlag(account.Id))) continue;
if (ImageMetadataDB.SetVisibility(id, acc, friendsOnly: false)) done++;
}
Console.WriteLine($"[images] {account.Id} set {done} photo(s) {acc}");
return Ack();
}
[HttpPost("/api/images/v1/modifydescription")]
[HttpPost("/api/images/v2/modifydescription")]
[HttpPut("/api/images/v1/modifydescription")]
public async Task<IActionResult> ImagesModifyDescription()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var (id, _, desc, ids) = await ReadImageEdit();
foreach (var one in ids)
{
var img = ImageMetadataDB.GetById(one);
if (img == null || (img.PlayerId != account.Id && !PlayerDB.CheckDevFlag(account.Id))) continue;
ImageMetadataDB.SetDescription(one, desc ?? "");
}
return Ack();
}
// ---- small gaps the 2025 client hits on every session -------------------------------
// Matchmaking "avoid juniors" preference: off, and a PUT is accepted.
[HttpGet("/Matchmaking/player/avoidjuniors")] public ContentResult AvoidJuniors() => Json("false");
[HttpPut("/Matchmaking/player/avoidjuniors")] public ContentResult SetAvoidJuniors() => Json("false");
[HttpPost("/Matchmaking/player/avoidjuniors")] public ContentResult SetAvoidJuniors2() => Json("false");
// Rooms 2.0 staged (unsaved-to-publish) entities: none.
// The room's staged ENTITY CHANGE LIST (Rooms 2.0 entity edits waiting to publish). The
// client reads an OBJECT here - `[]` threw "expected:'{', actual:'['" and "Failed to get
// room entity change list" on every Rooms 2.0 load (Player.log). Nothing here stages
// entity edits, so the list is empty.
[HttpGet("/Room_server/rooms/{roomId}/entities/staged")] public ContentResult StagedEntities(string roomId) => Json("{\"RoomId\":" + (long.TryParse(roomId, out var rid) ? rid : 0) + ",\"Entities\":[],\"Changes\":[],\"StagedEntities\":[],\"Version\":0}");
// Invention browse filters: the category tags the create flow uses (UGC.Room2InventionCategoryTagConcatenatedList).
[HttpGet("/api/inventions/v1/tagfilters")]
public ContentResult InventionTagFilters() => Json(Newtonsoft.Json.JsonConvert.SerializeObject(new
{
PinnedFilters = new[] { "weapon", "gameobject", "decoration", "environment", "gamemechanic" },
PopularFilters = new[] { "weapon", "gameobject", "decoration", "environment", "gamemechanic" },
}));
// Website share-link page views: nothing to consume.
[HttpPost("/link/pageview/consume")] public IActionResult LinkPageviewConsume() => Ok(new { });
[HttpPost("/api/images/v1/sendlink")] public IActionResult ImagesSendLink() => Ack();
// ---------------- Custom avatar items (UGC) ----------------
[HttpGet("/api/customAvatarItems/v1/itemOwnershipLimit")] public ContentResult ItemOwnershipLimit() => Json("10000");
[HttpGet("/econ/customAvatarItems/v1/itemOwnershipLimit")] public ContentResult ItemOwnershipLimitEcon() => Json("10000");
// Live 404s from the 2025 client (API log): the costume maker's "items you used to own"
// list, the consumable "who can I give this to" lookup, the PUT spelling of the design
// save, the GameSight analytics sink, the batched telemetry post and the commerce probes.
[HttpGet("/econ/customAvatarItems/v1/allPastOwnedIds")] public ContentResult AllPastOwnedIds() => EmptyArray();
[HttpGet("/api/consumables/v1/getTransferable/{id}")] public ContentResult ConsumableTransferable(string id) => EmptyArray();
[HttpPut("/api/customAvatarItems/v1/design")] public ContentResult CustomItemDesignPut() => CustomItemDesign();
[HttpPost("/api/gamesight/event")] public IActionResult GamesightEvent() => Ok();
[HttpPost("/data/events")] public IActionResult DataEvents() => Ok();
[HttpGet("/Commerce/purchase/v1/hasspentmoney")] public ContentResult HasSpentMoney() => Json("false");
[HttpPost("/Commerce/purchase/v1/initiatepurchase")] public IActionResult InitiatePurchase() => Ok(new { success = true, error = "", value = new { TransactionId = "campus-no-store" } });
// Per-room XP progress the 2025 client POSTs on leaving a room (was a 405); nothing
// tracks per-room XP, so it is acknowledged like the GET's "not enabled" answer.
[HttpPost("/Room_server/rooms/{roomId}/experience/player")] public IActionResult RoomExperienceReport(string roomId) => Ok(new { Enabled = false, Experience = (object?)null, ConcurrencyCode = (string?)null });
// NOTE: /cdn/config/* is NOT routed here. Program.cs serves it as static files from
// Data/CDN/Configs (RRPlusConfig_v3.json, SkuConfig_v1.json, Prod/*, and the opaque
// launch-validation blob 1b057e6e-...). A controller route added here for it shadowed
// that middleware and read a folder that doesn't exist, so the blob 404'd and every PC
// client failed boot with "Launch validation failed. Is Rec Room installed correctly?".
// Anti-cheat signature blobs: served from Data/CDN/Sigs when a file of that name exists.
[HttpGet("/cdn/sigs/{name}")]
public IActionResult CdnSig(string name)
{
string p = Path.Combine(Environment.CurrentDirectory, "Data", "CDN", "Sigs", Path.GetFileName(name));
return System.IO.File.Exists(p) ? PhysicalFile(p, "application/octet-stream") : NotFound();
}
// The costume maker's saved-designs lookup. Its exact shape is still being learned: the
// client rejected "{}" ("Malformed Response"), so it is answered as an empty LIST and every
// call is logged with its query so the real contract can be filled in from a capture.
[HttpGet("/api/customAvatarItems/v1/design")] [HttpPost("/api/customAvatarItems/v1/design")]
public ContentResult CustomItemDesign()
{
Console.WriteLine($"[ugc-design] {Request.Method} {Request.Path}{Request.QueryString} ct='{Request.ContentType}' len={Request.ContentLength} accept='{Request.Headers["Accept"]}'");
return EmptyArray();
}
[HttpPost("/api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItemDescs")]
public IActionResult LegacyCustomItemSavesByDesc() => Ok(new { customAvatarItemSavesByAvatarItemDesc = new { } });
// ---------------- Room currencies ----------------
// ---------------- Room keys ----------------
// ---------------- Inventions (lists empty; writes ack) ----------------
// /api/inventions/v1/version is REAL now (InventionController.GetVersion): it answers a
// bare RRInventionVersion OBJECT, and the [] this stub returned was one of the client's
// "Malformed Response: '[]'" - the room load stalled on it on every invention spawn.
[HttpGet("/api/inventions/v1/versions")] public ContentResult InvVersions() => EmptyArray();
[HttpGet("/api/inventions/v1/featureddormskins")] public ContentResult InvFeaturedDorm() => EmptyArray();
[HttpGet("/api/inventions/v1/dormskinsfromids")] [HttpPost("/api/inventions/v1/dormskinsfromids")]
public ContentResult InvDormFromIds() => EmptyArray();
[HttpPost("/api/inventions/v4/addversion")] public IActionResult InvAddVersion() => Ack();
// NOTE: v6/save, v9/save and v3/publish are REAL now — see InventionController.
// ---------------- Player events ----------------
[HttpPost("/api/playerevents/v1/broadcast")] public IActionResult PeBroadcast() => Ack();
[HttpPost("/api/playerevents/v1/bulkInvite")] public IActionResult PeBulkInvite() => Ack();
[HttpPost("/api/playerevents/v1/deleteResponse")] public IActionResult PeDeleteResponse() => Ack();
[HttpGet("/api/playerevents/v1/r")] [HttpPost("/api/playerevents/v1/r")]
public ContentResult PeR() => EmptyArray();
// ---------------- Storefront purchases (ack envelope; real charge is TODO) ----------------
[HttpPost("/api/storefronts/v1/buyForFreeGiftButton")] public IActionResult SfBuyFreeGift() => Ack();
[HttpPost("/api/storefronts/v1/buyProgressionEventXpBoost")] public IActionResult SfBuyXpBoost() => Ack();
[HttpPost("/api/storefronts/v1/buyPurchaseReminder")] public IActionResult SfBuyReminder() => Ack();
[HttpPost("/api/storefronts/v2/buyElite")] public IActionResult SfBuyElite() => Ack();
[HttpPost("/api/storefronts/v2/buyTier")] public IActionResult SfBuyTier() => Ack();
[HttpGet("/api/storefronts/v1/toptoday")] public ContentResult SfTopToday() => EmptyArray();
[HttpGet("/api/storefronts/v1/trialInvention")] public ContentResult SfTrialInvention() => Json("{}");
[HttpGet("/api/storefronts/v1/trialInvention/duration")] public ContentResult SfTrialDuration() => Json("0");
// ---------------- Reporting / platform / misc ----------------
[HttpPost("/api/PlayerReporting/v1/deviceId")] public IActionResult ReportDeviceId() => Ack();
[HttpPost("/api/AppIntegrity/v1/iospaymentqueuefailed")] public IActionResult IosPayFailed() => Ack();
[HttpGet("/api/AppIntegrity/v1/iosproducts")] public ContentResult IosProducts() => EmptyArray();
[HttpGet("/api/CampusCard/PS5RecRoomPlusEnabledForAllPlayers")] public ContentResult Ps5Plus() => Json("false");
[HttpGet("/api/apple/musicpromotion/code")] public ContentResult AppleMusicCode() => Json("{}");
[HttpGet("/api/config/v1/freegiftbutton")] public ContentResult FreeGiftButtonConfig() => Json("{}");
[HttpGet("/api/royale/v2/matchcomplete")] [HttpPost("/api/royale/v2/matchcomplete")]
public IActionResult RoyaleMatchComplete() => Ack();
// No Rec Royale season is running on Campus - return an empty "no current season" object
// (was 404, which the client surfaced as an error) instead of a real season.
[HttpGet("/api/royale/v1/current")] public ContentResult RoyaleCurrent() => Json("{}");
// Season storefront (battle-pass-style season store) by id - we don't run seasons, so an
// empty store instead of a 404. Shape mirrors the giftdrop store: an id + empty item list.
[HttpGet("/api/storefronts/v1/season/{seasonId}")]
public IActionResult SeasonStorefront(int seasonId) => Ok(new { SeasonId = seasonId, StorefrontType = seasonId, StoreItems = System.Array.Empty<object>(), Prices = System.Array.Empty<object>() });
// ---------------- External friend invite / referrals ----------------
[HttpPost("/api/externalfriendinvite/v1/createplatforminvite")] public IActionResult ExtCreatePlatformInvite() => Json("{}");
[HttpGet("/api/externalfriendinvite/v1/gettextmessagereferrers")] public ContentResult ExtTextReferrers() => EmptyArray();
[HttpPost("/api/externalfriendinvite/v1/sendtextmessageinvite")] public IActionResult ExtSendTextInvite() => Ack();
[HttpPost("/api/incentivizedreferrals/claim")] public IActionResult ReferralsClaim() => Ack();
[HttpGet("/api/incentivizedreferrals/referrals")] public ContentResult ReferralsList() => EmptyArray();
}
}
@@ -0,0 +1,211 @@
using Microsoft.AspNetCore.Mvc;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Png;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
namespace Campus_live_rewrite.Controllers
{
public class ImagesController : Controller
{
private static readonly HttpClient _recNetClient = new() { Timeout = TimeSpan.FromSeconds(3) };
private static DateTime _recNetRetryAfter = DateTime.MinValue;
// Pictures asked for at the site ROOT (/DefaultPFP.png, /pfp_6_....png, /<polaroid>.png -
// 400+ such 404s a day): the same files the image store serves under /img. Anything the
// static website doesn't have at that name is sent there, query string and all.
[HttpGet(@"/{file:regex(^\w[[\w.-]]*\.(png|jpg|jpeg)$)}")]
public IActionResult RootImage(string file) => Redirect("/img/" + file + Request.QueryString);
[Route("/img/{*img_path}")]
public async Task<IActionResult> ImgServer(string img_path, int width = 0, int height = 0, string sig = "p1")
{
img_path = Uri.UnescapeDataString(img_path ?? "").TrimStart('/');
// Confine to Data/Images. This route is catch-all too, and every candidate path
// below appends img_path to a subfolder of Data/Images - so "../../../etc/passwd"
// walked straight out and File.Exists happily served it. One check on the shared
// parent is enough: nothing legitimate lives outside Data/Images, and the deeper
// candidate folders are all beneath it. Also blocks the img.rec.net fallback URL
// being built from a traversal string.
string imagesRoot = Path.Combine(Environment.CurrentDirectory, "Data", "Images");
if (Campus_live_rewrite.api.server.PathSafety.ResolveUnder(imagesRoot, img_path) == null)
{
Console.WriteLine($"[img] REFUSED path traversal attempt: {img_path}");
return NotFound();
}
HttpContext.Response.Headers.Append("content-signature", "key-id=KEY:RSA:p1.rec.net; data=IWwe/pZ5vWWqNSkSM/54isgDxlZkdrP0sUrppKCbNktO2yCOTjq746xWiiLsueGuVcAGQqkjeRTimxolHckS/YXSYkEJxtiCXbLlsRia2DyAqtWVkGWsfczzFhp/56U66FVzolTspPCvjScOVlGO7dDIK7sJ+ndcRauWjsQsC6g3e7rUc6uwY099a6gy7sw6xr5BFZQSz8wg+fqyHYD/Sc4nQQVOTFZNNASqbJYhpNhEMXRnafCMuLl8a3mkGwvy3t4q2D/7SM48xrGZjEV47qNx1A91KCe28XVToFh4BzwEUU8nZ0d+KwV79MGarLo1cY8igc8FcoThKcovI4ClOg==");
HttpContext.Response.Headers.Append("Content-Disposition", $"inline; filename=\"{Path.GetFileName(img_path)}\"");
HttpContext.Response.Headers.Append("Access-Control-Allow-Origin", "*");
HttpContext.Response.Headers.Append("Access-Control-Allow-Headers", "*");
Response.Headers["Cache-Control"] = "public, max-age=14400";
HttpContext.Response.Headers.Append("Access-Control-Allow-Headers", "Content-Type, Authorization, Cache-Control");
HttpContext.Response.Headers.Append("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
var etag = $"\"{img_path.GetHashCode()}\"";
HttpContext.Response.Headers.Append("ETag", etag);
//return File(await System.IO.File.ReadAllBytesAsync(Path.Combine(Environment.CurrentDirectory, "Data", "Images", "imageee.jpg")), "image/png");
bool cropSquare = HttpContext.Request.Query.ContainsKey("cropsquare") &&
(HttpContext.Request.Query["cropsquare"].ToString().ToLower() == "true" ||
HttpContext.Request.Query["cropsquare"].ToString() == "1");
string[] paths = new[]
{
Path.Combine(Environment.CurrentDirectory, "Data", "Images", img_path),
Path.Combine(Environment.CurrentDirectory, "Data", "Images", "rro_images", img_path),
Path.Combine(Environment.CurrentDirectory, "Data", "Images", "rro_images", "base_rros", img_path),
Path.Combine(Environment.CurrentDirectory, "Data", "Images", "player_images", img_path),
Path.Combine(Environment.CurrentDirectory, "Data", "Images", "polaroid_images", img_path),
Path.Combine(Environment.CurrentDirectory, "Data", "Images", "DefaultImgs", img_path),
Path.Combine(Environment.CurrentDirectory, "Data", "Images", "rec_net", img_path),
Path.Combine(Environment.CurrentDirectory, "Data", "Images", "custom_shirt_imgs", img_path),
Path.Combine(Environment.CurrentDirectory, "Data", "Images", "LoadingScreenTips", img_path)
//Path.Combine(Environment.CurrentDirectory, "Data", "cdn_rooms", img_path)
};
foreach (var path in paths)
{
if (System.IO.File.Exists(path))
{
try
{
var imageBytes = await System.IO.File.ReadAllBytesAsync(path);
imageBytes = await ProcessImageAsync(imageBytes, cropSquare, width, height);
return File(imageBytes, "image/png");
}
catch
{
var fallbackBytes = await System.IO.File.ReadAllBytesAsync(path);
return File(fallbackBytes, "image/png");
}
}
}
string recNetLocalPath = Path.Combine(Environment.CurrentDirectory, "Data", "Images", "rec_net", img_path);
// img.rec.net is gone. Every miss used to open a fresh HttpClient and sit through a
// full TLS failure (seconds) before falling through - hundreds of times per boot, and
// each one stalled the request that wanted the image. Now: one shared client with a
// short timeout, and after any failure the upstream is skipped for 10 minutes.
if (!System.IO.File.Exists(recNetLocalPath) && DateTime.UtcNow >= _recNetRetryAfter)
{
try
{
byte[] data = await _recNetClient.GetByteArrayAsync($"https://img.rec.net/{img_path}");
Directory.CreateDirectory(Path.GetDirectoryName(recNetLocalPath)!);
await System.IO.File.WriteAllBytesAsync(recNetLocalPath, data);
try
{
var processed = await ProcessImageAsync(data, cropSquare, width, height);
return File(processed, GetMimeType(img_path));
}
catch
{
return File(data, GetMimeType(img_path));
}
}
catch (Exception ex) when (ex is HttpRequestException || ex is TaskCanceledException)
{
_recNetRetryAfter = DateTime.UtcNow.AddMinutes(10);
Console.WriteLine($"[rec_net fetch failed] {ex.Message} - skipping img.rec.net for 10 minutes");
}
}
else if (System.IO.File.Exists(recNetLocalPath))
{
// Read inside the try: the file can be gone between Exists and the read (a
// concurrent fetch writing it, a cleanup) and that was a 500 with a stack trace
// in the journal instead of the 404 the client handles.
try
{
var data = await System.IO.File.ReadAllBytesAsync(recNetLocalPath);
try
{
var processed = await ProcessImageAsync(data, cropSquare, width, height);
return File(processed, GetMimeType(img_path));
}
catch
{
return File(data, GetMimeType(img_path));
}
}
catch (IOException ex)
{
Console.WriteLine($"[img] rec_net/{img_path} unreadable: {ex.Message}");
}
}
// Was: "rro_image" (singular). The directory on disk is "rro_images", and
// DefaultRoomImage.jpg actually lives in "rec_net" - so the path was wrong
// twice, File.Exists was ALWAYS false, and this fell through to NotFound().
// That is where the client's "HTTP Error 404" entries come from. Try the
// real location first, then the plural directory, so a renamed/moved asset
// still resolves instead of 404ing.
foreach (var dir in new[] { "rec_net", "rro_images", "DefaultImgs" })
{
var notFoundImagePath = Path.Combine(
Environment.CurrentDirectory, "Data", "Images", dir, "DefaultRoomImage.jpg");
if (System.IO.File.Exists(notFoundImagePath))
{
var notFoundImageBytes = await System.IO.File.ReadAllBytesAsync(notFoundImagePath);
return File(notFoundImageBytes, "image/jpeg");
}
}
return NotFound();
}
private static string GetMimeType(string filePath)
{
var provider = new Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider();
if (!provider.TryGetContentType(filePath, out var contentType))
{
contentType = "image/png";
}
return contentType;
}
private static async Task<byte[]> ProcessImageAsync(byte[] imageBytes, bool cropSquare, int width, int height)
{
using var ms = new MemoryStream(imageBytes);
using var image = await Image.LoadAsync<Rgba32>(ms);
if (cropSquare)
{
int size = Math.Min(image.Width, image.Height);
int x = (image.Width - size) / 2;
int y = (image.Height - size) / 2;
image.Mutate(ctx => ctx.Crop(new Rectangle(x, y, size, size)));
int targetSize = width > 0 ? width : height;
if (targetSize > 0)
{
image.Mutate(ctx => ctx.Resize(targetSize, targetSize));
}
}
else if (width > 0 || height > 0)
{
int resizeWidth = width;
int resizeHeight = height;
if (width > 0 && height == 0)
{
resizeHeight = (int)((double)image.Height / image.Width * width);
}
else if (height > 0 && width == 0)
{
resizeWidth = (int)((double)image.Width / image.Height * height);
}
image.Mutate(ctx => ctx.Resize(resizeWidth, resizeHeight));
}
using var output = new MemoryStream();
await image.SaveAsync(output, new PngEncoder());
return output.ToArray();
}
}
}
@@ -0,0 +1,558 @@
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Campus_live_rewrite.api;
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server;
namespace Campus_live_rewrite.Controllers
{
// Real invention SAVE (Maker Pen "save selection as invention") + publish + my-list.
// The save endpoints used to ack with the wrong shape, so the client reported
// "Error uploading invention data". The client's decoder is lenient (it reads
// Invention.InventionId + the Status/Success envelope and drops unknown fields), so a
// correctly-STRUCTURED reply is what it needs. The circuit blob is uploaded separately and
// referenced here by inventionDataFilename.
[ApiController]
public class InventionController : ControllerBase
{
private static ContentResult J(object o, int status = 200) =>
new() { Content = JsonConvert.SerializeObject(o), ContentType = "application/json", StatusCode = status };
private async Task<JObject?> ReadBody()
{
try
{
using var r = new StreamReader(Request.Body);
var s = await r.ReadToEndAsync();
return string.IsNullOrWhiteSpace(s) ? new JObject() : JObject.Parse(s);
}
catch { return null; }
}
private static InventionDB.Invention BuildFromBody(ulong creator, JObject b)
{
string name = (b.Value<string>("name") ?? "").Trim(); if (name.Length == 0) name = "Untitled";
string desc = (b.Value<string>("description") ?? "").Trim(); if (desc.Length == 0) desc = "No description yet";
var inv = new InventionDB.Invention
{
CreatorPlayerId = creator,
Name = name.Length > 24 ? name.Substring(0, 24) : name,
Description = desc.Length > 512 ? desc.Substring(0, 512) : desc,
ImageName = b.Value<string>("imageName") ?? "",
BlobName = b.Value<string>("inventionDataFilename") ?? "",
InstantiationCost = b.Value<int?>("instantiationCost") ?? 0,
LightsCost = b.Value<int?>("lightsCost") ?? 0,
ChipsCost = b.Value<int?>("chipsCost") ?? 0,
CloudVariablesCost = b.Value<int?>("cloudVariablesCost") ?? 0,
AICost = b.Value<int?>("aiCost") ?? 0,
CreationRoomId = b.Value<int?>("creationRoomId") ?? 0,
UgcVersion = b.Value<int?>("ugcVersion") ?? 0,
HasBetaContent = b.Value<bool?>("hasBetaContent") ?? false,
LongDescription = b.Value<string>("longDescription"),
DisplayMetadataJson = b.Value<string>("displayMetadataJson"),
ReferencedInventions = b["referencedInventions"]?.ToObject<List<int>>() ?? new(),
ReferencedUnityAssetIds = b["referencedUnityAssetIds"]?.ToObject<List<string>>() ?? new(),
};
if (b["tagsRequest"] is JObject tr)
{
var custom = tr["CustomTags"]?.ToObject<List<string>>() ?? new();
var auto = tr["AutoTags"]?.ToObject<List<string>>() ?? new();
inv.Tags = custom.Concat(auto).Where(t => !string.IsNullOrWhiteSpace(t)).Distinct().ToList();
}
return InventionDB.Create(inv);
}
// GET /api/inventions/v1/version?inventionId=N&version=V - the bare RRInventionVersion,
// which carries the blob name the client downloads. Only the CURRENT version exists here
// (nothing writes history), so any other number 404s rather than naming a blob that is not
// there. version=0 means "whichever is current". (Reference: api/routes/avatar.ts.)
[HttpGet("/api/inventions/v1/version")]
public ContentResult GetVersion([FromQuery] int inventionId = 0, [FromQuery] int version = 0)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
if (inventionId <= 0) return J(new { error = "inventionId is required" }, 400);
var inv = InventionDB.GetById(inventionId);
if (inv == null) return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 404 };
if (version != 0 && version != inv.CurrentVersionNumber)
return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 404 };
return J(VersionDto(inv));
}
private static object VersionDto(InventionDB.Invention i) => new
{
InventionId = i.InventionId,
ReplicationId = i.ReplicationId,
VersionNumber = i.CurrentVersionNumber,
BlobName = i.BlobName,
BlobHash = (string?)null,
InstantiationCost = i.InstantiationCost,
LightsCost = i.LightsCost,
ChipsCost = i.ChipsCost,
CloudVariablesCost = i.CloudVariablesCost,
AICost = i.AICost,
HasBetaContent = i.HasBetaContent,
};
// The stored-record shape the read endpoints (v2/mine) + v6 save envelope use.
public static object InventionDto(InventionDB.Invention i) => new
{
InventionId = i.InventionId,
ReplicationId = i.ReplicationId,
CreatorPlayerId = i.CreatorPlayerId,
Name = i.Name,
Description = i.Description,
ImageName = i.ImageName,
CurrentVersionNumber = i.CurrentVersionNumber,
CurrentVersion = VersionDto(i),
Accessibility = i.Accessibility,
IsPublished = i.IsPublished,
IsFeatured = i.IsFeatured,
ModifiedAt = i.ModifiedAt,
CreatedAt = i.CreatedAt,
FirstPublishedAt = i.FirstPublishedAt,
CreationRoomId = i.CreationRoomId,
NumPlayersHaveUsedInRoom = i.NumDownloads,
NumDownloads = i.NumDownloads,
CheerCount = i.CheerCount,
// Permissions: KGCKBAIOBNL - Unassigned 0, LimitedOneUseOnly 10, DisallowKeyLock 15,
// UseOnly 20, EditAndSave 40, Publish 60, Charge 80, Unlimited 100. Both were 0
// (Unassigned), which is not a permission the maker pen will spawn from. The creator
// gets Unlimited over their own invention; everyone else gets what publishing implies.
CreatorPermission = 100,
GeneralPermission = i.IsPublished ? 20 : 0,
IsAGInvention = false,
IsCertifiedInvention = false,
Price = i.Price,
AllowTrial = false,
HideFromPlayer = false,
ReferencedInventions = i.ReferencedInventions ?? new(),
// The six keys the client's 28-key Invention DTO carries that this DTO never sent
// (wire-shapes: inventions-v2-search). UgcVersion is the important one: the maker pen
// palette filters its list by it, so an invention that arrives without one matches
// neither CV1 nor CV2 and never appears in the Inventions tab. 0 is not a valid
// version - default to 1 (CV1) for records saved before the field existed.
UgcVersion = i.UgcVersion > 0 ? i.UgcVersion : 1,
LatestVersionNumber = i.CurrentVersionNumber,
LongDescription = i.LongDescription ?? "",
ForceCannotPublish = false,
IsRecRoomApproved = false,
DisplayMetadataJson = i.DisplayMetadataJson,
};
private static object SaveResultV6(InventionDB.Invention i) => new
{
Status = 0,
Invention = InventionDto(i),
InventionVersion = VersionDto(i),
};
// POST /api/inventions/v6/save — JSON body (camelCase); returns { Status, Invention, InventionVersion }.
[HttpPost("/api/inventions/v6/save")]
public async Task<ContentResult> SaveV6()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return J(new { error = "Unauthorized" }, 401);
var b = await ReadBody();
if (b == null) return J(new { error = "Invalid request body" }, 400);
var inv = BuildFromBody(account.Id, b);
Console.WriteLine($"[invention] {account.Id} saved '{inv.Name}' (#{inv.InventionId}) blob={inv.BlobName}");
return J(SaveResultV6(inv));
}
// POST /api/inventions/v9/save — same save, ENVELOPED { Value, Success, Error, error_id }.
[HttpPost("/api/inventions/v9/save")]
public async Task<ContentResult> SaveV9()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return J(new { Value = (object?)null, Success = false, Error = "Unauthorized", error_id = (string?)null });
var b = await ReadBody();
if (b == null) return J(new { Value = (object?)null, Success = false, Error = "Invalid request body", error_id = (string?)null });
var inv = BuildFromBody(account.Id, b);
Console.WriteLine($"[invention] {account.Id} saved (v9) '{inv.Name}' (#{inv.InventionId}) blob={inv.BlobName}");
return J(new
{
Value = new { Status = 0, Invention = InventionDto(inv), InventionVersion = VersionDto(inv), TagsResponse = new { Result = 0, Tags = inv.Tags ?? new List<string>() } },
Success = true,
Error = "",
error_id = (string?)null,
});
}
// GET /api/inventions/v1/details?inventionId=X — the invention's tags card. Fetched right
// after a save to open the invention; was 404ing. Returns { Tags:[{Tag,Type}] } (404 if none).
[HttpGet("/api/inventions/v1/details")]
public ContentResult Details()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
if (!int.TryParse(Request.Query["inventionId"], out var id))
return J(new { error = "inventionId is required" }, 400);
var inv = InventionDB.GetById(id);
if (inv == null) return J(new { error = "No such invention" }, 404);
var tags = (inv.Tags ?? new List<string>()).Select(t => new { Tag = t, Type = 0 }).ToList();
return J(new { Tags = tags });
}
private static object V9(object? value, string? error = null) => new
{
Value = value, Success = error == null, Error = error ?? "", error_id = (string?)null
};
private static bool Staff(ulong id) => PlayerDB.CheckDevFlag(id) || PlayerDB.CheckModFlag(id);
// ---- browse / store -----------------------------------------------------------------
// Every one of these answered "[]" (search 468 times a day), so the invention store, the
// "inventions in this room" list and every batch lookup (room offers, wishlists, nested
// inventions) came up empty even with inventions published.
// GET /api/inventions/v2/search?value=&ugcVersion=&skip=&take= - bare array (wire-shapes:
// inventions-v2-search). "#name" is a tag or a creator's @name.
[HttpGet("/api/inventions/v2/search")]
public ContentResult Search([FromQuery] string? value, [FromQuery] int? ugcVersion, [FromQuery] int skip = 0, [FromQuery] int take = 100)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var hits = InventionStore.Search(value, skip, take);
if (ugcVersion.HasValue && ugcVersion.Value > 0)
hits = hits.Where(i => (i.UgcVersion > 0 ? i.UgcVersion : 1) == ugcVersion.Value).ToList();
return J(hits.Select(InventionDto).ToList());
}
// GET /api/inventions/v2/batch?id=1&id=2 - the ones the caller may see (published, or theirs).
[HttpGet("/api/inventions/v2/batch")]
public ContentResult Batch([FromQuery] List<long> id)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var ids = (id ?? new()).Where(x => x > 0 && x <= int.MaxValue).Select(x => (int)x);
var owned = InventionStore.OwnedIds(account.Id);
var list = InventionStore.GetByIds(ids)
.Where(i => i.IsPublished || i.CreatorPlayerId == account.Id || owned.Contains(i.InventionId) || Staff(account.Id))
.Select(InventionDto).ToList();
return J(list);
}
// GET /api/inventions/v2/mine - created + bought, newest first.
[HttpGet("/api/inventions/v2/mine")]
public ContentResult Mine()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
return J(InventionStore.Mine(account.Id).Select(InventionDto).ToList());
}
// GET /api/inventions/v1/room?id=N - published inventions made in that room.
[HttpGet("/api/inventions/v1/room")]
public ContentResult ByRoom([FromQuery] long id, [FromQuery] int skip = 0, [FromQuery] int take = 100)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
return J(InventionStore.ByRoom(id).Skip(Math.Max(0, skip)).Take(take <= 0 ? 100 : take).Select(InventionDto).ToList());
}
[HttpGet("/api/inventions/v1/featured")]
[HttpGet("/api/inventions/v1/toptoday")]
public ContentResult Featured([FromQuery] int skip = 0, [FromQuery] int take = 50)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
return J(InventionStore.Top(skip, take).Select(InventionDto).ToList());
}
// GET /api/inventions/v1/personaldetails/{id} - the caller's own relation (cheering?). Was 404.
[HttpGet("/api/inventions/v1/personaldetails/{inventionId:int}")]
public ContentResult PersonalDetails(int inventionId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
ulong me = error == null && account != null ? account.Id : 0;
return J(new { IsCheering = me != 0 && InventionStore.IsCheering(me, inventionId), IsOwned = me != 0 && InventionStore.Owns(me, inventionId) });
}
// GET /api/inventions/v1/fulllineageowner?id=1&id=2 - does the caller own every piece? bare bool.
[HttpGet("/api/inventions/v1/fulllineageowner")]
public ContentResult FullLineageOwner([FromQuery] List<string> id)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return J(false);
var ids = (id ?? new()).SelectMany(x => x.Split(',')).Select(x => int.TryParse(x.Trim(), out var n) ? n : 0).Where(n => n > 0).ToList();
return J(ids.Count > 0 && InventionStore.OwnsAll(account.Id, ids));
}
// POST /api/inventions/v2/cheer - {InventionId, Cheer?} (form or JSON). Toggles when no flag.
[HttpPost("/api/inventions/v2/cheer")]
[HttpPost("/api/inventions/v1/cheer")]
public ContentResult CheerInvention()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var f = Campus_live_rewrite.api.server.RoomModeration.Fields(Request);
int id = (int)Campus_live_rewrite.api.server.RoomModeration.Long(f, "InventionId", "inventionId", "id");
string flag = Campus_live_rewrite.api.server.RoomModeration.Str(f, "Cheer", "cheer", "IsCheering", "value") ?? "";
bool cheer = flag.Length == 0 ? !InventionStore.IsCheering(account.Id, id) : (flag.Equals("true", StringComparison.OrdinalIgnoreCase) || flag == "1");
var inv = InventionStore.SetCheer(account.Id, id, cheer);
if (inv == null) return J(V9(null, "No such invention"));
return J(V9(new { Status = 0, Invention = InventionDto(inv), InventionVersion = VersionDto(inv), IsCheering = cheer, CheerCount = inv.CheerCount }));
}
// ---- creator writes -----------------------------------------------------------------
// POST /api/inventions/v1/updateprice {InventionId, Price} -> the v6 save result.
[HttpPost("/api/inventions/v1/updateprice")]
[HttpPost("/api/inventions/v2/updateprice")]
public async Task<ContentResult> UpdatePrice()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var b = await ReadBody() ?? new JObject();
int id = b.Value<int?>("InventionId") ?? b.Value<int?>("inventionId") ?? 0;
if (id == 0 && int.TryParse(Request.Query["inventionId"], out var qid)) id = qid;
int price = b.Value<int?>("Price") ?? b.Value<int?>("price") ?? (int.TryParse(Request.Query["price"], out var qp) ? qp : -1);
if (price < 0) return J(new { error = "Price must be >= 0" }, 400);
var inv = InventionStore.Edit(id, account.Id, i => { i.Price = price; }, Staff(account.Id));
if (inv == null) return J(new { error = "No such invention" }, 404);
Console.WriteLine($"[invention] {account.Id} priced #{id} at {price}");
return J(SaveResultV6(inv));
}
// PUT /api/inventions/v2/metadata {InventionId, Name?, Description?, LongDescription?, ImageName?, TagsRequest?} -> v9 envelope.
[HttpPut("/api/inventions/v2/metadata")]
[HttpPost("/api/inventions/v2/metadata")]
[HttpPut("/api/inventions/v2/update")] // the editor's "save details" - same body (was a 404)
[HttpPost("/api/inventions/v2/update")]
public async Task<ContentResult> Metadata()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return J(V9(null, "Unauthorized"), 401);
var b = await ReadBody();
if (b == null) return J(V9(null, "Invalid request body"));
int id = b.Value<int?>("InventionId") ?? b.Value<int?>("inventionId") ?? 0;
var inv = InventionStore.Edit(id, account.Id, i =>
{
string? S(string k) => b[k]?.Type == JTokenType.String ? b.Value<string>(k) : null;
var name = (S("Name") ?? S("name"))?.Trim();
if (!string.IsNullOrEmpty(name)) i.Name = name.Length > 24 ? name[..24] : name;
var desc = S("Description") ?? S("description");
if (desc != null) i.Description = desc.Length > 512 ? desc[..512] : desc;
var ld = S("LongDescription") ?? S("longDescription");
if (ld != null) i.LongDescription = ld;
var img = S("ImageName") ?? S("imageName");
if (!string.IsNullOrEmpty(img)) i.ImageName = img;
var dm = S("DisplayMetadataJson") ?? S("displayMetadataJson");
if (dm != null) i.DisplayMetadataJson = dm;
var price = b.Value<int?>("Price") ?? b.Value<int?>("price");
if (price is >= 0) i.Price = price.Value;
if ((b["TagsRequest"] ?? b["tagsRequest"]) is JObject tr)
{
var custom = tr["CustomTags"]?.ToObject<List<string>>() ?? new();
var auto = tr["AutoTags"]?.ToObject<List<string>>() ?? new();
i.Tags = custom.Concat(auto).Where(t => !string.IsNullOrWhiteSpace(t)).Distinct().ToList();
}
}, Staff(account.Id));
if (inv == null) return J(V9(null, "No such invention, or not yours"));
return J(V9(new { Status = 0, Invention = InventionDto(inv), InventionVersion = VersionDto(inv), TagsResponse = new { Result = 0, Tags = inv.Tags ?? new List<string>() } }));
}
// POST /api/inventions/v1/settags {InventionId, CustomTags[], AutoTags[]} -> {Result, Tags}.
[HttpPost("/api/inventions/v1/settags")]
public async Task<ContentResult> SetTags()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var b = await ReadBody() ?? new JObject();
int id = b.Value<int?>("InventionId") ?? b.Value<int?>("inventionId") ?? 0;
var custom = (b["CustomTags"] ?? b["customTags"])?.ToObject<List<string>>() ?? new();
var auto = (b["AutoTags"] ?? b["autoTags"])?.ToObject<List<string>>() ?? new();
var inv = InventionStore.Edit(id, account.Id, i => { i.Tags = auto.Concat(custom).Where(t => !string.IsNullOrWhiteSpace(t)).Distinct().ToList(); }, Staff(account.Id));
if (inv == null) return J(new { Result = 2, Tags = new List<string>() });
return J(new { Result = 0, Tags = inv.Tags ?? new List<string>() });
}
// GET /api/inventions/v1?inventionId= - one invention, bare (reference). Unpublished ones
// are served too, the way the reference does.
[HttpGet("/api/inventions/v1")]
public ContentResult One([FromQuery] int inventionId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var inv = inventionId > 0 ? InventionStore.GetByIds(new[] { inventionId }).FirstOrDefault() : null;
if (inv == null) return J(new { error = "No such invention" }, 404);
return J(InventionDto(inv));
}
// GET|POST /api/inventions/v1/update - the older edit: the fields ride as QUERY params
// (inventionId, name, description, imageName); absent ones keep their value, an empty
// description clears it, an empty name / imageName is ignored (reference).
[HttpGet("/api/inventions/v1/update")]
[HttpPost("/api/inventions/v1/update")]
public ContentResult UpdateV1()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var f = RoomModeration.Fields(Request);
int id = (int)RoomModeration.Long(f, "inventionId", "InventionId", "id");
var inv = InventionStore.Edit(id, account.Id, i =>
{
string? name = RoomModeration.Str(f, "name", "Name")?.Trim();
if (!string.IsNullOrEmpty(name)) i.Name = name.Length > 24 ? name[..24] : name;
if (f.TryGetValue("description", out var d) || f.TryGetValue("Description", out d)) i.Description = (d ?? "").Length > 512 ? d![..512] : (d ?? "");
string? img = RoomModeration.Str(f, "imageName", "ImageName");
if (!string.IsNullOrEmpty(img)) i.ImageName = img;
}, Staff(account.Id));
if (inv == null) return J(new { Success = false, Error = "No such invention, or not yours" });
return J(InventionDto(inv));
}
// POST /api/inventions/v1/report - filed against the invention's CREATOR (reference).
[HttpPost("/api/inventions/v1/report")]
public async Task<ContentResult> Report()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var b = await ReadBody() ?? new JObject();
var f = RoomModeration.Fields(Request);
int id = b.Value<int?>("InventionId") ?? b.Value<int?>("inventionId") ?? (int)RoomModeration.Long(f, "InventionId", "inventionId");
var inv = id > 0 ? InventionStore.GetByIds(new[] { id }).FirstOrDefault() : null;
if (inv == null) return J(new { success = false, error = "No such invention" });
int category = b.Value<int?>("ReportCategory") ?? (int)RoomModeration.Long(f, "ReportCategory");
string details = b.Value<string>("Details") ?? b.Value<string>("ReportDescription") ?? RoomModeration.Str(f, "Details", "ReportDescription") ?? "";
Campus_live_rewrite.api.dbs.ReportsDB.Create(new Campus_live_rewrite.api.dbs.ReportsDB.Report
{
ReporterPlayerId = account.Id,
ReportedPlayerId = inv.CreatorPlayerId,
ReportCategory = category,
Details = $"[invention {inv.InventionId} '{inv.Name}'] {details}".Trim(),
});
Console.WriteLine($"[report] {account.Id} reported invention {inv.InventionId} by {inv.CreatorPlayerId}");
return J(new { success = true, error = "" });
}
// POST /api/inventions/v2/unpublish | v1/unpublish {InventionId} -> v9 envelope.
[HttpPost("/api/inventions/v2/unpublish")]
[HttpPost("/api/inventions/v1/unpublish")]
public async Task<ContentResult> Unpublish()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return J(V9(null, "Unauthorized"), 401);
var b = await ReadBody() ?? new JObject();
int id = b.Value<int?>("InventionId") ?? b.Value<int?>("inventionId") ?? 0;
if (id == 0 && int.TryParse(Request.Query["inventionId"], out var qid)) id = qid;
var inv = InventionStore.Edit(id, account.Id, i => { i.IsPublished = false; i.Accessibility = 0; }, Staff(account.Id));
if (inv == null) return J(V9(null, "No such invention"));
Console.WriteLine($"[invention] {account.Id} unpublished #{id}");
return J(V9(SaveResultV6(inv)));
}
// POST /api/inventions/v2/accessibility {InventionId, Accessibility} (0 private, 1 public, 2 unlisted).
[HttpPost("/api/inventions/v2/accessibility")]
[HttpPut("/api/inventions/v2/accessibility")]
public async Task<ContentResult> SetAccessibility()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return J(V9(null, "Unauthorized"), 401);
var b = await ReadBody() ?? new JObject();
int id = b.Value<int?>("InventionId") ?? b.Value<int?>("inventionId") ?? 0;
int acc = b.Value<int?>("Accessibility") ?? b.Value<int?>("accessibility") ?? 0;
var inv = InventionStore.Edit(id, account.Id, i =>
{
i.Accessibility = acc;
i.IsPublished = acc != 0;
if (i.IsPublished && i.FirstPublishedAt == null) i.FirstPublishedAt = DateTime.UtcNow;
}, Staff(account.Id));
if (inv == null) return J(V9(null, "No such invention"));
return J(V9(SaveResultV6(inv)));
}
// POST /api/inventions/v2/delete {InventionId} -> {Value:null, Success, Error, error_id}.
[HttpPost("/api/inventions/v2/delete")]
[HttpDelete("/api/inventions/v2/delete")]
public async Task<ContentResult> DeleteInvention()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return J(V9(null, "Unauthorized"), 401);
var b = await ReadBody() ?? new JObject();
int id = b.Value<int?>("InventionId") ?? b.Value<int?>("inventionId") ?? 0;
if (id == 0 && int.TryParse(Request.Query["inventionId"], out var qid)) id = qid;
bool ok = InventionStore.Delete(id, account.Id, Staff(account.Id));
Console.WriteLine($"[invention] {account.Id} delete #{id}: {(ok ? "deleted" : "refused")}");
return J(V9(null, ok ? null : "No such invention, or not yours"));
}
// POST /api/inventions/v4/publish - the same publish, v9 envelope.
[HttpPost("/api/inventions/v4/publish")]
public Task<ContentResult> PublishV4() => PublishV3();
// ---- buying ---------------------------------------------------------------------------
// POST /api/storefronts/v3/buyInvention {InventionId, RequestedPrice} (2025) and
// GET /api/storefronts/v2/buyInvention?inventionId=&requestedPrice= (2023). Charges Rec
// Center Tokens at the STORED price, records ownership (it lands in v2/mine and counts as
// a download) and answers the bare {InventionResponse, BalanceUpdateResponse} pair
// (wire-shapes: storefronts-v3-buyinvention). Neither route existed - the 2025 one 404'd.
[HttpPost("/api/storefronts/v3/buyInvention")]
[HttpGet("/api/storefronts/v2/buyInvention")]
[HttpPost("/api/storefronts/v2/buyInvention")]
public async Task<ContentResult> BuyInvention()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var b = await ReadBody() ?? new JObject();
int id = b.Value<int?>("InventionId") ?? b.Value<int?>("inventionId") ?? 0;
if (id == 0 && int.TryParse(Request.Query["inventionId"], out var qid)) id = qid;
int requested = b.Value<int?>("RequestedPrice") ?? b.Value<int?>("requestedPrice") ?? (int.TryParse(Request.Query["requestedPrice"], out var qp) ? qp : 0);
const int Tokens = (int)Campus_live_rewrite.api.server.Classes.CurrencyType.RecCenterTokens;
long balance = PlayerDB.GetBalance(account.Id, Tokens);
ContentResult Refuse(string why, int status = 200)
{
Console.WriteLine($"[invention-buy] {account.Id} #{id}: {why}");
return J(new { InventionResponse = V9(null, why), BalanceUpdateResponse = (object?)null }, status);
}
var inv = InventionDB.GetById(id);
if (inv == null) return Refuse("No such invention");
if (!inv.IsPublished && inv.CreatorPlayerId != account.Id) return Refuse("This invention is not for sale");
if (inv.CreatorPlayerId == account.Id) return Refuse("You already own your own invention");
if (InventionStore.Owns(account.Id, id)) return Refuse("Already owned");
int price = Math.Max(0, inv.Price);
if (requested != price)
Console.WriteLine($"[invention-buy] {account.Id} #{id}: client rendered {requested}, charging the stored {price}");
if (price > 0 && balance < price) return Refuse("Not enough tokens");
if (price > 0) balance = PlayerDB.AddBalance(account.Id, Tokens, -price);
InventionStore.AddOwned(account.Id, id, price);
EarningsDB.Record(inv.CreatorPlayerId, account.Id, "invention", id.ToString(), inv.Name, Tokens, price);
inv = InventionDB.GetById(id) ?? inv;
Campus_live_rewrite.api.server.GiftBoxes.PushBalance(account.Id, Tokens, balance, "invention purchase");
Console.WriteLine($"[invention-buy] @{account.Username} bought #{id} '{inv.Name}' for {price} -> balance {balance}");
return J(new
{
InventionResponse = V9(new { Status = 0, Invention = InventionDto(inv), InventionVersion = VersionDto(inv), TagsResponse = new { Result = 0, Tags = inv.Tags ?? new List<string>() } }),
BalanceUpdateResponse = new
{
BalanceUpdates = new[] { new { UpdateResponse = 0, Data = InventionDto(inv) } },
Balance = balance,
CurrencyType = Tokens,
Platform = Campus_live_rewrite.api.server.Classes.GameCurrency.AllPlatformsBucket
}
});
}
// POST /api/inventions/v3/publish — flip the invention public. Body/query carries the id.
[HttpPost("/api/inventions/v3/publish")]
public async Task<ContentResult> PublishV3()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return J(new { Value = (object?)null, Success = false, Error = "Unauthorized", error_id = (string?)null });
var b = await ReadBody() ?? new JObject();
int id = b.Value<int?>("inventionId") ?? b.Value<int?>("InventionId") ?? 0;
if (id == 0 && int.TryParse(Request.Query["inventionId"], out var qid)) id = qid;
var inv = InventionDB.Publish(id, account.Id, true);
if (inv == null) return J(new { Value = (object?)null, Success = false, Error = "No such invention", error_id = (string?)null });
Console.WriteLine($"[invention] {account.Id} published #{id}");
return J(new { Value = SaveResultV6(inv), Success = true, Error = "", error_id = (string?)null });
}
}
}
@@ -0,0 +1,120 @@
using LiteDB;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server.Classes;
namespace Campus_live_rewrite.Controllers
{
[ApiController]
[Route("link")]
public class LinkController : ControllerBase
{
[HttpPost("actionlink")]
public ContentResult CreateActionLink()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// data={"ExtraJson":"{"RoomId":9,"SubRoomId":10}","Platform":0,"Type":2}&validHours=2160&codeType=2&extraDataId=9
var data = Request.Form["data"].ToString();
var validHours = int.TryParse(Request.Form["validHours"], out var vh) ? vh : 24;
var codeType = int.TryParse(Request.Form["codeType"], out var ct) ? (LinkType)ct : LinkType.Unknown;
var extraDataId = int.TryParse(Request.Form["extraDataId"], out var edid) ? edid : 0;
var link = new ActionLink
{
creatorPlayerId = account.Id,
data = data,
CodeType = codeType,
ValidHours = vh,
ExtraDataId = edid,
isValid = true,
};
var actionlink = LinkDB.Insert(link);
string url = $"{Request.Scheme}://{Request.Host}";
// The client reads a BARE JSON STRING here (the link URL) - the {success,error,value}
// envelope failed its parse with "expected:'String Begin Token', actual:'{'" (Player.log,
// 20250718.01), so every share/invite link came up as "Malformed Response".
Console.WriteLine($"[link] {account.Id} actionlink type={codeType} extra={extraDataId} -> {actionlink.LinkId}");
return new ContentResult
{
Content = JsonConvert.SerializeObject(url + "/link?code=" + actionlink.LinkId),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("actionlink/{link}")]
public ContentResult GetActionLink(string link)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var found = LinkDB.Get(link);
if (found == null)
{
return new ContentResult
{
Content = "",
ContentType = "application/json",
StatusCode = 404,
};
}
return new ContentResult
{
Content = JsonConvert.SerializeObject(found),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPost("actionlink/{link}/consume")]
public ContentResult ConsumeActionLink(string link)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string formlink = Request.Form["id"].ToString(); // should be the same as link param
string codeTypestring = Request.Form["codeType"].ToString(); // enum
string newPlayer = Request.Form["newPlayer"].ToString(); // Can be False or True
string newInstall = Request.Form["newInstall"].ToString(); // Can be False or True
LinkType CodeType;
if (int.TryParse(codeTypestring, out int numericValue))
{
if (Enum.IsDefined(typeof(LinkType), numericValue))
{
CodeType = (LinkType)numericValue;
}
else
{
}
}
// Welp. This is very crappy. Redo this code when the game actually wants it.
var player = PlayerDB.GetInfluencerByCreatorCode(link);
if (player != null)
{
PlayerDB.SupportInfluencer(account.Id, player.player.Id);
}
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
success = true,
error = "",
value = (string?)null
}),
ContentType = "application/json",
StatusCode = 200
};
}
}
}
@@ -0,0 +1,1712 @@
using Microsoft.AspNetCore.Mvc;
using Campus_live_rewrite.api.dbs;
using Newtonsoft.Json;
using Campus_live_rewrite.api.server.Classes.db_class;
using static Campus_live_rewrite.api.dbs.HeartbeatDB;
using Campus_live_rewrite.api.server;
using System.Text;
using System.IdentityModel.Tokens.Jwt;
using Campus_live_rewrite.api;
using static System.Runtime.InteropServices.JavaScript.JSType;
using static Campus_live_rewrite.api.server.Classes.RRPlus;
using Discord.Net;
using System.Net.Http.Headers;
namespace Campus_live_rewrite.Controllers
{
[Route("Matchmaking")]
public class MatchmakingController : ControllerBase
{
public RoomInstance DefaultSessionDorm = new RoomInstance
{
isFull = false,
isInProgress = false,
isPrivate = true,
location = "76d98498-60a1-430c-ab76-b54a29b7a163",
maxCapacity = 4,
name = "^" + "DormRoom",
roomId = 1,
roomInstanceId = 1,
roomInstanceType = 0,
voiceAuthId = null,
voiceServerId = null,
subRoomId = 1,
matchmakingPolicy = 0,
};
private static readonly System.Collections.Concurrent.ConcurrentDictionary<ulong, DateTime> _lastOnlineNotice = new();
private static bool FriendOnlineNoticeDue(ulong accountId)
{
var now = DateTime.UtcNow;
if (_lastOnlineNotice.TryGetValue(accountId, out var last)
&& (now - last).TotalMinutes < ConfigCampus.FriendOnlineNoticeCooldownMinutes)
return false;
_lastOnlineNotice[accountId] = now;
return true;
}
// The most-populated clubhouse rooms. BARE ARRAY of {RoomId, ClubId, PlayerCount} - a {}
// raises the client's Malformed Response throw (wire-shapes: clubhousesearch-mostactivenow).
// ClubId is a plain long: send 0, never null. Was a 404.
[HttpGet("clubhousesearch/mostactivenow")]
public IActionResult MostActiveClubhousesNow()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var rows = HeartbeatDB.LivePlayerCountsByRoom()
.Where(kv => kv.Value > 0)
.OrderByDescending(kv => kv.Value)
.Take(20)
.Select(kv => new { RoomId = kv.Key, ClubId = 0L, PlayerCount = kv.Value })
.ToList();
return Ok(rows);
}
[HttpPost("player/login")]
[HttpPost("player/exclusivelogin")]
public async Task<ContentResult> PlayerLogin()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// The client posts this on every (re)connect - both /login and /exclusivelogin, and
// again after a Photon reconnect - so friends used to get "X is online" two or three
// times per session. One notice per player per ConfigCampus.FriendOnlineNoticeCooldownMinutes.
if (!FriendOnlineNoticeDue(account.Id))
{
return new ContentResult() { Content = "", ContentType = "application/json", StatusCode = 200 };
}
var relationships = FriendsDB.GetRelationships(account.Id);
List<ulong> mutualFriendIds = relationships.Where(r => r.RelationshipType == RelationshipType.Friend).Select(r => r.PlayerID).Distinct().ToList();
var message = new MessageData
{
FromPlayerId = (ulong)account.Id,
Id = (ulong)new Random().Next(),
SentTime = DateTime.UtcNow,
Type = WebsocketEvents.MessageType.FriendStatusOnline,
// Data is parsed as a JSON OBJECT by the client's notification decoder: the bare
// word "Online" logged "expected:'{', actual:'O', at offset:0" on every friend
// login and the frame was dropped.
Data = "{}",
RoomId = 1
};
var toSkip = new List<ulong>();
foreach (var friendId in mutualFriendIds)
{
if (SettingsDB.GetPlayerSettingValue("ReceiveFriendOnlineStatus", friendId) == "False")
{
toSkip.Add(friendId);
}
}
// Was: mutualFriendIds.Remove(friendId) INSIDE the foreach over
// mutualFriendIds - modifying a list while enumerating it throws
// InvalidOperationException ("Collection was modified"). It only fired
// when a friend actually had the setting off, so it lurked. Collect
// first, remove after.
mutualFriendIds = mutualFriendIds.Where(id => !toSkip.Contains(id)).ToList();
var ws_message = JsonConvert.SerializeObject(WebsocketEvents.CreateMessageResponse(message));
await Notifications.SendToPlayers(mutualFriendIds, ws_message);
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 200
};
}
[Route("player/logout")]
public ContentResult PlayerLogout()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// The account-creation bootstrap fires a spurious logout right after a new player is
// seeded into Orientation (instance -2); clearing it would bounce them to the dorm and
// skip the identity flow. A logout that still points at Orientation is a no-op ack
// (reference: match /player/logout).
if (Campus_live_rewrite.api.server.NewPlayerBootstrap.IsInOrientation(account.Id))
{
Console.WriteLine($"[logout] {account.Id}: in Orientation - presence kept");
return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 200 };
}
// Quitting the game leaves the party for real - the party thread is otherwise left
// holding a player who is not online, which is what made the member count read 2 when
// only one person was there.
try
{
var party = Campus_live_rewrite.api.dbs.ChatDB.GetPartyThreadFor(account.Id);
if (party != null)
{
Campus_live_rewrite.api.dbs.ChatDB.SystemNotice(party.ChatThreadId, $"Player <@U{account.Id}> left");
Campus_live_rewrite.api.dbs.ChatDB.RemoveMember(party.ChatThreadId, account.Id);
Console.WriteLine($"[party] {account.Id} logged out - removed from party thread {party.ChatThreadId}");
}
}
catch (Exception ex) { Console.WriteLine($"[party] logout leave failed: {ex.Message}"); }
HeartbeatDB.UpdatePlayerHeartbeat(account.Id, null, online: false);
Webhook.Send(account.Id,
"prod",
"Matchmaking info",
$"({account.Id}) @{account.Username} {account.DisplayName} is logging out of **Campus**",
5898927
);
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 200
};
}
// The client PUTs its VR movement mode (teleport / walk) whenever the player changes it;
// it was a 404. Stored on the heartbeat so the presence others see carries it.
[HttpPut("player/vrmovementmode")]
public IActionResult VrMovementMode()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
int mode = 0;
try
{
if (Request.HasFormContentType)
{
var v = Request.Form["vrMovementMode"].ToString();
if (string.IsNullOrEmpty(v)) v = Request.Form["VrMovementMode"].ToString();
int.TryParse(v, out mode);
}
}
catch { }
HeartbeatDB.UpdateVrMovementMode(account.Id, mode);
return Ok("");
}
[HttpPut("player/statusvisibility")]
[HttpPost("player/statusvisibility")]
public IActionResult StatusVisibility()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// Form (the client's), query or JSON; a number or the enum name
// (Public 0 / FriendsOnly 1 / FavoriteFriendsOnly 2 / Offline 3).
var f = RoomModeration.Fields(Request);
string raw = RoomModeration.Str(f, "statusVisibility", "StatusVisibility", "visibility", "value") ?? (f.Count == 1 ? f.Values.First() : "");
int statusVisibility;
if (!int.TryParse(raw, out statusVisibility))
statusVisibility = raw.ToLowerInvariant() switch
{
"public" or "everyone" or "all" => 0,
"friendsonly" or "friends" => 1,
"favoritefriendsonly" or "favorites" or "favourites" => 2,
"offline" or "noone" or "nobody" => 3,
_ => -1
};
if (statusVisibility < 0 || statusVisibility > 3)
{
Console.WriteLine($"[presence] {account.Id} statusvisibility: could not read '{raw}' from {RoomModeration.Dump(f)}");
return BadRequest(new { success = false, error = "statusVisibility must be 0-3" });
}
HeartbeatDB.UpdateStatusVisibility(account.Id, statusVisibility);
// Realtime: "Appear Online To" changed - re-push presence so friends who should now see
// (or stop seeing) this player update immediately, not on their next poll - and the
// player's OWN record to them, so the picker/status dot flips at once.
Notifications.FireAndForget(Notifications.PushPresenceToFriends(account.Id), $"presence-visibility {account.Id}");
// Answer the player's own presence record (bare, the GET player?id= shape) - the
// caller that discards it loses nothing; one that reads it is current immediately.
var self = HeartbeatDB.GetPresenceForList(account.Id, viewerIsSelf: true, viewerIsFriend: true, viewerIsFavorite: true);
return Content(JsonConvert.SerializeObject(self), "application/json");
}
// The presence heartbeat. THE CLIENT OBEYS WHATEVER WE ANSWER HERE.
//
// The request body is EMPTY - verified live, the client sends nothing. What looks
// like a client report ("playerId", "roomInstance", "appVersion"...) is OUR OWN
// response being read back: it matches HeartbeatDB.Heartbeat field for field and
// omits exactly photonRoomIdYeee, the one field we mark [JsonIgnore].
//
// So there is nothing to reconcile against. The client compares our answer to
// where it actually is, and on any disagreement logs "local presence is
// out-of-sync!" and WALKS ITSELF to whatever we said. Answering "nowhere" reads to
// it as "you got disconnected", and it goes home to its dorm.
//
// Everything that matters therefore happens on the read side: see
// HeartbeatDB.GetPlayerHeartbeat, which refuses to answer "nowhere" unless
// something deliberately put the player there.
[HttpPost("player/heartbeat")]
public IActionResult PlayerHeartbeat()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// The client DOES send a body here - 78 bytes of application/x-www-form-urlencoded.
//
// An earlier diagnostic reported it as empty and that was wrong: it read
// Request.Form first (which consumes the stream) and then re-read Request.Body,
// which by then had nothing left. Read the FORM, not the raw stream.
//
// Logged once per player so the field names are known without guessing at them.
if (Request.HasFormContentType && _heartbeatFormLogged.Add(account.Id))
{
try
{
Console.WriteLine($"[heartbeat] {account.Id} body: " +
string.Join("&", Request.Form.Select(kv => $"{kv.Key}={kv.Value}")));
}
catch (Exception ex)
{
Console.WriteLine($"[heartbeat] {account.Id} could not read form: {ex.Message}");
}
}
return Content(System.Text.Json.JsonSerializer.Serialize(HeartbeatDB.GetPlayerHeartbeat(account.Id)), "application/json");
}
// Players whose heartbeat body has already been logged - once each, since
// heartbeats fire every ~20s per player.
private static readonly System.Collections.Concurrent.ConcurrentDictionary<ulong, byte> _heartbeatFormSeen = new();
private static class _heartbeatFormLogged
{
public static bool Add(ulong id) => _heartbeatFormSeen.TryAdd(id, 0);
}
[HttpPost("matchmake/none")]
public ContentResult Matchmaking_none()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// Banned / timed out: do not seed a dorm session for them either. This is the
// login-lock heartbeat, and seeding here would hand a banned player a fresh
// room instance to connect to.
if (PlayerDB.HasActiveModerationBlock(account.Id))
{
Console.WriteLine($"[matchmake/none] REFUSED {account.Id}: account is banned or timed out");
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new { roomInstance = (object?)null }),
ContentType = "application/json",
StatusCode = 200
};
}
// "none" = matchmake with no target. The client posts it when it needs an instance but
// isn't going anywhere in particular - at startup, and during its own reconnect /
// recovery sequences. Reference (recflare match /matchmake/none): "It answers the
// instance the player is ALREADY in, so it never warps anyone out of the room they're
// standing in; only a player with no live presence falls back to their dorm."
//
// This used to CLEAR the stored instance instead. Combined with the client posting it
// mid-recovery (after a Photon drop, e.g. when the master client leaves), the next
// heartbeat answered "nowhere" and the client booted itself to the dorm - the second
// half of "one player leaves and the others get kicked" (the first half was
// notifydisconnect, above). Presence is only ever cleared by player/logout now.
var presence = HeartbeatDB.GetSelfPresence(account.Id).room;
if (presence == null)
{
try
{
var dormSession = Sessions.CreateDorm(account.Id, account.Username ?? "player");
if (dormSession != null)
{
HeartbeatDB.UpdatePlayerHeartbeat(account.Id, dormSession);
Console.WriteLine($"[matchmake/none] {account.Id} had no live presence: seeded dorm session photonRoomId={dormSession.photonRoomIdYeee}");
}
}
catch (Exception ex)
{
Console.WriteLine($"[matchmake/none] Failed to seed dorm for {account.Id}: {ex.Message}");
}
}
else
{
// Re-commit the instance they are in (keeps the row and the placement fresh).
HeartbeatDB.UpdatePlayerHeartbeat(account.Id, presence, online: true);
}
var CurrentHeartbeat = JsonConvert.SerializeObject(HeartbeatDB.GetPlayerHeartbeat(account.Id));
return new ContentResult()
{
Content = CurrentHeartbeat,
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPost("matchmake/dorm")]
public IActionResult MatchmakeToDorm([FromForm] string correlationId)
{
string auth = HttpContext.Request.Headers["Authorization"];
if (string.IsNullOrEmpty(auth))
return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 401 };
string playerdata = ClientSecurity.DecodeToken(auth);
if (playerdata == null || playerdata.Contains("error_token"))
return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 401 };
var account = JsonConvert.DeserializeObject<player_info>(playerdata);
string token = auth.Replace("Bearer ", "", StringComparison.OrdinalIgnoreCase);
// Was: jwt.Payload["platform"] via the indexer, then .ToString() on the
// result - KeyNotFoundException if the token carries no platform claim,
// and a NullReferenceException if it carries a null one. Neither is
// recoverable here, and this runs on every dorm join. Tolerate both.
Platforms platform = Platforms.All;
try
{
var jwt = new JwtSecurityTokenHandler().ReadJwtToken(token);
if (jwt.Payload.TryGetValue("platform", out var platformObj) && platformObj != null &&
int.TryParse(platformObj.ToString(), out int platformVal) &&
Enum.IsDefined(typeof(Platforms), platformVal))
{
platform = (Platforms)platformVal;
}
}
catch (Exception jwtEx)
{
Console.WriteLine($"[matchmake/dorm] JWT platform parse failed for {account.Id}: {jwtEx.Message}");
}
// Banned / timed out players do not matchmake anywhere - not even to their
// own dorm. Returning a null room instance leaves them with nowhere to land.
if (PlayerDB.HasActiveModerationBlock(account.Id))
{
Console.WriteLine($"[matchmake/dorm] REFUSED {account.Id}: account is banned or timed out");
return Ok(new FullRoomInstance
{
roomInstance = (RoomInstance?)null,
correlationId = correlationId
});
}
var doesPlayerDormExist = RoomDB.DoesPlayerDormExist(account.Id);
if (doesPlayerDormExist == false)
{
PlayerDB.CreateDormForPlayerId(account.Id);
}
try
{
var session = Sessions.CreateDorm(account.Id, account.Username);
if (session == null)
{
var hb = HeartbeatDB.GetPlayerHeartbeat(account.Id);
return Ok(hb);
}
HeartbeatDB.UpdatePlayerHeartbeat(account.Id, session, true, platform, DeviceClasses.Unknown);
RoomDB.IncreaseRoomVisit((ulong)session.roomId);
Webhook.Send(
account.Id,
"prod",
"Matchmaking Info",
$"({account.Id}) @{account.Username} is going to their Dorm Room",
5898927,
"",
(ulong)session.roomId
);
}
catch (Exception ex)
{
// This catch is why the dorm failure was invisible for so long: it
// swallowed the throw, UpdatePlayerHeartbeat never ran, and the method
// still returned 200 with whatever stale heartbeat happened to be on
// file. Keep swallowing (a 500 here would be worse for the client) but
// make it unmistakable and greppable in journalctl.
Console.WriteLine($"[matchmake/dorm] FAILED for player {account.Id}: {ex}");
}
var finalInstance = HeartbeatDB.GetPlayerHeartbeat(account.Id)?.roomInstance;
if (finalInstance == null)
{
// The client is being handed no room at all - it will appear to join
// nothing and will not start its heartbeat loop. Always worth a line.
Console.WriteLine($"[matchmake/dorm] WARNING player {account.Id}: returning a NULL roomInstance");
}
return Content(System.Text.Json.JsonSerializer.Serialize(new FullRoomInstance
{
roomInstance = finalInstance,
correlationId = correlationId
}), "application/json");
}
// The v2 room-matchmake body as the 2025 client posts it (reference openapi:
// MatchmakeRoomV2Request): JSON with real types. Only JoinMode (2 = private instance),
// CorrelationId and AdditionalPlayerIds are acted on; AdditionalPlayerIds is the caller's
// PARTY - null (not []) when alone - and is what pulls the party into the same instance.
public class MatchmakeV2Request
{
public int? JoinMode { get; set; }
public string CorrelationId { get; set; }
public List<long>? AdditionalPlayerIds { get; set; }
public int? InviteMode { get; set; }
public bool? ShouldKeepPlayerWithParty { get; set; }
public bool? BypassMovementModeRestriction { get; set; }
public int? MaxPersistenceVersion { get; set; }
public int? Ugc1SubVersion { get; set; }
public int? Ugc2SubVersion { get; set; }
public string? VoiceServerVersion { get; set; }
public string? LoginLock { get; set; }
public string? ClientJoinData { get; set; }
}
// The 2025 client POSTs the follow-a-player / accept-invite / join-event calls WITHOUT a
// JSON content-type (often an empty body), so a `[FromBody] MatchmakeV2Request` bind answered
// 415 Unsupported Media Type before the handler ran - which the client surfaces as the
// "Unable to change rooms" popup. These handlers read the (optional) CorrelationId tolerantly
// instead, so ANY content-type or an empty body is accepted. matchmake/v2/room + /dorm keep
// [FromBody] because the client does send them a proper JSON body.
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, byte> _v2BodyLogged = new();
private MatchmakeV2Request? ReadV2RequestBody()
{
try
{
// Diagnostic, a few times per route: the exact body the client posts (content type +
// first 400 chars), so the party field's real spelling can be read off the log.
// Always log the party-relevant fields of a join (small, joins are rare): this is
// the only place the client's party intent is visible to the server.
{
Request.EnableBuffering();
using var peek = new StreamReader(Request.Body, leaveOpen: true);
var rawPeek = peek.ReadToEndAsync().GetAwaiter().GetResult();
Request.Body.Position = 0;
string summary = rawPeek;
try
{
if (rawPeek.TrimStart().StartsWith("{"))
{
var jo = Newtonsoft.Json.Linq.JObject.Parse(rawPeek);
summary = $"JoinMode={jo["JoinMode"]} InviteMode={jo["InviteMode"]} KeepWithParty={jo["ShouldKeepPlayerWithParty"]} " +
$"AutoFollow={jo["AdditionalPlayersAutoFollow"]} AdditionalPlayerIds={jo["AdditionalPlayerIds"]?.ToString(Newtonsoft.Json.Formatting.None) ?? "null"}";
}
}
catch { }
if (summary.Length > 300) summary = summary.Substring(0, 300);
Console.WriteLine($"[matchmake-body] {Request.Path} {summary}");
}
// The 2025 client posts these as FORM-URLENCODED, not JSON:
// BypassMovementModeRestriction=..&LoginLock=..&VoiceServerVersion=..&CorrelationId=..&MaxPersistenceVersion=..
// Reading the body as JSON failed, so CorrelationId came back null - and a matchmake
// response with a null CorrelationId can't be matched to the client's request, so it
// ignored the immediate answer and only moved on the next heartbeat (~10s later,
// "error then it joins seconds after"). Read the FORM so CorrelationId is echoed.
if (Request.HasFormContentType)
{
var req = new MatchmakeV2Request();
if (Request.Form.TryGetValue("CorrelationId", out var corr))
req.CorrelationId = corr.ToString();
if (Request.Form.TryGetValue("JoinMode", out var jm) && int.TryParse(jm, out var jmv))
req.JoinMode = jmv;
// The 2023 client posts the party as a REPEATED field (one id each).
if (Request.Form.TryGetValue("AdditionalPlayerIds", out var ids))
req.AdditionalPlayerIds = ids.Select(v => long.TryParse(v, out var n) ? n : 0).Where(n => n > 0).Distinct().ToList();
return req;
}
using var reader = new StreamReader(HttpContext.Request.Body);
var raw = reader.ReadToEndAsync().GetAwaiter().GetResult();
if (string.IsNullOrWhiteSpace(raw)) return null;
return JsonConvert.DeserializeObject<MatchmakeV2Request>(raw);
}
catch { return null; }
}
[HttpPost("matchmake/v2/room/{RoomId}")]
[HttpPost("matchmake/room/{RoomId}")]
public IActionResult MatchmakeRoom(ulong RoomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var request = ReadV2RequestBody() ?? new MatchmakeV2Request();
string? corr = request.CorrelationId;
// Banned or timed out: matchmake NOWHERE.
//
// This used to hand back the player's current heartbeat, so if their room
// instance was still set the client happily went right back to it - which is
// how a banned player kept reappearing in the room they were banned from.
// Answer with a null room instance so there is nothing to return to.
if (PlayerDB.HasActiveModerationBlock(account.Id))
{
Console.WriteLine($"[matchmake] REFUSED {account.Id}: account is banned or timed out");
return MatchmakeResultV2(MatchmakingErrorCode.Banned, null, corr);
}
var room = RoomDB.GetRoom(RoomId);
if (room == null)
{
// Was NREing on room.Name below. Return current heartbeat so client doesn't
// interpret a null response as auth failure and bounce to login.
Console.WriteLine($"[matchmake/v2/room/{RoomId}] Room not found");
return MatchmakeResultV2(MatchmakingErrorCode.NoSuchRoom, null, corr);
}
// ---- private rooms need an invite ----------------------------------
// Roles ARE the invite on this server: Creator / CoOwner / TemporaryCoOwner /
// Moderator / Host may enter; anyone else is refused. A room-level ban blocks
// entry even to a public room.
//
// Refusing hands back the player's CURRENT heartbeat, so the client leaves them
// where they are rather than treating it as an auth failure and bouncing them
// to login.
if (room == null || !RoomDB.CanPlayerEnterRoom(room, account.Id))
{
string why = room == null ? "room not found"
: RoomDB.PlayerIsBannedFrom(room, account.Id) ? "banned from that room"
: "not published / no invite";
Console.WriteLine($"[matchmake] REFUSED {account.Id} -> room {RoomId}: {why}");
var refuseCode = room != null && RoomDB.PlayerIsBannedFrom(room, account.Id)
? MatchmakingErrorCode.BannedFromRoom : MatchmakingErrorCode.RoomIsPrivate;
return MatchmakeResultV2(refuseCode, null, corr);
}
string roomName = room.Name ?? "";
ulong roomId = room.RoomId;
string roomIdentifier = $"^{roomName}";
string tmp = "";
bool isPrivateRoom = request.JoinMode == 2;
// Public joins REUSE an instance someone is already in, so players actually
// meet. Creating a fresh instance every time (which is what this used to do)
// only worked because every public instance shared the Photon room name
// "room" - the same bug that put strangers from other rooms in your world.
RoomInstance session;
if (isPrivateRoom)
{
session = Sessions.CreateRoom(roomName, "", true);
}
else
{
// Only pool into someone else's session when sharing is allowed. A
// published PRIVATE room is enterable but not shareable, so each visitor
// gets their own instance and can bring their own friends.
session = RoomDB.CanPlayerShareInstance(room, account.Id)
? HeartbeatDB.FindJoinableInstance(roomId, account.Id)!
: null!;
if (session != null)
Console.WriteLine($"[matchmake] {account.Id} joining existing instance of ^{roomName} " +
$"(photon {session.photonRoomIdYeee}, instance {session.roomInstanceId})");
else
{
session = Sessions.CreateRoom(roomName)!;
if (session != null)
Console.WriteLine($"[matchmake] {account.Id} opening a NEW instance of ^{roomName} " +
$"(photon {session.photonRoomIdYeee}, instance {session.roomInstanceId})");
}
}
if (isPrivateRoom)
{
tmp = "[PRIVATE INSTANCE]";
}
RoomDB.IncreaseRoomVisit(roomId);
PlayerDB.SetRecentRooms(account.Id, roomId);
if (session == null)
{
Console.WriteLine($"[matchmake] {account.Id} -> room {RoomId}: no instance could be made");
return MatchmakeResultV2(MatchmakingErrorCode.NoSuchRoom, null, corr);
}
HeartbeatDB.UpdatePlayerHeartbeat(account.Id, session);
var CurrentHeartbeat = JsonConvert.SerializeObject(HeartbeatDB.GetPlayerHeartbeat(account.Id));
Webhook.Send(
account.Id,
"prod",
"Matchmaking Info",
$"({account.Id}) @{account.Username} is going to ^{roomName} {tmp}",
5898927,
"",
roomId
);
var landed = HeartbeatDB.GetPlayerHeartbeat(account.Id)?.roomInstance ?? session;
InviteParty(account.Id, account.Username, request.AdditionalPlayerIds, landed);
return MatchmakeResultV2(MatchmakingErrorCode.Success, landed, corr);
}
[HttpPost("matchmake/v2/room/{RoomId}/{SubRoomId}")]
[HttpPost("matchmake/room/{RoomId}/{SubRoomId}")]
public IActionResult MatchmakeRoom(ulong RoomId, ulong SubRoomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var request = ReadV2RequestBody() ?? new MatchmakeV2Request();
string? corr = request.CorrelationId;
var roomCheck = RoomDB.GetRoom(RoomId);
if (roomCheck == null)
{
Console.WriteLine($"[matchmake/v2/room/{RoomId}/{SubRoomId}] Room not found");
return MatchmakeResultV2(MatchmakingErrorCode.NoSuchRoom, null, corr);
}
if (PlayerDB.HasActiveModerationBlock(account.Id))
{
var heartbeat = HeartbeatDB.GetPlayerHeartbeat(account.Id);
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
heartbeat.playerId,
heartbeat.appVersion,
heartbeat.deviceClass,
errorCode = MatchmakingErrorCode.Banned,
heartbeat.isOnline,
heartbeat.lastOnline,
heartbeat.platform,
roomInstance = (string?)null,
heartbeat.statusVisibility,
heartbeat.vrMovementMode
}),
ContentType = "application/json",
StatusCode = 200
};
}
var room = RoomDB.GetRoom(RoomId);
// ---- private rooms need an invite ----------------------------------
// Roles ARE the invite on this server: Creator / CoOwner / TemporaryCoOwner /
// Moderator / Host may enter; anyone else is refused. A room-level ban blocks
// entry even to a public room.
//
// Refusing hands back the player's CURRENT heartbeat, so the client leaves them
// where they are rather than treating it as an auth failure and bouncing them
// to login.
if (room == null || !RoomDB.CanPlayerEnterRoom(room, account.Id))
{
string why = room == null ? "room not found"
: RoomDB.PlayerIsBannedFrom(room, account.Id) ? "banned from that room"
: "not published / no invite";
Console.WriteLine($"[matchmake] REFUSED {account.Id} -> room {RoomId}: {why}");
var refuseCode = room != null && RoomDB.PlayerIsBannedFrom(room, account.Id)
? MatchmakingErrorCode.BannedFromRoom : MatchmakingErrorCode.RoomIsPrivate;
return MatchmakeResultV2(refuseCode, null, corr);
}
string roomName = room.Name ?? "";
ulong roomId = room.RoomId;
string roomIdentifier = $"^{roomName}";
string subroomname = RoomDB.get_subroom_from_id(RoomId, SubRoomId);
string tmp = "";
bool isPrivateRoom = request.JoinMode == 2;
RoomInstance session = isPrivateRoom
? Sessions.CreateRoom(roomName, subroomname, true)
: Sessions.CreateRoom(roomName, subroomname);
if (isPrivateRoom)
{
tmp = "[PRIVATE INSTANCE]";
}
RoomDB.IncreaseRoomVisit(roomId);
PlayerDB.SetRecentRooms(account.Id, roomId);
if (session == null)
{
var heartbeat = JsonConvert.SerializeObject(HeartbeatDB.GetPlayerHeartbeat(account.Id));
return new ContentResult
{
Content = JsonConvert.SerializeObject(heartbeat),
ContentType = "application/json",
StatusCode = 200
};
}
HeartbeatDB.UpdatePlayerHeartbeat(account.Id, session);
var CurrentHeartbeat = JsonConvert.SerializeObject(HeartbeatDB.GetPlayerHeartbeat(account.Id));
Webhook.Send(
account.Id,
"prod",
"Matchmaking Info",
$"({account.Id}) @{account.Username} is going to ^{roomName}.{subroomname} {tmp}",
5898927,
"",
roomId
);
var landed = HeartbeatDB.GetPlayerHeartbeat(account.Id)?.roomInstance ?? session;
InviteParty(account.Id, account.Username, request.AdditionalPlayerIds, landed);
return MatchmakeResultV2(MatchmakingErrorCode.Success, landed, corr);
}
/// <summary>
/// JOIN EVENT. The client POSTs this when the button is pressed; it did not exist, so
/// every press 404'd and the client showed "Unable to change rooms".
///
/// Entry rules (see EventDB.CanPlayerJoin):
/// creator -> any time, so they can set up before it starts
/// invited -> any time
/// anyone else -> only once it has started, and not after it has ended
///
/// A refusal hands back the player's CURRENT heartbeat, the same way the room
/// matchmaker refuses: the client leaves them where they are instead of treating it
/// as an auth failure.
/// </summary>
[HttpPost("matchmake/event/{EventId}")]
public IActionResult MatchmakeEvent(ulong EventId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var request = ReadV2RequestBody();
Console.WriteLine($"[matchmake/event] ENTER {account.Id} -> event {EventId}");
// /matchmake/event is NOT a v2 path, so it answers the lowercase envelope. A refusal is a
// non-Success code with a null instance (NOT the caller's current room - that echoed a
// "go where you already are" that read as the "Unable to change rooms" stall).
IActionResult StayPut(MatchmakingErrorCode code, string why)
{
Console.WriteLine($"[matchmake/event] REFUSED {account.Id} -> event {EventId}: {why} (code {(int)code} {code})");
return MatchmakeResultLower(code, null, request?.CorrelationId);
}
if (PlayerDB.HasActiveModerationBlock(account.Id))
return StayPut(MatchmakingErrorCode.Banned, "account is banned or timed out");
var ev = EventDB.GetFullEventById(EventId);
if (ev == null)
return StayPut(MatchmakingErrorCode.NoSuchGame, "no such event");
if (!EventDB.CanPlayerJoin(ev, account.Id, out string reason))
return StayPut(MatchmakingErrorCode.EventNotStarted, reason);
var room = RoomDB.GetRoom(ev.RoomId);
if (room == null)
return StayPut(MatchmakingErrorCode.NoSuchRoom, $"event room {ev.RoomId} no longer exists");
// The event's room still applies its own rules - a private room the player has no
// role on is not opened up just because an event points at it.
if (!RoomDB.CanPlayerEnterRoom(room, account.Id))
return StayPut(MatchmakingErrorCode.RoomIsPrivate, $"cannot enter room {ev.RoomId} (private / banned)");
string roomName = room.Name ?? "";
// No subroom on the event -> the room's Home (else first) subroom, not "Unknown Room".
string subroomname = ev.SubRoomId.HasValue && ev.SubRoomId.Value != 0
? RoomDB.get_subroom_from_id(ev.RoomId, ev.SubRoomId.Value)
: (room.SubRooms?.FirstOrDefault(sr => string.Equals(sr.Name, "Home", StringComparison.OrdinalIgnoreCase)) ?? room.SubRooms?.FirstOrDefault())?.Name ?? "";
// Everyone attending an event belongs in the SAME instance, so join the one the
// event is broadcasting to when there is one, and only open a new instance when
// there is not. Without this each attendee would get their own instance and the
// event would be empty for everybody.
RoomInstance? session = null;
if (ev.BroadcastingRoomInstanceId != 0)
session = HeartbeatDB.FindInstanceById((long)ev.BroadcastingRoomInstanceId);
if (session != null && InstanceBlocks.IsBlocked(account.Id, session))
return StayPut(MatchmakingErrorCode.BlockedFromRoom, $"kicked from the event's instance {session.roomInstanceId}");
if (session == null)
{
session = HeartbeatDB.FindJoinableInstance(ev.RoomId, account.Id)
?? Sessions.CreateRoom(roomName, subroomname);
if (session != null)
{
// First one in defines the instance the rest will land in.
EventDB.SetBroadcastingInstance(EventId, (ulong)session.roomInstanceId, true);
Console.WriteLine($"[matchmake/event] event {EventId} is now live in instance {session.roomInstanceId}");
}
}
if (session == null)
return StayPut(MatchmakingErrorCode.InsufficientSpace, "could not create a room instance");
// Mark the instance as this event's so the client shows the "This Event" tab.
session.eventId = (long)EventId;
// ClubId is nullable: `null != 0` is TRUE, and the cast then threw "Nullable object must
// have a value" - every join of a non-club event 500'd after the instance was created.
if (ev.ClubId.HasValue && ev.ClubId.Value != 0) session.clubId = (long)ev.ClubId.Value;
RoomDB.IncreaseRoomVisit(ev.RoomId);
PlayerDB.SetRecentRooms(account.Id, ev.RoomId);
HeartbeatDB.UpdatePlayerHeartbeat(account.Id, session);
Console.WriteLine($"[matchmake/event] {account.Id} joined event {EventId} '{ev.Name}' " +
$"-> ^{roomName}.{subroomname} instance {session.roomInstanceId}");
// Success in the lowercase envelope with the event's live instance, so the client GoTos now.
return MatchmakeResultLower(MatchmakingErrorCode.Success, session, request?.CorrelationId);
}
/// <summary>
/// Marks a room instance as in progress. Also 404'd before now. The event UI uses it to
/// flip an event to "happening now" and to point attendees at the right instance.
/// </summary>
// The room panel's refresh (the circular-arrow button beside PRIVATE MODE). It 404'd -
// nothing implemented it - and, like Reset, it is an instance-wide action, so it is held
// to the same role floor (ConfigCampus.RoomResetMinRole): a moderator or better in the
// room the instance belongs to, or a dev. Anyone else gets a 403 and the client leaves
// the instance alone. The reference server does not implement this route either, so the
// ack shape is the same bare success the sibling inprogress route answers.
[HttpPost("roominstance/{roomInstanceId}/refresh")]
[HttpPut("roominstance/{roomInstanceId}/refresh")]
public IActionResult RefreshRoomInstance(long roomInstanceId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var inst = HeartbeatDB.FindInstanceById(roomInstanceId);
if (inst == null) return StatusCode(404, "");
var room = RoomDB.GetRoom((ulong)inst.roomId);
int role = (int)(RoomDB.PlayerRoleOn(room, account.Id) ?? RoomDBClasses.Role_data.None);
bool allowed = role >= ConfigCampus.RoomResetMinRole || PlayerDB.CheckDevFlag(account.Id);
if (!allowed)
{
Console.WriteLine($"[roominstance] {account.Id} (role {role}) tried to refresh instance {roomInstanceId} of room {inst.roomId} - refused (needs {ConfigCampus.RoomResetMinRole}+)");
return StatusCode(403, "");
}
// A refresh OPENS A NEW INSTANCE of the same room/subroom and moves everyone who was
// in the old one into it - the presence heartbeat walks each client over (it adopts
// whatever instance the server reports), so the room reloads clean for all of them.
// Before this it only re-sent the room DTO and nobody moved.
string subName = room?.SubRooms?.FirstOrDefault(sr => (long)sr.SubRoomId == inst.subRoomId)?.Name ?? "";
var fresh = Sessions.CreateRoom(room?.Name ?? inst.name ?? "", subName, inst.isPrivate);
if (fresh == null) return StatusCode(500, "");
fresh.eventId = inst.eventId; fresh.clubId = inst.clubId;
var movers = Campus_live_rewrite.api.server.RoomPermissions.PlayersInRoom((ulong)inst.roomId)
.Where(pid => HeartbeatDB.GetPlayerHeartbeat(pid)?.roomInstance?.roomInstanceId == roomInstanceId).ToList();
foreach (var pid in movers) HeartbeatDB.UpdatePlayerHeartbeat(pid, fresh);
Console.WriteLine($"[roominstance] {account.Id} (role {role}) refreshed instance {roomInstanceId} of room {inst.roomId} -> new instance {fresh.roomInstanceId} ({movers.Count} player(s) moved)");
return Ok(new { success = true, roomInstance = fresh });
}
[HttpPut("roominstance/{roomInstanceId}/inprogress")]
public IActionResult SetRoomInstanceInProgress(ulong roomInstanceId, [FromQuery] bool inProgress = true)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// The client sends the flag as a form field (inProgress=True|False); the query is a
// fallback. It is a property of the INSTANCE (reference: any player in it may flip
// it), so it lands on every presence row holding that instance.
try
{
if (Request.HasFormContentType && Request.Form.TryGetValue("inProgress", out var f) && bool.TryParse(f.ToString(), out var fb)) inProgress = fb;
}
catch { }
HeartbeatDB.UpdateInstanceEverywhere((long)roomInstanceId, i => i.isInProgress = inProgress);
var ev = EventDB.GetEventForRoomInstance(roomInstanceId);
if (ev != null)
{
EventDB.SetBroadcastingInstance(ev.PlayerEventId, roomInstanceId, inProgress);
Console.WriteLine($"[event] instance {roomInstanceId} inProgress={inProgress} (event {ev.PlayerEventId})");
}
return Ok(new { success = true });
}
// ---- the owner's Instances tab (reference match: GET /room/{id}/instances) ----------
// Live sessions of a room with who is in each. Creator / co-owner / staff only.
[HttpGet("room/{roomId}/instances")]
public IActionResult RoomInstances(ulong roomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var room = RoomDB.GetRoom(roomId);
if (room == null) return NotFound();
if (!RoomDB.CanModerateRoom(room, account.Id)) return StatusCode(403, "");
var list = HeartbeatDB.LiveInstancesOfRoom(roomId).Select(t => new
{
roomInstanceId = t.inst.roomInstanceId,
roomId = t.inst.roomId,
subRoomId = t.inst.subRoomId,
isFull = HeartbeatDB.InstanceIsFull(t.inst),
isPrivate = t.inst.isPrivate,
isInProgress = t.inst.isInProgress,
playerCount = t.players.Count,
maxCapacity = HeartbeatDB.EffectiveCapacity(t.inst),
createdAt = DateTime.UtcNow.ToString("o"),
playerIds = t.players.Select(p => (long)p).ToList(),
}).ToList();
return Ok(list);
}
// Mark a live instance private: public matchmaking stops pooling new players into it,
// the people already inside are unaffected. Creator / co-owner only (reference).
[HttpPost("roominstance/{roomInstanceId}/markprivate")]
[HttpPut("roominstance/{roomInstanceId}/markprivate")]
public IActionResult MarkInstancePrivate(ulong roomInstanceId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var inst = HeartbeatDB.FindInstanceById((long)roomInstanceId);
if (inst == null) return NotFound();
var room = RoomDB.GetRoom((ulong)inst.roomId);
if (room == null || !RoomDB.CanModerateRoom(room, account.Id)) return StatusCode(403, "");
int n = HeartbeatDB.UpdateInstanceEverywhere((long)roomInstanceId, i => i.isPrivate = true);
Console.WriteLine($"[matchmake] {account.Id} marked instance {roomInstanceId} of ^{inst.name} private ({n} row(s))");
return Ok(new { success = true, error = "" });
}
// Join one specific live instance of your own room - the Instances tab's "join" (reference
// match POST /matchmake/instance/{id}). Owner / co-owner / staff; enters even a full or
// in-progress instance, and never creates one.
[HttpPost("matchmake/instance/{instanceId}")]
[HttpPost("matchmake/v2/instance/{instanceId}")]
public IActionResult MatchmakeInstance(long instanceId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var request = ReadV2RequestBody();
string? corr = request?.CorrelationId;
if (PlayerDB.HasActiveModerationBlock(account.Id)) return StayPut(account.Id, corr, MatchmakingErrorCode.Banned, "caller banned/timed out");
var inst = HeartbeatDB.FindInstanceById(instanceId);
if (inst == null) return StayPut(account.Id, corr, MatchmakingErrorCode.NoSuchRoom, $"instance {instanceId} is not live");
var room = RoomDB.GetRoom((ulong)inst.roomId);
if (room == null || !RoomDB.CanModerateRoom(room, account.Id))
return StayPut(account.Id, corr, MatchmakingErrorCode.NoSuchRoom, "not a manager of that room");
HeartbeatDB.UpdatePlayerHeartbeat(account.Id, inst);
PlayerDB.SetRecentRooms(account.Id, (ulong)inst.roomId);
Console.WriteLine($"[matchmake] {account.Id} (owner) joined instance {instanceId} of ^{inst.name}");
return MatchmakeResultV2(MatchmakingErrorCode.Success, inst, corr);
}
// Matchmake into a club's clubhouse (reference match POST /matchmake/club/{id}): members
// only, into the room the club set as its clubhouse.
[HttpPost("matchmake/club/{clubId}")]
[HttpPost("matchmake/v2/club/{clubId}")]
public IActionResult MatchmakeClub(int clubId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var request = ReadV2RequestBody();
string? corr = request?.CorrelationId;
var club = ClubDB.GetById(clubId);
if (club == null || club.ClubhouseRoomId == null || club.ClubhouseRoomId <= 0)
return StayPut(account.Id, corr, MatchmakingErrorCode.NoSuchRoom, $"club {clubId} unknown or has no clubhouse");
if (ClubDB.MyMembership(clubId, account.Id) <= 0)
return StayPut(account.Id, corr, MatchmakingErrorCode.NoSuchRoom, $"not a member of club {clubId}");
return MatchmakeRoom((ulong)club.ClubhouseRoomId.Value);
}
// Join a player out of a chat / party thread ("join" on a party member's card - the 2025
// client posts matchmake/chatinvite/{threadId}/{playerId}, a live 404). Being on the same
// thread is the consent: both must be members, then it is the ordinary follow-a-player join.
[HttpPost("matchmake/chatinvite/{threadId}/{playerId}")]
[HttpPost("matchmake/v2/chatinvite/{threadId}/{playerId}")]
public IActionResult MatchmakeChatInvite(long threadId, ulong playerId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var request = ReadV2RequestBody();
string? corr = request?.CorrelationId;
if (playerId == account.Id) return StayPut(account.Id, corr, MatchmakingErrorCode.AlreadyInTargetInstance, "can't join yourself");
if (PlayerDB.HasActiveModerationBlock(account.Id)) return StayPut(account.Id, corr, MatchmakingErrorCode.Banned, "caller banned/timed out");
if (!ChatDB.IsMember(threadId, account.Id) || !ChatDB.IsMember(threadId, playerId))
return StayPut(account.Id, corr, MatchmakingErrorCode.InsufficientRelationship, $"not both on chat thread {threadId}");
if (FriendsDB.IsBlockedEitherWay(account.Id, playerId))
return StayPut(account.Id, corr, MatchmakingErrorCode.InsufficientRelationship, "one of them has blocked the other");
var (online, targetRoom) = HeartbeatDB.PeekPresence(playerId);
if (!online || targetRoom == null) targetRoom = HeartbeatDB.RecentInstanceFor(playerId);
if (targetRoom == null) return StayPut(account.Id, corr, MatchmakingErrorCode.PlayerNotOnline, "target offline / not in a room");
var room = RoomDB.GetRoom((ulong)targetRoom.roomId);
if (room != null && RoomDB.PlayerIsBannedFrom(room, account.Id))
return StayPut(account.Id, corr, MatchmakingErrorCode.BannedFromRoom, "banned from that room");
return JoinInstance(account.Id, account.Username, corr, targetRoom, playerId);
}
[HttpGet("rooms/requiring/{requirement}")]// ["RecCenter", "test123"]
public ContentResult RoomsRequiring(string requirement)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
requirement = requirement?.ToLowerInvariant();
string jsonResponse = requirement switch
{
"developer" => "[]",
"rrplus" => "[]",
_ => "[]"
};
return new ContentResult
{
Content = jsonResponse,
ContentType = "application/json",
StatusCode = 200
};
}
// Join the player you tapped in the friends list, or whose game invite you accepted
// (`POST /Matchmaking/matchmake/v2/player/{playerId}`). The 2025 client hits this for BOTH,
// and it was NOT implemented (live log: 404), so "join" and invite-accept did nothing.
//
// Lands the caller in the SAME live instance the target is standing in (same Photon room),
// read from the target's presence, so they actually meet. Access: mutual friends may always
// follow; a non-friend may only join a PUBLIC instance (a private/dorm instance needs
// friendship). Gated on a LIVE socket so we never chase a stale room, plus a room-ban check
// since this hands out Photon coordinates without going through the room resolver. On any
// refusal it returns the caller's CURRENT instance (leave them where they are) rather than a
// null room, so the client doesn't read it as a disconnect and bounce them home. Mirrors the
// reference `/matchmake/player/:playerId` + `/matchmake/v2/player/:playerId`.
// "Leave the caller where they are" - the safe answer to a failed join. Returning a null
// room instead reads to the client as a disconnect and walks them home to their dorm.
// The PascalCase RoomInstance the v2 client reads out of a matchmake response to know WHERE
// to go. `/matchmake/v2/*` and `/matchmake/invite/*` answer this shape; the lowercase
// FullRoomInstance we used before had none of these fields, so the client couldn't read the
// target's instance from a join-a-player / accept-invite response and fell back to the slow
// heartbeat poll (~30s) - the "Unable to change rooms, then it force-joins later" symptom.
// If this instance is one an event is broadcasting to, make sure it carries the event id
// (the client reads it to show the "This Event" tab). Cheap lazy stamp on read.
private static void StampInstanceEvent(RoomInstance? i)
{
if (i == null || i.eventId != 0) return;
var ev = EventDB.GetEventForRoomInstance((ulong)i.roomInstanceId);
if (ev != null) i.eventId = (long)ev.PlayerEventId;
}
private static object ToV2RoomInstance(RoomInstance i) => new
{
RoomInstanceId = i.roomInstanceId,
RoomId = i.roomId,
SubRoomId = i.subRoomId,
Location = string.IsNullOrEmpty(i.location) ? "76d98498-60a1-430c-ab76-b54a29b7a163" : i.location,
// NEVER null: the client's decoder is strict (reference: eventId 0 / clubId 0 /
// roomCode ""). A null here failed the parse of a SUCCESSFUL join, the client showed
// "Unable to change room", and the player only moved ~20 s later when the presence
// heartbeat dragged them over - the "invites are delayed" symptom.
EventId = i.eventId,
ClubId = i.clubId,
RoomCode = "",
Name = i.name,
MaxCapacity = i.maxCapacity,
IsFull = i.isFull,
IsPrivate = i.isPrivate,
IsInProgress = i.isInProgress,
EncryptVoiceChat = i.EncryptVoiceChat,
RoomInstanceType = i.roomInstanceType,
MatchmakingPolicy = i.matchmakingPolicy
};
// PascalCase matchmake envelope for /matchmake/v2/* and /matchmake/invite/* (what the 2025
// client parses): { ErrorCode, CorrelationId, RoomInstance }. ErrorCode 0 = Success; a
// refusal is a non-zero code with a null RoomInstance (the client then leaves the player put
// WITHOUT reading it as a disconnect - so we no longer echo their current instance back).
private IActionResult MatchmakeResultV2(MatchmakingErrorCode code, RoomInstance? instance, string? corr)
{
return Content(System.Text.Json.JsonSerializer.Serialize(new
{
ErrorCode = (int)code,
// Always a string: an invite accept posts an EMPTY body (seen live), and a null
// echo fails the client's strict decoder. Reference: all-zero GUID when none sent.
CorrelationId = string.IsNullOrEmpty(corr) ? "00000000-0000-0000-0000-000000000000" : corr,
RoomInstance = instance == null ? null : ToV2RoomInstance(StampAndReturn(instance))
}), "application/json");
}
private static RoomInstance StampAndReturn(RoomInstance i) { StampInstanceEvent(i); return i; }
// Lowercase matchmake envelope for the non-v2 paths (e.g. /matchmake/event/*):
// { errorCode, result, roomInstance, correlationId } with the RAW (lowercase) instance.
private IActionResult MatchmakeResultLower(MatchmakingErrorCode code, RoomInstance? instance, string? corr)
{
StampInstanceEvent(instance);
return Content(System.Text.Json.JsonSerializer.Serialize(new
{
errorCode = (int)code,
result = (int)code,
roomInstance = instance,
correlationId = corr
}), "application/json");
}
// Pull the caller's PARTY into the instance they just landed in (reference: inviteParty).
// Each member gets the same recorded room invite POST /invite sends, pointing at this
// instance; their client redeems it through /matchmake/invite/{id} or follows the leader
// through /matchmake/v2/player/{leader}. The row is what makes the frame redeemable - a
// frame with no row reads as expired the moment it arrives. Leader skipped, offline
// members skipped, best-effort per member: it never fails the leader's matchmake.
private void InviteParty(ulong leaderId, string? leaderName, List<long>? memberIds, RoomInstance? instance)
{
if (memberIds == null || memberIds.Count == 0 || instance == null) return;
foreach (var raw in memberIds.Distinct())
{
if (raw <= 0) continue;
ulong pid = (ulong)raw;
if (pid == leaderId) continue;
var (online, _) = HeartbeatDB.PeekPresence(pid);
if (!online) { Console.WriteLine($"[party] {leaderId}: member {pid} offline - not invited"); continue; }
try
{
var invite = RoomInvites.Create(leaderId, pid);
string data = JsonConvert.SerializeObject(new { InviteId = invite.RoomInviteId, Name = instance.name ?? "", InviteMode = 22 });
var msg = new MessageData
{
Id = (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
FromPlayerId = leaderId,
ToPlayerId = pid,
SentTime = DateTime.UtcNow,
Type = WebsocketEvents.MessageType.GameInviteV2,
Data = data,
RoomId = (ulong)instance.roomId,
PlayerEventId = null
};
var frame = JsonConvert.SerializeObject(WebsocketEvents.CreateMessageResponse(msg));
Notifications.FireAndForget(Notifications.SendToPlayer(pid, frame), $"party invite {leaderId}->{pid}");
Console.WriteLine($"[party] {leaderId} @{leaderName} pulled {pid} into ^{instance.name} (instance {instance.roomInstanceId}, invite {invite.RoomInviteId})");
}
catch (Exception ex) { Console.WriteLine($"[party] invite {leaderId}->{pid} failed: {ex.Message}"); }
}
}
private IActionResult StayPut(ulong accountId, string? corr, MatchmakingErrorCode code, string why)
{
Console.WriteLine($"[matchmake/join] {accountId}: {why} (code {(int)code} {code}); leaving them where they are");
return MatchmakeResultV2(code, null, corr);
}
private IActionResult JoinInstance(ulong accountId, string? username, string? corr, RoomInstance target, ulong followedPlayerId)
{
// Kicked out of this instance: not back in through a friend / invite either, until the
// block runs out. (Any other instance of the room is fine.)
var block = InstanceBlocks.Active(accountId, target);
if (block != null)
return StayPut(accountId, corr, MatchmakingErrorCode.BlockedFromRoom,
$"kicked from instance {target.roomInstanceId} of ^{target.name}, blocked until {block.Until:HH:mm}Z");
// A full instance (the subroom's MaxPlayers - 4 in Jumbotron) refuses the join
// instead of stuffing a fifth player in; the client shows "room is full".
if (HeartbeatDB.InstanceIsFull(target))
{
var mine = HeartbeatDB.PeekPresence(accountId).room;
if (mine == null || mine.roomInstanceId != target.roomInstanceId)
return StayPut(accountId, corr, MatchmakingErrorCode.InsufficientSpace,
$"instance {target.roomInstanceId} of ^{target.name} is full ({HeartbeatDB.CountInInstance(target.roomInstanceId)}/{HeartbeatDB.EffectiveCapacity(target)})");
}
HeartbeatDB.UpdatePlayerHeartbeat(accountId, target);
PlayerDB.SetRecentRooms(accountId, (ulong)target.roomId);
Console.WriteLine($"[matchmake/join] {accountId} @{username} joining {followedPlayerId} in " +
$"^{target.name} (instance {target.roomInstanceId}, photon {target.photonRoomIdYeee})");
// Return the TARGET's instance in the PascalCase shape so the client GoTos immediately.
return MatchmakeResultV2(MatchmakingErrorCode.Success, target, corr);
}
[HttpPost("matchmake/v2/player/{playerId}")]
[HttpPost("matchmake/player/{playerId}")]
public IActionResult MatchmakePlayer(ulong playerId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var request = ReadV2RequestBody();
Console.WriteLine($"[matchmake/join] ENTER {account.Id} -> player {playerId}");
string? corr = request?.CorrelationId;
if (playerId == account.Id) return StayPut(account.Id, corr, MatchmakingErrorCode.AlreadyInTargetInstance, "can't join yourself");
if (PlayerDB.HasActiveModerationBlock(account.Id)) return StayPut(account.Id, corr, MatchmakingErrorCode.Banned, "caller banned/timed out");
if (FriendsDB.IsBlockedEitherWay(account.Id, playerId))
return StayPut(account.Id, corr, MatchmakingErrorCode.InsufficientRelationship, "one of them has blocked the other");
// Where is the target right now? Live-socket gated (PeekPresence) so a crashed/closed
// player's stale room is never chased.
var (online, targetRoom) = HeartbeatDB.PeekPresence(playerId);
if (!online || targetRoom == null)
{
// The target has no live room. If they were in one within the last couple of
// minutes, the caller is almost certainly regrouping after the whole room dropped
// (master-client switch recovery: both players reset, both rows cleared, each
// asks to join the other). Send them to that instance so the group re-forms.
var recent = HeartbeatDB.RecentInstanceFor(playerId);
if (recent == null)
return StayPut(account.Id, corr, MatchmakingErrorCode.PlayerNotOnline, "target offline / not in a room");
Console.WriteLine($"[matchmake/join] {account.Id} -> player {playerId}: target dropped moments ago, regrouping into their last instance {recent.roomInstanceId}");
targetRoom = recent;
}
// Who may follow: mutual friends always; anyone holding a live invite FROM this player
// (the accept-invite path); otherwise only into a PUBLIC instance.
bool friends = FriendsDB.GetRelationships(account.Id)
.Any(r => r.PlayerID == playerId && r.RelationshipType == RelationshipType.Friend);
bool invited = RoomInvites.HasInviteFromTo(playerId, account.Id);
// A party member following the leader: the client never posts the party, so "we were
// in the same instance a moment ago" is what stands in for membership on the gate.
bool together = HeartbeatDB.WereTogetherRecently(account.Id, playerId);
if (!friends && !invited && !together && targetRoom.isPrivate)
return StayPut(account.Id, corr, MatchmakingErrorCode.RoomInstanceIsPrivate, "not friends/invited/party and target is in a private instance");
// An UNPUBLISHED room is the owner's draft: friendship alone does not get you in. Only a
// role on the room or a live invite from someone inside does (a party regroup counts).
var targetRoomData = RoomDB.GetRoom((ulong)targetRoom.roomId);
if (targetRoomData != null && !RoomDB.IsRoomPublished(targetRoomData) && !RoomDB.PlayerIsMemberOf(targetRoomData, account.Id) && !invited && !together)
return StayPut(account.Id, corr, MatchmakingErrorCode.RoomInstanceIsPrivate, "target is in an unpublished room and the caller was not invited");
if (together && !friends && !invited) Console.WriteLine($"[matchmake/join] {account.Id} -> {playerId}: allowed as a recent party companion");
var room = RoomDB.GetRoom((ulong)targetRoom.roomId);
if (room != null && RoomDB.PlayerIsBannedFrom(room, account.Id))
return StayPut(account.Id, corr, MatchmakingErrorCode.BannedFromRoom, "banned from that room");
if (invited) RoomInvites.ConsumeFromTo(playerId, account.Id);
return JoinInstance(account.Id, account.Username, corr, targetRoom, playerId);
}
// Redeem a game invite by its id (`POST /Matchmaking/matchmake/invite/{inviteId}` - the
// 2025 client's join button on an invite). Follows the INVITER's live presence, so it lands
// you wherever they are now, and is single-use. Was 404.
[HttpPost("matchmake/invite/{inviteId}")]
public IActionResult MatchmakeInvite(long inviteId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var request = ReadV2RequestBody();
Console.WriteLine($"[matchmake/invite] ENTER {account.Id} -> invite {inviteId}");
string? corr = request?.CorrelationId;
var inv = RoomInvites.Get(inviteId);
if (inv == null) return StayPut(account.Id, corr, MatchmakingErrorCode.RoomInviteExpired, $"invite {inviteId} expired/unknown");
if (inv.ToPlayerId != account.Id) return StayPut(account.Id, corr, MatchmakingErrorCode.RoomInviteExpired, "invite not addressed to caller");
if (FriendsDB.IsBlockedEitherWay(account.Id, inv.FromPlayerId))
return StayPut(account.Id, corr, MatchmakingErrorCode.InsufficientRelationship, "one of them has blocked the other");
var (online, targetRoom) = HeartbeatDB.PeekPresence(inv.FromPlayerId);
if (!online || targetRoom == null) return StayPut(account.Id, corr, MatchmakingErrorCode.PlayerNotOnline, "inviter offline / not in a room");
var room = RoomDB.GetRoom((ulong)targetRoom.roomId);
if (room != null && RoomDB.PlayerIsBannedFrom(room, account.Id))
return StayPut(account.Id, corr, MatchmakingErrorCode.BannedFromRoom, "banned from that room");
RoomInvites.Consume(inviteId);
return JoinInstance(account.Id, account.Username, corr, targetRoom, inv.FromPlayerId);
}
// Send a game invite (`POST /Matchmaking/invite`) - was 404, which is why the friends-list
// "Invite" button showed "Could not invite / Unable to send game invite". Records an invite
// and pushes a GameInviteV2 MessageReceived frame to the target so their client shows the
// join prompt. Mirrors RecFlare's POST /invite. Per the owner's rule, an OFFLINE (or
// appear-offline) target CANNOT be invited.
[HttpPost("invite")]
public async Task<IActionResult> SendGameInvite()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// Parse target + instance from form, JSON body, or query - the client's content-type
// for this call was unconfirmed and a [FromForm]-only bind silently read 0 (-> spurious
// 400 "playerId is required") whenever it wasn't exactly form-encoded.
ulong playerId = 0, roomInstanceId = 0;
if (Request.HasFormContentType)
{
// Form keys are case-sensitive; the client's casing here was unconfirmed, so read
// both playerId/PlayerId (and the target-id aliases the invite/message APIs use).
string FormAny(params string[] keys)
{
foreach (var k in keys) { var v = Request.Form[k].ToString(); if (!string.IsNullOrWhiteSpace(v)) return v; }
return "";
}
ulong.TryParse(FormAny("playerId", "PlayerId", "toPlayerId", "ToPlayerId", "id"), out playerId);
ulong.TryParse(FormAny("roomInstanceId", "RoomInstanceId"), out roomInstanceId);
}
else
{
try
{
using var reader = new StreamReader(HttpContext.Request.Body);
var raw = reader.ReadToEnd();
if (!string.IsNullOrWhiteSpace(raw))
{
var jo = Newtonsoft.Json.Linq.JObject.Parse(raw);
playerId = (ulong?)jo["playerId"] ?? (ulong?)jo["PlayerId"] ?? 0;
roomInstanceId = (ulong?)jo["roomInstanceId"] ?? (ulong?)jo["RoomInstanceId"] ?? 0;
}
}
catch { }
}
if (playerId == 0 && ulong.TryParse(Request.Query["playerId"], out var qp)) playerId = qp;
if (roomInstanceId == 0 && ulong.TryParse(Request.Query["roomInstanceId"], out var qr)) roomInstanceId = qr;
Console.WriteLine($"[invite] {account.Id} -> target={playerId} instance={roomInstanceId} form={Request.HasFormContentType}");
if (playerId == 0)
{
Console.WriteLine($"[invite] {account.Id} REFUSED - no playerId in request");
return BadRequest(new { success = false, error = "playerId is required" });
}
if (playerId == account.Id)
return BadRequest(new { success = false, error = "You can't invite yourself." });
if (FriendsDB.IsBlockedEitherWay(account.Id, playerId))
{
Console.WriteLine($"[invite] {account.Id} REFUSED - one of them has blocked the other ({playerId})");
return Ok(new { success = false, error = "You can't invite that player." });
}
// Offline / appearing offline -> not invitable (the owner's rule). Logged so a wrongly
// "offline" target - e.g. one whose notify socket dropped on a restart - is visible.
var (targetOnline, _) = HeartbeatDB.PeekPresence(playerId);
if (!targetOnline)
{
Console.WriteLine($"[invite] {account.Id} REFUSED - target {playerId} reads offline (no live notify socket)");
return BadRequest(new { success = false, error = "That player is offline." });
}
// The instance to invite into: the one the client named, else the caller's current room.
// Unlike before, a missing instance no longer 400s - the reference records + sends the
// invite anyway (RoomId/Name just ride empty), so an invite from a dorm/loading caller
// still reaches the target.
var instance = HeartbeatDB.FindInstanceById((long)roomInstanceId)
?? HeartbeatDB.GetPlayerHeartbeat(account.Id)?.roomInstance;
var invite = RoomInvites.Create(account.Id, playerId);
// GameInviteV2 (type 6) for this client build: Data is JSON-in-a-string; RoomId rides on
// the message. InviteMode 22 = PlayTogether (RecFlare GAME_INVITE_V2_INVITE_MODE).
string data = JsonConvert.SerializeObject(new
{
InviteId = invite.RoomInviteId,
Name = instance?.name ?? "",
InviteMode = 22
});
var msg = new MessageData
{
Id = (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
FromPlayerId = account.Id,
ToPlayerId = playerId,
SentTime = DateTime.UtcNow,
Type = WebsocketEvents.MessageType.GameInviteV2,
Data = data,
RoomId = (ulong)(instance?.roomId ?? 0),
PlayerEventId = null
};
try
{
var frame = JsonConvert.SerializeObject(WebsocketEvents.CreateMessageResponse(msg));
await Notifications.SendToPlayer(playerId, frame);
}
catch (Exception ex) { Console.WriteLine($"[invite] push to {playerId} failed: {ex.Message}"); }
// The invitee's client checks the inviter's presence before it will accept. Push it
// now (the invite makes the inviter visible to them - see GetPresenceForList).
try { await Notifications.PushPresenceTo(playerId, account.Id); } catch { }
Console.WriteLine($"[invite] {account.Id} @{account.Username} invited {playerId} to " +
$"^{instance?.name ?? "(no room)"} (instance {instance?.roomInstanceId ?? 0}, inviteId {invite.RoomInviteId})");
return Ok(new
{
RoomInviteId = invite.RoomInviteId,
FromPlayerId = account.Id,
ToPlayerId = playerId,
RoomId = instance?.roomId ?? 0
});
}
// Decline / dismiss an invite (`DELETE /Matchmaking/invite/{inviteId}`) - was 404. Only the
// recipient (or the sender, cancelling) may drop it.
[HttpDelete("invite/{inviteId}")]
public IActionResult DeleteGameInvite(long inviteId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var inv = RoomInvites.Get(inviteId);
if (inv != null && (inv.ToPlayerId == account.Id || inv.FromPlayerId == account.Id))
{
RoomInvites.Consume(inviteId);
Console.WriteLine($"[invite] {account.Id} dismissed invite {inviteId} ({inv.FromPlayerId} -> {inv.ToPlayerId})");
}
return Ok(new { success = true });
}
// The client's clean-exit signal (`POST /Matchmaking/player/notifydisconnect`) was NOT
// implemented (live log: 404), so a graceful quit never cleared the player's presence and
// friends kept seeing them "online in a room they aren't in". Clear the heartbeat here so
// presence drops immediately on a normal exit (the friends-list read is also live-socket
// gated now, but this makes the graceful case instant instead of waiting for the socket to
// notice the drop).
// The client posts this when its PHOTON connection to the room drops - which happens to
// every remaining player the moment the Photon master client (the first one into the
// instance) leaves - and then reconnects to the same room by itself. This used to CLEAR
// the player's room instance. Their next presence heartbeat then answered "nowhere", and
// the client treats that as an eviction:
//
// [RecNet.Features.Matchmaking] RecNet presence heartbeat response indicates local
// presence is out-of-sync!
// [RoomLoad.RoomManager.PresenceUpdates] RecNet is directing us to go to an Invalid
// RoomInstance. This is RecNet booting us from the room ... so we trigger Fallbacks.
//
// - and sent itself to the dorm. That is "one player leaves, everyone else gets kicked",
// verified in the server log: every "answer changed <instance> -> 0 (nowhere)" on
// 2026-09-11 followed that same player's own notifydisconnect by 5-40 s.
//
// The reference (recflare match /player/notifydisconnect) logs it and does nothing:
// presence is cleared by logout, and otherwise expires. Same here. A player who really
// left is cleared by player/logout or reset by their next login / matchmake, both of
// which still run.
[HttpPost("player/notifydisconnect")]
public IActionResult NotifyDisconnect()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string detail = "";
try
{
if (Request.HasFormContentType)
detail = string.Join("&", Request.Form.Select(kv => $"{kv.Key}={kv.Value}"));
}
catch { }
var current = HeartbeatDB.GetSelfPresence(account.Id).room;
Console.WriteLine($"[matchmake] {account.Id} notified a Photon disconnect ({detail}) - presence kept " +
$"(instance {current?.roomInstanceId ?? 0}); the client reconnects on its own");
return Ok(new { success = true });
}
[HttpGet("player")]
[HttpPost("player")]
public IActionResult GetPlayerHeartbeatById([FromQuery] List<ulong> id)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// POST form: the reference's presence heartbeat (a LoginLock guid, verified not
// stored) answering the CALLER's own presence; with ids in the body it is the bulk
// lookup. Was a 405 either way.
if (HttpMethods.IsPost(Request.Method) && (id == null || id.Count == 0))
{
var f = RoomModeration.Fields(Request);
var ids = RoomModeration.ULongs(f, "id", "ids", "playerIds", "PlayerIds");
if (ids.Count > 0) id = ids;
else return Ok(HeartbeatDB.GetPlayerHeartbeat(account.Id));
}
if (id == null || id.Count == 0)
{
return Ok(ConfigCampus.Bracket);
}
// The caller is the VIEWER. Precompute who they're friends with / have favorited once,
// so each target's "Appear Online To" (statusVisibility) can be honored relative to
// them (Friends-only / Favorites-only / No One) without a lookup per target.
var rels = FriendsDB.GetRelationships(account.Id);
var friendIds = rels.Where(r => r.RelationshipType == RelationshipType.Friend)
.Select(r => r.PlayerID).ToHashSet();
var favIds = rels.Where(r => r.Favorited != ReciprocalStatus.None)
.Select(r => r.PlayerID).ToHashSet();
var heartbeats = new List<object>();
foreach (var playerId in id)
{
// Friends/player-list presence for OTHERS: live-socket gated (so a crashed/closed
// player shows OFFLINE with no room instead of "online in a room they aren't in")
// AND status-visibility honored relative to the viewer. The caller's OWN
// authoritative heartbeat (which must never answer "nowhere") is served by
// player/heartbeat, not here.
var heartbeat = HeartbeatDB.GetPresenceForList(
playerId,
viewerIsSelf: playerId == account.Id,
viewerIsFriend: friendIds.Contains(playerId),
viewerIsFavorite: favIds.Contains(playerId),
viewerId: account.Id);
if (heartbeat != null)
heartbeats.Add(heartbeat);
}
return Ok(heartbeats);
}
[HttpGet("player/qos")]
public async Task<IActionResult> GetPlayerQos()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string path = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "QOS.json");
string content = await System.IO.File.ReadAllTextAsync(path);
// Was: Ok(<string>). ASP.NET's StringOutputFormatter writes a raw string as
// Content-Type: text/plain, so these JSON payloads were served mislabelled
// and the client would not parse them. Verified live: /outfits/me returned
// text/plain while /api/avatar/v2 returned application/json. Same bytes,
// correct content type.
return new ContentResult { Content = content, ContentType = "application/json", StatusCode = 200 };
}
[HttpGet("player/connection-info")]
public IActionResult GetPlayerConnectionInfo([FromQuery] ulong roomInstanceId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var heartbeat = HeartbeatDB.GetPlayerHeartbeat(account.Id);
// Answer about the instance the client ACTUALLY ASKED FOR.
//
// This used to ignore roomInstanceId entirely and reply with whatever room the
// caller's own heartbeat pointed at. While every public instance shared the
// Photon room name "room" that never showed up - any answer put you in the same
// room. Now that names are per-instance, replying about the wrong instance
// sends the client to a different Photon room mid-load, and it dies with
// "Failed to load room due disconnect during load (Code 8706-7)".
//
// Falls back to the caller's own instance when the id is unknown (0, or an
// instance nobody is in yet), which is the old behaviour.
var requested = HeartbeatDB.FindInstanceById((long)roomInstanceId);
var instance = requested ?? heartbeat?.roomInstance;
string photonRoom = instance?.photonRoomIdYeee ?? $"solo-{account.Id}";
if (requested == null && roomInstanceId != 0)
Console.WriteLine($"[connection-info] {account.Id} asked about instance {roomInstanceId} " +
$"- not found, falling back to their own ({instance?.roomInstanceId})");
// Tachyon voice is only handed out for a room with a REAL instance id, exactly like the
// reference's tachyonServerFor (roomInstanceId <= 0 -> empty -> Photon). The dorm/home
// is instance -1 (no live instance), so it MUST stay on Photon; advertising Tachyon for
// it made the client fail to connect to voice and blocked the whole room load (Code
// 8719). Public/created rooms have a positive instance and get Tachyon.
long voiceInstanceId = instance?.roomInstanceId ?? 0;
bool useTachyon = !string.IsNullOrEmpty(ConfigCampus.TachyonVoiceEndpoint) && voiceInstanceId > 0;
string voiceConn = useTachyon ? ConfigCampus.TachyonVoiceEndpoint : "";
string voiceSrv = useTachyon ? ConfigCampus.TachyonVoiceServerId : "";
Console.WriteLine($"[conn-info] acct={account.Id} instance={voiceInstanceId} " +
$"voice='{voiceConn}' serverId='{voiceSrv}' tachyonCfg='{ConfigCampus.TachyonVoiceEndpoint}'");
// BOTH shapes in one body. This build's DTO is documented as a BARE 9-key object
// (wire-shapes: player-connection-info) while the envelope below is what has actually
// been serving working Photon joins here - Utf8Json drops whichever set it does not
// know, so emitting both cannot break either reading.
var connExperiments = new
{
NetworkTransformSyncInterval = 10.0,
ShouldUseUnreliableOnChange = false,
ShouldAvoidDiscontinuityRPCs = true,
ShouldAvoidRedundantDiscontinuity = false,
R2RuntimeStaticBaking = true,
R2AutoEmbodiment = true,
R2RuntimeStaticBakingMinShapeThreshold = 1,
ShouldUseGameServerNetworking = false
};
return Ok(new
{
PhotonAuthToken = ClientSecurity.Createphotonauthtoken(account.Id, photonRoom),
PhotonRealtimeAppId = ConfigCampus.PhotonRealtimeId,
PhotonVoiceAppId = ConfigCampus.PhotonVoiceId,
PhotonChatAppId = ConfigCampus.PhotonChatId,
PhotonRegion = "us",
PhotonRoomId = photonRoom,
VoiceConnectionInfo = voiceConn,
VoiceServerId = voiceSrv,
Experiments = connExperiments,
success = true,
value = new
{
photonAuthToken = ClientSecurity.Createphotonauthtoken(account.Id, photonRoom),// "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxNTAzMjY5NzUwIiwicm4ucGxhdGlkIjoiNzY1NjExOTk2MTM4Mzg5MjgiLCJybi5wbGF0IjoiMCIsInJuLmRldmljZWNsYXNzIjoiMiIsInJuLmVudiI6InByb2QiLCJleHAiOjE3NTQzMTYzNTMsImF1ZCI6ImM4YWFhODYwLTFjMGMtNDMzOC1iYWYyLWE1NTNjMDFiMmE1MyJ9.qY_O0tNeZeH2QKuPUIQPhRPnOpp2uhZn3b8eo-ybV3w",
photonRealtimeAppId = ConfigCampus.PhotonRealtimeId,
photonVoiceAppId = ConfigCampus.PhotonVoiceId,
photonChatAppId = ConfigCampus.PhotonChatId,
photonRegion = "us", // qos us-east1
photonRoomId = photonRoom,
// voiceConnectionInfo & voiceServerId: EMPTY STRINGS, matching RecFlare, which
// sets both to varOr(TACHYON_HOST_PORT/NAME, '') - i.e. "" when no dedicated
// (Tachyon) voice server is configured. Its comment: "Empty rather than null:
// the client's decoder is likelier to accept a missing-value string than a null
// on a string field." With no dedicated server named, the client falls back to
// PHOTON VOICE (photonVoiceAppId above) for peer-to-peer voice in photonRoomId -
// which is how players actually hear each other.
//
// Earlier tries here were "{}" (an OBJECT - parsed as a voice-server DTO, found
// nothing, and aborted with "Code 8719" blocking room load) and then null
// (which just skipped voice entirely - mic worked, nobody could hear you). The
// empty STRING is the value the reference uses and neither of those was.
//
// TACHYON: when a dedicated voice server is configured (ConfigCampus
// .TachyonVoiceEndpoint = "<ip>:<port>"), hand the client that endpoint so it
// routes voice through Tachyon instead of Photon P2P. voiceServerId must be a
// non-empty id alongside it; the client uses voiceConnectionInfo ("<ip>:<port>")
// to reach the server. Endpoint empty -> both stay "" -> Photon fallback (known
// good). Tachyon resolves each player's room via GET /tachyon?id=<AccountId>
// (TachyonController) so voice is scoped to the room they're actually in.
voiceConnectionInfo = voiceConn,
voiceServerId = voiceSrv,
experiments = new
{
networkTransformSyncInterval = 10.0,
shouldUseUnreliableOnChange = false,
shouldAvoidDiscontinuityRPCs = true,
shouldAvoidRedundantDiscontinuity = false,
r2RuntimeStaticBaking = true,
r2AutoEmbodiment = true,
r2RuntimeStaticBakingMinShapeThreshold = 1,
r2UseCheapReplicas = true,
shouldUseGameServerNetworking = false // true make it connect to 127.0.0.1:7777 qos, false it connects to photon servers ns.exitgames.com
}
},
// Match the reference envelope EXACTLY: { success, value, error }. The reference
// always includes error: null (see match openapi ConnectionInfoResponse). We were
// omitting it. The client may validate the whole envelope before trusting
// value.voiceConnectionInfo, so a missing `error` can make it drop the voice info
// ("No VoiceConnectionInfo") while Photon still connects via a looser read.
error = (object?)null
});
}
[HttpPut("player/gameserverregionpings")]
public IActionResult UpdatePlayerGameServerRegionPings()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok("");
}
// Client publishes its measured Photon-region pings here so the server can pick the
// closest region for matchmaking. We don't do region-aware matchmaking (single Photon
// Cloud app, no region selection), so accept + discard. Returning 404 leaves the
// client stuck in a "still measuring pings" state and delays room joins.
[HttpPut("player/photonregionpings")]
[HttpPost("player/photonregionpings")]
public IActionResult UpdatePhotonRegionPings()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
return Ok("");
}
[HttpPost("roominstance/{roomInstanceId}/reportjoinresult")]
public IActionResult ReportJoinResultForRoomInstanceId(ulong roomInstanceId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok();
}
}
}
@@ -0,0 +1,361 @@
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using System.Collections.Concurrent;
using Newtonsoft.Json;
namespace CampusLiveSource.Controllers
{
[ApiController]
[Route("Notifications")]
public class NotificationsController : Controller
{
public static ConcurrentDictionary<ulong, WebSocket> WebSockets { get; } = new();
// viewer -> the OTHER players this connection asked for realtime updates about, via the
// hub's SubscribeToPlayers / UnsubscribeFrom invocations. The 2025 client subscribes to
// everyone it is displaying - friends list, the in-room player list, party members -
// and expects PresenceUpdate / AccountUpdate frames for them. This used to be acked and
// forgotten, so only FRIENDS ever received a presence push and nothing else updated live.
public static ConcurrentDictionary<ulong, HashSet<ulong>> Subscriptions { get; } = new();
/// <summary>Every connected viewer that subscribed to <paramref name="subjectId"/>.</summary>
public static List<ulong> SubscribersOf(ulong subjectId)
{
var result = new List<ulong>();
foreach (var kv in Subscriptions)
{
lock (kv.Value) { if (kv.Value.Contains(subjectId)) result.Add(kv.Key); }
}
return result;
}
// SignalR gives no schema for the argument, so accept every plausible spelling:
// [{PlayerIds:[1,2]}] / [{playerIds:[..]}] (what this client sends), [[1,2]], and 1,2.
// null = unreadable (never treated as "nobody").
private static List<ulong>? ParsePlayerIds(JsonElement root)
{
if (!root.TryGetProperty("arguments", out var args) || args.ValueKind != JsonValueKind.Array) return null;
var ids = new List<ulong>();
bool any = false;
foreach (var arg in args.EnumerateArray())
{
if (arg.ValueKind == JsonValueKind.Object)
{
foreach (var prop in arg.EnumerateObject())
{
if (!prop.Name.Equals("PlayerIds", StringComparison.OrdinalIgnoreCase)) continue;
if (prop.Value.ValueKind != JsonValueKind.Array) return null;
any = true;
foreach (var v in prop.Value.EnumerateArray())
if (v.ValueKind == JsonValueKind.Number && v.TryGetUInt64(out var id)) ids.Add(id);
}
}
else if (arg.ValueKind == JsonValueKind.Array)
{
any = true;
foreach (var v in arg.EnumerateArray())
if (v.ValueKind == JsonValueKind.Number && v.TryGetUInt64(out var id)) ids.Add(id);
}
else if (arg.ValueKind == JsonValueKind.Number && arg.TryGetUInt64(out var single))
{
any = true; ids.Add(single);
}
}
return any ? ids : null;
}
// ===========================================================
// Negotiation endpoint
// ===========================================================
[HttpPost("hub/v1/negotiate")]
public ContentResult Negotiate()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var response = new
{
negotiateVersion = 0,
connectionId = account.Id.ToString(),
availableTransports = new[]
{
new {
transport = "WebSockets",
transferFormats = new[] { "Text", "Binary" }
}
}
};
return JsonResult(response);
}
// ===========================================================
// Notification preferences/config
// ===========================================================
[HttpGet("preferences")]
public IActionResult GetPreferences()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(new
{
mutedCategories = Array.Empty<object>()
});
}
// The caller's CRM (campaign/messaging) config, fetched at every login. An OBJECT, not
// an envelope and not a list: the client parses it as a config object, and the `[]`
// this answered before failed that parse ("Malformed Response: '[]'" on every boot -
// Player.log). Empty rather than invented, like the reference: nothing here runs
// campaigns, and made-up keys would switch on messaging paths with nothing behind them.
[HttpGet("crm/me/config/v3")]
public ContentResult GetCRMConfig()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return new ContentResult { Content = "{}", ContentType = "application/json", StatusCode = 200 };
}
[HttpGet("accounts/{id}/receives/GameplayInvites")]
public ContentResult GetGameplayInvites(ulong id)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return JsonResult(true);
}
[HttpGet("config/categories")]
public IActionResult GetCategories()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string path = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "NotiConfigCategories.json");
if (!System.IO.File.Exists(path))
{
Console.WriteLine("Warning: Notification config json file not found, returning []");
return Ok(new
{
results = Array.Empty<object>(),
totalResults = 0
});
}
return PhysicalFile(path, "application/json");
}
// ===========================================================
// WebSocket handler
// ===========================================================
[HttpGet("hub/v1")]
public async Task HandleHub([FromQuery] string id)
{
if (!HttpContext.WebSockets.IsWebSocketRequest)
{
HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
return;
}
if (!ulong.TryParse(id, out var playerId))
{
HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
return;
}
using var socket = await HttpContext.WebSockets.AcceptWebSocketAsync();
Console.WriteLine($"[WebSocket] Player {playerId} connected.");
await HandleConnectionAsync(playerId, socket);
}
private static async Task HandleConnectionAsync(ulong playerId, WebSocket socket)
{
using var pingCts = new CancellationTokenSource();
try
{
await CloseExistingConnection(playerId);
WebSockets[playerId] = socket;
await SendHandshakeAsync(socket);
// Realtime presence: this player just came ONLINE - tell their online friends so
// their friends lists flip to online instantly instead of on the next poll.
Campus_live_rewrite.api.server.Notifications.FireAndForget(
Campus_live_rewrite.api.server.Notifications.PushPresenceToFriends(playerId),
$"presence-online {playerId}");
// Start ping loop
_ = Task.Run(() => PingLoopAsync(socket, pingCts.Token));
var buffer = new byte[4096];
while (socket.State == WebSocketState.Open)
{
var result = await socket.ReceiveAsync(buffer, CancellationToken.None);
if (result.MessageType == WebSocketMessageType.Close)
break;
var message = Encoding.UTF8.GetString(buffer, 0, result.Count).TrimEnd('\x1e');
Console.WriteLine($"Player ({playerId}) sent {message}");
await HandleClientMessageAsync(playerId, socket, message);
}
}
catch (Exception ex)
{
Console.WriteLine($"[Error:{playerId}] {ex.Message}");
}
finally
{
pingCts.Cancel();
WebSockets.TryRemove(playerId, out _);
Subscriptions.TryRemove(playerId, out _);
Campus_live_rewrite.api.dbs.HeartbeatDB.StampLastSeen(playerId); // "last online" = when the socket closed
// Realtime presence: the socket is gone, so this player now reads OFFLINE - tell
// their online friends so their friends lists flip to offline instantly (the socket
// is the ground truth GetPresenceForList gates on).
Campus_live_rewrite.api.server.Notifications.FireAndForget(
Campus_live_rewrite.api.server.Notifications.PushPresenceToFriends(playerId),
$"presence-offline {playerId}");
if (socket.State is WebSocketState.Open or WebSocketState.CloseReceived)
{
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing connection", CancellationToken.None);
}
Console.WriteLine($"[WebSocket] Player {playerId} disconnected.");
}
}
private static async Task HandleClientMessageAsync(ulong playerId, WebSocket socket, string message)
{
try
{
using var doc = JsonDocument.Parse(message);
var root = doc.RootElement;
if (!root.TryGetProperty("type", out var typeProp) || typeProp.GetInt32() != 1) return;
string target = root.TryGetProperty("target", out var t) ? t.GetString() ?? "" : "";
string? invocationId = root.TryGetProperty("invocationId", out var inv) ? inv.GetString() : null;
async Task Complete(object? result, string? error = null)
{
if (string.IsNullOrEmpty(invocationId)) return;
if (error != null) await SendJsonAsync(socket, new { type = 3, invocationId, error });
else await SendJsonAsync(socket, new { type = 3, invocationId, result });
}
switch (target)
{
case "SubscribeToPlayers":
{
var ids = ParsePlayerIds(root);
if (ids == null)
{
Console.WriteLine($"[hub] {playerId}: unreadable SubscribeToPlayers argument - ignored");
await Complete(null);
break;
}
var set = Subscriptions.GetOrAdd(playerId, _ => new HashSet<ulong>());
var added = new List<ulong>();
lock (set) { foreach (var id in ids) if (id != playerId && set.Add(id)) added.Add(id); }
await Complete(null);
// Send the current state of every NEW subject straight away, so a list renders
// live from the moment it opens instead of after that player's next change.
foreach (var id in added)
await Campus_live_rewrite.api.server.Notifications.PushPresenceTo(playerId, id);
Console.WriteLine($"[hub] {playerId}: subscribed to {ids.Count} player(s), {added.Count} new");
break;
}
case "UnsubscribeFrom":
case "UnsubscribeFromPlayers":
{
var ids = ParsePlayerIds(root);
if (ids != null && Subscriptions.TryGetValue(playerId, out var set))
lock (set) { foreach (var id in ids) set.Remove(id); }
await Complete(null);
break;
}
case "GetSubscriptions":
{
ulong[] current = Array.Empty<ulong>();
if (Subscriptions.TryGetValue(playerId, out var set)) lock (set) current = set.ToArray();
await Complete(current);
break;
}
default:
// A hub method we don't implement is a client expectation we've missed.
Console.WriteLine($"[hub] {playerId}: unknown invocation target '{target}'");
await Complete(null, $"Unknown method '{target}'");
break;
}
}
catch (Exception ex)
{
Console.WriteLine($"[ParseError] {ex.Message}");
}
}
private static async Task SendHandshakeAsync(WebSocket socket)
{
var handshake = new { protocol = "json", version = 1 };
await SendJsonAsync(socket, handshake);
}
private static async Task PingLoopAsync(WebSocket socket, CancellationToken token)
{
try
{
while (!token.IsCancellationRequested && socket.State == WebSocketState.Open)
{
var ping = new { type = 6 };
await SendJsonAsync(socket, ping);
await Task.Delay(10000, token);
}
}
catch (TaskCanceledException) { }
catch (Exception ex)
{
Console.WriteLine($"[PingError] {ex.Message}");
}
}
private static async Task CloseExistingConnection(ulong playerId)
{
if (WebSockets.TryRemove(playerId, out var oldSocket) && oldSocket.State == WebSocketState.Open)
{
await oldSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Replaced by new connection", CancellationToken.None);
Console.WriteLine($"[WebSocket] Closed existing connection for {playerId}");
}
}
private static async Task SendJsonAsync(WebSocket socket, object obj)
{
var json = JsonConvert.SerializeObject(obj) + "\x1e";
var bytes = Encoding.UTF8.GetBytes(json);
await socket.SendAsync(bytes, WebSocketMessageType.Text, true, CancellationToken.None);
}
private static ContentResult JsonResult(object data) => new()
{
Content = JsonConvert.SerializeObject(data),
ContentType = "application/json",
StatusCode = 200
};
}
}
@@ -0,0 +1,52 @@
using Microsoft.AspNetCore.Mvc;
using Campus_live_rewrite.api.server;
namespace Campus_live_rewrite.Controllers
{
// Patch handshake. CampusPatch (the DLL) POSTs here once at startup (main.cpp) with the
// shared secret in the X-Campus-Patch header. The SERVER holds the real secret and compares:
// a match answers 200, anything else answers 403. A DLL with the wrong/absent header is
// denied here, and main.cpp aborts on a non-200 so that client never connects.
//
// The secret lives ONLY on the server (ConfigCampus.PatchSignature) - the DLL just echoes
// what it was built with; the server is what decides.
[ApiController]
public class PatchController : ControllerBase
{
public const string HeaderName = "X-Campus-Patch";
[HttpPost("/api/patch/verify")]
[HttpGet("/api/patch/verify")]
public IActionResult Verify()
{
string sent = Request.Headers[HeaderName].FirstOrDefault() ?? "";
string expected = ConfigCampus.PatchSignature ?? "";
string ip = Request.Headers["CF-Connecting-IP"].FirstOrDefault()
?? Request.Headers["X-Forwarded-For"].FirstOrDefault()
?? HttpContext.Connection.RemoteIpAddress?.ToString()
?? "?";
bool ok = !string.IsNullOrEmpty(expected) && FixedTimeEquals(sent, expected);
if (!ok)
{
Console.WriteLine($"[patchverify] DENIED {ip} - signature {(string.IsNullOrEmpty(sent) ? "absent" : "mismatch")}");
return StatusCode(StatusCodes.Status403Forbidden, new { ok = false });
}
// Just a friendly startup check for the DLL - real enforcement is the per-request
// X-Campus-Patch header validated in PatchGateMiddleware. No whitelist is kept.
Console.WriteLine($"[patchverify] OK {ip}");
return Ok(new { ok = true });
}
// Constant-time compare so the secret can't be brute-forced a byte at a time by timing.
private static bool FixedTimeEquals(string a, string b)
{
var ab = System.Text.Encoding.UTF8.GetBytes(a ?? "");
var bb = System.Text.Encoding.UTF8.GetBytes(b ?? "");
return System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(ab, bb);
}
}
}
@@ -0,0 +1,87 @@
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Campus_live_rewrite.api.server;
using Campus_live_rewrite.api.server.Classes.db_class;
namespace Campus_live_rewrite.Controllers
{
// Photon Cloud "Custom Authentication" webhook.
//
// In the Photon dashboard for each app (Realtime / Voice / Chat), set:
// Authentication -> Custom Server -> Custom -> AuthUrl:
// https://gameauth.ddns.net/photon/auth
//
// Photon will call this endpoint on every client connect, passing whatever token the client
// presented in AuthValues (the client hands out photon_access_token via RoomController's
// /Room_server/photon_access_token endpoint - see Createphotonacesstoken in ClientSecurity).
// We validate it as our own JWT, and echo back the real account id as the Photon UserId -
// which the client CANNOT forge at the Photon protocol level (matches the pattern the 2023
// Campus-Server used, see PhotonAuthController.cs there).
//
// Contract: reply 200 with JSON { ResultCode, UserId, Nickname }.
// ResultCode 1 = authenticated, 2 = failed / unauthorized, 3 = required params missing.
// If custom auth isn't configured on the Photon apps this endpoint just never gets called -
// it costs nothing to ship, and makes the server ready the moment custom auth is turned on.
[ApiController]
public class PhotonAuthController : ControllerBase
{
[HttpGet("/photon/auth")]
[HttpPost("/photon/auth")]
public async Task<IActionResult> Authenticate([FromQuery] string? token)
{
// The token can arrive as a query param (AuthGetParameters), form/body (AuthPostData),
// or as the raw body - accept all shapes so either GET or POST configuration works.
string? t = token;
if (string.IsNullOrEmpty(t))
foreach (var k in new[] { "token", "access_token", "t", "sig", "authToken" })
if (Request.Query.TryGetValue(k, out var q) && !string.IsNullOrEmpty(q)) { t = q; break; }
if (string.IsNullOrEmpty(t) && Request.HasFormContentType)
foreach (var k in new[] { "token", "access_token", "t", "authToken" })
if (Request.Form.TryGetValue(k, out var f) && !string.IsNullOrEmpty(f)) { t = f; break; }
if (string.IsNullOrEmpty(t))
{
try
{
using var r = new StreamReader(Request.Body);
var body = (await r.ReadToEndAsync()).Trim().Trim('"');
if (!string.IsNullOrWhiteSpace(body) && body.Count(c => c == '.') == 2) t = body;
}
catch { }
}
if (string.IsNullOrEmpty(t))
{
Console.WriteLine($"{DateTime.Now:HH:mm:ss} [photon-auth] no token in request ({Request.QueryString}) -> ResultCode 3");
return Ok(new { ResultCode = 3, Message = "missing token" });
}
string decoded = ClientSecurity.DecodeToken(t);
if (string.IsNullOrEmpty(decoded) || decoded.StartsWith("error_"))
{
Console.WriteLine($"{DateTime.Now:HH:mm:ss} [photon-auth] token failed validation ({decoded}) -> ResultCode 2");
return Ok(new { ResultCode = 2, Message = "invalid token" });
}
player_info? account = null;
try { account = JsonConvert.DeserializeObject<player_info>(decoded); } catch { }
if (account == null)
{
Console.WriteLine($"{DateTime.Now:HH:mm:ss} [photon-auth] token decoded but no player_info -> ResultCode 2");
return Ok(new { ResultCode = 2, Message = "no account" });
}
string nickname = account.Username ?? account.Id.ToString();
Console.WriteLine($"{DateTime.Now:HH:mm:ss} [photon-auth] authenticated @{nickname} as UserId {account.Id}");
return Ok(new
{
ResultCode = 1,
UserId = account.Id.ToString(),
Nickname = nickname
});
}
}
}
@@ -0,0 +1,63 @@
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server;
namespace Campus_live_rewrite.Controllers
{
// Room comments - the notes players pin in a room's scene. The client talks to this on the
// /RoomComments base URL (WebController's service map). Ported from RecFlare's roomcomments
// service; there were no handlers before, so both routes 404'd.
[ApiController]
public class RoomCommentsController : ControllerBase
{
// A room's comments, newest first. PUBLIC - a comment is a fixture of the room and the
// client fetches the list on load, so no token is needed. An unknown room simply has
// none ([], not a 404).
[HttpGet("/RoomComments/comments/get/{roomId}")]
public IActionResult GetComments(long roomId,
[FromQuery] int? count = null, [FromQuery] int? minId = null, [FromQuery] int? subRoomId = null)
{
var comments = RoomCommentsDB.Get(roomId, count, minId, subRoomId);
return new ContentResult
{
Content = JsonConvert.SerializeObject(comments),
ContentType = "application/json",
StatusCode = 200
};
}
// Leave a comment in a room. Auth-gated: the author is the bearer token's account, never
// trusted from the body. Blank message or missing subRoomId -> 400. Message over 1000
// chars is truncated, not rejected. Answers the created comment so the client can draw
// the bubble it just placed without re-fetching the whole list.
[HttpPost("/RoomComments/comments/create/{roomId}")]
public IActionResult CreateComment(long roomId,
[FromForm] string? message, [FromForm] string? subRoomId, [FromForm] string? style,
[FromForm] string? positionX, [FromForm] string? positionY, [FromForm] string? positionZ)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
if (!long.TryParse(subRoomId, out long sr))
return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 400 };
double F(string? s) => double.TryParse(s, System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var v) ? v : 0d;
int I(string? s) => int.TryParse(s, out var v) ? v : 0;
var comment = RoomCommentsDB.Create(roomId, account.Id, sr, message ?? "", I(style),
F(positionX), F(positionY), F(positionZ));
if (comment == null) // blank message
return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 400 };
return new ContentResult
{
Content = JsonConvert.SerializeObject(comment),
ContentType = "application/json",
StatusCode = 200
};
}
}
}
@@ -0,0 +1,3013 @@
using System.Security.Principal;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Campus_live_rewrite.api;
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server;
using Campus_live_rewrite.api.server.Classes;
using Campus_live_rewrite.api.server.Classes.db_class;
using System.IdentityModel.Tokens.Jwt;
using static Campus_live_rewrite.api.server.Moderation_Detail;
using Microsoft.AspNetCore.Http.HttpResults;
using static Campus_live_rewrite.api.server.Classes.db_class.RoomDBClasses;
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Nodes;
namespace Campus_live_rewrite.Controllers
{
[Route("Room_server")]
public class RoomController : ControllerBase
{
[HttpGet("rooms")]
public ContentResult Room_server_rooms([FromQuery] ulong? roomid = null, [FromQuery] string? name = null, [FromQuery] ulong? include = null, [FromQuery] int? unityAssetTarget = null, [FromQuery] int? unityAssetVersion = null)
{
try
{
// DELETE by roomid
if (HttpContext.Request.Method == HttpMethods.Delete && roomid.HasValue)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
RoomDBClasses.RoomRoot room = RoomDB.GetRoom(roomid.Value);
if (room == null)
{
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
Success = false,
Value = (string?)null,
ErrorId = "Rooms.DoesntExist",
Error = "This room does not exist!"
}),
ContentType = "application/json",
StatusCode = 200
};
}
if (room.CreatorAccountId != account.Id)
{
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
Success = false,
Value = (string?)null,
ErrorId = "Rooms.PermissionDenied",
Error = "You are not the owner of this room!"
}),
ContentType = "application/json",
StatusCode = 200
};
}
RoomDB.DeleteRoom(roomid.Value);
return new ContentResult()
{
Content = "[]",
ContentType = "application/json",
StatusCode = 200
};
}
// Lookup by roomid
if (roomid.HasValue)
{
RoomDBClasses.RoomRoot existingRoom = RoomDB.GetRoom(roomid.Value);
if (existingRoom == null)
{
return new ContentResult()
{
Content = "[]",
ContentType = "application/json",
StatusCode = 200
};
}
return new ContentResult()
{
Content = JsonConvert.SerializeObject(StripUnservableSaves(DirectLookup(existingRoom))),
ContentType = "application/json",
StatusCode = 200
};
}
// Lookup by name
if (!string.IsNullOrEmpty(name))
{
RoomDBClasses.RoomRoot room = RoomDB.GetRoomByName(name);
if (room == null)
{
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 404
};
}
return new ContentResult()
{
Content = JsonConvert.SerializeObject(StripUnservableSaves(room)),
ContentType = "application/json",
StatusCode = 200
};
}
// Neither roomid nor name provided
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 404
};
}
catch (Exception ex)
{
Console.WriteLine(ex.ToString());
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 404
};
}
}
// Strip a subroom's CurrentSave when its .room blob cannot be served.
//
// The client loads a room from the CurrentSave embedded in the ROOM OBJECT, not
// from the /saves endpoint - so filtering /saves alone was not enough, and
// "Error downloading subroom data! (Code 8715)" persisted. It requests
// /cdn/room/<DataBlob>, which 404s when the file is absent from
// Data/CDN/RoomBlobs (the cdn.rec.net fallback is dead - those blobs are no
// longer public), and the load aborts.
//
// Joining an existing instance never fetches the blob (state replicates over
// Photon from whoever is already inside), which is exactly why Rec Center worked
// via a friend but failed alone.
//
// Nulling CurrentSave makes the room load its default Unity scene - the same
// path rooms with no save (Park, dorms) already take successfully.
//
// Safe to mutate: GetRoom returns a freshly deserialized LiteDB object and no
// caller writes it back (verified), so this never persists. Self-correcting -
// put the real .room files in Data/CDN/RoomBlobs and saves return untouched.
// List overload. Room LISTINGS matter as much as the single-room GETs: the client
// caches these and can join straight from a cached entry (rooms/visitedby/me is
// requested moments before every join), so an unservable blob left in a listing
// resurfaces the same failure by another route.
//
// Only ever call this on GET/serialize paths. The PUT/POST mutation endpoints
// (SetRoomName, SetRoomData, ...) load-modify-save the same objects, so stripping
// there would PERSIST the null into the DB and destroy the reference for good.
private static List<RoomDBClasses.RoomRoot> StripUnservableSaves(List<RoomDBClasses.RoomRoot>? rooms)
=> WithCCU(Campus_live_rewrite.api.server.RoomBlobFilter.Strip(rooms));
/// <summary>
/// The viewer's account id, or 0 when the request is unauthenticated.
///
/// Some room endpoints (bulk, hot, search) never called ValidateToken, so there is
/// no `account` in scope there. Visibility still has to be decided per viewer, so
/// read it from the token directly. 0 means "no one" - which sees only published
/// rooms, the correct default for an anonymous caller.
/// </summary>
private ulong ViewerId()
{
try
{
var (acc, err) = AuthHelper.ValidateToken(HttpContext);
return err == null && acc != null ? acc.Id : 0UL;
}
catch
{
return 0UL;
}
}
// ---- Visibility -------------------------------------------------------
//
// Applied at every point a room is SERVED, not just in listings. Fetching a room
// straight by id used to bypass every check: any player could read another
// player's unpublished private room - name, subrooms, save data - simply by
// asking for /Room_server/rooms/78.
//
// A room is visible when it is published, or when the viewer holds a role on it
// (owner / co-owner / moderator / host). See RoomDB.CanPlayerSeeRoom.
/// <summary>Returns the room, or null when this viewer is not allowed to see it.</summary>
private static RoomDBClasses.RoomRoot? VisibleTo(RoomDBClasses.RoomRoot? room, ulong viewerId)
=> RoomDB.CanPlayerSeeRoom(room, viewerId) ? room : null;
// A DIRECT lookup (rooms/{id}, rooms?id=, rooms/bulk) answers the record whatever its
// accessibility, exactly like the reference (and rec.net): Private / Unlisted only keep a
// room off the browse lists and search - CanPlayerEnterRoom still gates joining, and the
// saves are stripped separately. Hiding it here (answering null / []) broke every player
// who was INSIDE a private room or another player's dorm: their friend card, search
// result and profile all resolve the room they're in through these routes, the client
// reads the empty answer as NoSuchRoom, and the card / profile never rendered - "some
// players' profiles won't load for anyone". Only excludePrivateRooms=True filters, and
// it filters on listing, not on who is asking.
private static RoomDBClasses.RoomRoot? DirectLookup(RoomDBClasses.RoomRoot? room, bool excludePrivate = false)
=> room == null ? null : (excludePrivate && !RoomDB.IsRoomListed(room)) ? null : room;
/// <summary>Drops every room this viewer is not allowed to see.</summary>
private static List<RoomDBClasses.RoomRoot> VisibleTo(List<RoomDBClasses.RoomRoot>? rooms, ulong viewerId)
=> (rooms ?? new List<RoomDBClasses.RoomRoot>())
.Where(r => RoomDB.CanPlayerSeeRoom(r, viewerId))
.ToList();
private static RoomDBClasses.RoomRoot? StripUnservableSaves(RoomDBClasses.RoomRoot? room)
=> WithCCU(Campus_live_rewrite.api.server.RoomBlobFilter.Strip(room));
// Fill the client's CCU key (live player count) on whatever this response is about to
// return. One presence pass per response, not per room.
private static RoomDBClasses.RoomRoot? WithCCU(RoomDBClasses.RoomRoot? room)
{
if (room == null) return null;
try { room.CCU = HeartbeatDB.LivePlayerCountsByRoom().TryGetValue((long)room.RoomId, out var n) ? n : 0; }
catch { }
return room;
}
private static List<RoomDBClasses.RoomRoot> WithCCU(List<RoomDBClasses.RoomRoot>? rooms)
{
rooms ??= new List<RoomDBClasses.RoomRoot>();
try
{
var counts = HeartbeatDB.LivePlayerCountsByRoom();
foreach (var r in rooms)
if (r != null) r.CCU = counts.TryGetValue((long)r.RoomId, out var n) ? n : 0;
}
catch { }
return rooms;
}
[HttpGet("rooms/{roomid}")]
public ContentResult GetRoomById(ulong roomid)
{
var room = RoomDB.GetRoom(roomid);
if (room == null)
{
return new ContentResult
{
Content = "[]",
ContentType = "application/json",
StatusCode = 200
};
}
return new ContentResult
{
Content = JsonConvert.SerializeObject(StripUnservableSaves(DirectLookup(room))),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpDelete("rooms/{roomid}")]
public ContentResult DeleteRoomById(ulong roomid)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var room = RoomDB.GetRoom(roomid);
if (room == null)
{
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
Success = false,
Value = (string?)null,
ErrorId = "Rooms.DoesntExist",
Error = "This room does not exist!"
}),
ContentType = "application/json",
StatusCode = 200
};
}
if (room.CreatorAccountId != account.Id)
{
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
Success = false,
Value = (string?)null,
ErrorId = "Rooms.PermissionDenied",
Error = "You are not the owner of this room!"
}),
ContentType = "application/json",
StatusCode = 200
};
}
RoomDB.DeleteRoom(roomid);
// This used to BAN the caller - Set_moderation_block(account.Id, ..., isBan: true,
// duration: 1). account.Id is the room OWNER, i.e. whoever just deleted their own
// room, so deleting a room banned you from the game. isBan sends websocket event 22,
// which blacks the client out, logs it out and closes it. Duration is in SECONDS, so
// the block cleared after one second and you could log straight back in - which is
// exactly why it looked like "something/someone keeps closing my game" rather than
// a ban.
//
// The intent was presumably to get the owner out of the room they just deleted. That
// is a host kick, not a ban: isHostKick drops them to their dorm with no ban screen,
// no logout and no moderation record. Same shape ChatCommands.HandleKick uses.
PlayerDB.Set_moderation_block(
account.Id,
ReportCategory.Moderator,
duration: 1,
gameSessionId: -1,
isHostKick: true,
message: "The room you were in was deleted.",
playerIdReporter: null,
isBan: false,
bannedByPlayerId: 0
);
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
success = true,
error = "",
value = (string?)null
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPost("rooms/bulk")]
public IActionResult Room_server_rooms_bulk_post()
{
var f = RoomModeration.Fields(Request);
var ids = RoomModeration.ULongs(f, "id", "ids", "roomIds", "Ids");
string? name = RoomModeration.Str(f, "name");
bool excl = (RoomModeration.Str(f, "excludePrivateRooms") ?? "").Equals("true", StringComparison.OrdinalIgnoreCase);
if (ids.Count <= 1) return Room_server_rooms_bulk(ids.Count == 1 ? ids[0] : 0, name, null, excl);
ulong viewer = ViewerId();
var rooms = new List<object>();
foreach (var id in ids.Take(100))
{
var room = RoomDB.GetRoom(id);
if (room == null) continue;
if (DirectLookup(room, excl) == null) continue;
rooms.Add(StripUnservableSaves(room));
}
return Content(JsonConvert.SerializeObject(rooms), "application/json");
}
[HttpGet("rooms/bulk")]
public IActionResult Room_server_rooms_bulk([FromQuery(Name = "id")] ulong roomId, [FromQuery] string? name, [FromQuery] ulong? include, [FromQuery] bool? excludePrivateRooms)
{
RoomDBClasses.RoomRoot room = RoomDB.GetRoomByName(name);
if (room == null)
{
RoomDBClasses.RoomRoot room2 = RoomDB.GetRoom(roomId);
if (room2 == null)
{
return Ok(ConfigCampus.Bracket);
}
// by-ID branch - this is the one the client uses (rooms/bulk?id=2) and it
// was handing back CurrentSave unfiltered, which is how the DataBlob
// kept reaching the client even after the room-load endpoints were fixed.
return Content(DirectLookup(room2, excludePrivateRooms == true) == null ? "[]" : $"[{JsonConvert.SerializeObject(StripUnservableSaves(room2))}]", "application/json");
}
return Content(DirectLookup(room, excludePrivateRooms == true) == null ? "[]" : $"[{JsonConvert.SerializeObject(StripUnservableSaves(room))}]", "application/json");
}
// These three back the Create -> "My Rooms" list. createdby/me used to return a
// hardcoded "[]" stub, and ownedby/me fell through to the ulong route below where
// "me" bound to 0 - so the tab was always empty even with rooms in the DB.
// contributedby/me did not exist at all and 404'd.
[HttpGet("rooms/createdby/me")]
public ContentResult roomscreatedbyme()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return new ContentResult
{
Content = JsonConvert.SerializeObject(StripUnservableSaves(RoomDB.GetRoomsByCreatorPaged(account.Id, 0, ConfigCampus.ProfileRoomsCap))),
ContentType = "application/json",
StatusCode = 200
};
}
// The profile's Rooms tab asks by PLAYER ID (createdby/{id}, contributedby/{id}), not "me";
// only the /me spellings existed, so another player's rooms - and your own after a restart,
// once the client stopped using the locally cached list - came back 404 and the tab was
// empty while the showcase (which does take an id) still filled.
[HttpGet("rooms/createdby/{playerId:long}")]
public ContentResult RoomsCreatedBy(ulong playerId, int skip = 0, int take = 0)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
if (take <= 0) take = ConfigCampus.ProfileRoomsCap;
var rooms = RoomDB.GetRoomsByCreatorPaged(playerId, skip, take);
return new ContentResult
{
Content = JsonConvert.SerializeObject(StripUnservableSaves(VisibleTo(rooms, account.Id).Where(r => !r.IsDorm).ToList())),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("rooms/contributedby/{playerId:long}")]
public ContentResult RoomsContributedBy(ulong playerId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return new ContentResult
{
// GetRoomsContributedTo now leads with the player's OWN rooms, so this is the
// profile's whole room list. VisibleTo is what keeps another player's unpublished
// rooms off it - a viewer with no role on a room sees it only once it is public.
Content = JsonConvert.SerializeObject(StripUnservableSaves(
VisibleTo(RoomDB.GetRoomsContributedTo(playerId), account.Id)
.Where(r => !r.IsDorm).ToList())),
ContentType = "application/json",
StatusCode = 200
};
}
// MUST be declared before rooms/ownedby/{PlayerId}: a literal segment beats the
// parameter route, otherwise "me" is parsed as a ulong and silently becomes 0.
[HttpGet("rooms/ownedby/me")]
public ContentResult RoomsOwnedByMe()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return new ContentResult
{
Content = JsonConvert.SerializeObject(StripUnservableSaves(RoomDB.GetRoomsByCreatorPaged(account.Id, 0, ConfigCampus.ProfileRoomsCap))),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("rooms/contributedby/me")]
public ContentResult RoomsContributedByMe()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return new ContentResult
{
Content = JsonConvert.SerializeObject(StripUnservableSaves(
RoomDB.GetRoomsContributedTo(account.Id).Where(r => !r.IsDorm).ToList())),
ContentType = "application/json",
StatusCode = 200
};
}
// What each role may do in ONE subroom - the Permissions tab of Room Settings. JSON array
// body, each entry addressed by its (Permission, Role) pair: Override:true stores the
// value, Override:false is the unchecked box and clears the pair back to the default.
// The stored table is what GET /photon_access_token merges over its defaults for every
// player standing in that subroom - so this IS "edit what the perms do", and it lives on
// the subroom record, which is what a room save carries. (Reference: creator-only. Here
// co-owners may too: "high enough permissions" - a co-owner already builds in the room.)
[HttpPut("rooms/{roomId}/subrooms/{subRoomId}/permissions")]
[HttpPost("rooms/{roomId}/subrooms/{subRoomId}/permissions")]
public async Task<IActionResult> SetSubRoomPermissions(ulong roomId, ulong subRoomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var room = RoomDB.GetRoom(roomId);
if (room == null || RoomDB.GetSubRoom(roomId, subRoomId) == null) return StatusCode(404, "");
if (!RoomDB.PlayerCanManageRoles(roomId, account.Id))
{
Console.WriteLine($"[room-perms] {account.Id} may not edit permissions on room {roomId} - refused");
return StatusCode(403, "");
}
string raw;
using (var reader = new StreamReader(Request.Body)) raw = await reader.ReadToEndAsync();
var changes = Campus_live_rewrite.api.server.RoomPermissions.ParseBody(raw);
if (changes == null)
{
Console.WriteLine($"[room-perms] {account.Id}: unreadable body on room {roomId}/{subRoomId}: {raw.Substring(0, Math.Min(200, raw.Length))}");
return StatusCode(400, "");
}
var current = RoomDB.GetSubRoom(roomId, subRoomId)?.Permissions;
var table = Campus_live_rewrite.api.server.RoomPermissions.Apply(current, changes);
RoomDB.SetSubRoomPermissions(roomId, subRoomId, table);
Console.WriteLine($"[room-perms] {account.Id} set {changes.Count} permission(s) on room {roomId} subroom {subRoomId}: " +
string.Join(", ", changes.Select(c => $"{c.Permission}@{c.Role}={(c.Override ? c.Value : "default")}")));
// Instant: everyone standing in the room re-reads it (and re-pulls photon_access_token
// with the new table) instead of waiting for their next spawn.
foreach (var pid in Campus_live_rewrite.api.server.RoomPermissions.PlayersInRoom(roomId))
Campus_live_rewrite.api.server.RoomPermissions.PushRoomUpdate(pid, room);
// The 2025 client's ChangeAndRefresh reads the answer through the enveloped room
// helper - an empty body is "Response was empty" / "Failed to change subroom
// permissions" in Player.log, even though the change was stored. (Reference: empty.)
return RoomWriteEnvelope(roomId);
}
// The ROOM-level spelling (PUT rooms/{id}/permissions, 404'd): the same body, applied to
// every subroom of the room, so a room with one subroom behaves exactly like the subroom
// route and a multi-subroom room gets the change everywhere.
[HttpPut("rooms/{roomId}/permissions")]
[HttpPost("rooms/{roomId}/permissions")]
public async Task<IActionResult> SetRoomPermissions(ulong roomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var room = RoomDB.GetRoom(roomId);
if (room == null) return StatusCode(404, "");
if (!RoomDB.PlayerCanManageRoles(roomId, account.Id)) return StatusCode(403, "");
string raw;
using (var reader = new StreamReader(Request.Body)) raw = await reader.ReadToEndAsync();
var changes = Campus_live_rewrite.api.server.RoomPermissions.ParseBody(raw);
if (changes == null) return StatusCode(400, "");
RoomDB.AttachSubRooms(new[] { room });
foreach (var sub in room.SubRooms ?? new List<SubRoom>())
{
var table = Campus_live_rewrite.api.server.RoomPermissions.Apply(sub.Permissions, changes);
RoomDB.SetSubRoomPermissions(roomId, (ulong)sub.SubRoomId, table);
}
Console.WriteLine($"[room-perms] {account.Id} set {changes.Count} permission(s) on every subroom of room {roomId}");
foreach (var pid in Campus_live_rewrite.api.server.RoomPermissions.PlayersInRoom(roomId))
Campus_live_rewrite.api.server.RoomPermissions.PushRoomUpdate(pid, room);
return RoomWriteEnvelope(roomId);
}
[HttpGet("rooms/{roomId}/subrooms/{subRoomId}/permissions")]
public IActionResult GetSubRoomPermissions(ulong roomId, ulong subRoomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var sub = RoomDB.GetSubRoom(roomId, subRoomId);
if (sub == null) return StatusCode(404, "");
return Ok(sub.Permissions ?? new List<Campus_live_rewrite.api.server.RoomPermissions.Entry>());
}
[HttpGet("photon_access_token")]
public IActionResult photon_access_token()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var heartbeat = HeartbeatDB.GetPlayerHeartbeat(account.Id);
// The table the client applies when it spawns: RoomPermissions.Defaults (reset and
// delete-all now start at Moderator, not at everyone) with the overrides the room's
// managers saved on the SUBROOM the caller is standing in merged over it, by
// (Permission, Role) - see PUT rooms/{id}/subrooms/{sub}/permissions.
var permissions = Campus_live_rewrite.api.server.RoomPermissions.Defaults(account.Id);
var inst = heartbeat?.roomInstance;
if (inst != null && inst.subRoomId > 0)
{
var sub = RoomDB.GetSubRoom((ulong)inst.roomId, (ulong)inst.subRoomId);
permissions = Campus_live_rewrite.api.server.RoomPermissions.Merge(permissions, sub?.Permissions);
}
return Ok(new
{
RoomInstanceId = inst?.roomInstanceId,
PhotonAccessToken = ClientSecurity.Createphotonacesstoken(account.Id),
Permissions = permissions.ToArray()
});
}
[HttpGet("rooms/hot")]
public ContentResult HotRooms(string tag, int skip = 0, int take = 30)
{
var rooms = RoomDB.GetHotRooms();
if (rooms == null || rooms.Count == 0)
{
return new ContentResult
{
Content = JsonConvert.SerializeObject(new Resultslist<RoomDBClasses.RoomRoot>
{
Results = new List<RoomDBClasses.RoomRoot>(),
TotalResults = 0
}),
ContentType = "application/json",
StatusCode = 200
};
}
IEnumerable<RoomDBClasses.RoomRoot> query = rooms;
bool isRRO = false;
if (!string.IsNullOrEmpty(tag))
{
string t = tag.ToLower();
// --- RRO Filter ---
if (t == "rro" || t == "recroomoriginal")
{
isRRO = true;
query = query.Where(r =>
r.Tags != null &&
r.Tags.Any(x =>
{
string tt = x.Tag?.ToLower();
return tt == "rro" || tt == "recroomoriginal";
})
);
}
else if (t == "new")
{
query = query.OrderByDescending(r => r.CreatedAt)
.Take(30);
}
}
else
{
// Default mode: EXCLUDE rro + base
query = query.Where(r =>
r.Tags == null ||
!r.Tags.Any(x =>
{
string tt = x.Tag?.ToLower();
return tt == "base" || tt == "rro" || tt == "recroomoriginal";
})
);
}
// Only public rooms when NOT requesting RRO
if (!isRRO)
{
query = query.Where(r => r.Accessibility == RoomDBClasses.RoomAccessibility.Public);
}
// Order by popularity
query = query.OrderByDescending(r => r.Stats?.VisitCount ?? 0)
.ThenByDescending(r => r.Stats?.CheerCount ?? 0);
// Pagination
int totalCount = query.Count();
var paged = query.Skip(skip).Take(take).ToList();
// Subrooms only for the page being returned (see RoomDB.GetHotRooms).
RoomDB.AttachSubRooms(paged);
int countTotal = rooms.Count;
int countRRO = rooms.Count(r =>
r?.Tags != null &&
r.Tags.Any(t =>
{
var x = t.Tag?.ToLower();
return x == "rro" || x == "recroomoriginal";
})
);
Console.WriteLine($"HOT TOTAL ROOMS: {countTotal}");
Console.WriteLine($"HOT RRO ROOMS: {countRRO}");
return new ContentResult
{
Content = JsonConvert.SerializeObject(new Resultslist<RoomDBClasses.RoomRoot>
{
Results = StripUnservableSaves(VisibleTo(paged, ViewerId())),
TotalResults = totalCount
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("rooms/{roomid}/interactionby/me")]
public IActionResult Room_server_room_interactionby_me(ulong roomid)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var interaction = PlayerDB.GetInteraction(account.Id, roomid);
// ContentResult, not Ok(string): a raw string goes out as text/plain (see CommerceController).
return Content(JsonConvert.SerializeObject(new
{
Cheered = interaction.Cheered,
Favorited = interaction.Favorited,
LastVisitedAt = DateTime.Now // could persist if you want
}), "application/json");
}
[Route("rooms/{roomid}/interactionby/me/cheer")]
public IActionResult Room_server_room_interactionby_me_cheer(ulong roomid)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
bool cheered = PlayerDB.ToggleCheer(account.Id, roomid);
var favorited = PlayerDB.GetInteraction(account.Id, roomid).Favorited;
return Content(JsonConvert.SerializeObject(new
{
Cheered = cheered,
Favorited = favorited,
LastVisitedAt = DateTime.Now
}), "application/json");
}
[Route("rooms/{roomid}/interactionby/me/favorite")]
public IActionResult Room_server_room_interactionby_me_favorite(ulong roomid)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
bool favorited = PlayerDB.ToggleFavorite(account.Id, roomid);
var cheered = PlayerDB.GetInteraction(account.Id, roomid).Cheered;
return Content(JsonConvert.SerializeObject(new
{
Cheered = cheered,
Favorited = favorited,
LastVisitedAt = DateTime.Now
}), "application/json");
}
[HttpGet("rooms/ownedby/{PlayerId}")]
public ContentResult RoomsOwnedBy(ulong PlayerId, int skip = 0, int take = 0)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// Page at the DB level - only this page's rooms (and their subrooms) are loaded. An
// account that owns thousands (e.g. @Importer) used to load EVERY room + a subroom query
// each on every profile view, overloading the server.
if (take <= 0) take = ConfigCampus.ProfileRoomsCap;
var owned = RoomDB.GetRoomsByCreatorPaged(PlayerId, skip, take);
// The dorm is personal space, served by /dormroom/me - never part of a rooms list.
return new ContentResult
{
Content = JsonConvert.SerializeObject(StripUnservableSaves(VisibleTo(owned, account.Id).Where(r => !r.IsDorm).ToList())),
ContentType = "application/json",
StatusCode = 200
};
}
// Search suggestions for the search box - type-ahead. Ported from RecFlare.
//
// Returns a BARE ARRAY OF STRINGS (not rooms, not an envelope): room names and tags
// drawn from the public, non-dorm rooms that /rooms/search searches, so every
// suggestion finds something when submitted. A tag suggestion carries its '#' so it
// searches by tag; a query starting with '#' suggests tags ONLY. Matches that START
// with the query come first, then ones that merely contain it, alphabetical within
// each. `take` caps the list (the client asks for 4). searchSessionId is ignored.
//
// This did not exist, so search-as-you-type got a 404 and no suggestions ever showed.
[HttpGet("rooms/autocomplete_search")]
public ContentResult RoomAutocompleteSearch(string? query = "", int take = 10, string? searchSessionId = null)
{
query = (query ?? "").Trim();
bool tagsOnly = query.StartsWith("#");
string q = query.TrimStart('#').ToLowerInvariant();
var suggestions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var room in RoomDB.GetAllRooms())
{
if (room == null || room.IsDorm) continue;
if (!RoomDB.IsRoomListed(room)) continue; // only rooms search can actually find
if (!tagsOnly && !string.IsNullOrWhiteSpace(room.Name))
suggestions.Add(room.Name);
if (room.Tags != null)
foreach (var t in room.Tags)
if (!string.IsNullOrWhiteSpace(t?.Tag))
suggestions.Add("#" + t.Tag);
}
// Rank: starts-with the query before merely-contains, alphabetical within each.
// A tag's '#' is ignored when matching, so "#rr" and "rr" both hit the "rro" tag.
static string Bare(string s) => s.TrimStart('#').ToLowerInvariant();
var ordered = suggestions
.Where(s => tagsOnly ? s.StartsWith("#") : true)
.Where(s => q.Length == 0 || Bare(s).Contains(q))
.OrderByDescending(s => q.Length > 0 && Bare(s).StartsWith(q))
.ThenBy(s => s, StringComparer.OrdinalIgnoreCase)
.Take(take <= 0 ? 10 : take)
.ToList();
return new ContentResult
{
Content = JsonConvert.SerializeObject(ordered),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("rooms/search")]
public ContentResult RoomSearch(string query, int skip = 0, int take = 30)
{
var rooms = RoomDB.Search(query);
if (rooms == null || rooms.Count == 0)
{
return new ContentResult
{
Content = JsonConvert.SerializeObject(
new RoomDBClasses.Resultslist<RoomDBClasses.RoomRoot>
{
Results = new List<RoomDBClasses.RoomRoot>(),
TotalResults = 0
}),
ContentType = "application/json",
StatusCode = 200
};
}
int total = rooms.Count;
var paged = rooms.Skip(skip).Take(take).ToList();
return new ContentResult
{
Content = JsonConvert.SerializeObject(
new RoomDBClasses.Resultslist<RoomDBClasses.RoomRoot>
{
Results = StripUnservableSaves(VisibleTo(paged, ViewerId())),
TotalResults = total
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("rooms/visitedby/me")]
public ContentResult RoomsVisitedByMe(int skip = 0, int take = 20)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return new ContentResult()
{
Content = PlayerDB.GetRecentRooms(account.Id, skip, take),
ContentType = "application/json",
StatusCode = 200
};
}
// The "recent rooms" section on ANOTHER player's profile. Only /me existed, so opening
// someone else's profile 404'd this section. Respects the target's recent-room-history
// privacy setting exactly as Rec Room does: when they've hidden it, the viewer gets [].
// Your own id always returns your history (privacy is about what OTHERS see).
[HttpGet("rooms/visitedby/{playerid}")]
public ContentResult RoomsVisitedByPlayer(ulong playerid, int skip = 0, int take = 20)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
if (playerid != account.Id)
{
var target = PlayerDB.GetFullAccount(playerid);
bool visible = target?.player_Extra?.IsRecentRoomHistoryVisible ?? false;
if (!visible)
return new ContentResult { Content = "[]", ContentType = "application/json", StatusCode = 200 };
}
return new ContentResult()
{
Content = PlayerDB.GetRecentRooms(playerid, skip, take),
ContentType = "application/json",
StatusCode = 200
};
}
// The room showcase on a player's profile. This used to be a hardcoded "[]"
// stub, which is why the Profile tab rendered as one big empty panel even for
// accounts that own rooms. The client wants a plain list of room ids: [1, 2, 3].
//
// Dorms and private rooms are deliberately excluded - a showcase is public, and
// listing someone's private rooms to any viewer would leak them.
// The showcase is the handful of rooms a player PICKS for the top of their profile
// (PUT showcase, room ids). It used to list every room they own, which is what made
// everything land in "Room Showcase" instead of the Rooms tab. No pick = empty, exactly
// like the real service; the Rooms tab (createdby/{id}) is where all of them live.
[HttpPut("showcase")]
[HttpPost("showcase")]
[HttpPut("showcase/me")]
[HttpPost("showcase/me")]
public ContentResult SetRoomShowcase()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var ids = new List<ulong>();
void Add(string? v) { if (!string.IsNullOrWhiteSpace(v)) foreach (var p in v.Split(',')) if (ulong.TryParse(p.Trim(), out var id) && id != 0 && !ids.Contains(id)) ids.Add(id); }
try
{
if (Request.HasFormContentType)
foreach (var k in new[] { "roomIds", "roomId", "ids", "id", "RoomIds" }) foreach (var v in Request.Form[k]) Add(v);
foreach (var k in new[] { "roomIds", "roomId", "ids", "id" }) foreach (var v in Request.Query[k]) Add(v);
if (ids.Count == 0 && !Request.HasFormContentType)
{
Request.EnableBuffering(); Request.Body.Position = 0;
using var reader = new StreamReader(Request.Body, leaveOpen: true);
string raw = reader.ReadToEndAsync().GetAwaiter().GetResult();
if (!string.IsNullOrWhiteSpace(raw))
{
var node = System.Text.Json.Nodes.JsonNode.Parse(raw);
if (node is System.Text.Json.Nodes.JsonArray ja) foreach (var n in ja) Add(n?.ToString());
else if (node is System.Text.Json.Nodes.JsonObject jo) foreach (var kv in jo) { if (kv.Value is System.Text.Json.Nodes.JsonArray a2) foreach (var n in a2) Add(n?.ToString()); else Add(kv.Value?.ToString()); }
}
}
}
catch (Exception ex) { Console.WriteLine($"[showcase] {account.Id}: could not read body - {ex.Message}"); }
// Only rooms the player actually created may be showcased.
ids = ids.Where(id => RoomDB.GetRoom(id)?.CreatorAccountId == account.Id).Take(ConfigCampus.ShowcaseCap).ToList();
SettingsDB.SetPlayerSetting("Campus.RoomShowcase", string.Join(",", ids), account.Id);
Console.WriteLine($"[showcase] {account.Id} set showcase to [{string.Join(",", ids)}]");
return new ContentResult { Content = JsonConvert.SerializeObject(ids), ContentType = "application/json", StatusCode = 200 };
}
[HttpGet("showcase/{playerid}")]
public ContentResult RoomShowcaseForPlayer(ulong playerid)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var picked = (SettingsDB.GetPlayerSettingValue("Campus.RoomShowcase", playerid) ?? "")
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(p => ulong.TryParse(p.Trim(), out var id) ? id : 0)
.Where(id => id != 0)
.ToList();
var rooms = picked.Select(id => RoomDB.GetRoom(id)).Where(r => r != null && r!.CreatorAccountId == playerid).Select(r => r!).ToList();
// NO FALLBACK. A showcase with nothing picked is EMPTY.
//
// This briefly fell back to "every room the player created", which did put the rooms
// on the profile - but under the Room Showcase heading, all 33 of them, instead of in
// the rooms list where the co-owned rooms (contributedby) correctly land. The rooms
// list is fed by rooms/createdby/{id}, which is the route that was actually missing
// (only the /me spelling existed, so a profile by id 404'd and the grid was empty).
// That route is what makes an owner see their own rooms; this one stays a pick list.
// Show what THIS VIEWER is allowed to see, not just public rooms.
//
// This used to be "Accessibility == Public", which hid almost everything: a
// freshly created room defaults to PRIVATE (RoomAccessibility.Private = 0), so
// players saw an empty portfolio even on their own profile. Now:
// - your own profile shows all your rooms, private included
// - someone else's shows their public/unlisted rooms, plus any private room
// you personally hold a role on (owner / co-owner / moderator / host)
// Dorms are never listed - they are personal space, not portfolio pieces.
var ids = rooms
.Where(r => r != null
&& !r.IsDorm
&& RoomDB.CanPlayerSeeRoom(r, account.Id))
.Select(r => r.RoomId)
.Take(ConfigCampus.ShowcaseCap)
.ToList();
return new ContentResult()
{
Content = JsonConvert.SerializeObject(ids),
ContentType = "application/json",
StatusCode = 200
};
}
// Room role management (co-owner / moderator / host). These were ack/empty-array stubs, so
// "invite as co-owner" did nothing and the roles list always showed empty. Now they persist.
// Ban history for one account in one room. ENVELOPED {Value:{ActiveBan, PreviousBans}}
// (wire-shapes: rooms-bans-history); "never banned" is a null ActiveBan and an empty list,
// and nothing throws on either. Was a 404.
[HttpGet("rooms/{roomId}/bans/history")]
public IActionResult RoomBanHistory(ulong roomId, [FromQuery] long id)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
// A room ban is the Banned ROLE on the room plus a RoomBans record (RoomDB.BanFromRoom);
// the record carries who / when / why / until, and closed ones are the history.
var room = RoomDB.GetRoom(roomId);
object? BanRow(RoomDBClasses.RoomBanDetails d) => new
{
Status = d.Status, // Active 0 / Elapsed 1 / Lifted 2
UnbannedByAccountId = d.UnbannedByAccountId.HasValue ? (int?)d.UnbannedByAccountId.Value : null,
BanEndTime = d.BanEndTime,
Reason = d.Reason ?? "",
AccountId = (int)d.AccountId,
BannedByAccountId = d.BannedByAccountId != 0 ? (int?)d.BannedByAccountId : null,
BanStartTime = d.BanStartTime
};
var current = room != null ? RoomDB.ActiveRoomBan(room, (ulong)id) : null;
object? active = current != null ? BanRow(current) : null;
var previous = (room?.RoomBans ?? new())
.Where(b => b.AccountId == id && b.Status != 0)
.OrderByDescending(b => b.BanStartTime)
.Select(b => BanRow(b)!)
.ToArray();
return Ok(new
{
Value = new { ActiveBan = active, PreviousBans = previous },
Success = true,
Error = (string?)null,
error_id = (string?)null
});
}
// What every role / permission WRITE answers: the room-write envelope with the updated
// room as its value, {success, error, value}. The 2025 client sends all of these through
// the room facade's enveloped helper (OALMDGJKBJH.PJPLCMBOAFB<T>) and parses the answer
// as an object - Player.log, inviting a co-owner: "Malformed Response: '[{"AccountId":1,
// ...}]' ... expected:'{', actual:'['", then "Failed to invite account to role"; and on
// the permissions PUT: "Response was empty" / "Failed to change subroom permissions".
// The bare roles ARRAY is what GET /roles answers (rooms-roles-wire-shape) and only that.
// The wire keys accept any casing, so this is the same lowercase envelope PUT /name uses.
private ContentResult RoomWriteEnvelope(ulong roomId) => new ContentResult
{
Content = JsonConvert.SerializeObject(new { success = true, error = "", value = RoomDB.GetRoom(roomId) }),
ContentType = "application/json",
StatusCode = 200
};
[HttpGet("rooms/{roomId}/roles")]
public IActionResult GetRoomRolesEndpoint(ulong roomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
return Ok(RoomDB.GetRoomRoles(roomId)); // BARE array - the one roles route that is
}
// Remove a player's role outright (the roles panel's "remove"). Same gate as invite.
[HttpDelete("rooms/{roomId}/roles/{playerId}")]
public IActionResult RemoveRoomRole(ulong roomId, long playerId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
// Removing YOURSELF: decline a pending invite, or step down from a role you hold.
if ((ulong)playerId == account.Id)
{
if (RoomDB.PendingRoleInvite(roomId, playerId) != RoomDBClasses.Role_data.None)
return ResolveRoleInvite(roomId, account.Id, accept: false);
var mine = RoomDB.GetRoom(roomId);
if (mine != null && mine.CreatorAccountId == account.Id) return StatusCode(403, "");
RoomDB.SetRoomRole(roomId, playerId, RoomDBClasses.Role_data.None, (long)account.Id);
Console.WriteLine($"[room-role] {account.Id} left their role on room {roomId}");
return RoomWriteEnvelope(roomId);
}
if (!RoomDB.PlayerCanManageRoles(roomId, account.Id))
return StatusCode(403, "");
bool ok = RoomDB.SetRoomRole(roomId, playerId, RoomDBClasses.Role_data.None, (long)account.Id);
Console.WriteLine($"[room-role] {account.Id} removed player {playerId}'s role on room {roomId} -> {ok}");
return RoomWriteEnvelope(roomId);
}
[HttpPut("rooms/{roomId}/roles/{playerId}")]
[HttpPost("rooms/{roomId}/roles/{playerId}")]
[HttpPut("rooms/{roomId}/roles/{playerId}/invite")]
[HttpPost("rooms/{roomId}/roles/{playerId}/invite")]
public IActionResult InviteRoomRole(ulong roomId, long playerId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
// The INVITEE answering their own pending invite comes through the same route with
// their own id: that is an accept, never a self-grant (nothing here lets a caller give
// themselves a role - the grant is exactly the role a manager offered).
if ((ulong)playerId == account.Id)
{
var pending = RoomDB.PendingRoleInvite(roomId, playerId);
if (pending == RoomDBClasses.Role_data.None)
{
// Already accepted (the notification tapped twice, or a retry after the first
// answer failed to parse): a no-op success, with the room. A player with no
// role and no invite is still refused - nothing here grants.
var held = RoomDB.PlayerRoleOn(RoomDB.GetRoom(roomId), account.Id) ?? RoomDBClasses.Role_data.None;
if (held != RoomDBClasses.Role_data.None && held != RoomDBClasses.Role_data.Banned)
return RoomWriteEnvelope(roomId);
Console.WriteLine($"[room-role] {account.Id} tried to set their OWN role on room {roomId} with no pending invite - refused");
return StatusCode(403, "");
}
return ResolveRoleInvite(roomId, account.Id, accept: true);
}
// Only the room's creator, a co-owner, or a dev/admin may offer roles.
if (!RoomDB.PlayerCanManageRoles(roomId, account.Id))
return StatusCode(403, "");
// The client PUTs a small form body: role=<Role_data value> (30 CoOwner, 20 Moderator,
// 10 Host, 0 None=remove). Fall back to query / invitedRole; default CoOwner if absent.
string roleStr = Request.HasFormContentType ? Request.Form["role"].ToString() : "";
if (string.IsNullOrWhiteSpace(roleStr)) roleStr = Request.Query["role"].ToString();
if (string.IsNullOrWhiteSpace(roleStr) && Request.HasFormContentType) roleStr = Request.Form["invitedRole"].ToString();
int roleVal = int.TryParse(roleStr, out var rv) ? rv : (int)RoomDBClasses.Role_data.CoOwner;
var role = Enum.IsDefined(typeof(RoomDBClasses.Role_data), (byte)roleVal)
? (RoomDBClasses.Role_data)(byte)roleVal : RoomDBClasses.Role_data.CoOwner;
// A creator can't be demoted through here, can't be invited to their own room, and
// nobody can hand out Creator.
var target_room = RoomDB.GetRoom(roomId);
if (target_room != null && (ulong)playerId == target_room.CreatorAccountId)
{
Console.WriteLine($"[room-role] {account.Id} tried to change the creator's role on room {roomId} - refused");
return StatusCode(403, "");
}
if (role == RoomDBClasses.Role_data.Creator) role = RoomDBClasses.Role_data.CoOwner;
if (role == RoomDBClasses.Role_data.Banned) role = RoomDBClasses.Role_data.None;
bool ok;
if (role == RoomDBClasses.Role_data.None)
{
ok = RoomDB.SetRoomRole(roomId, playerId, role, (long)account.Id);
Console.WriteLine($"[room-role] {account.Id} removed player {playerId}'s role on room {roomId} -> {ok}");
if (ok) Campus_live_rewrite.api.server.RoomPermissions.PushRoomUpdate((ulong)playerId, RoomDB.GetRoom(roomId));
return RoomWriteEnvelope(roomId);
}
// Co-owner and contributor are OFFERED and wait for the invitee's Accept - they hand
// over the room's contents. Moderator and host are GRANTED on the spot: they are
// staffing roles, and an invite for them is what the owner reported as "adding a mod
// makes them get an invite". The target's client is told immediately either way.
if (role == RoomDBClasses.Role_data.Moderator || role == RoomDBClasses.Role_data.Host)
{
ok = RoomDB.SetRoomRole(roomId, playerId, role, (long)account.Id);
Console.WriteLine($"[room-role] {account.Id} made player {playerId} {role} ({roleVal}) on room {roomId} -> {ok} (granted directly)");
if (ok) Campus_live_rewrite.api.server.RoomPermissions.PushRoomUpdate((ulong)playerId, RoomDB.GetRoom(roomId));
return RoomWriteEnvelope(roomId);
}
ok = RoomDB.InviteRoomRole(roomId, playerId, role, (long)account.Id);
Console.WriteLine($"[room-role] {account.Id} invited player {playerId} as {role} ({roleVal}) on room {roomId} -> {ok} (pending acceptance)");
if (ok)
{
try
{
var msg = PlayerDB.AddMessage((ulong)playerId, account.Id, WebsocketEvents.MessageType.RoomCoOwnerInvited, ((int)role).ToString(), roomId, null);
if (msg != null) Notifications.SendToPlayer((ulong)playerId, JsonConvert.SerializeObject(WebsocketEvents.CreateMessageResponse(msg)));
}
catch (Exception ex) { Console.WriteLine($"[room-role] invite notification failed: {ex.Message}"); }
}
// The client re-GETs /roles right after; return the updated list so it reflects immediately.
return RoomWriteEnvelope(roomId);
}
private IActionResult ResolveRoleInvite(ulong roomId, ulong accountId, bool accept)
{
var pending = RoomDB.PendingRoleInvite(roomId, (long)accountId);
if (pending == RoomDBClasses.Role_data.None) return StatusCode(404, "");
bool ok = RoomDB.ResolveRoleInvite(roomId, (long)accountId, accept);
Console.WriteLine($"[room-role] {accountId} {(accept ? "ACCEPTED" : "declined")} the {pending} invite on room {roomId} -> {ok}");
if (ok && accept) Campus_live_rewrite.api.server.RoomPermissions.PushRoomUpdate(accountId, RoomDB.GetRoom(roomId));
if (ok && accept)
{
// RoomCoOwnerAdded (60) reads "You are now a Co-Owner of X" - it is addressed to the
// player who GAINED the role, not to the room's owner. Sending it to the creator is
// what made the owner receive "You are now a Co-Owner" of their own room.
var room = RoomDB.GetRoom(roomId);
if (room != null && room.CreatorAccountId != accountId)
{
try
{
var msg = PlayerDB.AddMessage(accountId, room.CreatorAccountId, WebsocketEvents.MessageType.RoomCoOwnerAdded, ((int)pending).ToString(), roomId, null);
if (msg != null) Notifications.SendToPlayer(accountId, JsonConvert.SerializeObject(WebsocketEvents.CreateMessageResponse(msg)));
}
catch { }
}
}
return RoomWriteEnvelope(roomId);
}
// The explicit accept / decline spellings, plus "myrole" (the reference lists GET
// rooms/{id}/roles/myrole; a PUT there is the same accept).
[HttpPut("rooms/{roomId}/roles/{playerId}/accept")]
[HttpPost("rooms/{roomId}/roles/{playerId}/accept")]
public IActionResult AcceptRoomRole(ulong roomId, long playerId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
if ((ulong)playerId != account.Id) return StatusCode(403, "");
return ResolveRoleInvite(roomId, account.Id, accept: true);
}
[HttpPut("rooms/{roomId}/roles/{playerId}/decline")]
[HttpPost("rooms/{roomId}/roles/{playerId}/decline")]
[HttpDelete("rooms/{roomId}/roles/{playerId}/invite")]
public IActionResult DeclineRoomRole(ulong roomId, long playerId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
if ((ulong)playerId == account.Id) return ResolveRoleInvite(roomId, account.Id, accept: false);
// A manager withdrawing an offer they made.
if (!RoomDB.PlayerCanManageRoles(roomId, account.Id)) return StatusCode(403, "");
RoomDB.ResolveRoleInvite(roomId, playerId, accept: false);
return RoomWriteEnvelope(roomId);
}
[HttpGet("rooms/{roomId}/roles/myrole")]
public IActionResult MyRoomRole(ulong roomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var room = RoomDB.GetRoom(roomId);
return Ok((int)(RoomDB.PlayerRoleOn(room, account.Id) ?? RoomDBClasses.Role_data.None));
}
[HttpPut("rooms/{roomId}/roles/myrole")]
[HttpPost("rooms/{roomId}/roles/myrole")]
public IActionResult SetMyRoomRole(ulong roomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
return ResolveRoleInvite(roomId, account.Id, accept: true);
}
// Anything else under /roles: log the shape so an unknown client spelling names itself.
[Route("rooms/{roomId}/roles/{**rest}")]
public IActionResult UnhandledRoleRoute(ulong roomId, string rest)
{
string body = "";
try { if (Request.HasFormContentType) body = string.Join("&", Request.Form.Select(kv => kv.Key + "=" + kv.Value)); } catch { }
Console.WriteLine($"[room-role] UNHANDLED {Request.Method} rooms/{roomId}/roles/{rest}{Request.QueryString} body='{body}'");
return StatusCode(404, "");
}
[HttpGet("rooms/base")]
public ContentResult BaseRooms()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
List<RoomDBClasses.RoomRoot> room = RoomDB.Search("#base", isBase: true);
return new ContentResult()
{
Content = JsonConvert.SerializeObject(StripUnservableSaves(VisibleTo(room, ViewerId()))),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPost("rooms/{roomId}/clone")]
public ContentResult CloneRoom(ulong roomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string RoomName = HttpContext.Request.Form["name"];
if (string.IsNullOrEmpty(RoomName))
{
return Utils.Error("You must enter a name for your room.");
}
var checkifroomexists = RoomDB.GetRoomByName(RoomName);
if (checkifroomexists != null)
{
return Utils.Error("A room with that name already exists!");
}
bool CheckSwears = !string.IsNullOrEmpty(RoomName) && Sanitize.ContainsSwears(RoomName);
if (CheckSwears)
{
return Utils.Error("Room name contains swear words!");
}
// Anyone could clone ANY room (unpublished drafts included) and end up owning a copy.
// Only the room's own creator/co-owners, or anyone when the owner allowed cloning on a
// room others can see.
var source = RoomDB.GetRoom(roomId);
if (source == null) return Utils.Error("This room does not exist!");
bool mineToClone = source.CreatorAccountId == account.Id || RoomDB.IsCoOwner(source, account.Id) || PlayerDB.CheckDevFlag(account.Id);
bool template = RoomDB.RoomHasTag(source, "base") || source.IsRRO; // the "create a room" templates
if (!mineToClone && !template && !(source.CloningAllowed && RoomDB.CanPlayerSeeRoom(source, account.Id)))
{
Console.WriteLine($"[rooms] {account.Id} tried to clone room {roomId} ({source.Name}) without permission - refused");
return Utils.Error("This room can't be cloned.");
}
ulong? temp = RoomDB.CloneRoom(roomId, RoomName, account.Id);
if (temp == null)
{
return Utils.Error("You can't clone this room!");
}
var NewRoom = RoomDB.GetRoom(temp.Value);
if (NewRoom == null)
{
Console.WriteLine($"[Room_server_room_clone] get_room({temp.Value}) returned null");
}
Console.WriteLine($"[Room_server_room_clone] clone_room returned id: {temp}");
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
success = true,
error = "",
value = NewRoom
}),
ContentType = "application/json",
StatusCode = 200
};
}
// set room stuff
[HttpPut("rooms/{roomId}/name")]
public ContentResult SetRoomName(ulong roomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string NewRoomName = HttpContext.Request.Form["name"].ToString();
RoomDBClasses.RoomRoot? room_data = RoomDB.GetRoom(roomId);
Uri.UnescapeDataString(NewRoomName);
if (room_data == null)
{
return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!");
}
if (room_data.CreatorAccountId != account?.Id && !RoomDB.IsCoOwner(room_data, account?.Id ?? 0))
{
return Utils.ErrorRoom("Rooms.PermissionDenied", "You are not the owner of this room!");
}
if (room_data.IsDorm == true)
{
return Utils.ErrorRoom("Rooms.Dorm", "You cannot edit your dorm room's name!");
}
if (NewRoomName.Contains(" "))
{
return Utils.ErrorRoom("Rooms.NoSpaces", "Your room name must not contain spaces!");
}
if (Regex.IsMatch(NewRoomName, @"[^\u0000-\u007F]"))
{
return Utils.ErrorRoom("Rooms.NoUnicodeCharacters", "Your room name contains Unicode characters!");
}
bool CheckSwears = !string.IsNullOrEmpty(NewRoomName) && Sanitize.ContainsSwears(NewRoomName);
if (CheckSwears)
{
return Utils.ErrorRoom("Rooms.NoSwearWords", "Your room name contains swear words!");
}
RoomDBClasses.RoomRoot? temp = RoomDB.SetRoomName(roomId, NewRoomName);
RoomDBClasses.RoomRoot? UpdatedRoom = RoomDB.GetRoom(roomId);
if (UpdatedRoom != null)
{
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
success = true,
error = "",
value = UpdatedRoom
}),
ContentType = "application/json",
StatusCode = 200
};
}
else
{
return Utils.ErrorRoom("Rooms.DuplicateName", "A room with the same name already exists!");
}
}
[HttpPut("rooms/{roomId}/description")]
public ContentResult SetRoomDescriptionAPI(ulong roomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string NewDescription = HttpContext.Request.Form["description"].ToString();
Uri.UnescapeDataString(NewDescription);
RoomDBClasses.RoomRoot room_data = RoomDB.GetRoom(roomId);
if (room_data == null)
{
return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!");
}
if (room_data.CreatorAccountId != account?.Id && !RoomDB.IsCoOwner(room_data, account?.Id ?? 0))
{
return Utils.ErrorRoom("Rooms.PermissionDenied", "You are not the owner of this room!");
}
bool CheckSwears = !string.IsNullOrEmpty(NewDescription) && Sanitize.ContainsSwears(NewDescription);
if (CheckSwears)
{
return Utils.ErrorRoom("Rooms.NoSwearWords", "Your room description contains swear words!");
}
RoomDBClasses.RoomRoot temp = RoomDB.SetRoomDescription(roomId, NewDescription);
RoomDBClasses.RoomRoot UpdatedRoom = RoomDB.GetRoom(roomId);
if (UpdatedRoom != null)
{
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
success = true,
error = "",
value = UpdatedRoom
}),
ContentType = "application/json",
StatusCode = 200
};
}
else
{
return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!");
}
}
[HttpPut("rooms/{roomId}/accessibility")]
public ContentResult SetRoomAccessibility(ulong roomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string NewAccessibilityString = HttpContext.Request.Form["accessibility"].ToString();
RoomDBClasses.RoomAccessibility accessibility = RoomDBClasses.RoomAccessibility.Private;
if (int.TryParse(NewAccessibilityString, out int accessibilityValue) && Enum.IsDefined(typeof(RoomDBClasses.RoomAccessibility), accessibilityValue))
{
accessibility = (RoomDBClasses.RoomAccessibility)accessibilityValue;
}
else
{
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
success = false,
error = "",
value = (object?)null
}),
ContentType = "application/json",
StatusCode = 400
};
}
RoomDBClasses.RoomRoot room_data = RoomDB.GetRoom(roomId);
if (room_data == null)
{
return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!");
}
if (room_data.CreatorAccountId != account?.Id && !RoomDB.IsCoOwner(room_data, account?.Id ?? 0))
{
return Utils.ErrorRoom("Rooms.PermissionDenied", "You are not the owner of this room!");
}
RoomDBClasses.RoomRoot temp = RoomDB.SetRoomAccessibility(roomId, accessibility);
RoomDBClasses.RoomRoot UpdatedRoom = RoomDB.GetRoom(roomId);
if (UpdatedRoom != null)
{
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
success = true,
error = "",
value = UpdatedRoom
}),
ContentType = "application/json",
StatusCode = 200
};
}
else
{
return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!");
}
}
// Transfer a room to a new owner (reference rooms PUT /rooms/{id}/creator): the new owner's
// role entry becomes Creator (255), the old owner's becomes CoOwner (30), and
// CreatorAccountId - the field every gate and "rooms I made" list reads - moves with it.
// Owner only (a co-owner cannot give a room away), and a dorm can never be transferred.
[HttpPut("rooms/{roomId}/creator")]
[HttpPost("rooms/{roomId}/creator")]
public ContentResult TransferRoom(ulong roomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var f = RoomModeration.Fields(Request);
ulong target = RoomModeration.ULong(f, "accountId", "id", "playerId", "newOwnerId");
var room = RoomDB.GetRoom(roomId);
if (room == null) return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!");
bool isOwner = room.CreatorAccountId == account.Id || PlayerDB.CheckDevFlag(account.Id);
if (!isOwner) return Utils.ErrorRoom("Rooms.PermissionDenied", "Only the owner can transfer this room!");
if (room.IsDorm) return Utils.ErrorRoom("Rooms.PermissionDenied", "A dorm cannot be transferred!");
if (target == 0 || PlayerDB.GetAccountById(target) == null) return Utils.ErrorRoom("Rooms.InvalidAccount", "You must name a valid player!");
if (target == room.CreatorAccountId) return Utils.ErrorRoom("Rooms.InvalidAccount", "That player already owns this room!");
var stored = RoomDB.col.FindOne(x => x.RoomId == roomId);
if (stored == null) return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!");
ulong previous = stored.CreatorAccountId;
stored.Roles ??= new List<RoomDBClasses.RoleClass>();
stored.Roles.RemoveAll(x => (ulong)x.AccountId == target);
stored.Roles.Add(new RoomDBClasses.RoleClass { AccountId = (long)target, Role = RoomDBClasses.Role_data.Creator, InvitedRole = 0, LastChangedByAccountId = (long)account.Id });
var old = stored.Roles.FirstOrDefault(x => (ulong)x.AccountId == previous);
if (old != null) { old.Role = RoomDBClasses.Role_data.CoOwner; old.InvitedRole = 0; old.LastChangedByAccountId = (long)account.Id; }
else if (previous != 0) stored.Roles.Add(new RoomDBClasses.RoleClass { AccountId = (long)previous, Role = RoomDBClasses.Role_data.CoOwner, InvitedRole = 0, LastChangedByAccountId = (long)account.Id });
stored.CreatorAccountId = target;
RoomDB.col.Update(stored);
Console.WriteLine($"[rooms] room {roomId} '{stored.Name}': ownership {previous} -> {target} (by {account.Id})");
var updated = RoomDB.GetRoom(roomId);
foreach (var pid in RoomPermissions.PlayersInRoom(roomId).Concat(new[] { previous, target }).Distinct())
RoomPermissions.PushRoomUpdate(pid, updated);
return new ContentResult
{
Content = JsonConvert.SerializeObject(new { success = true, error = "", value = updated }),
ContentType = "application/json",
StatusCode = 200
};
}
// A subroom's own visibility (reference rooms PUT /rooms/{id}/subrooms/{sub}/accessibility):
// the client sends the RoomAccessibility NAME (accessibility=Private); the ordinal works too.
// Owner only. Answers the updated ROOM so the client re-renders its subroom list.
[HttpPut("rooms/{roomId}/subrooms/{subRoomId}/accessibility")]
[HttpPost("rooms/{roomId}/subrooms/{subRoomId}/accessibility")]
public ContentResult SetSubroomAccessibility(ulong roomId, ulong subRoomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var f = RoomModeration.Fields(Request);
string raw = RoomModeration.Str(f, "accessibility", "Accessibility") ?? "";
RoomDBClasses.RoomAccessibility acc;
if (int.TryParse(raw, out var n) && Enum.IsDefined(typeof(RoomDBClasses.RoomAccessibility), n)) acc = (RoomDBClasses.RoomAccessibility)n;
else if (!Enum.TryParse(raw, true, out acc)) return Utils.ErrorRoom("Rooms.InvalidArgument", "Unknown accessibility!");
var room = RoomDB.GetRoom(roomId);
if (room == null) return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!");
if (room.CreatorAccountId != account.Id && !RoomDB.IsCoOwner(room, account.Id) && !PlayerDB.CheckDevFlag(account.Id))
return Utils.ErrorRoom("Rooms.PermissionDenied", "You are not the owner of this room!");
var sub = room.SubRooms?.FirstOrDefault(s => s.SubRoomId == subRoomId);
if (sub == null) return Utils.ErrorRoom("Rooms.DoesntExist", "This subroom does not exist!");
RoomDB.ModifySubroom(roomId, subRoomId, sub.Name ?? "", acc, sub.MaxPlayers);
var updated = RoomDB.GetRoom(roomId);
RoomPermissions.PushRoomUpdate(account.Id, updated);
return new ContentResult
{
Content = JsonConvert.SerializeObject(new { success = true, error = "", value = updated }),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPut("rooms/{roomId}/image")]
public ContentResult SetRoomImageName(ulong roomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string NewImageName = HttpContext.Request.Form["imageName"].ToString();
RoomDBClasses.RoomRoot room_data = RoomDB.GetRoom(roomId);
if (room_data == null)
{
return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!");
}
if (room_data.CreatorAccountId != account?.Id && !RoomDB.IsCoOwner(room_data, account?.Id ?? 0))
{
return Utils.ErrorRoom("Rooms.PermissionDenied", "You are not the owner of this room!");
}
RoomDBClasses.RoomRoot temp = RoomDB.SetRoomImageName(roomId, NewImageName);
RoomDBClasses.RoomRoot UpdatedRoom = RoomDB.GetRoom(roomId);
if (UpdatedRoom != null)
{
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
success = true,
error = "",
value = UpdatedRoom
}),
ContentType = "application/json",
StatusCode = 200
};
}
else
{
return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!");
}
}
[HttpPost("rooms/{roomId}/subrooms/{subroomId}/data")]
public async Task<ContentResult> SetRoomData(ulong roomId, ulong subroomId, [FromBody] RoomFileData request)
{
string authtoken = HttpContext.Request.Headers["Authorization"].ToString();
if (string.IsNullOrEmpty(authtoken))
{
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 401
};
}
string playerdata = ClientSecurity.DecodeToken(authtoken);
if (playerdata == null)
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 401
};
if (playerdata.Contains("error_token"))
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 401
};
player_info account = JsonConvert.DeserializeObject<player_info>(playerdata);
string token = authtoken.ToString().Replace("Bearer ", "", StringComparison.OrdinalIgnoreCase);
var handler = new JwtSecurityTokenHandler();
var jwt = handler.ReadJwtToken(token);
// "platform" is normally numeric, but int.Parse here threw an UNHANDLED
// FormatException -> HTTP 500 on any token carrying a name ("Standalone").
// A room save is the one request you never want to lose, so accept either
// shape and fall back to All rather than dropping the save on the floor.
string currentplatformstr = jwt.Payload.TryGetValue("platform", out var platObj)
? platObj?.ToString() ?? "" : "";
Platforms platformparsed;
if (int.TryParse(currentplatformstr, out int currentplatform))
platformparsed = Enum.IsDefined(typeof(Platforms), currentplatform) ? (Platforms)currentplatform : Platforms.All;
else if (!Enum.TryParse(currentplatformstr, ignoreCase: true, out platformparsed))
platformparsed = Platforms.All;
// The subroom blob (the actual build) and the ROOM-level blob are two
// different uploads. Both lines used to read SubRoomData, so RoomData was
// silently discarded and subroomFilename was dead code.
string subroomFilename = request.SubRoomData?.Filename ?? "";
string? roomLevelBlob = request.RoomData?.Filename;
string RoomDesc = request.Description ?? "";
RoomDBClasses.RoomRoot room_data = RoomDB.GetRoom(roomId);
if (room_data == null)
{
return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!");
}
if (!RoomDB.CanEditContent(room_data, account?.Id ?? 0)) // creator, co-owner, or an ACCEPTED contributor
{
return Utils.ErrorRoom("Rooms.PermissionDenied", "You are not the owner of this room!");
}
// Pass the versions the client reported with this save. Without them every
// save was recorded as 0/0/0 and the client treated the room as dirty
// immediately, prompting to save on exit even when nothing had changed.
// A save never changes the room's VISIBILITY (accessibility is the owner's setting).
// What AutoPublish decides is whether this save goes LIVE now or is staged as an
// unpublished save until the owner publishes it (publish_save) - published and
// unpublished saves are kept apart, so visitors keep loading the published build.
RoomDBClasses.RoomRoot room = RoomDB.SetRoomSubroomRoomFile(
roomId,
subroomId,
subroomFilename,
RoomDesc,
account.Id,
platformparsed,
request.PersistenceVersion,
request.OMVersion,
request.UgcSubVersion,
roomLevelBlob,
request.AutoPublish,
out var createdSave);
RoomDBClasses.RoomRoot UpdatedRoom = RoomDB.GetRoom(roomId);
if (UpdatedRoom != null)
{
// The client parses value as { room, subRoomDataSave } (see wire-shapes
// subroom-data-save-endpoint). We used to hand back the bare room, so the
// deserializer NRE'd on the missing pair, the promise rejected with the generic
// "Failed to save room", and the room stayed dirty - every save after the first
// one in an instance "failed" even though the blob had been stored.
// subRoomDataSave is a camelCase projection with its OWN field set, not the
// PascalCase CurrentSave embedded in the room.
// The save just made - staged or published - not necessarily the live CurrentSave.
var save = createdSave ?? RoomDB.GetSubRoom(roomId, subroomId)?.CurrentSave;
object? subRoomDataSave = save == null ? null : new
{
subRoomDataSaveId = (long)save.SubRoomDataSaveId,
subRoomId = (long)save.SubRoomId,
unityAssetId = save.UnityAssetId,
unityAsset = (string?)null,
unityAssetHash = (string?)null,
dataBlob = save.DataBlob ?? "",
dataBlobHash = request.SubRoomData?.Hash ?? "",
savedByAccountId = (long?)save.SavedByAccountId,
savedOnPlatform = (int)(save.SavedOnPlatform ?? Platforms.All),
savedOnDeviceClass = (int)(save.SavedOnDeviceClass ?? DeviceClasses.Screen),
description = save.Description,
createdAt = save.CreatedAt
};
Campus_live_rewrite.api.server.RoomPermissions.PushRoomUpdate(account.Id, UpdatedRoom);
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
success = true,
error = (string?)null,
value = new { room = UpdatedRoom, subRoomDataSave }
}),
ContentType = "application/json",
StatusCode = 200
};
}
else
{
return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!");
}
}
[HttpPut("rooms/{roomId}/cloning")]
public ContentResult SetRoomCloning(ulong roomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string? cloningAllowedStr = HttpContext.Request.Form["cloningAllowed"];
if (!bool.TryParse(cloningAllowedStr, out bool cloningAllowed))
{
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 400
};
}
RoomDBClasses.RoomRoot room_data = RoomDB.GetRoom(roomId);
if (room_data.IsDorm == true)
{
return Utils.ErrorRoom("Rooms.DormNotAllowed", "You cannot edit that in this dorm.");
}
if (room_data == null)
{
return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!");
}
if (room_data.CreatorAccountId != account?.Id && !RoomDB.IsCoOwner(room_data, account?.Id ?? 0))
{
return Utils.ErrorRoom("Rooms.PermissionDenied", "You are not the owner of this room!");
}
RoomDBClasses.RoomRoot temp = RoomDB.SetRoomCloning(roomId, cloningAllowed);
RoomDBClasses.RoomRoot UpdatedRoom = RoomDB.GetRoom(roomId);
if (UpdatedRoom != null)
{
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
success = true,
error = "",
value = UpdatedRoom
}),
ContentType = "application/json",
StatusCode = 200
};
}
else
{
return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!");
}
}
[HttpPut("rooms/{roomId}/restrictions")]
[HttpPut("rooms/{roomId}/v2/restrictions")] // the 2025 client's spelling (404'd)
[HttpPost("rooms/{roomId}/v2/restrictions")]
public ContentResult SetRoomRestrictions(ulong roomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
bool ParseBool(string key, bool defaultValue = false)
{
string? str = HttpContext.Request.Form[key];
return bool.TryParse(str, out bool result) ? result : defaultValue;
}
bool supportsScreens = ParseBool("supportsScreens");
bool supportsWalkVR = ParseBool("supportsWalkVR");
bool supportsTeleportVR = ParseBool("supportsTeleportVR");
bool supportsJuniors = ParseBool("supportsJuniors");
RoomDBClasses.RoomRoot? room_data = RoomDB.GetRoom(roomId);
if (room_data?.IsDorm == true)
{
return Utils.ErrorRoom("Rooms.DormNotAllowed", "You cannot edit that in dorms.");
}
if (room_data == null)
{
return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!");
}
if (room_data.CreatorAccountId != account?.Id && !RoomDB.IsCoOwner(room_data, account?.Id ?? 0))
{
return Utils.ErrorRoom("Rooms.PermissionDenied", "You are not the owner of this room!");
}
RoomDBClasses.RoomRoot? temp = RoomDB.SetRoomRestrictions(roomId, supportsScreens, supportsWalkVR, supportsTeleportVR, supportsJuniors);
RoomDBClasses.RoomRoot? UpdatedRoom = RoomDB.GetRoom(roomId);
if (UpdatedRoom != null)
{
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
success = true,
error = "",
value = UpdatedRoom
}),
ContentType = "application/json",
StatusCode = 200
};
}
else
{
return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!");
}
}
[HttpPut("rooms/{roomId}/loadscreen")]
public ContentResult SetRoomLoadingScreen(ulong roomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string imageName = HttpContext.Request.Form["imageName"].ToString();
string title = HttpContext.Request.Form["title"].ToString();
string subtitle = HttpContext.Request.Form["subtitle"].ToString();
RoomDBClasses.RoomRoot? room_data = RoomDB.GetRoom(roomId);
if (room_data == null)
{
return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!");
}
if (room_data.CreatorAccountId != account?.Id && !RoomDB.IsCoOwner(room_data, account?.Id ?? 0))
{
return Utils.ErrorRoom("Rooms.PermissionDenied", "You are not the owner of this room!");
}
if (!string.IsNullOrEmpty(title) && title.Length > 24)
{
return Utils.ErrorRoom("Room.LoadScreen.Title", "Title cannot exceed 24 characters!");
}
if (!string.IsNullOrEmpty(subtitle) && subtitle.Length > 150)
{
return Utils.ErrorRoom("Room.LoadScreen.Subtitle", "Subtitle cannot exceed 150 characters!");
}
RoomDBClasses.RoomRoot? temp = RoomDB.SetRoomLoadscreen(roomId, imageName, title, subtitle);
RoomDBClasses.RoomRoot? UpdatedRoom = RoomDB.GetRoom(roomId);
if (UpdatedRoom != null)
{
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
success = true,
error = "",
value = UpdatedRoom
}),
ContentType = "application/json",
StatusCode = 200
};
}
else
{
return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!");
}
}
[HttpPut("rooms/{roomId}/warning")]
public ContentResult SetRoomWarning(ulong roomId, [FromForm] WarningMaskType warningMask)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
RoomDBClasses.RoomRoot room_data = RoomDB.GetRoom(roomId);
if (room_data == null)
{
return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!");
}
if (room_data.CreatorAccountId != account?.Id && !RoomDB.IsCoOwner(room_data, account?.Id ?? 0))
{
return Utils.ErrorRoom("Rooms.PermissionDenied", "You are not the owner of this room!");
}
RoomDBClasses.RoomRoot temp = RoomDB.SetRoomWarningMask(roomId, warningMask);
RoomDBClasses.RoomRoot UpdatedRoom = RoomDB.GetRoom(roomId);
if (UpdatedRoom != null)
{
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
success = true,
error = "",
value = UpdatedRoom
}),
ContentType = "application/json",
StatusCode = 200
};
}
else
{
return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!");
}
}
[HttpPut("rooms/{roomId}/tags")]
public IActionResult SetRoomTags(ulong roomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var room = RoomDB.GetRoom(roomId);
if (room == null)
return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!");
if (!RoomDB.UserCanEditRoom(roomId, account.Id))
return Utils.ErrorRoom("Rooms.PermissionDenied", "You are not the owner of this room!");
var restricted = new HashSet<string>
{
"rro",
"recroomoriginal",
"featured",
"base"
};
List<string> Values(string name) => Request.HasFormContentType
? Request.Form[name].Select(t => Uri.UnescapeDataString(t ?? "").Trim().ToLowerInvariant()).Where(t => t.Length > 0).Distinct().ToList()
: new List<string>();
var tags = Values("tag");
var autoTags = Values("autoTag");
string? primaryGenre = Values("primaryGenreTag").FirstOrDefault();
if (tags.Count == 0 && autoTags.Count == 0 && primaryGenre == null)
return Utils.ErrorRoom("Rooms.InvalidTag", "No valid tags provided.");
if (tags.Any(t => restricted.Contains(t)) || (primaryGenre != null && restricted.Contains(primaryGenre)))
return Utils.ErrorRoom("Rooms.TagNotAllowed", "One or more tags are not allowed!");
// Derived tags are a closed set: what the client's own toggles produce.
var allowedAuto = new HashSet<string> { "limitsv2", "beta", "rooms2", "r2" };
if (autoTags.Any(t => !allowedAuto.Contains(t)))
return Utils.ErrorRoom("Rooms.TagNotAllowed", "One or more tags are not allowed!");
// A lone `tag` toggles; anything more is the whole-state save (see RoomDB.ApplyRoomTagEdit).
bool isToggle = tags.Count == 1 && autoTags.Count == 0 && primaryGenre == null;
Console.WriteLine($"[rooms] {account.Id} tags on room {roomId}: {(isToggle ? "toggle " + tags[0] : $"set [{string.Join(",", tags)}] auto [{string.Join(",", autoTags)}] genre {primaryGenre ?? "-"}")}");
var updatedRoom = RoomDB.ApplyRoomTagEdit(roomId, isToggle ? tags[0] : null, isToggle ? null : (tags.Count > 0 ? tags : null), autoTags, primaryGenre);
if (updatedRoom == null)
return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!");
return Ok(new
{
success = true,
error = "",
value = updatedRoom
});
}
[HttpGet("rooms/favoritedby/me")]
public ContentResult MyFavoritedRooms()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return new ContentResult()
{
// Was: JsonConvert.SerializeObject(...) wrapped around a method that
// ALREADY returns serialized JSON, so the client received the string
// "[]" (quoted) instead of the array []. Verified live - this endpoint
// was returning "[]" and it is the source of the client's
// `Malformed Response: '[]'` errors.
Content = PlayerDB.GetFavoritedRooms(account.Id),
ContentType = "application/json",
StatusCode = 200
};
}
// Don't advertise a save whose .room blob we cannot actually serve.
//
// Symptom this fixes: joining a room ALONE failed with no subroom data, but
// joining a friend already inside worked. Creating an instance makes the client
// load the room from its save - it requests /cdn/room/<DataBlob>, which 404s
// when the file is absent from Data/CDN/RoomBlobs (the cdn.rec.net fallback in
// CDNController is dead - those blobs are no longer public). Joining an existing
// instance never fetches the blob; state replicates over Photon from whoever is
// already there. That is exactly why the same room worked via a friend.
//
// Rooms with no save at all (Park, dorms) were unaffected - they just load their
// default Unity scene. So make an unservable save look like no save and let the
// room fall back to that same scene.
//
// Scope: only changes rooms that are ALREADY broken. If the blob is present the
// save is returned exactly as before. Self-correcting - drop the real .room files
// into Data/CDN/RoomBlobs and the saves come back with no code change.
//
// Trade-off: an affected room loads its base scene instead of its saved build.
// Joinable-but-plain beats unjoinable, and it is reversible.
private static List<object> FilterUnservableSaves(dynamic? results, ulong roomId, ulong subroomId)
{
var servable = new List<object>();
int dropped = 0;
if (results != null)
{
foreach (var s in results)
{
string? blob = (string?)s?.DataBlob;
if (BlobHasContent(blob)) servable.Add(s!);
else dropped++;
}
}
if (dropped > 0)
{
Console.WriteLine($"[rooms/{roomId}/subrooms/{subroomId}/saves] dropped {dropped} save(s) " +
"whose .room blob is missing or empty in Data/CDN/RoomBlobs - room will load its " +
"default scene instead of failing to load at all");
}
return servable;
}
[HttpGet("rooms/{roomId}/subrooms/{subroomId}/saves")] // SCUFFED CODE fix later
public ContentResult GetRoomSaves(ulong roomId, ulong subroomId, int unityAssetTarget = 0, int unityAssetVersion = 1, int skip = 0, int take = 20)
{
var save = RoomDB.GetRoomSaves(roomId, subroomId, skip, take);
if (save == null)
{
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 404
};
}
var servable = FilterUnservableSaves(save.Results, roomId, subroomId);
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
Results = servable,
TotalResults = servable.Count
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("rooms/{roomId}/subrooms/{subroomId}/saves/no_unity_assets")] // SCUFFED CODE fix later
public ContentResult GetRoomSaves2(ulong roomId, ulong subroomId, int unityAssetTarget = 0, int unityAssetVersion = 1, int skip = 0, int take = 20)
{
var save = RoomDB.GetRoomSaves(roomId, subroomId, skip, take);
if (save == null)
{
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 404
};
}
var servable = FilterUnservableSaves(save.Results, roomId, subroomId);
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
Results = servable,
TotalResults = servable.Count
}),
ContentType = "application/json",
StatusCode = 200
};
}
// GetRoomSaveById returns a boxed object whose concrete type varies, so read
// DataBlob off its JSON form rather than guessing at properties vs fields -
// an earlier reflection-based version silently matched nothing and let the
// blob through.
// Single source of truth for "can the client actually download this blob?".
// NOTE: existence alone is not enough - see BlobHasContent for why.
private static bool BlobHasContent(string? blob)
=> Campus_live_rewrite.api.server.RoomBlobFilter.BlobHasContent(blob);
private static bool SaveBlobIsServable(object save)
{
string? blob;
try
{
blob = Newtonsoft.Json.Linq.JObject
.FromObject(save)["DataBlob"]?.ToString();
}
catch
{
return true; // unknown shape - never block on it
}
return BlobHasContent(blob);
}
[HttpGet("rooms/{roomId}/subrooms/{subroomId}/saves/{subRoomDataSaveId}")]
public IActionResult GetRoomSave(ulong roomId, ulong subroomId, ulong subRoomDataSaveId, [FromQuery] int unityAssetTarget, [FromQuery] int unityAssetVersion)
{
var save = RoomDB.GetRoomSaveById(roomId, subroomId, subRoomDataSaveId, unityAssetTarget, unityAssetVersion);
// Same rule as everywhere else: a save whose .room blob is not in
// Data/CDN/RoomBlobs cannot be loaded, so report it as absent rather than
// handing back a DataBlob that will 404 at /cdn/room/<blob>.
if (save != null && !SaveBlobIsServable(save))
{
Console.WriteLine($"[rooms/{roomId}/subrooms/{subroomId}/saves/{subRoomDataSaveId}] " +
"blob missing or empty in Data/CDN/RoomBlobs - reporting save as absent");
save = null;
}
if (save == null)
{
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 404
};
}
return Ok(save);
}
[HttpGet("rooms/carousel/rising")]
public ContentResult RisingRooms()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return new ContentResult()
{
Content = "[]", // todo db
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("featuredrooms/current")]
public ContentResult CurrentFeaturedRooms()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
/*
Response:
{
"FeaturedRoomGroupId": 1,
"name": "Featured Rooms",
"StartAt": "2025-12-01T11:01:00Z",
"EndAt": "9999-12-08T11:00:00Z",
"Rooms": [
{
"RoomId": 7243703403195289000,
"RoomName": "WinterAlmonds",
"ImageName": "96w7irfsiucisrei9fsmhipqn.jpg",
"IsRecRoomApproved": false,
"ExcludeFromLists": false,
"ExcludeFromSearch": false
}
]
}
*/
// Build the ONE shared featured group from FeaturedRoomsStore (admin-curated, same
// list the website reads). Skips ids whose room no longer exists. Returned as an ARRAY
// of groups (what the client expects - the old placeholder was an empty array too).
var featuredRooms = new List<object>();
foreach (var rid in Campus_live_rewrite.api.server.FeaturedRoomsStore.GetIds())
{
var r = RoomDB.GetRoom(rid);
if (r == null || r.IsDorm) continue;
featuredRooms.Add(new
{
RoomId = r.RoomId,
RoomName = r.Name ?? "",
ImageName = r.ImageName ?? "", // client's DTO wants a string, never null
IsRecRoomApproved = r.IsRecRoomApproved,
ExcludeFromLists = false,
ExcludeFromSearch = false,
});
}
// A SINGLE group object (FeaturedRoomGroupDto), NOT an array - that's what the client's
// deserializer for this call expects (confirmed against the reference server). Wrapping
// it in an array made the client fail to parse it, so the in-game Featured Room was blank.
var group = new
{
FeaturedRoomGroupId = 1,
name = "Featured Rooms",
StartAt = "2025-01-01T00:00:00Z",
EndAt = "9999-12-31T00:00:00Z",
Rooms = featuredRooms,
};
return new ContentResult()
{
Content = JsonConvert.SerializeObject(group),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPost("rooms/{roomid}/subrooms")]
public ContentResult CreateSubroomForRoom(ulong roomid)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string SubroomName = HttpContext.Request.Form["name"].ToString();
Uri.UnescapeDataString(SubroomName);
RoomDBClasses.RoomRoot room_data = RoomDB.GetRoom(roomid);
if (room_data == null)
{
return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!");
}
if (room_data.CreatorAccountId != account?.Id && !RoomDB.IsCoOwner(room_data, account?.Id ?? 0))
{
return Utils.ErrorRoom("Rooms.PermissionDenied", "You are not the owner of this room!");
}
if (room_data.IsDorm == true)
{
return Utils.ErrorRoom("Rooms.PermissionDenied", "You cannot create a subroom for your dorm room!");
}
bool CheckSwears = !string.IsNullOrEmpty(SubroomName) && Sanitize.ContainsSwears(SubroomName);
if (CheckSwears)
{
return Utils.ErrorRoom("Rooms.NoSwearWords", "Your subroom name contains swear words!");
}
RoomDBClasses.RoomRoot temp = RoomDB.CreateSubroomForRoom(roomid, SubroomName);
RoomDBClasses.RoomRoot UpdatedRoom = RoomDB.GetRoom(roomid);
if (UpdatedRoom != null)
{
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
success = true,
error = "",
value = UpdatedRoom
}),
ContentType = "application/json",
StatusCode = 200
};
}
else
{
return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!");
}
}
[HttpDelete("rooms/{roomid}/subrooms/{subroomid}")]
public ContentResult DeleteSubroom(ulong roomid, ulong subroomid)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
RoomDBClasses.RoomRoot room_data = RoomDB.GetRoom(roomid);
if (room_data == null)
{
return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!");
}
if (room_data.CreatorAccountId != account?.Id && !RoomDB.IsCoOwner(room_data, account?.Id ?? 0))
{
return Utils.ErrorRoom("Rooms.PermissionDenied", "You are not the owner of this room!");
}
bool success = RoomDB.DeleteSubroom(roomid, subroomid);
RoomDBClasses.RoomRoot UpdatedRoom = RoomDB.GetRoom(roomid);
if (UpdatedRoom != null)
{
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
success = true,
error = "",
value = UpdatedRoom
}),
ContentType = "application/json",
StatusCode = 200
};
}
else
{
return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!");
}
}
// POST rooms/{id}/subrooms/{sid}/clone - a copy of the subroom (fresh SubRoomId, same
// scene / settings / saves) in the same room. Owner or co-owner. Answers the updated ROOM
// in the envelope, like create/delete - the client re-renders its subroom list from value.
[HttpPost("rooms/{roomid}/subrooms/{subroomid}/clone")]
public ContentResult CloneSubroom(ulong roomid, ulong subroomid)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var room_data = RoomDB.GetRoom(roomid);
if (room_data == null) return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!");
if (room_data.CreatorAccountId != account?.Id && !RoomDB.IsCoOwner(room_data, account?.Id ?? 0))
return Utils.ErrorRoom("Rooms.PermissionDenied", "You are not the owner of this room!");
if (room_data.IsDorm == true) return Utils.ErrorRoom("Rooms.PermissionDenied", "You cannot clone a dorm subroom!");
var cloned = RoomDB.CloneSubroom(roomid, subroomid);
if (cloned == null) return Utils.ErrorRoom("Rooms.DoesntExist", "This subroom does not exist!");
Console.WriteLine($"[rooms] {account!.Id} cloned subroom {subroomid} of room {roomid} -> {cloned.SubRoomId}");
var updated = RoomDB.GetRoom(roomid);
Campus_live_rewrite.api.server.RoomPermissions.PushRoomUpdate(account.Id, updated);
return new ContentResult { Content = JsonConvert.SerializeObject(new { success = true, error = "", value = updated }), ContentType = "application/json", StatusCode = 200 };
}
// POST rooms/{id}/subrooms/{sid}/move - reorder a subroom in the room's list. The
// position comes as index / newIndex / position (0-based) or direction (up/down). Shape
// unpinned; every field is logged so it can be.
[HttpPost("rooms/{roomid}/subrooms/{subroomid}/move")]
[HttpPut("rooms/{roomid}/subrooms/{subroomid}/move")]
public ContentResult MoveSubroom(ulong roomid, ulong subroomid)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var room_data = RoomDB.GetRoom(roomid);
if (room_data == null) return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!");
if (room_data.CreatorAccountId != account?.Id && !RoomDB.IsCoOwner(room_data, account?.Id ?? 0))
return Utils.ErrorRoom("Rooms.PermissionDenied", "You are not the owner of this room!");
var f = Campus_live_rewrite.api.server.RoomModeration.Fields(Request);
Console.WriteLine($"[rooms] {account!.Id} move subroom {subroomid} of {roomid}: {Campus_live_rewrite.api.server.RoomModeration.Dump(f)}");
string[] idxKeys = { "index", "newIndex", "position", "toIndex", "targetIndex" };
long idx = idxKeys.Any(k => f.ContainsKey(k)) ? Campus_live_rewrite.api.server.RoomModeration.Long(f, idxKeys) : -1;
string dir = (Campus_live_rewrite.api.server.RoomModeration.Str(f, "direction", "dir") ?? "").ToLowerInvariant();
var subs = room_data.SubRooms ?? new List<RoomDBClasses.SubRoom>();
int cur = subs.FindIndex(x => x.SubRoomId == subroomid);
if (cur < 0) return Utils.ErrorRoom("Rooms.DoesntExist", "This subroom does not exist!");
int to = idx >= 0 ? (int)Math.Min(idx, subs.Count - 1) : dir == "up" ? Math.Max(0, cur - 1) : dir == "down" ? Math.Min(subs.Count - 1, cur + 1) : cur;
if (to != cur)
{
var item = subs[cur]; subs.RemoveAt(cur); subs.Insert(to, item);
RoomDB.ReorderSubrooms(roomid, subs.Select(x => x.SubRoomId).ToList());
}
var updated = RoomDB.GetRoom(roomid);
return new ContentResult { Content = JsonConvert.SerializeObject(new { success = true, error = "", value = updated }), ContentType = "application/json", StatusCode = 200 };
}
// POST rooms/{id}/subrooms/{sid}/publish_save - make the save named by the
// subRoomDataSaveId form field the one players load (its CurrentSave). Saves here are
// never staged, so this is the "restore an older save" half of the reference's route.
// Owner-only, like the reference. Answers the updated ROOM in the envelope.
[HttpPost("rooms/{roomid}/subrooms/{subroomid}/publish_save")]
public ContentResult PublishSubroomSave(ulong roomid, ulong subroomid)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var room_data = RoomDB.GetRoom(roomid);
if (room_data == null) return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!");
if (room_data.CreatorAccountId != account?.Id && !RoomDB.IsCoOwner(room_data, account?.Id ?? 0))
return Utils.ErrorRoom("Rooms.PermissionDenied", "You are not the owner of this room!");
var f = Campus_live_rewrite.api.server.RoomModeration.Fields(Request);
long saveId = Campus_live_rewrite.api.server.RoomModeration.Long(f, "subRoomDataSaveId", "SubRoomDataSaveId", "saveId", "id");
if (saveId <= 0) return Utils.ErrorRoom("Rooms.InvalidSave", "You must provide a valid save!");
if (!RoomDB.PublishSubroomSave(roomid, subroomid, (ulong)saveId))
return Utils.ErrorRoom("Rooms.DoesntExist", "That save does not exist!");
Console.WriteLine($"[rooms] {account!.Id} published save {saveId} on subroom {subroomid} of room {roomid}");
var updated = RoomDB.GetRoom(roomid);
Campus_live_rewrite.api.server.RoomPermissions.PushRoomUpdate(account.Id, updated);
return new ContentResult { Content = JsonConvert.SerializeObject(new { success = true, error = "", value = updated }), ContentType = "application/json", StatusCode = 200 };
}
[HttpPut("rooms/{roomid}/subrooms/{subroomid}/modify")]
public ContentResult ModifySubroom(ulong roomid, ulong subroomid, [FromForm] string name, RoomDBClasses.RoomAccessibility accessibility, int maxPlayers = 32)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
RoomDBClasses.RoomRoot room_data = RoomDB.GetRoom(roomid);
if (room_data == null)
{
return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!");
}
if (room_data.CreatorAccountId != account?.Id && !RoomDB.IsCoOwner(room_data, account?.Id ?? 0))
{
return Utils.ErrorRoom("Rooms.PermissionDenied", "You are not the owner of this room!");
}
bool success = RoomDB.ModifySubroom(roomid, subroomid, name, accessibility, maxPlayers);
RoomDBClasses.RoomRoot UpdatedRoom = RoomDB.GetRoom(roomid);
if (UpdatedRoom != null)
{
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
success = true,
error = "",
value = UpdatedRoom
}),
ContentType = "application/json",
StatusCode = 200
};
}
else
{
return Utils.ErrorRoom("Rooms.DoesntExist", "This room does not exist!");
}
}
[Route("unity_assets/{guid}/{target}/{version}")]
public IActionResult GetUnityAssets(string guid, int target, int version)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(new
{
UnityAssetId = guid,
Target = target,
Version = version,
Filename = $"{guid}.assetbundle",
Hash = (string?)null
});
}
// Rooms 2.0 rooms resolve their pre-baked mesh assetbundles here. Rec Room generated those
// bundles at publish time on its own backend, so we DON'T have them for imported (or most)
// rooms - and returning 404 hard-fails the load with "Error downloading room assets (Code
// 8715)". The client can BAKE geometry at runtime from the raw shape data in the .room blob
// (GatherBakedPrimitiveDataJob / GatherBakedSplineDataJob), so the right answer for an id we
// have no baked bundles for is a VALID, EMPTY result - never a 404. When we DO have a cached
// BakedBulk/<id>_assets.json (base rooms), we return it.
[Route("/Room_server/unity_assets/baked/bulk")]
public async Task<IActionResult> GetUnityAssetsBakedBulk()
{
// The request carries one or more asset ids. Accept every shape the client might use:
// repeated/comma form field(s), query, or a JSON body (array, {ids:[...]}, or {id:...}).
var ids = new List<string>();
void AddCsv(string? s) { if (!string.IsNullOrWhiteSpace(s)) ids.AddRange(s.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(x => x.Trim())); }
try
{
if (Request.HasFormContentType)
{
foreach (var k in new[] { "id", "ids", "Id", "Ids", "assetId", "assetIds" })
foreach (var v in Request.Form[k]) AddCsv(v);
}
}
catch { }
foreach (var k in new[] { "id", "ids" })
foreach (var v in Request.Query[k]) AddCsv(v);
string rawBody = "";
if (ids.Count == 0)
{
try
{
Request.EnableBuffering();
Request.Body.Position = 0;
using var reader = new StreamReader(Request.Body, leaveOpen: true);
rawBody = await reader.ReadToEndAsync();
Request.Body.Position = 0;
if (!string.IsNullOrWhiteSpace(rawBody))
{
var node = JsonNode.Parse(rawBody);
if (node is JsonArray ja) foreach (var n in ja) { if (n != null) AddCsv(n.ToString()); }
else if (node is JsonObject jo)
{
foreach (var key in new[] { "ids", "id", "Ids", "Id", "assetIds", "assetId" })
if (jo[key] is JsonArray a2) foreach (var n in a2) { if (n != null) AddCsv(n.ToString()); }
else if (jo[key] != null) AddCsv(jo[key]!.ToString());
}
else if (node != null) AddCsv(node.ToString());
}
}
catch { }
}
ids = ids.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct().ToList();
Console.WriteLine($"[baked/bulk] method={Request.Method} ct={Request.ContentType} ids=[{string.Join(",", ids)}] rawBody={(rawBody.Length > 300 ? rawBody.Substring(0, 300) : rawBody)}");
// Build one group per requested id: its cached baked bundles if we have them, else empty
// (client runtime-bakes). If we couldn't parse any id, still return an empty array (200).
string dir = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "BakedBulk");
var groups = new List<object>();
foreach (var id in ids)
{
JsonArray? baked = null;
try
{
string path = Path.Combine(dir, $"{id}_assets.json");
if (System.IO.File.Exists(path)) baked = JsonNode.Parse(System.IO.File.ReadAllText(path)) as JsonArray;
}
catch { }
groups.Add(new
{
UnityAssetId = id,
CreatedByAccountId = 1,
BakedUnityAssets = (object?)baked ?? new JsonArray(),
SubAssets = new List<object>(),
SourceAssetId = (string?)null
});
}
return Ok(groups);
}
[HttpGet("/Room_server/publishState/configs")]
public IActionResult GetPublishStateConfigs()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
/*
{
"value": {
"UpdateMaxCount": 3,
"UpdateRollingWindowInDays": 365,
"UpdateExpirationInDays": 30,
"UpdateCooldownInDays": 45
},
"success": true,
"error_id": null,
"error": null
}
*/
return Ok(new
{
value = new
{
UpdateMaxCount = 3,
UpdateRollingWindowInDays = 365,
UpdateExpirationInDays = 30,
UpdateCooldownInDays = 45
},
success = true,
error_id = (string?)null,
error = (string?)null
});
}
[HttpGet("/Room_server/dormroom/me")]
public IActionResult GetMyDormRoom()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var FullAccount = PlayerDB.GetFullAccount(account.Id);
return Ok(FullAccount.player_Extra.DormRoomID);
}
// Rooms like this one: the paged {Results, TotalResults} shape the room lists use, from
// shared tags (then the hot list). Answered "[]" before - the client's "Malformed
// Response: '[]'" and "Error refreshing rooms from source RoomSimilarity" every room load.
[HttpGet("/Room_server/rooms/{roomId}/similar")]
public ContentResult SimilarRooms(ulong roomId, int skip = 0, int take = 30)
{
var seed = RoomDB.GetRoom(roomId);
var tags = new HashSet<string>((seed?.Tags ?? new()).Select(t => (t.Tag ?? "").ToLowerInvariant()).Where(t => t.Length > 0 && t != "rro" && t != "base" && t != "recroomoriginal"));
var pool = RoomDB.GetHotRooms().Where(r => r.RoomId != roomId && !r.IsDorm && RoomDB.IsRoomListed(r) && !RoomDB.IsRecRoomOriginal(r)).ToList();
var ranked = pool
.Select(r => (room: r, shared: (r.Tags ?? new()).Count(t => tags.Contains((t.Tag ?? "").ToLowerInvariant()))))
.OrderByDescending(x => x.shared)
.ThenByDescending(x => x.room.Stats?.CheerCount ?? 0)
.ThenByDescending(x => x.room.Stats?.VisitCount ?? 0)
.Select(x => x.room)
.ToList();
var page = ranked.Skip(Math.Max(0, skip)).Take(Math.Clamp(take, 1, 100)).ToList();
RoomDB.AttachSubRooms(page);
return new ContentResult
{
Content = JsonConvert.SerializeObject(new RoomDBClasses.Resultslist<RoomDBClasses.RoomRoot>
{
Results = StripUnservableSaves(VisibleTo(page, ViewerId())),
TotalResults = ranked.Count
}),
ContentType = "application/json",
StatusCode = 200
};
}
// Room leaderboard slots (Room Settings -> Leaderboards). A configure / clear is
// acknowledged; nothing stores slots yet. DELETE 405'd before.
[HttpGet("/Room_server/rooms/{roomId}/leaderboards/{leaderboardId}")]
public ContentResult RoomLeaderboard(ulong roomId, int leaderboardId) =>
new ContentResult { Content = "[]", ContentType = "application/json", StatusCode = 200 };
[HttpPost("/Room_server/rooms/{roomId}/leaderboards/{leaderboardId}")]
[HttpPut("/Room_server/rooms/{roomId}/leaderboards/{leaderboardId}")]
[HttpDelete("/Room_server/rooms/{roomId}/leaderboards/{leaderboardId}")]
public IActionResult RoomLeaderboardWrite(ulong roomId, int leaderboardId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
if (!RoomDB.UserCanEditRoom(roomId, account.Id)) return StatusCode(403, "");
Console.WriteLine($"[leaderboard] {Request.Method} room {roomId} slot {leaderboardId} by {account.Id}");
return Ok(new { success = true, error = "", value = (object?)null });
}
// The room's XP settings write (Room Settings -> Experience) - echoes what GET answers.
[HttpPost("/Room_server/rooms/{roomId}/experience")]
[HttpPut("/Room_server/rooms/{roomId}/experience")]
public IActionResult SetExperienceForRoom(ulong roomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
return Ok(new { DailyLimit = 1000000, Enabled = false, RoomExperienceId = 0 });
}
[HttpGet("/Room_server/rooms/{roomId}/experience")]
public IActionResult GetExperienceForRoom(ulong roomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(new
{
DailyLimit = 1000000,
Enabled = false,
RoomExperienceId = 0
});
}
[HttpGet("/Room_server/rooms/{roomId}/experience/player")]
public IActionResult GetPlayerExperienceInRoom(ulong roomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(new
{
Enabled = false,
Experience = (object?)null,
ConcurrencyCode = (string?)null
});
}
// Whether a player is banned from a room - read from the same bans the join gates refuse
// on, so it answers what would actually happen. Was a constant false, which is why the
// client offered "ban" on someone already banned and never showed "unban".
[HttpGet("/Room_server/rooms/{roomId}/bans/{playerId}/isBanned")]
public IActionResult GetIsBanned(ulong roomId, ulong playerId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var room = RoomDB.GetRoom(roomId);
return Ok(new
{
success = true,
error = (string?)null,
error_id = (string?)null,
value = room != null && RoomDB.PlayerIsBannedFrom(room, playerId)
});
}
private ContentResult BanEnvelope(RoomDBClasses.RoomBanDetails? ban, ulong roomId, string error = "") => new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
success = ban != null,
error,
value = ban == null ? null : new
{
RoomId = (long)roomId,
BannedPlayerId = ban.AccountId,
BanMask = 0,
BannedByAccountId = ban.BannedByAccountId,
CreatedAt = ban.BanStartTime
}
}),
ContentType = "application/json",
StatusCode = 200
};
// The room's active bans (Room Settings -> Moderation), newest first. camelCase entries
// (reference: RoomBanEntryDto). Was a constant empty list.
[HttpGet("/Room_server/rooms/{roomId}/bans")]
public IActionResult GetRoomBans(ulong roomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var room = RoomDB.GetRoom(roomId);
if (room == null) return Ok(Array.Empty<object>());
return Ok(RoomDB.ActiveRoomBans(room).Select(b => new
{
accountId = b.AccountId,
bannedByAccountId = b.BannedByAccountId,
banStartTime = b.BanStartTime
}));
}
// Ban a player from the room (form: id, banMask, and optionally reason / duration in
// seconds). The room's owners, its moderators, or staff. The banned player gets the
// ModerationKick frame their client leaves on, is moved out of the instance, and the
// matchmaker refuses them until the ban is lifted. Answers {success,error,value:ban}.
[HttpPost("/Room_server/rooms/{roomId}/bans")]
[HttpPut("/Room_server/rooms/{roomId}/bans")]
public IActionResult BanFromRoom(ulong roomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var room = RoomDB.GetRoom(roomId);
if (room == null) return BanEnvelope(null, roomId, "This room does not exist!");
if (!RoomDB.CanModerateRoom(room, account.Id)) return StatusCode(403, "");
var f = RoomModeration.Fields(Request);
ulong target = RoomModeration.ULong(f, "id", "accountId", "playerId", "bannedPlayerId");
if (target == 0) return BanEnvelope(null, roomId, "You must provide a valid player to ban!");
if (target == account.Id) return BanEnvelope(null, roomId, "You cannot ban yourself!");
if (room.CreatorAccountId == target || RoomDB.IsCoOwner(room, target))
return BanEnvelope(null, roomId, "You cannot ban an owner of this room!");
if (PlayerDB.CheckDevFlag(target) && !PlayerDB.CheckDevFlag(account.Id))
return BanEnvelope(null, roomId, "You cannot ban a staff member!");
long seconds = RoomModeration.Long(f, "duration", "durationSeconds", "banDuration");
string reason = RoomModeration.Str(f, "reason", "message", "displayReason") ?? "";
var ban = RoomDB.BanFromRoom(roomId, target, account.Id, reason, seconds > 0 ? TimeSpan.FromSeconds(seconds) : null);
if (ban == null) return BanEnvelope(null, roomId, "Could not ban that player.");
bool isHostKick = room.CreatorAccountId == account.Id || RoomDB.IsCoOwner(room, account.Id);
// isBan stays FALSE on the kick frame: `IsBan: true` is what the client renders as a ban
// from the whole GAME (reference rooms POST /bans). The room ban itself is the stored row
// the matchmaker refuses on (BannedFromRoom, 55); the frame only makes them leave.
RoomModeration.RemoveFromRoom(target, roomId, 0, $"You have been banned from {room.Name}.", account.Id, isBan: false, isHostKick: isHostKick);
ModerationLogDB.Record("roomban", target, account.Id, roomId, 0, seconds, ReportCategory.Moderator, reason);
return BanEnvelope(ban, roomId);
}
// Lift a room ban. Answers the closed ban in the same envelope. Lifting also clears any
// kick block the player has on the room's instances, so "unban" always means "let them
// back in".
[HttpDelete("/Room_server/rooms/{roomId}/bans/{playerId:long}")]
[HttpPost("/Room_server/rooms/{roomId}/bans/{playerId:long}/unban")]
public IActionResult UnbanFromRoom(ulong roomId, ulong playerId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var room = RoomDB.GetRoom(roomId);
if (room == null) return BanEnvelope(null, roomId, "This room does not exist!");
if (!RoomDB.CanModerateRoom(room, account.Id)) return StatusCode(403, "");
int lifted = InstanceBlocks.Clear(playerId, (long)roomId);
var ban = RoomDB.UnbanFromRoom(roomId, playerId, account.Id);
if (ban == null && lifted == 0) return BanEnvelope(null, roomId, "This player is not banned from this room!");
if (ban == null)
ban = new RoomDBClasses.RoomBanDetails { AccountId = (long)playerId, BannedByAccountId = 0, BanStartTime = DateTime.UtcNow, Status = 2, UnbannedByAccountId = (long)account.Id, Reason = "" };
ModerationLogDB.Record("roomunban", playerId, account.Id, roomId, 0, 0, ReportCategory.Moderator, "");
return BanEnvelope(ban, roomId);
}
// The 2025 client's bulk forms of the two calls above: DELETE rooms/{id}/bans/bulk lifts
// the bans on every id given (form / JSON / query: ids, accountIds, playerIds, id);
// POST / PUT bans/bulk issues them. DELETE used to fall into the single-player route with
// playerId = 0 and answer "This player is not banned from this room!" (Player.log:
// LLCLDPPAGIJ) whatever was asked. Answers the ban envelope with a list as its value.
[HttpDelete("/Room_server/rooms/{roomId}/bans/bulk")]
[HttpPost("/Room_server/rooms/{roomId}/bans/bulk/unban")]
public IActionResult UnbanBulk(ulong roomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var room = RoomDB.GetRoom(roomId);
if (room == null) return BanEnvelope(null, roomId, "This room does not exist!");
if (!RoomDB.CanModerateRoom(room, account.Id)) return StatusCode(403, "");
var f = RoomModeration.Fields(Request);
var ids = RoomModeration.ULongs(f, "ids", "accountIds", "playerIds", "bannedPlayerIds", "id", "accountId", "playerId");
Console.WriteLine($"[roomban] bulk unban on room {roomId} by {account.Id}: {RoomModeration.Dump(f)}");
if (ids.Count == 0) return BanEnvelope(null, roomId, "You must provide a valid player to unban!");
var rows = new List<object>();
foreach (var pid in ids)
{
int lifted = InstanceBlocks.Clear(pid, (long)roomId);
var ban = RoomDB.UnbanFromRoom(roomId, pid, account.Id);
if (ban == null && lifted == 0) continue;
ModerationLogDB.Record("roomunban", pid, account.Id, roomId, 0, 0, ReportCategory.Moderator, "");
rows.Add(new { RoomId = (long)roomId, BannedPlayerId = (long)pid, BanMask = 0, BannedByAccountId = ban?.BannedByAccountId ?? 0, CreatedAt = ban?.BanStartTime ?? DateTime.UtcNow });
}
if (rows.Count == 0) return BanEnvelope(null, roomId, "This player is not banned from this room!");
return new ContentResult
{
Content = JsonConvert.SerializeObject(new { success = true, error = "", value = rows }),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPost("/Room_server/rooms/{roomId}/bans/bulk")]
[HttpPut("/Room_server/rooms/{roomId}/bans/bulk")]
public IActionResult BanBulk(ulong roomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var room = RoomDB.GetRoom(roomId);
if (room == null) return BanEnvelope(null, roomId, "This room does not exist!");
if (!RoomDB.CanModerateRoom(room, account.Id)) return StatusCode(403, "");
var f = RoomModeration.Fields(Request);
var ids = RoomModeration.ULongs(f, "ids", "accountIds", "playerIds", "bannedPlayerIds", "id", "accountId", "playerId");
Console.WriteLine($"[roomban] bulk ban on room {roomId} by {account.Id}: {RoomModeration.Dump(f)}");
if (ids.Count == 0) return BanEnvelope(null, roomId, "You must provide a valid player to ban!");
long seconds = RoomModeration.Long(f, "duration", "durationSeconds", "banDuration");
string reason = RoomModeration.Str(f, "reason", "message", "displayReason") ?? "";
bool isHostKick = room.CreatorAccountId == account.Id || RoomDB.IsCoOwner(room, account.Id);
var rows = new List<object>();
foreach (var pid in ids)
{
if (pid == account.Id || room.CreatorAccountId == pid || RoomDB.IsCoOwner(room, pid)) continue;
if (PlayerDB.CheckDevFlag(pid) && !PlayerDB.CheckDevFlag(account.Id)) continue;
var ban = RoomDB.BanFromRoom(roomId, pid, account.Id, reason, seconds > 0 ? TimeSpan.FromSeconds(seconds) : null);
if (ban == null) continue;
RoomModeration.RemoveFromRoom(pid, roomId, 0, $"You have been banned from {room.Name}.", account.Id, isBan: false, isHostKick: isHostKick);
ModerationLogDB.Record("roomban", pid, account.Id, roomId, 0, seconds, ReportCategory.Moderator, reason);
rows.Add(new { RoomId = (long)roomId, BannedPlayerId = ban.AccountId, BanMask = 0, BannedByAccountId = ban.BannedByAccountId, CreatedAt = ban.BanStartTime });
}
if (rows.Count == 0) return BanEnvelope(null, roomId, "Could not ban that player.");
return new ContentResult
{
Content = JsonConvert.SerializeObject(new { success = true, error = "", value = rows }),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/Room_server/rooms/{roomId}/playerdata/me")]
public IActionResult GetMyPlayerData(ulong roomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var data = PlayerDB.GetPlayerData(account.Id, roomId);
// The client expects a JSON body from this endpoint — 404 makes it treat the whole
// room-join as "failed to fetch player state" and bounce. Return an empty payload
// when there's no stored state yet (fresh player, never visited this room).
return Ok(new
{
Data = data ?? ""
});
}
[HttpPut("/Room_server/rooms/{roomId}/playerdata/me")]
public IActionResult SetMyPlayerData(ulong roomId, [FromForm] string data)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
bool success = PlayerDB.SetPlayerData(account.Id, roomId, data);
if (!success)
{
return BadRequest();
}
Console.WriteLine("Was updating playerdata successful: " + success);
return NoContent();
}
[HttpGet("/Room_server/rooms/{roomId}/boosts/{playerId}/count")]
public IActionResult GetBoostCountForRoom(ulong roomId, ulong playerId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok("0");
}
[HttpGet("/Room_server/rooms/{roomId}/assets")]
public IActionResult GetRoomAssets(ulong roomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(new
{
success = true,
error = (string?)null,
error_id = (string?)null,
value = Array.Empty<object>()
});
}
// https://rooms.rec.net/rooms/1089513655118942017/snapshots/ade3aee2-e469-4445-728b-08de39979f5f/loadinfo?subRoomId=1384652020053214301
[HttpGet("/Room_server/rooms/{roomId}/snapshots/{snapshotId}/loadinfo")]
public IActionResult GetRoomAssets(ulong roomId, string snapshotId, [FromQuery] ulong subRoomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var room = RoomDB.GetRoom(roomId);
if (room == null) return NotFound();
var subRoom = room.SubRooms?.FirstOrDefault(sr => sr.SubRoomId == subRoomId);
return Ok(new
{
success = true,
error = (string?)null,
error_id = (string?)null,
value = new
{
Assets = Array.Empty<object>(),
RoomId = roomId,
SnapshotId = snapshotId,
SubRoom = new
{
SubRoomDataSaveId = subRoom?.CurrentSave?.SubRoomDataSaveId
},
SubRoomId = subRoomId,
UgcSubVersion = room.UgcSubVersion,
UgcVersion = room.UgcVersion
}
});
}
}
}
@@ -0,0 +1,685 @@
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Campus_live_rewrite.api;
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server;
using Classes = Campus_live_rewrite.api.server.Classes;
using static Campus_live_rewrite.api.dbs.RoomEconDB;
namespace Campus_live_rewrite.Controllers
{
/// <summary>
/// Room keys, room currencies and room consumables - created in Room Settings, sold in-room,
/// paid for with tokens (or with a room currency), owned afterwards. Every one of these
/// routes answered "[]" / an ack before, so a price set in-game was never charged and nothing
/// was ever kept.
///
/// Purchases with tokens pay the ROOM'S CREATOR their cut (EarningsDB). Request fields are
/// read by name from form, query or JSON (RoomModeration.Fields) - the exact spellings this
/// build posts are logged on every write so they can be pinned. Purchase answers carry BOTH
/// the bare balance-response keys and the {Value,Success,Error,error_id} envelope, so either
/// reader finds what it needs.
/// </summary>
[ApiController]
public class RoomEconController : ControllerBase
{
private const int Tokens = (int)Classes.CurrencyType.RecCenterTokens;
private static ContentResult J(object o, int status = 200) =>
new() { Content = JsonConvert.SerializeObject(o), ContentType = "application/json", StatusCode = status };
private static object Envelope(object? value, string? error = null) => new
{
Value = value, Success = error == null, Error = error, error_id = (string?)null
};
private static bool CanEdit(long roomId, ulong accountId) =>
RoomDB.UserCanEditRoom((ulong)roomId, accountId) || PlayerDB.CheckDevFlag(accountId);
private static ulong RoomCreator(long roomId) => RoomDB.GetRoom((ulong)roomId)?.CreatorAccountId ?? 0;
private Dictionary<string, string> F() => RoomModeration.Fields(Request);
private static long L(Dictionary<string, string> f, params string[] n) => RoomModeration.Long(f, n);
private static string? S(Dictionary<string, string> f, params string[] n) => RoomModeration.Str(f, n);
private static Guid? G(Dictionary<string, string> f, params string[] n)
{
var s = S(f, n);
return s != null && Guid.TryParse(s.Trim(), out var g) && g != Guid.Empty ? g : null;
}
private object BalanceAnswer(long balance, object? data, int updateResponse = 0)
{
var bare = new
{
Balance = balance,
CurrencyType = Tokens,
Platform = Classes.GameCurrency.AllPlatformsBucket,
BalanceType = Classes.GameCurrency.AllPlatformsBucket,
BalanceUpdates = new[] { new { UpdateResponse = updateResponse, Data = data } }
};
return new { Value = bare, Success = true, Error = (string?)null, error_id = (string?)null, bare.Balance, bare.CurrencyType, bare.Platform, bare.BalanceType, bare.BalanceUpdates };
}
private ContentResult Refuse(string why, int status = 200) =>
J(new { Value = (object?)null, Success = false, Error = why, error_id = (string?)null, Balance = 0, BalanceUpdates = Array.Empty<object>() }, status);
// ======================================================================= KEYS
[HttpGet("/api/roomkeys/v1/mine")]
public ContentResult MyKeys()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
return J(KeysOwnedBy(account.Id).Select(KeyDto).ToList());
}
[HttpGet("/api/roomkeys/v1/room")]
public ContentResult KeysOfRoom([FromQuery] long roomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
return J(KeysForRoom(roomId).Select(KeyDto).ToList());
}
// ?roomKeyId= -> that key; ?roomId= -> any key of that room. Bare bool.
[HttpGet("/api/roomkeys/v1/owns")]
public ContentResult OwnsKeyRoute()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return J(false);
var f = F();
long keyId = L(f, "roomKeyId", "RoomKeyId", "id");
long roomId = L(f, "roomId", "RoomId");
bool owns = keyId > 0 ? OwnsKey(account.Id, keyId)
: roomId > 0 && KeysForRoom(roomId).Any(k => OwnsKey(account.Id, k.RoomKeyId));
return J(owns);
}
[HttpGet("/api/roomkeys/v1/owns/bulk")]
[HttpPost("/api/roomkeys/v1/owns/bulk")]
public ContentResult OwnsKeysBulk()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var ids = RoomModeration.ULongs(F(), "id", "ids", "roomKeyIds", "RoomKeyIds", "roomKeyId");
return J(ids.Select(id => new { RoomKeyId = (long)id, IsOwned = OwnsKey(account.Id, (long)id), Owned = OwnsKey(account.Id, (long)id) }).ToList());
}
[HttpPost("/api/roomkeys/v1/create")]
[HttpPut("/api/roomkeys/v1/create")]
public ContentResult CreateKeyRoute()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var f = F();
Console.WriteLine($"[roomkeys] {account.Id} create: {RoomModeration.Dump(f)}");
long roomId = L(f, "RoomId", "roomId");
if (roomId <= 0) return J(Envelope(null, "RoomId is required"), 400);
if (!CanEdit(roomId, account.Id)) return J(Envelope(null, "You cannot edit this room"), 403);
var k = CreateKey(new RoomKey
{
RoomId = roomId, CreatorId = RoomCreator(roomId),
Name = (S(f, "Name", "name") ?? "Room Key").Trim(),
Description = S(f, "Description", "description") ?? "",
Price = (int)Math.Max(0, L(f, "Price", "price")),
PurchaseCurrencyId = G(f, "PurchaseCurrencyId", "purchaseCurrencyId", "CurrencyId"),
ImageName = S(f, "ImageName", "imageName"),
Type = (int)L(f, "Type", "type")
});
Console.WriteLine($"[roomkeys] room {roomId}: key #{k.RoomKeyId} '{k.Name}' for {k.Price} {(k.PurchaseCurrencyId.HasValue ? "room currency" : "tokens")}");
var dto = KeyDto(k);
return J(new { Value = dto, Success = true, Error = (string?)null, error_id = (string?)null, RoomKey = dto,
k.RoomKeyId, k.ReplicationId, k.RoomId, k.Name, k.Description, k.Price, k.PurchaseCurrencyId, CreatedAt = k.CreatedAt.ToString("o"), ImageName = k.ImageName ?? "", k.Type });
}
[HttpPost("/api/roomkeys/v1/update")]
[HttpPut("/api/roomkeys/v1/update")]
[HttpPut("/api/roomkeys/v1/{roomKeyId:long}")]
[HttpPost("/api/roomkeys/v1/{roomKeyId:long}")]
public ContentResult UpdateKeyRoute(long roomKeyId = 0)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var f = F();
if (roomKeyId == 0) roomKeyId = L(f, "RoomKeyId", "roomKeyId", "id");
var k = GetKey(roomKeyId);
if (k == null) return J(Envelope(null, "No such key"), 404);
if (!CanEdit(k.RoomId, account.Id)) return J(Envelope(null, "You cannot edit this room"), 403);
Console.WriteLine($"[roomkeys] {account.Id} update #{roomKeyId}: {RoomModeration.Dump(f)}");
k = UpdateKey(roomKeyId, x =>
{
if (S(f, "Name", "name") is string n && n.Trim().Length > 0) x.Name = n.Trim();
if (S(f, "Description", "description") is string d) x.Description = d;
if (f.ContainsKey("Price") || f.ContainsKey("price")) x.Price = (int)Math.Max(0, L(f, "Price", "price"));
if (f.ContainsKey("PurchaseCurrencyId") || f.ContainsKey("purchaseCurrencyId")) x.PurchaseCurrencyId = G(f, "PurchaseCurrencyId", "purchaseCurrencyId");
if (S(f, "ImageName", "imageName") is string img) x.ImageName = img;
})!;
return J(Envelope(KeyDto(k)));
}
[HttpDelete("/api/roomkeys/v1/{roomKeyId:long}")]
[HttpPost("/api/roomkeys/v1/delete")]
public ContentResult DeleteKeyRoute(long roomKeyId = 0)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
if (roomKeyId == 0) roomKeyId = L(F(), "RoomKeyId", "roomKeyId", "id");
var k = GetKey(roomKeyId);
if (k == null) return J(Envelope(null, "No such key"), 404);
if (!CanEdit(k.RoomId, account.Id)) return J(Envelope(null, "You cannot edit this room"), 403);
UpdateKey(roomKeyId, x => x.Deleted = true);
return J(Envelope(null));
}
// Owner hands keys out / takes them back (form or JSON: RoomKeyId + AccountIds).
[HttpPost("/api/roomkeys/v1/awardbulk")]
public ContentResult AwardKeys()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var f = F();
long keyId = L(f, "RoomKeyId", "roomKeyId", "id");
var k = GetKey(keyId);
if (k == null) return J(Envelope(null, "No such key"), 404);
if (!CanEdit(k.RoomId, account.Id)) return J(Envelope(null, "You cannot edit this room"), 403);
var ids = RoomModeration.ULongs(f, "AccountIds", "accountIds", "PlayerIds", "playerIds", "AccountId", "accountId");
int n = 0; foreach (var pid in ids) if (GrantKey(pid, k)) n++;
Console.WriteLine($"[roomkeys] {account.Id} awarded key #{keyId} to {n}/{ids.Count} player(s)");
return J(Envelope(ids.Select(pid => new { AccountId = pid, RoomKeyId = keyId, Success = true, Error = (string?)null }).ToList()));
}
[HttpPost("/api/roomkeys/v1/revoke")]
public ContentResult RevokeKeys()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var f = F();
long keyId = L(f, "RoomKeyId", "roomKeyId", "id");
var k = GetKey(keyId);
if (k == null) return J(Envelope(null, "No such key"), 404);
if (!CanEdit(k.RoomId, account.Id)) return J(Envelope(null, "You cannot edit this room"), 403);
var ids = RoomModeration.ULongs(f, "AccountIds", "accountIds", "PlayerIds", "playerIds", "AccountId", "accountId");
foreach (var pid in ids) RevokeKey(pid, keyId);
return J(Envelope(null));
}
// Buy a key with tokens ({RoomKeyId, RequestedPrice}) or with the room currency it is priced in.
[HttpPost("/api/storefronts/v1/buyRoomKey")]
[HttpPost("/api/storefronts/v1/PurchaseRoomKeyWithCurrency")]
[HttpPost("/api/roomkeys/v1/purchase")]
public ContentResult BuyKey()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var f = F();
Console.WriteLine($"[roomkeys] {account.Id} buy {Request.Path}: {RoomModeration.Dump(f)}");
long keyId = L(f, "RoomKeyId", "roomKeyId", "id", "Id");
var k = GetKey(keyId);
if (k == null) return Refuse("No such key");
if (OwnsKey(account.Id, keyId)) return Refuse("You already own this key");
long requested = L(f, "RequestedPrice", "requestedPrice", "Price");
if (requested > 0 && requested != k.Price)
Console.WriteLine($"[roomkeys] client rendered {requested} for key #{keyId}, charging the stored {k.Price}");
if (k.PurchaseCurrencyId.HasValue)
{
var cur = GetCurrency(k.PurchaseCurrencyId.Value);
if (cur == null) return Refuse("This key's currency no longer exists");
var left = AddCurrency(account.Id, cur, -k.Price);
if (left == null) return Refuse($"Not enough {cur.Name}");
GrantKey(account.Id, k);
Console.WriteLine($"[roomkeys] @{account.Username} bought key #{keyId} '{k.Name}' for {k.Price} {cur.Name} -> {left}");
return J(new { Value = new { Balance = left.Value, CurrencyId = cur.CurrencyId, CurrencyType = 300, RoomKey = KeyDto(k) }, Success = true, Error = (string?)null, error_id = (string?)null,
Balance = left.Value, CurrencyType = 300, Platform = Classes.GameCurrency.AllPlatformsBucket, BalanceUpdates = new[] { new { UpdateResponse = 0, Data = KeyDto(k) } } });
}
long balance = PlayerDB.GetBalance(account.Id, Tokens);
if (k.Price > 0 && balance < k.Price) return Refuse("Not enough tokens");
if (k.Price > 0) balance = PlayerDB.AddBalance(account.Id, Tokens, -k.Price);
GrantKey(account.Id, k);
EarningsDB.Record(k.CreatorId != 0 ? k.CreatorId : RoomCreator(k.RoomId), account.Id, "roomkey", k.RoomKeyId.ToString(), k.Name, Tokens, k.Price);
GiftBoxes.PushBalance(account.Id, Tokens, balance, "room key");
Console.WriteLine($"[roomkeys] @{account.Username} bought key #{keyId} '{k.Name}' for {k.Price} tokens -> {balance}");
return J(BalanceAnswer(balance, KeyDto(k)));
}
// ======================================================================= CURRENCIES
[HttpGet("/api/roomcurrencies/v1/currencies")]
public ContentResult CurrenciesOfRoom([FromQuery] long roomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
return J(CurrenciesForRoom(roomId).Select(CurrencyDto).ToList());
}
// 7 form fields: RoomId, Name, Description, Limit, Shape, Color, ImageName -> ENVELOPED.
[HttpPost("/api/roomcurrencies/v1/createCurrency")]
public ContentResult CreateCurrencyRoute()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var f = F();
Console.WriteLine($"[roomcurrency] {account.Id} create: {RoomModeration.Dump(f)}");
long roomId = L(f, "RoomId", "roomId");
if (roomId <= 0) return J(Envelope(null, "RoomId is required"));
if (!CanEdit(roomId, account.Id)) return J(Envelope(null, "You cannot edit this room"));
var c = CreateCurrency(new RoomCurrency
{
RoomId = roomId, CreatorId = RoomCreator(roomId),
Name = (S(f, "Name", "name") ?? "Coins").Trim(),
Description = S(f, "Description", "description") ?? "",
Limit = f.ContainsKey("Limit") || f.ContainsKey("limit") ? L(f, "Limit", "limit") : 1_000_000,
Shape = (int)L(f, "Shape", "shape"),
Color = f.ContainsKey("Color") || f.ContainsKey("color") ? (int)L(f, "Color", "color") : -1,
ImageName = S(f, "ImageName", "imageName")
});
Console.WriteLine($"[roomcurrency] room {roomId}: currency {c.CurrencyId} '{c.Name}'");
return J(Envelope(CurrencyDto(c)));
}
[HttpPost("/api/roomcurrencies/v1/updateCurrency")]
[HttpPut("/api/roomcurrencies/v1/updateCurrency")]
public ContentResult UpdateCurrencyRoute()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var f = F();
var id = G(f, "CurrencyId", "currencyId", "id");
var c = id.HasValue ? GetCurrency(id.Value) : null;
if (c == null) return J(Envelope(null, "No such currency"));
if (!CanEdit(c.RoomId, account.Id)) return J(Envelope(null, "You cannot edit this room"));
Console.WriteLine($"[roomcurrency] {account.Id} update {id}: {RoomModeration.Dump(f)}");
c = UpdateCurrency(id!.Value, x =>
{
if (S(f, "Name", "name") is string n && n.Trim().Length > 0) x.Name = n.Trim();
if (S(f, "Description", "description") is string d) x.Description = d;
if (f.ContainsKey("Limit") || f.ContainsKey("limit")) x.Limit = L(f, "Limit", "limit");
if (f.ContainsKey("Shape") || f.ContainsKey("shape")) x.Shape = (int)L(f, "Shape", "shape");
if (f.ContainsKey("Color") || f.ContainsKey("color")) x.Color = (int)L(f, "Color", "color");
if (S(f, "ImageName", "imageName") is string img) x.ImageName = img;
})!;
return J(Envelope(CurrencyDto(c)));
}
[HttpPost("/api/roomcurrencies/v1/deleteCurrency")]
[HttpDelete("/api/roomcurrencies/v1/deleteCurrency")]
public ContentResult DeleteCurrencyRoute()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var f = F();
var id = G(f, "CurrencyId", "currencyId", "id");
var c = id.HasValue ? GetCurrency(id.Value) : null;
if (c == null) return J(Envelope(null, "No such currency"));
if (!CanEdit(c.RoomId, account.Id)) return J(Envelope(null, "You cannot edit this room"));
UpdateCurrency(id!.Value, x => x.Deleted = true);
return J(Envelope(null));
}
[HttpGet("/api/roomcurrencies/v1/getBalance")]
[HttpPost("/api/roomcurrencies/v1/getBalance")]
public ContentResult GetBalanceRoute()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var f = F();
var id = G(f, "CurrencyId", "currencyId", "id");
ulong who = RoomModeration.ULong(f, "AccountId", "accountId", "playerId");
if (who == 0) who = account.Id;
long bal = id.HasValue ? GetCurrencyBalance(who, id.Value) : 0;
return J(new { CurrencyId = id, AccountId = who, Balance = bal, Amount = bal });
}
[HttpGet("/api/roomcurrencies/v1/getAllBalances")]
public ContentResult GetAllBalancesRoute([FromQuery] long roomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var list = CurrenciesForRoom(roomId).Select(c => new
{
c.CurrencyId, AccountId = account.Id, RoomId = c.RoomId, Balance = GetCurrencyBalance(account.Id, c.CurrencyId), Amount = GetCurrencyBalance(account.Id, c.CurrencyId)
}).ToList();
return J(list);
}
// JSON array [{AccountId, CurrencyId, Amount}] -> per-award results (circuits / owner grants).
[HttpPost("/api/roomcurrencies/v1/awardCurrency/bulk")]
public async Task<ContentResult> AwardCurrencyBulk()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
string raw = ""; try { using var r = new StreamReader(Request.Body); raw = await r.ReadToEndAsync(); } catch { }
var results = new List<object>();
try
{
var tok = Newtonsoft.Json.Linq.JToken.Parse(string.IsNullOrWhiteSpace(raw) ? "[]" : raw);
var arr = tok as Newtonsoft.Json.Linq.JArray ?? (tok["Awards"] ?? tok["awards"] ?? tok["Requests"]) as Newtonsoft.Json.Linq.JArray ?? new Newtonsoft.Json.Linq.JArray();
foreach (var a in arr)
{
ulong pid = (ulong?)a["AccountId"] ?? (ulong?)a["accountId"] ?? (ulong?)a["PlayerId"] ?? 0;
Guid.TryParse((string?)a["CurrencyId"] ?? (string?)a["currencyId"] ?? "", out var cid);
long amount = (long?)a["Amount"] ?? (long?)a["amount"] ?? (long?)a["CurrencyAmount"] ?? 0;
var cur = cid != Guid.Empty ? GetCurrency(cid) : null;
if (cur == null || pid == 0) { results.Add(new { AccountId = pid, CurrencyId = cid, Success = false, Error = "No such currency", ResultingBalance = 0L }); continue; }
// The room's owners (and the room's own circuits, which run as a player in it) may award.
var hb = HeartbeatDB.GetPlayerHeartbeat(account.Id);
bool inRoom = hb?.roomInstance != null && hb.roomInstance.roomId == cur.RoomId;
if (!CanEdit(cur.RoomId, account.Id) && !inRoom) { results.Add(new { AccountId = pid, CurrencyId = cid, Success = false, Error = "Not allowed", ResultingBalance = 0L }); continue; }
var nb = AddCurrency(pid, cur, amount);
results.Add(new { AccountId = pid, CurrencyId = cid, Success = nb != null, Error = nb == null ? "Insufficient balance" : (string?)null, ResultingBalance = nb ?? GetCurrencyBalance(pid, cid) });
}
}
catch (Exception ex) { Console.WriteLine($"[roomcurrency] award parse failed: {ex.Message} body={raw[..Math.Min(200, raw.Length)]}"); }
return J(results);
}
[HttpPost("/api/roomcurrencies/v1/createPurchaseOffer")]
public ContentResult CreateOfferRoute()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var f = F();
Console.WriteLine($"[roomcurrency] {account.Id} create offer: {RoomModeration.Dump(f)}");
var cid = G(f, "CurrencyId", "currencyId");
var cur = cid.HasValue ? GetCurrency(cid.Value) : null;
if (cur == null) return J(Envelope(null, "No such currency"));
if (!CanEdit(cur.RoomId, account.Id)) return J(Envelope(null, "You cannot edit this room"));
var o = CreateOffer(new CurrencyOffer
{
CurrencyId = cur.CurrencyId, RoomId = cur.RoomId,
Order = (int)L(f, "Order", "order"),
Name = (S(f, "Name", "name") ?? $"{cur.Name} pack").Trim(),
CurrencyAmount = Math.Max(0, L(f, "CurrencyAmount", "currencyAmount", "Amount")),
Price = Math.Max(0, L(f, "Price", "price"))
});
return J(Envelope(OfferDto(o)));
}
[HttpPost("/api/roomcurrencies/v1/updatePurchaseOffer")]
[HttpPut("/api/roomcurrencies/v1/updatePurchaseOffer")]
public ContentResult UpdateOfferRoute()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var f = F();
var oid = G(f, "CurrencyPurchaseOfferId", "currencyPurchaseOfferId", "OfferId", "id");
var o = oid.HasValue ? GetOffer(oid.Value) : null;
if (o == null) return J(Envelope(null, "No such offer"));
if (!CanEdit(o.RoomId, account.Id)) return J(Envelope(null, "You cannot edit this room"));
Console.WriteLine($"[roomcurrency] {account.Id} update offer {oid}: {RoomModeration.Dump(f)}");
o = UpdateOffer(oid!.Value, x =>
{
if (S(f, "Name", "name") is string n && n.Trim().Length > 0) x.Name = n.Trim();
if (f.ContainsKey("Order") || f.ContainsKey("order")) x.Order = (int)L(f, "Order", "order");
if (f.ContainsKey("CurrencyAmount") || f.ContainsKey("currencyAmount")) x.CurrencyAmount = Math.Max(0, L(f, "CurrencyAmount", "currencyAmount"));
if (f.ContainsKey("Price") || f.ContainsKey("price")) x.Price = Math.Max(0, L(f, "Price", "price"));
})!;
return J(Envelope(OfferDto(o)));
}
[HttpPost("/api/roomcurrencies/v1/deletePurchaseOffer")]
[HttpDelete("/api/roomcurrencies/v1/deletePurchaseOffer")]
public ContentResult DeleteOfferRoute()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var f = F();
var oid = G(f, "CurrencyPurchaseOfferId", "currencyPurchaseOfferId", "OfferId", "id");
var o = oid.HasValue ? GetOffer(oid.Value) : null;
if (o == null) return J(Envelope(null, "No such offer"));
if (!CanEdit(o.RoomId, account.Id)) return J(Envelope(null, "You cannot edit this room"));
UpdateOffer(oid!.Value, x => x.Deleted = true);
return J(Envelope(null));
}
// GET ?ids=<guid>&ids=... (<100) or POST form ids= (>=100). One element PER REQUESTED id.
[HttpGet("/api/roomcurrencies/v1/getPurchaseOffersBatch")]
[HttpPost("/api/roomcurrencies/v1/getPurchaseOffersBatch")]
public ContentResult OffersBatch()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var f = F();
var raw = (S(f, "ids", "Ids", "id", "currencyIds") ?? "").Split(new[] { ',', ';', ' ' }, StringSplitOptions.RemoveEmptyEntries);
var list = new List<object>();
foreach (var s in raw)
{
if (!Guid.TryParse(s.Trim(), out var cid)) continue;
list.Add(new { CurrencyId = cid, PurchaseOffers = OffersFor(cid).Select(OfferDto).ToList() });
}
return J(list);
}
// Buy a currency offer with tokens: {CurrencyPurchaseOfferId, RequestedPrice}.
[HttpPost("/api/roomCurrencies/v2/purchase")]
[HttpPost("/api/roomcurrencies/v1/purchase")]
public ContentResult BuyCurrency()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var f = F();
Console.WriteLine($"[roomcurrency] {account.Id} buy: {RoomModeration.Dump(f)}");
var oid = G(f, "CurrencyPurchaseOfferId", "currencyPurchaseOfferId", "OfferId", "offerId", "PurchaseOfferId", "id");
var o = oid.HasValue ? GetOffer(oid.Value) : null;
if (o == null) return Refuse("No such offer");
var cur = GetCurrency(o.CurrencyId);
if (cur == null) return Refuse("This currency no longer exists");
long requested = L(f, "RequestedPrice", "requestedPrice", "Price");
if (requested > 0 && requested != o.Price) Console.WriteLine($"[roomcurrency] client rendered {requested} for offer {oid}, charging the stored {o.Price}");
long balance = PlayerDB.GetBalance(account.Id, Tokens);
if (o.Price > 0 && balance < o.Price) return Refuse("Not enough tokens");
if (o.Price > 0) balance = PlayerDB.AddBalance(account.Id, Tokens, -o.Price);
long got = AddCurrency(account.Id, cur, o.CurrencyAmount) ?? GetCurrencyBalance(account.Id, cur.CurrencyId);
EarningsDB.Record(cur.CreatorId != 0 ? cur.CreatorId : RoomCreator(cur.RoomId), account.Id, "roomcurrency", cur.CurrencyId.ToString(), $"{o.CurrencyAmount} {cur.Name}", Tokens, o.Price);
GiftBoxes.PushBalance(account.Id, Tokens, balance, "room currency");
Console.WriteLine($"[roomcurrency] @{account.Username} bought {o.CurrencyAmount} {cur.Name} for {o.Price} tokens -> {balance} tokens, {got} {cur.Name}");
var data = new { CurrencyId = cur.CurrencyId, Balance = got, Amount = o.CurrencyAmount, PurchaseOffer = OfferDto(o), Currency = CurrencyDto(cur) };
return J(new
{
Value = new { Balance = balance, CurrencyType = Tokens, Platform = Classes.GameCurrency.AllPlatformsBucket, BalanceUpdates = new[] { new { UpdateResponse = 0, Data = data } }, CurrencyBalance = got, CurrencyId = cur.CurrencyId },
Success = true, Error = (string?)null, error_id = (string?)null,
Balance = balance, CurrencyType = Tokens, Platform = Classes.GameCurrency.AllPlatformsBucket, BalanceType = Classes.GameCurrency.AllPlatformsBucket,
BalanceUpdates = new[] { new { UpdateResponse = 0, Data = data } },
CurrencyBalance = got, CurrencyId = cur.CurrencyId
});
}
// ======================================================================= CONSUMABLES
[HttpGet("/api/roomconsumables/v1/roomConsumable/room/{roomId:long}")]
public ContentResult ConsumablesOfRoom(long roomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
return J(ConsumablesForRoom(roomId).Select(ConsumableDto).ToList());
}
[HttpGet("/api/roomconsumables/v1/roomConsumable/room/{roomId:long}/me")]
public ContentResult MyConsumablesInRoom(long roomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var list = ConsumablesOwnedInRoom(account.Id, roomId).Select(o =>
{
var c = GetConsumable(o.RoomConsumableId);
return new
{
o.RoomConsumableId, o.RoomId, AccountId = account.Id, PlayerId = account.Id, o.Count, Quantity = o.Count,
ModifiedAt = o.ModifiedAt.ToString("o"), RoomConsumable = c != null ? ConsumableDto(c) : null
};
}).ToList();
return J(list);
}
[HttpGet("/api/roomconsumables/v1/roomConsumable/{id:guid}")]
public ContentResult GetConsumableRoute(Guid id)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var c = GetConsumable(id);
return c == null ? J(Envelope(null, "No such consumable"), 404) : J(ConsumableDto(c));
}
[HttpGet("/api/roomconsumables/v1/roomConsumable/{id:guid}/isOwned")]
public ContentResult ConsumableOwned(Guid id)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return J(false);
return J(ConsumableCount(account.Id, id) > 0);
}
// Create (PUT/POST roomConsumable) - form/JSON: RoomId, Name, Description, ImageName, Price, PurchaseCurrencyId, MaximumCountPerPurchase.
[HttpPut("/api/roomconsumables/v1/roomConsumable")]
[HttpPost("/api/roomconsumables/v1/roomConsumable")]
[HttpPut("/roomconsumables/v1/roomConsumable")]
[HttpPost("/api/roomconsumables/v1/roomConsumable/create")]
public ContentResult CreateConsumableRoute()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var f = F();
Console.WriteLine($"[roomconsumable] {account.Id} create: {RoomModeration.Dump(f)}");
long roomId = L(f, "RoomId", "roomId");
if (roomId <= 0) return J(Envelope(null, "RoomId is required"), 400);
if (!CanEdit(roomId, account.Id)) return J(Envelope(null, "You cannot edit this room"), 403);
var c = CreateConsumable(new RoomConsumable
{
RoomId = roomId, CreatorId = RoomCreator(roomId),
Name = (S(f, "Name", "name") ?? "Consumable").Trim(),
Description = S(f, "Description", "description") ?? "",
ImageName = S(f, "ImageName", "imageName"),
Price = Math.Max(0, L(f, "Price", "price")),
PurchaseCurrencyId = G(f, "PurchaseCurrencyId", "purchaseCurrencyId"),
MaximumCountPerPurchase = f.ContainsKey("MaximumCountPerPurchase") || f.ContainsKey("maximumCountPerPurchase") ? (int)Math.Max(1, L(f, "MaximumCountPerPurchase", "maximumCountPerPurchase")) : 10
});
var dto = ConsumableDto(c);
return J(new { Value = dto, Success = true, Error = (string?)null, error_id = (string?)null,
c.RoomConsumableId, c.RoomId, c.Name, c.Description, ImageName = c.ImageName ?? "", c.Price, c.PurchaseCurrencyId, ModifiedAt = c.ModifiedAt.ToString("o"), c.MaximumCountPerPurchase });
}
[HttpPut("/api/roomconsumables/v1/roomConsumable/{id:guid}")]
[HttpPost("/api/roomconsumables/v1/roomConsumable/{id:guid}")]
[HttpPost("/api/roomconsumables/v1/roomConsumable/{id:guid}/update")]
public ContentResult UpdateConsumableRoute(Guid id)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var c = GetConsumable(id);
if (c == null) return J(Envelope(null, "No such consumable"), 404);
if (!CanEdit(c.RoomId, account.Id)) return J(Envelope(null, "You cannot edit this room"), 403);
var f = F();
Console.WriteLine($"[roomconsumable] {account.Id} update {id}: {RoomModeration.Dump(f)}");
c = UpdateConsumable(id, x =>
{
if (S(f, "Name", "name") is string n && n.Trim().Length > 0) x.Name = n.Trim();
if (S(f, "Description", "description") is string d) x.Description = d;
if (S(f, "ImageName", "imageName") is string img) x.ImageName = img;
if (f.ContainsKey("Price") || f.ContainsKey("price")) x.Price = Math.Max(0, L(f, "Price", "price"));
if (f.ContainsKey("PurchaseCurrencyId") || f.ContainsKey("purchaseCurrencyId")) x.PurchaseCurrencyId = G(f, "PurchaseCurrencyId", "purchaseCurrencyId");
if (f.ContainsKey("MaximumCountPerPurchase") || f.ContainsKey("maximumCountPerPurchase")) x.MaximumCountPerPurchase = (int)Math.Max(1, L(f, "MaximumCountPerPurchase", "maximumCountPerPurchase"));
})!;
return J(Envelope(ConsumableDto(c)));
}
[HttpDelete("/api/roomconsumables/v1/roomConsumable/{id:guid}")]
[HttpPost("/api/roomconsumables/v1/roomConsumable/{id:guid}/delete")]
public ContentResult DeleteConsumableRoute(Guid id)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var c = GetConsumable(id);
if (c == null) return J(Envelope(null, "No such consumable"), 404);
if (!CanEdit(c.RoomId, account.Id)) return J(Envelope(null, "You cannot edit this room"), 403);
UpdateConsumable(id, x => x.Deleted = true);
return J(Envelope(null));
}
// Use one (or Count) - the room's circuits consume on the player's behalf.
[HttpPost("/api/roomconsumables/v1/roomConsumable/{id:guid}/consume")]
public ContentResult ConsumeRoute(Guid id)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var c = GetConsumable(id);
if (c == null) return J(Envelope(null, "No such consumable"), 404);
var f = F();
int n = (int)Math.Max(1, L(f, "Count", "count", "Amount", "amount"));
var left = AddConsumable(account.Id, c, -n);
if (left == null) return J(Envelope(null, "You do not have enough of that"));
return J(new { Value = new { RoomConsumableId = id, Count = left.Value, Quantity = left.Value }, Success = true, Error = (string?)null, error_id = (string?)null, RoomConsumableId = id, Count = left.Value });
}
[HttpPost("/api/roomconsumables/v1/roomConsumable/awardBulk")]
public async Task<ContentResult> AwardConsumables()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
string raw = ""; try { using var r = new StreamReader(Request.Body); raw = await r.ReadToEndAsync(); } catch { }
var results = new List<object>();
try
{
var tok = Newtonsoft.Json.Linq.JToken.Parse(string.IsNullOrWhiteSpace(raw) ? "[]" : raw);
var arr = tok as Newtonsoft.Json.Linq.JArray ?? (tok["Awards"] ?? tok["awards"]) as Newtonsoft.Json.Linq.JArray ?? new Newtonsoft.Json.Linq.JArray();
foreach (var a in arr)
{
ulong pid = (ulong?)a["AccountId"] ?? (ulong?)a["accountId"] ?? (ulong?)a["PlayerId"] ?? 0;
Guid.TryParse((string?)a["RoomConsumableId"] ?? (string?)a["roomConsumableId"] ?? "", out var cid);
int count = (int?)a["Count"] ?? (int?)a["count"] ?? (int?)a["Amount"] ?? 1;
var c = cid != Guid.Empty ? GetConsumable(cid) : null;
if (c == null || pid == 0) { results.Add(new { AccountId = pid, RoomConsumableId = cid, Success = false, Error = "No such consumable" }); continue; }
var hb = HeartbeatDB.GetPlayerHeartbeat(account.Id);
bool inRoom = hb?.roomInstance != null && hb.roomInstance.roomId == c.RoomId;
if (!CanEdit(c.RoomId, account.Id) && !inRoom) { results.Add(new { AccountId = pid, RoomConsumableId = cid, Success = false, Error = "Not allowed" }); continue; }
var nc = AddConsumable(pid, c, count);
results.Add(new { AccountId = pid, RoomConsumableId = cid, Success = nc != null, Error = (string?)null, Count = nc ?? 0 });
}
}
catch (Exception ex) { Console.WriteLine($"[roomconsumable] award parse failed: {ex.Message}"); }
return J(results);
}
// Buy with tokens or with the room currency the consumable is priced in: {Count, RequestedPrice}.
[HttpPost("/api/roomconsumables/v1/roomconsumable/{id:guid}/purchase/tokens")]
[HttpPost("/api/roomconsumables/v1/roomconsumable/{id:guid}/purchase/currency")]
[HttpPost("/api/roomconsumables/v1/roomConsumable/{id:guid}/purchase")]
public ContentResult BuyConsumable(Guid id)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var f = F();
Console.WriteLine($"[roomconsumable] {account.Id} buy {Request.Path}: {RoomModeration.Dump(f)}");
var c = GetConsumable(id);
if (c == null) return Refuse("No such consumable");
int count = (int)Math.Max(1, L(f, "Count", "count", "Amount", "amount", "Quantity"));
if (c.MaximumCountPerPurchase > 0 && count > c.MaximumCountPerPurchase) count = c.MaximumCountPerPurchase;
long unit = Math.Max(0, c.Price);
long total = unit * count;
long requested = L(f, "RequestedPrice", "requestedPrice", "Price");
if (requested > 0 && requested != unit && requested != total) Console.WriteLine($"[roomconsumable] client rendered {requested}, charging {total} ({unit} x {count})");
bool withCurrency = Request.Path.Value!.EndsWith("/currency", StringComparison.OrdinalIgnoreCase) || (c.PurchaseCurrencyId.HasValue && !Request.Path.Value.EndsWith("/tokens", StringComparison.OrdinalIgnoreCase));
if (withCurrency && c.PurchaseCurrencyId.HasValue)
{
var cur = GetCurrency(c.PurchaseCurrencyId.Value);
if (cur == null) return Refuse("This item's currency no longer exists");
var left = AddCurrency(account.Id, cur, -total);
if (left == null) return Refuse($"Not enough {cur.Name}");
int have = AddConsumable(account.Id, c, count) ?? 0;
Console.WriteLine($"[roomconsumable] @{account.Username} bought {count}x '{c.Name}' for {total} {cur.Name} -> {left}, has {have}");
var d = new { RoomConsumableId = id, Count = have, Quantity = have, RoomConsumable = ConsumableDto(c) };
return J(new { Value = new { Balance = left.Value, CurrencyId = cur.CurrencyId, CurrencyType = 300, BalanceUpdates = new[] { new { UpdateResponse = 0, Data = d } } }, Success = true, Error = (string?)null, error_id = (string?)null,
Balance = left.Value, CurrencyType = 300, CurrencyId = cur.CurrencyId, Platform = Classes.GameCurrency.AllPlatformsBucket, BalanceUpdates = new[] { new { UpdateResponse = 0, Data = d } } });
}
long balance = PlayerDB.GetBalance(account.Id, Tokens);
if (total > 0 && balance < total) return Refuse("Not enough tokens");
if (total > 0) balance = PlayerDB.AddBalance(account.Id, Tokens, -total);
int now = AddConsumable(account.Id, c, count) ?? 0;
EarningsDB.Record(c.CreatorId != 0 ? c.CreatorId : RoomCreator(c.RoomId), account.Id, "roomconsumable", c.RoomConsumableId.ToString(), c.Name, Tokens, total);
GiftBoxes.PushBalance(account.Id, Tokens, balance, "room consumable");
Console.WriteLine($"[roomconsumable] @{account.Username} bought {count}x '{c.Name}' for {total} tokens -> {balance}, has {now}");
return J(BalanceAnswer(balance, new { RoomConsumableId = id, Count = now, Quantity = now, RoomConsumable = ConsumableDto(c) }));
}
}
}
@@ -0,0 +1,466 @@
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server;
using System.ComponentModel.DataAnnotations;
using System.Text;
using static Campus_live_rewrite.api.dbs.HeartbeatDB;
using static Campus_live_rewrite.api.server.Classes.db_class.LeaderboardDBClasses;
using static Campus_live_rewrite.api.server.Images;
namespace Campus_live_rewrite.Controllers
{
[Route("server")]
public class PlayerSettingsController : ControllerBase
{
[HttpGet("PlayerSettings/playersettings")]
public IActionResult GetPlayerSettings()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var settings = SettingsDB.LoadSettings(account.Id);
// Was: Ok(<string>). ASP.NET's StringOutputFormatter writes a raw string as
// Content-Type: text/plain, so these JSON payloads were served mislabelled
// and the client would not parse them. Verified live: /outfits/me returned
// text/plain while /api/avatar/v2 returned application/json. Same bytes,
// correct content type.
return new ContentResult { Content = JsonConvert.SerializeObject(settings), ContentType = "application/json", StatusCode = 200 };
}
[HttpPut("PlayerSettings/playersettings")]
public IActionResult UpdatePlayerSetting([FromForm, Required] string key, [FromForm, Required] string value)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
if (string.IsNullOrWhiteSpace(key))
return BadRequest("");
SettingsDB.SetPlayerSetting(key, value ?? "", account.Id);
// Account-creation / tutorial keys are logged: they are what the client's onboarding
// branches on, and a client that flips them itself is otherwise invisible.
if (key.StartsWith("Recroom.AccountCreation", StringComparison.OrdinalIgnoreCase) || key.StartsWith("TUTORIAL", StringComparison.OrdinalIgnoreCase) || key.Contains("ORIENTATION", StringComparison.OrdinalIgnoreCase))
Console.WriteLine($"[settings] {account.Id} PUT {key} = {value}");
return Ok(new
{
success = true,
errorId = (string?)null,
error = (string?)null
});
}
[HttpDelete("PlayerSettings/playersettings")]
public IActionResult UpdatePlayerSetting([FromForm, Required] string key)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
if (string.IsNullOrWhiteSpace(key))
return BadRequest("");
SettingsDB.DeletePlayerKey(key ?? "", account.Id);
return Ok(new
{
success = true,
errorId = (string?)null,
error = (string?)null
});
}
[HttpPost("Storage/upload")]
public async Task<ContentResult> server_storage_upload([FromForm] FileUploadModel model)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var ua = Request.Headers["User-Agent"].ToString();
if (ua.Contains("PostmanRuntime"))
{
return new ContentResult
{
Content = "",
ContentType = "application/json",
StatusCode = 403
};
}
if (PlayerDB.HasActiveModerationBlock(account.Id))
{
return new ContentResult
{
Content = "",
ContentType = "application/json",
StatusCode = 403
};
}
try
{
if (model?.File == null || model.File.Length <= 0)
{
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 400
};
}
byte[] array = Array.Empty<byte>();
MemoryStream stream = new MemoryStream();
await model.File.CopyToAsync(stream);
array = stream.ToArray();
FileType_data data_type = model.FileType;
// Only files the game itself produced get stored (see UploadGuard).
var refused = UploadGuard.Gate(HttpContext, account, UploadGuard.KindOf(data_type), array, $"Storage/upload:{data_type}");
if (refused != null) return refused;
try
{
var fileResult = Images.SaveFileUnifiedv2(array, data_type);
if (fileResult != null && fileResult.saved)
{
if (fileResult.FileType == FileType_data.Image)
{
_ = Task.Run(() => Webhook.Send(
account.Id, "prod", "Server info",
$"({account.Id}) @{account.Username} has uploaded an polaroid image",
5898927, $"https://gameauth.ddns.net/img/{fileResult.rawfilename}"
));
}
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
filename = fileResult.FileName,
Hash = "e", // Not needed for game, game expect it so hardcode
OwnershipProof = "e" // Not needed for game, game expect it so hardcode
}, Formatting.Indented),
ContentType = "application/json",
StatusCode = 200
};
}
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
success = false,
error = "Failed to upload!",
value = (string?)null
}),
ContentType = "application/json",
StatusCode = 200
};
}
catch (Exception e)
{
Console.WriteLine(e.Message);
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
filename = (string?)null,
Hash = (string?)null,
OwnershipProof = (string?)null,
}, Formatting.Indented),
ContentType = "application/json",
StatusCode = 200
};
}
}
catch (Exception e)
{
Console.WriteLine(e);
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
filename = (string?)null,
Hash = (string?)null,
OwnershipProof = (string?)null,
}, Formatting.Indented),
ContentType = "application/json",
StatusCode = 200
};
}
}
// A defunct limited-time contest's hit recorder (Glitch City grenade event). Was a live
// 404; nothing tracks the contest on this server, so ack with an empty object rather than
// 404 (the leaderboard/room UI that pings it shouldn't error).
[HttpPost("Leaderboard/{contest}/RecordHits")]
public ContentResult ContestRecordHits(string contest) => new ContentResult
{
Content = "{}",
ContentType = "application/json",
StatusCode = 200
};
// Every leaderboard read is per-player and rank is computed at read time, so no response
// may ever be served from a cache - stamped on all three read routes.
private void NoCache()
{
Response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate";
Response.Headers["Pragma"] = "no-cache";
}
[HttpPost("Leaderboard/leaderboard/GetNearbyScores")]
public async Task<ContentResult> GetNearbyScores()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
NoCache();
string body = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync();
try
{
var req = JsonConvert.DeserializeObject<GetNearbyScoresRequest>(body);
if (req == null)
throw new Exception("Invalid");
var rows = LeaderboardDB.GetNearbyScores(
req.PlayerId,
req.StatChannel,
req.RoomId,
req.WindowSize,
req.SortAscending
);
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new { Rows = rows }),
ContentType = "application/json",
StatusCode = 200
};
}
catch
{
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new { Rows = new List<Entry>() }),
ContentType = "application/json",
StatusCode = 200
};
}
}
[HttpPost("Leaderboard/leaderboard/GetPlayerRank")]
public async Task<ContentResult> GetPlayerRank()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
NoCache();
string body = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync();
try
{
var req = JsonConvert.DeserializeObject<GetNearbyScoresRequest>(body);
if (req == null)
throw new Exception("Invalid");
var rank = LeaderboardDB.GetPlayerRank(
req.PlayerId,
req.StatChannel,
req.RoomId,
req.SortAscending
);
if (rank == null)
{
// No row on this board = UNRANKED. The client expects NUMERIC sentinels, not
// null: Score 0 (no score) and Rank 99999 (a big number, since 0 is a real rank
// = first place). Echo the BODY's PlayerId - the client matches the answer to the
// player it asked about.
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
PlayerId = req.PlayerId,
Score = 0,
Rank = 99999,
}),
ContentType = "application/json",
StatusCode = 200
};
}
return new ContentResult()
{
Content = JsonConvert.SerializeObject(rank),
ContentType = "application/json",
StatusCode = 200
};
}
catch (Exception ex)
{
Console.WriteLine(ex);
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
PlayerId = account.Id,
Score = 0,
Rank = 99999,
}),
ContentType = "application/json",
StatusCode = 200
};
}
}
[HttpPost("Leaderboard/leaderboard/CheckAndSetStat")]
public async Task<ContentResult> CheckAndSetStat()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string body = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync();
try
{
var req = JsonConvert.DeserializeObject<CheckAndSetStatRequest>(body);
if (req == null)
throw new Exception("Invalid");
var room = RoomDB.GetRoom(req.RoomId);
// Plausibility gate, Rec Room Original rooms only (LeaderboardGuard): a score that
// jumps a thousand in a second is a hacked client, not a game. Refused writes are
// not stored, are logged, and land in the admin panel's Reports as Cheating. The
// answer is still the bare 0 the client expects - a refusal must not read as a
// broken leaderboard.
var stored = LeaderboardDB.GetEntry(account.Id, req.StatChannel, req.RoomId);
var verdict = Campus_live_rewrite.api.server.LeaderboardGuard.Check(account.Id, req.StatChannel, req.RoomId, req.StatValue, stored, out string why);
if (verdict == Campus_live_rewrite.api.server.LeaderboardGuard.Verdict.TooFast
|| verdict == Campus_live_rewrite.api.server.LeaderboardGuard.Verdict.TooMuchToday)
{
Campus_live_rewrite.api.server.LeaderboardGuard.Refuse(account.Id, account.Username ?? "", req.StatChannel, req.RoomId, room?.Name ?? "?", req.StatValue, verdict, why);
return new ContentResult() { Content = "0", ContentType = "application/json", StatusCode = 200 };
}
// Compare-and-set on the caller's own row: write StatValue only if the stored value
// still equals CurrentStatValue (null = write unconditionally).
LeaderboardDB.SetStat(
account.Id,
req.StatChannel,
req.RoomId,
req.StatValue,
req.CurrentStatValue
);
// The score pays out (ConfigCampus.LeaderboardPaysRoomCurrency): the gain over the
// stored score, in the currency of the room the player is standing in - only a
// balance room (Crescendo -> Silver, Isle -> Gold) pays, and only what the guard
// above accepted. A run that does not beat the stored score pays nothing.
if (ConfigCampus.LeaderboardPaysRoomCurrency)
{
long gain = (long)req.StatValue - (stored?.Score ?? 0);
var live = HeartbeatDB.GetPlayerHeartbeat(account.Id)?.roomInstance;
var cur = Campus_live_rewrite.api.server.Classes.GameCurrency.ForBalanceRoom(live?.name);
if (gain > 0 && cur != null && live != null && (ulong)live.roomId == req.RoomId
&& Campus_live_rewrite.api.server.Classes.GameCurrency.IsPlayerGrantable((int)cur.Value))
{
long total = PlayerDB.AddBalance(account.Id, (int)cur.Value, gain);
Campus_live_rewrite.api.server.GiftBoxes.PushBalance(account.Id, (int)cur.Value, total, "leaderboard");
Console.WriteLine($"[leaderboard] {account.Id} scored {req.StatValue} (was {stored?.Score ?? 0}) in '{room?.Name}' -> +{gain} {cur} = {total}");
}
}
Webhook.Send(
account.Id,
"prod",
"Leaderboard Info",
$"Player @{account.Username} ({account.Id}) has updated their score to {req.StatValue} in {(room != null ? room.Name : "Unknown Room")}",
5898927
);
// The live service answers the BARE number 0 here (not an envelope, not the result
// enum) whether or not the compare passed - that's what the client's parser expects.
return new ContentResult()
{
Content = "0",
ContentType = "application/json",
StatusCode = 200
};
}
catch (Exception ex)
{
Console.WriteLine(ex);
// Still answer a bare 0 - the client's parser expects a number here, and a 400 would
// read as a failed stat write / broken leaderboard.
return new ContentResult()
{
Content = "0",
ContentType = "application/json",
StatusCode = 200
};
}
}
[HttpPost("Leaderboard/leaderboard/GetRanks")]
public async Task<ContentResult> GetRanks()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
NoCache();
string body = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync();
try
{
var req = JsonConvert.DeserializeObject<GetRanksRequest>(body);
if (req == null)
throw new Exception("Invalid");
var rows = LeaderboardDB.GetRanks(
req.StatChannel,
req.RoomId,
req.RankStart,
req.RankEnd,
req.SortAscending
);
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new { Rows = rows }),
ContentType = "application/json",
StatusCode = 200
};
}
catch (Exception ex)
{
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new { Rows = new List<Entry>() }),
ContentType = "application/json",
StatusCode = 200
};
}
}
}
}
@@ -0,0 +1,1596 @@
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server;
using Campus_live_rewrite.api.server.Classes;
using Campus_live_rewrite.api.server.Classes.db_class;
// SavedImageTypeEnum / ImageAccessibility are nested in ImageMetadataDB, same as APIController.
using static ImageMetadataDB;
using MessageType = Campus_live_rewrite.api.WebsocketEvents.MessageType;
namespace Campus_live_rewrite.Controllers
{
// Real data for the public website - the endpoints that replace data.js's demo content
// (six invented rooms, picsum photo URLs, dicebear avatars, hardcoded "online friends").
//
// These are deliberately SEPARATE from the game endpoints rather than the site calling
// /rooms/hot and /api/images/v3/... directly:
// - the game routes all sit behind the patch-header gate, which a browser cannot pass;
// - they return the client's DTOs (huge - room DataBlobs, save payloads, platform ids)
// where the site needs a handful of display fields;
// - they leak more than an anonymous web visitor should see.
// So this projects the same DB reads down to a small, public-safe shape.
//
// Everything here treats "signed out" as valid: the site is public. Endpoints that need
// an account (friends, notifications, "my" anything) say so and 401 instead.
[ApiController]
public class SiteContentController : ControllerBase
{
private const string SiteCookie = "campus_site";
// Mirrors SiteController.CurrentUser. Kept local rather than shared through a base
// class so the two controllers can be read independently.
private player_info? CurrentUser()
{
string token = Request.Cookies[SiteCookie] ?? "";
if (string.IsNullOrEmpty(token)) return null;
// MUST require the "website" surface, exactly like SiteController.CurrentUser - the
// website token is minted with surface="website" and DecodeToken now refuses website
// tokens on every path that doesn't ask for them. Without this, every endpoint in
// this controller (friends, notifications, profile, photos, rooms) 401s the site.
string data = ClientSecurity.DecodeToken(token, requireSurface: "website");
if (string.IsNullOrEmpty(data) || data.StartsWith("error_")) return null;
try { return JsonConvert.DeserializeObject<player_info>(data); }
catch { return null; }
}
private ulong ViewerId() => CurrentUser()?.Id ?? 0UL;
// ---- shared projections -----------------------------------------------------------
// Everything image-shaped on the site goes through /img/<name>, which ImagesController
// already serves. Null when the account/room has no image, so the front-end can fall
// back to its generated avatar rather than requesting a 404.
private static string? ImgUrl(string? name) =>
string.IsNullOrWhiteSpace(name) ? null : "/img/" + name;
private static object RoomCard(RoomDBClasses.RoomRoot r, string? creatorName) => new
{
id = r.RoomId,
name = r.Name,
description = r.Description ?? "",
image = ImgUrl(r.ImageName),
creator = creatorName,
creatorId = r.CreatorAccountId,
visits = r.Stats?.VisitCount ?? 0,
cheers = r.Stats?.CheerCount ?? 0,
favorites = r.Stats?.FavoriteCount ?? 0,
maxPlayers = r.MaxPlayers,
approved = r.IsRecRoomApproved,
// who's in the room right now - the "N playing now" chips on the site
players = HeartbeatDB.CountInRoom(r.RoomId),
};
// Room rows carry a CreatorAccountId, not a name. Resolving one account per row would
// be N queries per page, so the caller batches the distinct ids through this first.
private static Dictionary<ulong, string> CreatorNames(IEnumerable<ulong> ids)
{
var map = new Dictionary<ulong, string>();
foreach (var id in ids.Distinct())
{
if (id == 0) continue;
var acc = PlayerDB.GetAccountById(id);
if (acc != null)
map[id] = acc.DisplayName ?? acc.Username ?? "";
}
return map;
}
private List<object> ProjectRooms(IEnumerable<RoomDBClasses.RoomRoot?> rooms, int take)
{
ulong viewer = ViewerId();
var visible = rooms
.Where(r => r != null && !r.IsDorm && RoomDB.CanPlayerSeeRoom(r, viewer))
.Select(r => r!)
.Take(take)
.ToList();
var names = CreatorNames(visible.Select(r => r.CreatorAccountId));
return visible
.Select(r => RoomCard(r, names.TryGetValue(r.CreatorAccountId, out var n) ? n : null))
.ToList();
}
// ---- rooms ------------------------------------------------------------------------
// GET /api/site/rooms/hot?take=12
// Replaces data.js `rooms` - the six hand-written Whisker Heights / Midnight Mews
// entries that every visitor saw regardless of what was actually on the server.
[HttpGet("/api/site/rooms/hot")]
public IActionResult HotRooms(int take = 12)
{
take = Math.Clamp(take, 1, 50);
try
{
return Ok(new { ok = true, results = ProjectRooms(RoomDB.GetHotRooms(), take) });
}
catch (Exception ex)
{
Console.WriteLine($"[site] hot rooms failed: {ex.Message}");
return StatusCode(500, new { ok = false, error = "could not load rooms" });
}
}
// GET /api/site/rooms/browse?tab=hot|rro|new&q=&skip=0&take=24
// The website's Rooms page. hot = most visited, rro = the Rec Room Originals, new = latest
// created; q filters by name (and creator). Public/visible rooms only, paged.
[HttpGet("/api/site/rooms/browse")]
public IActionResult BrowseRooms(string tab = "hot", string q = "", int skip = 0, int take = 24)
{
take = Math.Clamp(take, 1, 60);
skip = Math.Max(0, skip);
try
{
ulong viewer = ViewerId();
IEnumerable<RoomDBClasses.RoomRoot> rooms = RoomDB.GetHotRooms()
.Where(r => r != null && !r.IsDorm && RoomDB.CanPlayerSeeRoom(r, viewer));
tab = (tab ?? "hot").Trim().ToLowerInvariant();
if (tab == "rro") rooms = rooms.Where(RoomDB.IsRecRoomOriginal);
else if (tab == "new") rooms = rooms.Where(r => !RoomDB.IsRecRoomOriginal(r));
q = (q ?? "").Trim().TrimStart('^');
if (q.Length > 0)
rooms = rooms.Where(r => (r.Name ?? "").Contains(q, StringComparison.OrdinalIgnoreCase)
|| (r.Description ?? "").Contains(q, StringComparison.OrdinalIgnoreCase));
rooms = tab == "new"
? rooms.OrderByDescending(r => r.CreatedAt)
: rooms.OrderByDescending(r => r.Stats?.VisitCount ?? 0).ThenByDescending(r => r.Stats?.CheerCount ?? 0);
var all = rooms.ToList();
var page = all.Skip(skip).Take(take).ToList();
var names = CreatorNames(page.Select(r => r.CreatorAccountId));
var results = page.Select(r => RoomCard(r, names.TryGetValue(r.CreatorAccountId, out var n) ? n : null)).ToList();
return Ok(new { ok = true, results, total = all.Count, skip, take });
}
catch (Exception ex)
{
Console.WriteLine($"[site] browse rooms failed: {ex.Message}");
return StatusCode(500, new { ok = false, error = "could not load rooms" });
}
}
// GET /api/site/rooms/featured
// The SAME admin-curated featured rooms the game shows (FeaturedRoomsStore), in the same
// order. Public/visible rooms only. Empty list when nothing is featured (the section hides).
[HttpGet("/api/site/rooms/featured")]
public IActionResult FeaturedRooms(int take = 12)
{
try
{
ulong viewer = ViewerId();
var rooms = new List<RoomDBClasses.RoomRoot>();
foreach (var id in FeaturedRoomsStore.GetIds())
{
var r = RoomDB.GetRoom(id);
if (r != null && !r.IsDorm && RoomDB.CanPlayerSeeRoom(r, viewer))
rooms.Add(r);
if (rooms.Count >= take) break;
}
var names = CreatorNames(rooms.Select(r => r.CreatorAccountId));
var results = rooms
.Select(r => RoomCard(r, names.TryGetValue(r.CreatorAccountId, out var n) ? n : null))
.ToList();
return Ok(new { ok = true, results });
}
catch (Exception ex)
{
Console.WriteLine($"[site] featured rooms failed: {ex.Message}");
return StatusCode(500, new { ok = false, error = "could not load featured rooms" });
}
}
// GET /api/site/rooms/{id} - the room.html detail page. {id} is the room NAME (how rooms
// are shared/searched); a numeric slug still works as an account-... er, room id fallback.
[HttpGet("/api/site/rooms/{id}")]
public IActionResult Room(string id)
{
try
{
var room = RoomDB.GetRoomByName(id);
if (room == null && ulong.TryParse(id, out var rid)) room = RoomDB.GetRoom(rid);
// Dorms are personal spaces, not published rooms: never shown on the website.
if (room == null || room.IsDorm || !RoomDB.CanPlayerSeeRoom(room, ViewerId()))
return NotFound(new { ok = false, error = "room not found" });
var names = CreatorNames(new[] { room.CreatorAccountId });
names.TryGetValue(room.CreatorAccountId, out var creator);
// Photos taken in this room, newest first - the room page's gallery.
// Same two filters as the profile feed: real camera photos only, and public
// only - this page is reachable signed out, so it must never surface a
// private photo regardless of what GetRoomImages returns.
var photos = ImageMetadataDB.GetRoomPublicPhotos((int)room.RoomId, 48) ?? new();
ulong viewerId = ViewerId();
var photoAuthors = CreatorNames(photos.Select(i => i.PlayerId));
var photoCards = photos.Select(i =>
{
var acc = PlayerDB.GetAccountById(i.PlayerId);
return new
{
id = i.Id,
image = ImgUrl(i.ImageName),
description = i.Description,
cheers = i.CheerCount,
cheered = viewerId != 0 && ImageMetadataDB.HasUserCheered(i.Id, viewerId),
comments = i.CommentCount,
createdAt = i.CreatedAt,
taggedPlayers = ResolveTagged(i.TaggedPlayerIds),
author = acc == null ? null : new { id = acc.Id, username = acc.Username, display_name = acc.DisplayName, profile_image = ImgUrl(acc.profileImage) },
};
}).ToList();
return Ok(new
{
ok = true,
room = RoomCard(room, creator),
subrooms = room.SubRooms?.Select(s => new { id = s.SubRoomId, name = s.Name }).ToList(),
photos = photoCards,
});
}
catch (Exception ex)
{
Console.WriteLine($"[site] room {id} failed: {ex.Message}");
return StatusCode(500, new { ok = false, error = "could not load the room" });
}
}
// ---- profiles ---------------------------------------------------------------------
// GET /api/site/profile/{handle} (handle = username, or a numeric account id)
//
// One call for the whole profile page. In-game this is 8 separate per-id fetches
// (account, bio, privacy, progression, reputation, photo feed, showcase, visited) -
// see the profile-panel notes. The site has no reason to pay eight round trips.
[HttpGet("/api/site/profile/{handle}")]
public IActionResult Profile(string handle)
{
try
{
// Resolve by USERNAME first, THEN fall back to a numeric account id. A purely
// numeric USERNAME (e.g. "123", whose account id is actually 221) must resolve to
// that user, not to account id 123 - the old id-first check got them confused.
player_data? full = PlayerDB.GetFullAccountByName(handle);
if (full == null && ulong.TryParse(handle, out var byId))
full = PlayerDB.GetFullAccount(byId);
if (full?.player == null)
return NotFound(new { ok = false, error = "player not found" });
ulong id = full.playerid;
ulong viewer = ViewerId();
// Block: if the profile owner has this viewer blocked (their own Ignored flag set
// toward the viewer), the viewer may not see the profile. GetRelationships(owner)
// projects PlayerID = the OTHER player and Ignored = the owner's own value, so a
// set Ignored toward the viewer means the owner blocked them. Owner and the not-
// signed-in (viewer 0) case are never blocked.
if (viewer != 0 && viewer != id)
{
// Only the OWNER's own block hides their profile. (Ignored != None also matched
// Remote - i.e. the viewer having blocked the owner - which locked the blocker
// out of the profile they'd need to unblock from.)
var ownerRel = FriendsDB.GetRelationships(id)?
.FirstOrDefault(r => r.PlayerID == viewer);
if (ownerRel != null && FriendsDB.IsOwnBlock(ownerRel.Ignored))
return StatusCode(403, new
{
ok = false,
blocked = true,
error = "This Profile cannot be viewed at this time",
});
}
var progress = Progress.GetPlayerLevel(id);
var rep = full.player.PlayerReputation ?? new mPlayerReputation();
// Photo feed: their photos plus ones they're tagged in, filtered by the
// requester so a private photo isn't exposed to a stranger.
// GetPlayerFeed handles the accessibility filter (public-only unless you
// are looking at yourself) but returns every image TYPE. Restrict to
// ShareCamera so the photo tab shows photos the player actually took, not
// their profile picture and room thumbnails repeated back at them.
bool viewerIsFriend = viewer != 0 && viewer != id && AreFriends(viewer, id);
var rawFeed = ImageMetadataDB.GetPlayerFeed(viewer, id, 0, 200, viewerIsFriend) ?? new();
Console.WriteLine($"[site] profile {id} feed types: " +
string.Join(", ", rawFeed.GroupBy(i => i.SavedImageType).Select(g => $"{g.Key}={g.Count()}")));
// "Photos the player took" = actual camera photos (ShareCamera), plus any untyped
// (None) image, which is what the photo tab shows. Thumbnails (outfit/room/profile/
// invention/event/loadscreen) are deliberately excluded so the tab isn't the
// avatar and room covers repeated back. isPrivate drives the lock badge; for your
// OWN profile private photos are included (GetPlayerFeed only hides others'
// private), so a lock marks the ones only you can see.
// Each photo carries two flags so the profile can split them across tabs:
// taken = this player took it (PlayerId == id)
// tagged = this player is tagged in it (theirs OR someone else's photo)
var photos = rawFeed
.Where(i => i.SavedImageType == SavedImageTypeEnum.ShareCamera
|| i.SavedImageType == SavedImageTypeEnum.None)
.Take(96)
.Select(i => new
{
id = i.SavedImageId,
image = ImgUrl(i.ImageName),
cheers = i.CheerCount,
comments = i.CommentCount,
roomId = i.RoomId,
description = i.Description,
createdAt = i.CreatedAt,
isPrivate = i.Accessibility != ImageAccessibility.Public,
visibility = i.FriendsOnly ? "friends" : (i.Accessibility == ImageAccessibility.Public ? "public" : "private"),
taken = i.PlayerId == id,
tagged = i.TaggedPlayerIds != null && i.TaggedPlayerIds.Contains(id),
})
.ToList();
var rooms = ProjectRooms(RoomDB.GetRoomsByCreator(id) ?? new(), 12);
// Presence is disclosed ONLY to a signed-in viewer. A signed-out visitor (viewer 0)
// gets null, and the site hides the status line entirely - you can't see anyone's
// online/offline state unless you're logged in.
object? status = null;
if (viewer != 0)
{
var (isOnline, room) = HeartbeatDB.PeekPresence(id);
// when they were last seen heartbeating, so the site can say "last online 3h ago"
string? lastOnline = null;
try { lastOnline = HeartbeatDB.LastSeen(id); } catch { }
status = new
{
online = isOnline,
where = isOnline ? room?.name?.TrimStart('^', '@') : null,
lastOnline = isOnline ? null : lastOnline,
// What they're listening to on Spotify, if their setting lets THIS viewer see it.
spotify = SpotifyLink.PayloadFor(id, viewer).GetAwaiter().GetResult(),
};
}
return Ok(new
{
ok = true,
status,
user = new
{
id,
username = full.player.Username,
display_name = full.player.DisplayName,
profile_image = ImgUrl(full.player.profileImage),
banner_image = ImgUrl(string.IsNullOrEmpty(full.player.bannerImage) ? ConfigCampus.DefaultBannerImage : full.player.bannerImage),
bio = full.player_Extra?.Bio ?? "",
socials = SocialLinks.ProfileRows(id, full.player_Extra),
level = progress?.Level ?? full.player.Level,
xp = progress?.XP ?? full.player.XP,
created_at = full.player.created_at,
is_junior = full.player.IsJunior,
},
reputation = rep,
subscribers = PlayerDB.GetSubscriberIds(id).Count,
friends = FriendsDB.GetRelationships(id)?
.Count(r => r.RelationshipType == RelationshipType.Friend) ?? 0,
photos,
rooms,
// Only meaningful when signed in; null tells the front-end to render the
// "Add friend" button in its neutral state rather than guessing.
relationship = viewer == 0 || viewer == id ? null : RelationshipWith(viewer, id),
// The viewer's own state toward this profile, so the buttons render truthfully.
subscribed = viewer != 0 && viewer != id && PlayerDB.GetSubscribedToIds(viewer).Contains(id),
favourite = viewer != 0 && viewer != id && (FriendsDB.GetRelationships(viewer)?
.Any(r => r.PlayerID == id && r.Favorited != ReciprocalStatus.None) ?? false),
// "I have blocked this player" (the viewer's own part of the pair)
blockedByMe = viewer != 0 && viewer != id && FriendsDB.HasBlocked(viewer, id),
// the viewer's website-side extras for this friend
nickname = viewer != 0 && viewer != id ? SitePrefsDB.NicknameFor(viewer, id) : null,
pinned = viewer != 0 && viewer != id && SitePrefsDB.IsPinned(viewer, id),
});
}
catch (Exception ex)
{
Console.WriteLine($"[site] profile {handle} failed: {ex.Message}");
return StatusCode(500, new { ok = false, error = "could not load the profile" });
}
}
// The caller's OWN side of the pair - never the target's row. Reading the wrong side
// inverts pending-request direction (a request you sent shows as received).
private static string? RelationshipWith(ulong viewer, ulong target)
{
var rel = FriendsDB.GetRelationships(viewer)
?.FirstOrDefault(r => r.PlayerID == target || r.OtherPlayerID == target);
return rel?.RelationshipType.ToString();
}
// ---- friend actions from the website ----------------------------------------------
// These run the SAME FriendsDB operations the game's /api/relationships/v2/* endpoints do,
// but authenticate with the WEBSITE session (campus_site cookie via CurrentUser) instead
// of the game bearer token - so a friend request sent from the site shows up in-game, and
// vice-versa. Arg order mirrors the game controllers exactly: (otherPlayerId, myId).
public class FriendActionBody { public ulong id { get; set; } }
private IActionResult FriendAction(FriendActionBody? body, Func<ulong, ulong, RelationshipDetail?> op, string what)
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
if (body == null || body.id == 0 || body.id == me.Id)
return BadRequest(new { ok = false, error = "invalid target" });
// Light anti-spam: one friend action per account per 2s.
if (!RateLimit.TryConsume($"sitefriend:{me.Id}", cooldownSeconds: 2, out int retry))
{
Response.Headers["Retry-After"] = retry.ToString();
return StatusCode(429, new { ok = false, error = "Slow down a moment." });
}
try
{
// Caller is the FROM player (the sender), target is the TO player - the order the
// reference/client expects. SendFriendRequest marks the FROM row FriendRequestSent,
// so the sender's own relationship reads "Sent" and the profile stays on "Pending"
// after a refresh. (Accept/Remove are symmetric, so the order is harmless there.)
var rel = op(me.Id, body.id);
Console.WriteLine($"[site] {me.Id} {what} {body.id} -> {rel?.RelationshipType.ToString() ?? "null"}");
SiteNotifications.Notify(me.Id);
SiteNotifications.Notify(body.id);
return Ok(new { ok = true, relationship = rel?.RelationshipType.ToString() });
}
catch (Exception ex)
{
Console.WriteLine($"[site] friend {what} {me.Id}->{body.id} failed: {ex.Message}");
return StatusCode(500, new { ok = false, error = "could not update the friendship" });
}
}
// The friends of a profile: All / Favourites, plus pending requests when it is your own.
// Presence comes from the game hub (online = socket connected), so the list is the same
// truth the in-game friends list shows. Other people's lists show accepted friends only.
[HttpGet("/api/site/profile/{handle}/friends")]
public IActionResult ProfileFriends(string handle)
{
try
{
player_data? full = PlayerDB.GetFullAccountByName(handle);
if (full == null && ulong.TryParse(handle, out var byId)) full = PlayerDB.GetFullAccount(byId);
if (full?.player == null) return NotFound(new { ok = false, error = "player not found" });
ulong id = full.playerid;
ulong viewer = ViewerId();
bool own = viewer != 0 && viewer == id;
var rels = FriendsDB.GetRelationships(id) ?? new List<RelationshipDetail>();
var online = CampusLiveSource.Controllers.NotificationsController.WebSockets.Keys.ToHashSet();
var viewerRels = own ? rels : (viewer != 0 ? FriendsDB.GetRelationships(viewer) ?? new List<RelationshipDetail>() : new List<RelationshipDetail>());
object Row(RelationshipDetail r, bool favourite)
{
var acc = PlayerDB.GetAccountById(r.PlayerID);
if (acc == null) return null!;
bool isOnline = online.Contains(r.PlayerID);
return new
{
id = acc.Id,
username = acc.Username,
display_name = acc.DisplayName,
profile_image = ImgUrl(acc.profileImage),
favourite,
online = isOnline,
where = isOnline ? HeartbeatDB.GetPlayerCurrentRoomName(r.PlayerID).TrimStart('^', '@') : null,
spotify = SpotifyLink.CachedPayloadFor(r.PlayerID, viewer),
};
}
var friends = rels.Where(r => r.RelationshipType == RelationshipType.Friend)
// "favourite" is always from the VIEWER's point of view.
.Select(r => Row(r, own ? r.Favorited != ReciprocalStatus.None
: viewerRels.Any(v => v.PlayerID == r.PlayerID && v.Favorited != ReciprocalStatus.None)))
.Where(x => x != null).ToList();
var pending = own
? rels.Where(r => r.RelationshipType == RelationshipType.FriendRequestReceived).Select(r => Row(r, false)).Where(x => x != null).ToList()
: new List<object>();
var sent = own
? rels.Where(r => r.RelationshipType == RelationshipType.FriendRequestSent).Select(r => Row(r, false)).Where(x => x != null).ToList()
: new List<object>();
return Ok(new { ok = true, own, friends, pending, sent });
}
catch (Exception ex)
{
Console.WriteLine($"[site] profile friends {handle} failed: {ex.Message}");
return StatusCode(500, new { ok = false, error = "could not load friends" });
}
}
// The people subscribed to a profile, and the creators that profile subscribes to. Both
// read the same store the in-game Subscribe button writes. Public, like the counts.
[HttpGet("/api/site/profile/{handle}/subscribers")]
public IActionResult ProfileSubscribers(string handle) => SubscriptionList(handle, subscribers: true);
[HttpGet("/api/site/profile/{handle}/subscriptions")]
public IActionResult ProfileSubscriptions(string handle) => SubscriptionList(handle, subscribers: false);
private IActionResult SubscriptionList(string handle, bool subscribers)
{
try
{
player_data? full = PlayerDB.GetFullAccountByName(handle);
if (full == null && ulong.TryParse(handle, out var byId)) full = PlayerDB.GetFullAccount(byId);
if (full?.player == null) return NotFound(new { ok = false, error = "player not found" });
ulong id = full.playerid;
ulong viewer = ViewerId();
var ids = subscribers ? PlayerDB.GetSubscriberIds(id) : PlayerDB.GetSubscribedToIds(id);
var mine = viewer != 0 ? PlayerDB.GetSubscribedToIds(viewer) : new List<ulong>();
var online = CampusLiveSource.Controllers.NotificationsController.WebSockets.Keys.ToHashSet();
var results = new List<object>();
foreach (var pid in ids.Distinct())
{
var acc = PlayerDB.GetAccountById(pid);
if (acc == null) continue;
bool isOnline = online.Contains(pid);
results.Add(new
{
id = acc.Id,
username = acc.Username,
display_name = acc.DisplayName,
profile_image = ImgUrl(acc.profileImage),
online = isOnline,
where = isOnline ? HeartbeatDB.GetPlayerCurrentRoomName(pid).TrimStart('^', '@') : null,
subscribers = PlayerDB.GetSubscriberIds(pid).Count,
// whether the VIEWER is subscribed to this person (so the row can offer a button)
subscribed = viewer != 0 && viewer != pid && mine.Contains(pid),
});
}
return Ok(new { ok = true, own = viewer != 0 && viewer == id, results });
}
catch (Exception ex)
{
Console.WriteLine($"[site] subscription list {handle} failed: {ex.Message}");
return StatusCode(500, new { ok = false, error = "could not load the list" });
}
}
public class FavouriteBody { public ulong id { get; set; } public bool on { get; set; } }
// Favourite / unfavourite a friend from the website. Same store and same realtime pushes as
// the in-game star (FriendsDB.SetRelationshipFlag), so the game updates the moment it's clicked.
[HttpPost("/api/site/friends/favourite")]
public IActionResult SiteFavourite([FromBody] FavouriteBody? body)
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
if (body == null || body.id == 0 || body.id == me.Id) return BadRequest(new { ok = false, error = "invalid target" });
if (!AreFriends(me.Id, body.id)) return BadRequest(new { ok = false, error = "you can only favourite a friend" });
var rel = FriendsDB.SetRelationshipFlag(me.Id, body.id, FriendInfo.Favorited, body.on);
return Ok(new { ok = true, favourite = rel.Favorited != ReciprocalStatus.None });
}
public class BlockBody { public ulong id { get; set; } public bool on { get; set; } }
// POST /api/site/friends/block {id, on} - block / unblock from the website. The SAME
// FriendsDB.SetBlock the in-game button uses, so the block (and its direction) shows in
// the game instantly and only the blocker's own part is lifted by an unblock.
[HttpPost("/api/site/friends/block")]
public IActionResult SiteBlock([FromBody] BlockBody? body)
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
if (body == null || body.id == 0 || body.id == me.Id) return BadRequest(new { ok = false, error = "invalid target" });
if (PlayerDB.GetAccountById(body.id) == null) return NotFound(new { ok = false, error = "player not found" });
var rel = FriendsDB.SetBlock(me.Id, body.id, body.on);
return Ok(new { ok = true, blocked = FriendsDB.IsOwnBlock(rel.Ignored), blockedMe = rel.Ignored == ReciprocalStatus.Remote || rel.Ignored == ReciprocalStatus.Mutual });
}
public class NicknameBody { public ulong id { get; set; } public string? nickname { get; set; } }
public class PinBody { public ulong id { get; set; } public bool on { get; set; } }
// POST /api/site/friends/nickname {id, nickname} - what YOU call this friend on the site
// (empty removes it). Website-only; stored per owner in SitePrefsDB.
[HttpPost("/api/site/friends/nickname")]
public IActionResult SiteNickname([FromBody] NicknameBody? body)
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
if (body == null || body.id == 0 || body.id == me.Id) return BadRequest(new { ok = false, error = "invalid target" });
if (!AreFriends(me.Id, body.id)) return BadRequest(new { ok = false, error = "you can only nickname a friend" });
string? nick = (body.nickname ?? "").Trim();
if (nick.Length > 0) nick = Campus_live_rewrite.api.server.Classes.Sanitize.SanitizeMessage(nick);
nick = SitePrefsDB.SetNickname(me.Id, body.id, nick);
return Ok(new { ok = true, nickname = nick });
}
// POST /api/site/friends/pin {id, on} - pin / unpin a friend on your home page rail.
[HttpPost("/api/site/friends/pin")]
public IActionResult SitePin([FromBody] PinBody? body)
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
if (body == null || body.id == 0 || body.id == me.Id) return BadRequest(new { ok = false, error = "invalid target" });
if (body.on && !AreFriends(me.Id, body.id)) return BadRequest(new { ok = false, error = "you can only pin a friend" });
return Ok(new { ok = true, pinned = SitePrefsDB.SetPinned(me.Id, body.id, body.on) });
}
// GET /api/site/me/pinned - the friends pinned to my home page, with their live status.
[HttpGet("/api/site/me/pinned")]
public IActionResult MyPinned()
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
var prefs = SitePrefsDB.Get(me.Id);
var rows = new List<object>();
foreach (var pid in prefs.Pinned)
{
var acc = PlayerDB.GetAccountById(pid);
if (acc == null || !AreFriends(me.Id, pid)) continue;
var (isOnline, room) = HeartbeatDB.PeekPresence(pid);
rows.Add(new
{
id = pid, username = acc.Username, display_name = acc.DisplayName, profile_image = ImgUrl(acc.profileImage),
nickname = SitePrefsDB.NicknameFor(me.Id, pid),
online = isOnline, where = isOnline ? room?.name?.TrimStart('^', '@') : null,
});
}
return Ok(new { ok = true, pinned = rows });
}
public class SubscribeBody { public ulong id { get; set; } public bool on { get; set; } }
// Subscribe / unsubscribe to a creator from the website - the same store the in-game
// Subscribe button writes (ClubsController), so both places agree instantly.
[HttpPost("/api/site/subscribe")]
public IActionResult SiteSubscribe([FromBody] SubscribeBody? body)
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
if (body == null || body.id == 0 || body.id == me.Id) return BadRequest(new { ok = false, error = "invalid target" });
if (!RateLimit.TryConsume($"sitesub:{me.Id}", cooldownSeconds: 2, out int retry))
{
Response.Headers["Retry-After"] = retry.ToString();
return StatusCode(429, new { ok = false, error = "Slow down a moment." });
}
try
{
if (body.on) PlayerDB.AddSubscription(me.Id, body.id); else PlayerDB.RemoveSubscription(me.Id, body.id);
Console.WriteLine($"[site] {me.Id} {(body.on ? "subscribed to" : "unsubscribed from")} {body.id}");
SiteNotifications.Notify(me.Id);
SiteNotifications.Notify(body.id);
return Ok(new { ok = true, subscribed = body.on, subscribers = PlayerDB.GetSubscriberIds(body.id).Count });
}
catch (Exception ex)
{
Console.WriteLine($"[site] subscribe {me.Id}->{body.id} failed: {ex.Message}");
return StatusCode(500, new { ok = false, error = "could not update the subscription" });
}
}
[HttpPost("/api/site/friends/request")]
public IActionResult SiteSendFriendRequest([FromBody] FriendActionBody? body)
=> FriendAction(body, FriendsDB.SendFriendRequest, "sent request to");
[HttpPost("/api/site/friends/accept")]
public IActionResult SiteAcceptFriend([FromBody] FriendActionBody? body)
=> FriendAction(body, FriendsDB.AcceptFriendRequest, "accepted");
[HttpPost("/api/site/friends/remove")]
public IActionResult SiteRemoveFriend([FromBody] FriendActionBody? body)
=> FriendAction(body, FriendsDB.RemoveFriend, "removed/cancelled");
// ---- notifications (website inbox) ------------------------------------------------
// A real, persistent, game-synced inbox. Aggregates the two server-side sources of
// truth so the website shows the same notifications the game does:
// 1. Incoming friend requests - pending FriendRequestReceived rows in the relationship
// DB. Actionable here (Accept -> /friends/accept, Decline -> /friends/remove) and
// they live until actioned, exactly like in-game.
// 2. Saved messages - player_Extra.Messages (cheers, coach messages, ...), the same
// store /api/messages/v2/get serves the game, dismissed via the same delete.
// Game / party / join invites can't be actioned from a browser, so they render as
// "Launch the game to view." Read fresh every call, so web and game stay in sync.
// The invite kinds a browser cannot act on - they need the running game/session.
private static readonly HashSet<MessageType> LaunchGameTypes = new()
{
MessageType.GameInvite, MessageType.GameInviteV2, MessageType.GameInviteDeclined,
MessageType.PartyActivitySwitch, MessageType.PartyActivitySwitchV2, MessageType.FriendInvite,
MessageType.RequestGameInvite, MessageType.RequestGameInviteDeclined,
};
public class NotifDismissBody { public string? id { get; set; } }
// Project a player to {id, username, display_name, profile_image} plus a display name.
private static (object? proj, string name) WhoNamed(ulong pid)
{
if (pid == 0) return (null, "Someone");
var a = PlayerDB.GetAccountById(pid);
if (a == null) return (null, "Someone");
string nm = !string.IsNullOrWhiteSpace(a.DisplayName) ? a.DisplayName
: (!string.IsNullOrWhiteSpace(a.Username) ? a.Username : "Someone");
return (new { id = a.Id, username = a.Username, display_name = a.DisplayName, profile_image = ImgUrl(a.profileImage) }, nm);
}
// Aggregates a player's inbox from the two server-side sources of truth. Static so a
// diagnostic/self-test can call it directly with a player id (no cookie needed).
public static List<object> BuildNotifications(ulong meId)
{
var items = new List<object>();
// 1) incoming friend requests (actionable, persist until accepted/declined)
try
{
var rels = FriendsDB.GetRelationships(meId) ?? new List<RelationshipDetail>();
foreach (var r in rels.Where(r => r.RelationshipType == RelationshipType.FriendRequestReceived))
{
ulong other = r.PlayerID; // GetRelationships projects PlayerID = the other player
var (proj, name) = WhoNamed(other);
items.Add(new
{
id = "fr:" + other,
kind = "friend_request",
actionable = true,
launchGame = false,
fromPlayer = proj,
targetId = other,
text = $"{name} sent you a friend request",
time = (DateTime?)null
});
}
}
catch { }
// 2) saved messages (cheers, coach messages, and any launch-game invites if present)
try
{
var msgs = PlayerDB.GetMessages(meId) ?? new List<MessageData>();
foreach (var m in msgs.OrderByDescending(m => m.SentTime))
{
// The stored FriendInvite message is the GAME's copy of a pending request; the
// website already lists the request itself (friend_request rows above).
if (m.Type == MessageType.FriendInvite) continue;
bool launch = LaunchGameTypes.Contains(m.Type);
bool anon = m.Type == MessageType.PlayerCheerAnonymous;
var (proj, name) = anon ? ((object?)null, "Someone") : WhoNamed(m.FromPlayerId);
string text;
if (launch) text = "Launch the game to view.";
else text = m.Type switch
{
MessageType.PlayerCheer => $"{name} cheered you",
MessageType.PlayerCheerAnonymous => "Someone cheered you",
MessageType.FriendRequestAccepted => $"{name} accepted your friend request",
MessageType.CoachMessage => string.IsNullOrWhiteSpace(m.Data) ? "Message from the Campus team" : m.Data!,
MessageType.TextMessage => $"{name} sent you a message",
_ => "New notification"
};
items.Add(new
{
id = "msg:" + m.Id,
kind = launch ? "launch" : "message",
actionable = false,
launchGame = launch,
fromPlayer = proj,
text,
time = (DateTime?)m.SentTime
});
}
}
catch { }
return items;
}
[HttpGet("/api/site/notifications")]
public IActionResult GetSiteNotifications()
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
var items = BuildNotifications(me.Id);
return Ok(new { ok = true, count = items.Count, items });
}
// Realtime push: the browser opens this websocket; whenever a notification is created for
// this player (SiteNotifications.Notify, fired from Notifications.SendToPlayer and
// PlayerDB.AddMessage) the socket receives a {"type":"refresh"} and the page re-fetches
// the inbox above. Separate from the game hub so it never disturbs the player's game
// socket. Cookie-authed like the rest of the site endpoints.
[HttpGet("/api/site/notifications/ws")]
public async Task SiteNotificationsSocket()
{
if (!HttpContext.WebSockets.IsWebSocketRequest)
{
HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
return;
}
var me = CurrentUser();
if (me == null)
{
HttpContext.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
using var socket = await HttpContext.WebSockets.AcceptWebSocketAsync();
await Campus_live_rewrite.api.server.SiteNotifications.HandleAsync(me.Id, socket);
}
// Dismiss a SAVED message (msg:<id>) - deletes it from the same store the game reads,
// so it clears in both places. Friend requests are not dismissed here; they are accepted
// or declined through /api/site/friends/*.
[HttpPost("/api/site/notifications/dismiss")]
public IActionResult SiteDismissNotification([FromBody] NotifDismissBody? body)
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
if (body == null || string.IsNullOrWhiteSpace(body.id))
return BadRequest(new { ok = false, error = "no id" });
if (body.id.StartsWith("msg:") && ulong.TryParse(body.id.Substring(4), out var mid))
{
DismissMessageEverywhere(me.Id, mid);
return Ok(new { ok = true });
}
return BadRequest(new { ok = false, error = "not a dismissable notification" });
}
// Delete the stored message AND tell the running game to drop it (MessageDeleted frame),
// so "mark as read" on the website removes the notification in-game right away instead of
// on the next notifications fetch.
private static void DismissMessageEverywhere(ulong playerId, ulong messageId)
{
PlayerDB.DeleteMessage(playerId, messageId);
try
{
Notifications.FireAndForget(
Notifications.SendToPlayer(playerId, Newtonsoft.Json.JsonConvert.SerializeObject(Campus_live_rewrite.api.WebsocketEvents.CreateMessageDeleted(messageId))),
"site dismiss -> game");
}
catch { }
}
// POST /api/site/notifications/dismissall - "Mark all as read": every dismissable
// notification (friend requests stay - they are actioned, not read) goes in one call.
[HttpPost("/api/site/notifications/dismissall")]
public IActionResult SiteDismissAllNotifications()
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
var full = PlayerDB.GetFullAccount(me.Id);
var msgs = full?.player_Extra?.Messages?.Select(m => m.Id).ToList() ?? new List<ulong>();
foreach (var id in msgs) DismissMessageEverywhere(me.Id, id);
SiteNotifications.Notify(me.Id);
return Ok(new { ok = true, dismissed = msgs.Count });
}
// ---- photos -----------------------------------------------------------------------
// GET /api/site/photos/recent?take=24
// The home feed. Replaces the picsum.photos seed URLs and the invented post list.
[HttpGet("/api/site/photos/recent")]
public IActionResult RecentPhotos(int take = 24)
{
take = Math.Clamp(take, 1, 60);
try
{
// GetRecentPublicPhotos, not GetGlobalImages/GetSlideshowImages:
// - GetGlobalImages returns EVERY public image type (profile pictures, room
// and outfit thumbnails showed up as if they were photos) and sorts by
// CheerCount, so "Recent Photos" was neither photos-only nor recent.
// - GetSlideshowImages is Public + ShareCamera only, but THIS server saves
// camera photos as Type None, so it matched nothing and the feed was empty.
// GetRecentPublicPhotos is Public + (None|ShareCamera), newest first - the actual
// camera photos, excluding thumbnails, and unauthenticated-safe.
var rows = ImageMetadataDB.GetRecentPublicPhotos(take) ?? new();
// Batch the author lookups the same way rooms do.
var authors = new Dictionary<ulong, player_info>();
foreach (var pid in rows.Select(r => r.PlayerId).Distinct())
{
var acc = PlayerDB.GetAccountById(pid);
if (acc != null) authors[pid] = acc;
}
ulong viewerId = ViewerId();
var results = rows.Select(i =>
{
authors.TryGetValue(i.PlayerId, out var a);
return new
{
id = i.Id,
image = ImgUrl(i.ImageName),
description = i.Description,
cheers = i.CheerCount,
cheered = viewerId != 0 && ImageMetadataDB.HasUserCheered(i.Id, viewerId),
comments = i.CommentCount,
roomId = i.RoomId,
createdAt = i.CreatedAt,
author = a == null ? null : new
{
id = a.Id,
username = a.Username,
display_name = a.DisplayName,
profile_image = ImgUrl(a.profileImage),
},
};
}).ToList();
return Ok(new { ok = true, results });
}
catch (Exception ex)
{
Console.WriteLine($"[site] recent photos failed: {ex.Message}");
return StatusCode(500, new { ok = false, error = "could not load photos" });
}
}
// True when a and b are friends (a's relationship list contains b as a Friend).
private bool AreFriends(ulong a, ulong b)
{
if (a == 0 || b == 0 || a == b) return false;
var rels = FriendsDB.GetRelationships(a);
return rels != null && rels.Any(r => r.PlayerID == b && r.RelationshipType == RelationshipType.Friend);
}
// The website "visibility" of a photo, projected from the two stored fields.
private static string VisibilityOf(ImageMetadataDB.FullSavedImage img) =>
img.FriendsOnly ? "friends"
: (img.Accessibility == ImageMetadataDB.ImageAccessibility.Public ? "public" : "private");
// Resolve a list of tagged account ids to display rows (skips ids with no account).
private List<object> ResolveTagged(List<ulong>? ids)
{
var outp = new List<object>();
if (ids == null) return outp;
foreach (var pid in ids.Distinct())
{
var acc = PlayerDB.GetAccountById(pid);
if (acc == null) continue;
outp.Add(new
{
id = acc.Id,
username = acc.Username,
display_name = acc.DisplayName,
profile_image = ImgUrl(acc.profileImage),
});
}
return outp;
}
// GET /api/site/photos/{id} - one photo, for the image detail page (/image/{id}).
[HttpGet("/api/site/photos/{id:int}")]
public IActionResult Photo(int id)
{
try
{
var img = ImageMetadataDB.GetById(id);
if (img == null)
return NotFound(new { ok = false, error = "photo not found" });
ulong viewer = ViewerId();
bool isOwner = viewer != 0 && viewer == img.PlayerId;
bool isPublic = img.Accessibility == ImageMetadataDB.ImageAccessibility.Public && !img.FriendsOnly;
bool canView = isOwner || isPublic
|| (img.FriendsOnly && AreFriends(img.PlayerId, viewer));
if (!canView)
return StatusCode(403, new { ok = false, error = "This photo is private." });
var a = PlayerDB.GetAccountById(img.PlayerId);
// the room it was taken in, for the "^Room" link (dorms are personal - not linked)
RoomDBClasses.RoomRoot? photoRoom = null;
try { photoRoom = img.RoomId > 0 ? RoomDB.GetRoom((ulong)img.RoomId) : null; } catch { }
if (photoRoom != null && photoRoom.IsDorm) photoRoom = null;
return Ok(new
{
ok = true,
photo = new
{
id = img.Id,
image = ImgUrl(img.ImageName),
description = img.Description,
cheers = img.CheerCount,
cheered = viewer != 0 && ImageMetadataDB.HasUserCheered(img.Id, viewer),
comments = img.CommentCount,
commentList = CommentRows(ImageMetadataDB.GetComments(img.Id)),
roomId = img.RoomId,
room = photoRoom == null ? null : new { id = photoRoom.RoomId, name = photoRoom.Name },
createdAt = img.CreatedAt,
isOwner,
visibility = VisibilityOf(img),
taggedPlayers = ResolveTagged(img.TaggedPlayerIds),
author = a == null ? null : new
{
id = a.Id,
username = a.Username,
display_name = a.DisplayName,
profile_image = ImgUrl(a.profileImage),
},
},
});
}
catch (Exception ex)
{
Console.WriteLine($"[site] photo {id} failed: {ex.Message}");
return StatusCode(500, new { ok = false, error = "could not load the photo" });
}
}
public class PhotoVisibilityBody { public string? visibility { get; set; } }
public class PhotoTextBody { public string? text { get; set; } public string? description { get; set; } }
private List<object> CommentRows(List<ImageMetadataDB.ImageComment> list)
{
var outp = new List<object>();
foreach (var c in list)
{
var acc = PlayerDB.GetAccountById(c.PlayerId);
outp.Add(new
{
id = c.Id,
playerId = c.PlayerId,
username = acc?.Username ?? $"Player{c.PlayerId}",
display_name = acc?.DisplayName ?? acc?.Username ?? $"Player{c.PlayerId}",
profile_image = ImgUrl(acc?.profileImage),
text = c.Text,
createdAt = c.CreatedAt,
});
}
return outp;
}
private static bool CanSeePhoto(ImageMetadataDB.FullSavedImage img, ulong viewer, Func<ulong, ulong, bool> friends)
=> (viewer != 0 && viewer == img.PlayerId)
|| (img.Accessibility == ImageMetadataDB.ImageAccessibility.Public && !img.FriendsOnly)
|| (img.FriendsOnly && friends(img.PlayerId, viewer));
// GET /api/site/me/photos?taken=1&tagged=1 - every camera photo of mine (private ones
// included - it's my own export), for Settings > Export > Export Photos. Only the list:
// the browser fetches each /img/ file itself and builds the zip client-side, so nothing
// is assembled or stored on the server.
[HttpGet("/api/site/me/photos")]
public IActionResult MyPhotos([FromQuery] bool taken = true, [FromQuery] bool tagged = true)
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
var rows = (ImageMetadataDB.GetPlayerFeed(me.Id, me.Id, 0, 5000) ?? new())
.Where(i => i.SavedImageType == SavedImageTypeEnum.ShareCamera || i.SavedImageType == SavedImageTypeEnum.None)
.Select(i => new
{
id = i.SavedImageId,
image = ImgUrl(i.ImageName),
file = i.ImageName,
description = i.Description,
createdAt = i.CreatedAt,
taken = i.PlayerId == me.Id,
tagged = i.TaggedPlayerIds != null && i.TaggedPlayerIds.Contains(me.Id),
})
.Where(x => (taken && x.taken) || (tagged && x.tagged))
.ToList();
return Ok(new { ok = true, photos = rows });
}
// ---- reports ----------------------------------------------------------------------
//
// POST /api/site/report {type: player|photo|comment, id, photoId?, reason, details}
// The site's Report buttons. Lands in the SAME queue as in-game reports (ReportsDB), so
// the admin panel's Reports tab shows both and the same ban / warn / dismiss actions
// apply. The reported player is resolved server-side from what was reported, never
// trusted from the body. Anonymous to the reported player; the reporter is recorded.
public class SiteReportBody
{
public string? type { get; set; }
public string? id { get; set; }
public int? photoId { get; set; }
public string? reason { get; set; }
public string? details { get; set; }
}
private static readonly Dictionary<ulong, List<DateTime>> _reportRate = new();
private static readonly string[] ReportReasons = { "Spam or scam", "Harassment or bullying", "Inappropriate content", "Hate speech", "Impersonation", "Other" };
[HttpPost("/api/site/report")]
public IActionResult SiteReport([FromBody] SiteReportBody? body)
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "Log in to send a report." });
string type = (body?.type ?? "").Trim().ToLowerInvariant();
string reason = (body?.reason ?? "").Trim();
if (!ReportReasons.Contains(reason)) return BadRequest(new { ok = false, error = "Pick a reason." });
string details = (body?.details ?? "").Trim();
if (details.Length > 300) details = details[..300];
// 5 reports per 10 minutes per account - same limit the page enforces, but here
// so it can't be skipped.
lock (_reportRate)
{
if (!_reportRate.TryGetValue(me.Id, out var times)) _reportRate[me.Id] = times = new();
times.RemoveAll(t => t < DateTime.UtcNow.AddMinutes(-10));
if (times.Count >= 5) return StatusCode(429, new { ok = false, error = "You've sent too many reports. Please try again later." });
times.Add(DateTime.UtcNow);
}
ulong reported = 0; long subjectId = 0; string link = ""; string subjectText = "";
switch (type)
{
case "player":
{
string handle = (body?.id ?? "").Trim().TrimStart('@');
reported = ulong.TryParse(handle, out var pid) ? pid : PlayerDB.GetAccountIdByUsernameCI(handle);
var acc = reported != 0 ? PlayerDB.GetAccountById(reported) : null;
if (acc == null) return NotFound(new { ok = false, error = "player not found" });
subjectId = (long)reported; link = "/user/" + Uri.EscapeDataString(acc.Username ?? handle);
subjectText = "profile @" + acc.Username;
break;
}
case "photo":
{
if (!int.TryParse(body?.id, out var imgId)) return BadRequest(new { ok = false, error = "bad photo id" });
var img = ImageMetadataDB.GetById(imgId);
if (img == null) return NotFound(new { ok = false, error = "photo not found" });
reported = img.PlayerId; subjectId = imgId; link = "/image/" + imgId;
subjectText = "photo #" + imgId + (string.IsNullOrWhiteSpace(img.Description) ? "" : " \"" + img.Description + "\"");
break;
}
case "comment":
{
if (!int.TryParse(body?.id, out var cid)) return BadRequest(new { ok = false, error = "bad comment id" });
var c = ImageMetadataDB.GetComment(cid);
if (c == null) return NotFound(new { ok = false, error = "comment not found" });
reported = c.PlayerId; subjectId = cid; link = "/image/" + c.SavedImageId;
subjectText = "comment on photo #" + c.SavedImageId + ": \"" + c.Text + "\"";
break;
}
default:
return BadRequest(new { ok = false, error = "bad report type" });
}
if (reported == me.Id) return BadRequest(new { ok = false, error = "You can't report yourself." });
// one open report per reporter per thing - a second click doesn't stack the queue
if (ReportsDB.HasOpenDuplicate(me.Id, type, subjectId, reported))
return Ok(new { ok = true, duplicate = true });
var category = reason switch
{
"Harassment or bullying" => Moderation_Detail.ReportCategory.Harassment,
"Hate speech" => Moderation_Detail.ReportCategory.CoC_Discrimination,
"Inappropriate content" => Moderation_Detail.ReportCategory.CoC_Trolling,
"Impersonation" => Moderation_Detail.ReportCategory.CoC_NameOrProfile,
_ => Moderation_Detail.ReportCategory.Misc,
};
var r = ReportsDB.Create(new ReportsDB.Report
{
ReporterPlayerId = me.Id,
ReportedPlayerId = reported,
ReportCategory = (int)category,
Details = (subjectText + (details.Length > 0 ? "\n\n" + details : "")).Trim(),
Source = "website",
Subject = type,
SubjectId = subjectId,
SubjectLink = link,
Reason = reason,
});
return Ok(new { ok = true, id = r.Id });
}
// POST /api/site/photos/{id}/cheer -> toggles the SAME cheer the game records
// (ImageMetadataDB cheers), so a cheer on the site shows in-game and vice versa.
[HttpPost("/api/site/photos/{id:int}/cheer")]
public IActionResult CheerPhoto(int id)
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
var img = ImageMetadataDB.GetById(id);
if (img == null) return NotFound(new { ok = false, error = "photo not found" });
if (!CanSeePhoto(img, me.Id, AreFriends)) return StatusCode(403, new { ok = false, error = "This photo is private." });
bool now = ImageMetadataDB.ToggleCheer(id, me.Id, !ImageMetadataDB.HasUserCheered(id, me.Id));
int count = ImageMetadataDB.GetCheerCount(id);
ImageMetadataDB.UpdateImageCheerCount(id, count);
return Ok(new { ok = true, cheered = now, cheers = count });
}
// GET /api/site/photos/{id}/comments ; POST {text} ; DELETE /comments/{commentId}
[HttpGet("/api/site/photos/{id:int}/comments")]
public IActionResult PhotoComments(int id)
{
var img = ImageMetadataDB.GetById(id);
if (img == null) return NotFound(new { ok = false, error = "photo not found" });
if (!CanSeePhoto(img, ViewerId(), AreFriends)) return StatusCode(403, new { ok = false, error = "This photo is private." });
return Ok(new { ok = true, comments = CommentRows(ImageMetadataDB.GetComments(id)) });
}
[HttpPost("/api/site/photos/{id:int}/comments")]
public IActionResult AddPhotoComment(int id, [FromBody] PhotoTextBody? body)
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
var img = ImageMetadataDB.GetById(id);
if (img == null) return NotFound(new { ok = false, error = "photo not found" });
if (!CanSeePhoto(img, me.Id, AreFriends)) return StatusCode(403, new { ok = false, error = "This photo is private." });
string text = (body?.text ?? "").Trim();
if (text.Length == 0) return BadRequest(new { ok = false, error = "Say something first." });
if (text.Length > 200) text = text[..200];
text = Campus_live_rewrite.api.server.Classes.Sanitize.SanitizeMessage(text);
var c = ImageMetadataDB.AddComment(id, me.Id, text);
if (c == null) return NotFound(new { ok = false, error = "photo not found" });
Console.WriteLine($"[site] {me.Id} commented on photo {id}");
return Ok(new { ok = true, comment = CommentRows(new List<ImageMetadataDB.ImageComment> { c })[0], comments = ImageMetadataDB.GetById(id)?.CommentCount ?? 0 });
}
[HttpDelete("/api/site/photos/{id:int}/comments/{commentId:int}")]
[HttpPost("/api/site/photos/{id:int}/comments/{commentId:int}/delete")]
public IActionResult DeletePhotoComment(int id, int commentId)
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
if (!ImageMetadataDB.DeleteComment(commentId, me.Id)) return StatusCode(403, new { ok = false, error = "not your comment" });
return Ok(new { ok = true, comments = ImageMetadataDB.GetById(id)?.CommentCount ?? 0 });
}
// POST /api/site/photos/{id}/description {description} - the photo's title/caption,
// the same field the game shows (modifydescription).
[HttpPost("/api/site/photos/{id:int}/description")]
public IActionResult SetPhotoDescription(int id, [FromBody] PhotoTextBody? body)
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
var img = ImageMetadataDB.GetById(id);
if (img == null) return NotFound(new { ok = false, error = "photo not found" });
if (img.PlayerId != me.Id && !PlayerDB.CheckDevFlag(me.Id)) return StatusCode(403, new { ok = false, error = "not your photo" });
string text = (body?.description ?? body?.text ?? "").Trim();
if (text.Length > 120) text = text[..120];
text = Campus_live_rewrite.api.server.Classes.Sanitize.SanitizeMessage(text);
ImageMetadataDB.SetDescription(id, text);
return Ok(new { ok = true, description = text });
}
public class BannerBody { public int photoId { get; set; } public bool reset { get; set; } }
// POST /api/site/me/banner {photoId} | {reset:true} - use one of your (or a public) photos
// as your profile banner; reset puts the starting banner.png back. Pushed to the game.
[HttpPost("/api/site/me/banner")]
public IActionResult SetMyBanner([FromBody] BannerBody? body)
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
string name;
if (body == null || body.reset || body.photoId <= 0) name = ConfigCampus.DefaultBannerImage;
else
{
var img = ImageMetadataDB.GetById(body.photoId);
if (img == null) return NotFound(new { ok = false, error = "photo not found" });
if (!CanSeePhoto(img, me.Id, AreFriends)) return StatusCode(403, new { ok = false, error = "This photo is private." });
name = img.ImageName;
}
PlayerDB.SetPlayerBanner(name, me.Id);
Notifications.FireAndForget(Notifications.PushAccountUpdate(me.Id), "site banner refresh");
return Ok(new { ok = true, banner_image = ImgUrl(name) });
}
// DELETE /api/site/photos/{id} (owner or staff) - the same delete the game does.
[HttpDelete("/api/site/photos/{id:int}")]
[HttpPost("/api/site/photos/{id:int}/delete")]
public IActionResult DeletePhoto(int id)
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
var img = ImageMetadataDB.GetById(id);
if (img == null) return NotFound(new { ok = false, error = "photo not found" });
if (img.PlayerId != me.Id && !PlayerDB.CheckDevFlag(me.Id)) return StatusCode(403, new { ok = false, error = "not your photo" });
ImageMetadataDB.DeleteImage(id);
Console.WriteLine($"[site] {me.Id} deleted photo {id}");
return Ok(new { ok = true });
}
public class PhotoUntagBody { public ulong playerId { get; set; } }
// POST /api/site/photos/{id}/visibility {visibility:"public"|"friends"|"private"}
// Owner-only. public/private map straight to the in-game Accessibility (0/1); "friends"
// stores Private + FriendsOnly so it never leaks in-game but shows to friends on the site.
[HttpPost("/api/site/photos/{id:int}/visibility")]
public IActionResult SetPhotoVisibility(int id, [FromBody] PhotoVisibilityBody? body)
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
var img = ImageMetadataDB.GetById(id);
if (img == null) return NotFound(new { ok = false, error = "photo not found" });
if (img.PlayerId != me.Id) return StatusCode(403, new { ok = false, error = "not your photo" });
ImageMetadataDB.ImageAccessibility acc;
bool friendsOnly;
switch ((body?.visibility ?? "").Trim().ToLowerInvariant())
{
case "public": acc = ImageMetadataDB.ImageAccessibility.Public; friendsOnly = false; break;
case "friends": acc = ImageMetadataDB.ImageAccessibility.Private; friendsOnly = true; break;
case "private": acc = ImageMetadataDB.ImageAccessibility.Private; friendsOnly = false; break;
default: return BadRequest(new { ok = false, error = "visibility must be public, friends or private" });
}
ImageMetadataDB.SetVisibility(id, acc, friendsOnly);
Console.WriteLine($"[site] photo {id} visibility -> {body!.visibility} by {me.Id}");
return Ok(new { ok = true, visibility = friendsOnly ? "friends" : (acc == ImageMetadataDB.ImageAccessibility.Public ? "public" : "private") });
}
// POST /api/site/photos/{id}/untag {playerId}
// Owner-only: remove a tagged player from the photo. Returns the updated tag list.
[HttpPost("/api/site/photos/{id:int}/untag")]
public IActionResult UntagPhoto(int id, [FromBody] PhotoUntagBody? body)
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
var img = ImageMetadataDB.GetById(id);
if (img == null) return NotFound(new { ok = false, error = "photo not found" });
if (img.PlayerId != me.Id) return StatusCode(403, new { ok = false, error = "not your photo" });
if (body == null || body.playerId == 0) return BadRequest(new { ok = false, error = "playerId is required" });
var updated = ImageMetadataDB.RemoveTag(id, body.playerId);
Console.WriteLine($"[site] photo {id} untag {body.playerId} by {me.Id}");
return Ok(new { ok = true, taggedPlayers = ResolveTagged(updated) });
}
// ---- presence ---------------------------------------------------------------------
// GET /api/site/online
// Who is actually in-game right now, and where. Replaces data.js `onlineFriends`.
// Public (the site already has an "online players" surface), so it exposes only
// display name, avatar and room NAME - never room ids or instance data.
[HttpGet("/api/site/online")]
public IActionResult Online(int take = 20)
{
take = Math.Clamp(take, 1, 100);
try
{
var results = new List<object>();
foreach (var (playerId, _) in CampusLiveSource.Controllers.NotificationsController.WebSockets)
{
if (results.Count >= take) break;
var acc = PlayerDB.GetAccountById(playerId);
if (acc == null) continue;
string where = HeartbeatDB.GetPlayerCurrentRoomName(playerId);
results.Add(new
{
id = acc.Id,
username = acc.Username,
display_name = acc.DisplayName,
profile_image = ImgUrl(acc.profileImage),
// The DB sentinel is the literal string "not in a room." - map it to
// something a person would read rather than showing it verbatim.
where = string.IsNullOrWhiteSpace(where) || where == "not in a room."
? "Online now"
: where.TrimStart('^', '@'),
});
}
return Ok(new { ok = true, results, total = CampusLiveSource.Controllers.NotificationsController.WebSockets.Count });
}
catch (Exception ex)
{
Console.WriteLine($"[site] online list failed: {ex.Message}");
return StatusCode(500, new { ok = false, error = "could not load online players" });
}
}
// GET /api/site/friends - the signed-in user's friends, with live presence.
// Replaces the localStorage-only friends list, which was per-browser and invisible
// to the game.
// GET /api/site/stats - the signed-in creator's numbers: room visits, what sold, tokens
// pending and earned, per item (with its picture). Drives the website's Stats page.
[HttpGet("/api/site/stats")]
public IActionResult SiteStats()
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
try
{
var rooms = RoomDB.GetRoomsByCreator(me.Id).Where(r => !r.IsDorm).ToList();
long visits = rooms.Sum(r => (long)(r.Stats?.VisitCount ?? 0));
long cheers = rooms.Sum(r => (long)(r.Stats?.CheerCount ?? 0));
var inventions = InventionDB.GetByCreator(me.Id);
var (pending, paid, next) = EarningsDB.Summary(me.Id);
var sales = EarningsDB.ForPlayer(me.Id, 5000);
string? Image(string kind, string itemId)
{
try
{
switch (kind)
{
case "invention": return int.TryParse(itemId, out var iid) ? ImgUrl(InventionDB.GetById(iid)?.ImageName) : null;
case "ugc": return ImgUrl((string?)CustomItemsCache.Get(itemId)?["ImageName"]);
case "roomkey": return long.TryParse(itemId, out var kid) ? ImgUrl(RoomEconDB.GetKey(kid)?.ImageName) : null;
case "roomcurrency": return Guid.TryParse(itemId, out var cid) ? ImgUrl(RoomEconDB.GetCurrency(cid)?.ImageName) : null;
case "roomconsumable": return Guid.TryParse(itemId, out var xid) ? ImgUrl(RoomEconDB.GetConsumable(xid)?.ImageName) : null;
}
}
catch { }
return null;
}
var items = sales.GroupBy(e => (e.Kind, e.ItemId)).Select(g => new
{
kind = g.Key.Kind,
itemId = g.Key.ItemId,
name = g.OrderByDescending(e => e.BoughtAt).First().ItemName,
image = Image(g.Key.Kind, g.Key.ItemId),
sold = g.Count(),
revenue = g.Sum(e => e.Spent),
pending = g.Where(e => e.PaidAt == null).Sum(e => e.Share),
earned = g.Where(e => e.PaidAt != null).Sum(e => e.Share),
lastSold = g.Max(e => e.BoughtAt).ToString("o"),
nextPayout = g.Where(e => e.PaidAt == null).Select(e => (DateTime?)e.PayoutAt).Min()?.ToString("o")
}).OrderByDescending(x => x.sold).ThenByDescending(x => x.lastSold).ToList();
return Ok(new
{
ok = true,
username = me.Username,
tokens = PlayerDB.GetBalance(me.Id, (int)Campus_live_rewrite.api.server.Classes.CurrencyType.RecCenterTokens),
level = Campus_live_rewrite.api.server.Classes.Progress.GetPlayerLevel(me.Id)?.Level ?? 1,
rooms = rooms.Count,
roomVisits = visits,
roomCheers = cheers,
inventions = inventions.Count,
inventionsPublished = inventions.Count(i => i.IsPublished),
inventionsBought = inventions.Sum(i => (long)i.NumDownloads),
customItems = CustomItemsCache.ByCreatorRecs(me.Id).Count,
keys = rooms.Sum(r => RoomEconDB.KeysForRoom((long)r.RoomId).Count),
currencies = rooms.Sum(r => RoomEconDB.CurrenciesForRoom((long)r.RoomId).Count),
consumables = rooms.Sum(r => RoomEconDB.ConsumablesForRoom((long)r.RoomId).Count),
salesCount = sales.Count,
revenue = sales.Sum(e => e.Spent),
tokensPending = pending,
tokensEarned = paid,
nextPayout = next?.ToString("o"),
percent = ConfigCampus.CreatorEarningsPercent,
pendDays = ConfigCampus.CreatorEarningsPendDays,
// Per-day totals for the earnings graph (share = your cut, spent = what buyers paid).
series = sales.GroupBy(e => e.BoughtAt.ToString("yyyy-MM-dd"))
.Select(g => new { d = g.Key, share = g.Sum(e => e.Share), spent = g.Sum(e => e.Spent), n = g.Count() })
.OrderBy(x => x.d).ToList(),
items,
recent = sales.Take(50).Select(e => new
{
e.Kind, item = e.ItemName, e.Spent, e.Share, buyer = PlayerDB.GetAccountById(e.BuyerId)?.Username ?? e.BuyerId.ToString(),
at = e.BoughtAt.ToString("o"), payoutAt = e.PayoutAt.ToString("o"), paid = e.PaidAt != null
})
});
}
catch (Exception ex)
{
Console.WriteLine($"[site] stats failed: {ex.Message}");
return StatusCode(500, new { ok = false, error = "could not load stats" });
}
}
[HttpGet("/api/site/friends")]
public IActionResult Friends()
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
try
{
var rels = FriendsDB.GetRelationships(me.Id) ?? new List<RelationshipDetail>();
var online = CampusLiveSource.Controllers.NotificationsController.WebSockets.Keys.ToHashSet();
var results = new List<object>();
foreach (var r in rels)
{
// GetRelationships already projects from the caller's point of view, so
// PlayerID is the OTHER player here.
ulong other = r.PlayerID;
var acc = PlayerDB.GetAccountById(other);
if (acc == null) continue;
bool isOnline = online.Contains(other);
results.Add(new
{
id = acc.Id,
username = acc.Username,
display_name = acc.DisplayName,
profile_image = ImgUrl(acc.profileImage),
status = r.RelationshipType.ToString(),
favourite = r.Favorited != ReciprocalStatus.None,
online = isOnline,
where = isOnline ? HeartbeatDB.GetPlayerCurrentRoomName(other).TrimStart('^', '@') : null,
spotify = SpotifyLink.CachedPayloadFor(other, me.Id),
});
}
return Ok(new { ok = true, results });
}
catch (Exception ex)
{
Console.WriteLine($"[site] friends failed: {ex.Message}");
return StatusCode(500, new { ok = false, error = "could not load friends" });
}
}
// ---- me ---------------------------------------------------------------------------
// POST /api/site/profile {display_name?, bio?}
// Settings page. These used to be written to localStorage only ('campus-mynick',
// 'campus-mybio'), so they never reached the game or another browser.
[HttpPost("/api/site/profile")]
public IActionResult UpdateProfile([FromBody] ProfileEdit? body)
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
if (body == null) return BadRequest(new { ok = false, error = "nothing to update" });
bool changed = false;
// Name lock (admin panel): neither name may change while it is on. Checked before
// anything is written so a locked player can still edit their bio.
bool nameLocked = PlayerDB.IsNameLocked(me.Id);
if (nameLocked && body.username != null && !string.Equals(body.username.Trim(), me.Username, StringComparison.OrdinalIgnoreCase))
return BadRequest(new { ok = false, error = "Your name has been locked by a moderator and cannot be changed." });
if (nameLocked && body.display_name != null && body.display_name.Trim() != (me.DisplayName ?? ""))
return BadRequest(new { ok = false, error = "Your name has been locked by a moderator and cannot be changed." });
// @ name (username). Unique, so this can fail; check it FIRST before writing anything.
if (body.username != null)
{
string un = body.username.Trim();
if (!string.Equals(un, me.Username, StringComparison.OrdinalIgnoreCase))
{
if (un.Length is < 3 or > 24)
return BadRequest(new { ok = false, error = "@ name must be 3-24 characters." });
if (!System.Text.RegularExpressions.Regex.IsMatch(un, "^[A-Za-z0-9_.]+$"))
return BadRequest(new { ok = false, error = "@ name can only use letters, numbers, _ and ." });
if (!PlayerDB.SetPlayerUsername(un, me.Id))
return BadRequest(new { ok = false, error = "That @ name is already taken." });
changed = true;
}
}
if (body.display_name != null)
{
string dn = body.display_name.Trim();
if (dn.Length is < 1 or > 24)
return BadRequest(new { ok = false, error = "Display name must be 1-24 characters." });
PlayerDB.SetDisplayName(dn, me.Id);
changed = true;
}
if (body.bio != null)
{
string bio = body.bio.Trim();
if (bio.Length > 300)
return BadRequest(new { ok = false, error = "Bio must be 300 characters or fewer." });
PlayerDB.SetPlayerBio(bio, me.Id);
changed = true;
}
// Push the change to the game (and anyone viewing this profile) so a new @name /
// display name shows without a relog. AccountUpdate carries name fields; bio isn't in
// that DTO, so a bio change still persists and shows on the next profile open in-game.
if (changed)
Notifications.FireAndForget(Notifications.PushAccountUpdate(me.Id), "site profile edit");
var full = PlayerDB.GetFullAccount(me.Id);
return Ok(new
{
ok = true,
username = full?.player?.Username,
display_name = full?.player?.DisplayName,
bio = full?.player_Extra?.Bio ?? "",
});
}
public class ProfileEdit
{
public string? username { get; set; }
public string? display_name { get; set; }
public string? bio { get; set; }
}
}
}
@@ -0,0 +1,987 @@
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server;
using Campus_live_rewrite.api.server.Classes.db_class;
namespace Campus_live_rewrite.Controllers
{
// Public website backend.
//
// Replaces Website/server.py (Flask). That process held a token.json with a bearer token
// + HMAC signing key and made HTTP round trips BACK to gameauth.ddns.net to answer the
// site's own search calls. Running in-process removes the extra hop, the second listener,
// and the on-disk credential file entirely: searches read PlayerDB/RoomDB directly and
// images are already served by ImagesController at /img/.
//
// Paths and response shapes are kept byte-compatible with what app.js already fetches
// ({ ok, results } / { ok, error }), so the front-end change is limited to enabling the
// calls rather than rewriting them.
//
// AUTH MODEL - deliberately separate from the game and admin surfaces:
// - cookie name is `campus_site`, NOT `Authorization`. AuthHelper.ValidateToken falls
// back to an `Authorization` cookie for the admin panel; reusing that name would hand
// every website visitor the panel's cookie fallback on game endpoints.
// - the cookie is HttpOnly, so page script cannot read the token.
// - 12h lifetime, matching the game client's access-token window.
[ApiController]
public class SiteController : ControllerBase
{
private const string SiteCookie = "campus_site";
private static readonly TimeSpan SiteSessionTtl = TimeSpan.FromHours(12);
// ---- session helpers --------------------------------------------------------------
private void IssueSiteCookie(ulong accountId)
{
string jwt = ClientSecurity.RefreshToken_Web(accountId, SiteSessionTtl, "website");
Response.Cookies.Append(SiteCookie, jwt, new CookieOptions
{
// Expire the cookie with the token rather than outliving it, so the browser
// stops sending a credential the server will only reject.
Expires = DateTimeOffset.UtcNow.Add(SiteSessionTtl),
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Lax,
Path = "/"
});
}
private void ClearSiteCookie()
{
Response.Cookies.Append(SiteCookie, "", new CookieOptions
{
Expires = DateTimeOffset.UnixEpoch,
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Lax,
Path = "/"
});
}
// Returns the signed-in account, or null. DecodeToken re-reads the account from the DB
// on every call, so a ban or a rename applies immediately instead of at token refresh.
private player_info? CurrentUser()
{
string token = Request.Cookies[SiteCookie] ?? "";
if (string.IsNullOrEmpty(token)) return null;
// ONLY website-minted tokens are valid website sessions. A game or admin token (or a
// legacy no-surface one) is refused here, so in-game / admin auth can't act as a
// website session - the auth surfaces are kept separate.
string playerData = ClientSecurity.DecodeToken(token, requireSurface: "website");
if (string.IsNullOrEmpty(playerData) || playerData.StartsWith("error_")) return null;
try { return JsonConvert.DeserializeObject<player_info>(playerData); }
catch { return null; }
}
private object PublicUser(player_data full) => new
{
id = full.playerid,
username = full.player?.Username,
display_name = full.player?.DisplayName,
profile_image = full.player?.profileImage,
banner_image = full.player?.bannerImage,
bio = full.player_Extra?.Bio,
level = full.player?.Level ?? 0,
xp = full.player?.XP ?? 0,
// RecCenterTokens (currency type 2) - the same "Tokens" the game shows. Only on the
// authed /me response, never the public profile, so a balance isn't exposed publicly.
tokens = PlayerDB.GetBalance(full.playerid, 2),
banned = PlayerDB.HasActiveModerationBlock(full.playerid),
};
// ---- accounts ---------------------------------------------------------------------
// POST /api/site/login {username, password}
[HttpPost("/api/site/login")]
public IActionResult Login([FromBody] SiteCreds? body)
{
if (body == null || string.IsNullOrWhiteSpace(body.username) || string.IsNullOrWhiteSpace(body.password))
return BadRequest(new { ok = false, error = "Enter a username and password." });
// Same helper the launcher and admin panel use, so password upgrades (legacy SHA256
// rows -> PBKDF2) and lockout behaviour stay in ONE place.
var result = PlayerDB.GetAccountPassword(body.username.Trim(), body.password, isAdminPanel: false);
if (!result.success)
return Unauthorized(new { ok = false, error = result.error ?? "Incorrect username or password." });
// A banned account must not get a website session either - otherwise the ban only
// covers the game client.
if (PlayerDB.HasActiveModerationBlock(result.accid))
return StatusCode(403, new { ok = false, error = "This account is currently suspended." });
IssueSiteCookie(result.accid);
var full = PlayerDB.GetFullAccount(result.accid);
return Ok(new { ok = true, user = full == null ? null : PublicUser(full) });
}
// POST /api/site/register
//
// Campus accounts are now MADE here, on the website (like a normal web account), and the
// game only logs in to them. The account carries no Steam binding at creation; the Steam
// id is stamped on the first in-game login (AccountLock), which is where the one-account-
// per-Steam / per-device rule is enforced. Protections against mass creation:
// - layered signup caps: per IP per day/week, per /24 per day, and a GLOBAL per-hour cap
// (ConfigCampus.SignupsPer*; see PlayerDB.CheckSignupLimit),
// - username must be unique (case-insensitive) and match a strict charset,
// - reserved names blocked, a hidden honeypot field traps naive bots,
// - password confirmation checked server-side.
[HttpPost("/api/site/register")]
public IActionResult Register([FromBody] SiteRegister? body)
{
if (body == null)
return BadRequest(new { ok = false, error = "Missing sign-up details." });
// Honeypot: a real form leaves `website` empty (it is hidden). A bot that fills every
// field trips it. Generic message so it learns nothing.
if (!string.IsNullOrWhiteSpace(body.website))
{
Console.WriteLine("[web-signup] honeypot tripped - refused");
return BadRequest(new { ok = false, error = "Could not create account." });
}
string username = (body.username ?? "").Trim();
string pass = body.password ?? "";
string confirm = body.confirm_password ?? "";
string email = (body.email ?? "").Trim();
// ---- date of birth (required) ----
// One username field only now, so the @name IS the display name. But first: age gates
// everything. Under 13 -> we IGNORE the chosen name, hand out a random one (privacy), and
// flag the account junior (chat/social restricted client-side).
if (string.IsNullOrWhiteSpace(body.birthday) ||
!DateTime.TryParse(body.birthday, System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal,
out var dob))
return BadRequest(new { ok = false, error = "Enter your date of birth." });
var today = DateTime.UtcNow.Date;
if (dob.Date > today)
return BadRequest(new { ok = false, error = "Enter a valid date of birth." });
int age = today.Year - dob.Year;
if (dob.Date > today.AddYears(-age)) age--; // not had this year's birthday yet
if (age > 120)
return BadRequest(new { ok = false, error = "Enter a valid date of birth." });
bool isJunior = age < 13;
string birthdayIso = dob.ToString("yyyy-MM-ddT00:00:00.000Z");
// ---- username ----
// Juniors never choose an identifying name: we generate one (e.g. FunkyHippo1293) and
// skip the user's input entirely. Everyone else's single username is validated and used
// as BOTH the @name and the display name.
string display;
if (isJunior)
{
username = GenerateJuniorUsername();
display = username;
}
else
{
if (username.Length < 3 || username.Length > 20)
return BadRequest(new { ok = false, error = "Username must be 3-20 characters." });
if (!System.Text.RegularExpressions.Regex.IsMatch(username, "^[A-Za-z0-9_]+$"))
return BadRequest(new { ok = false, error = "Username can only use letters, numbers and underscores." });
if (IsReservedName(username))
return BadRequest(new { ok = false, error = "That username isn't available." });
display = username; // one field: the @name is the display name
}
// ---- password ----
if (pass.Length < 6 || pass.Length > 64)
return BadRequest(new { ok = false, error = "Password must be 6-64 characters." });
if (pass != confirm)
return BadRequest(new { ok = false, error = "The passwords don't match." });
// ---- email (optional) ----
if (email.Length > 0)
{
if (email.Length > 254 ||
!System.Text.RegularExpressions.Regex.IsMatch(email, @"^[^@\s]+@[^@\s]+\.[^@\s]+$"))
return BadRequest(new { ok = false, error = "Enter a valid email, or leave it blank." });
}
// ---- IP rate limit ----
// The owner's own accounts (Coach/Kyle), when signed in on the site, may make test
// accounts without waiting out the week.
string? ip = ClientIp();
var signedIn = CurrentUser();
bool ownerBypass = signedIn != null &&
Campus_live_rewrite.api.server.AccountLock.IsExempt(signedIn.Id);
if (!ownerBypass)
{
var limit = PlayerDB.CheckSignupLimit(ip, out DateTime nextAllowed);
if (limit != PlayerDB.SignupLimit.None)
{
var wait = nextAllowed - DateTime.UtcNow;
string when = wait.TotalHours < 1 ? "in a few minutes"
: wait.TotalHours < 36 ? "in about " + Math.Max(1, (int)Math.Ceiling(wait.TotalHours)) + " hours"
: "in about " + (int)Math.Ceiling(wait.TotalDays) + " days";
Console.WriteLine("[web-signup] refused (" + limit + ") IP " + ip + " - next allowed " + nextAllowed.ToString("u"));
string msg = limit == PlayerDB.SignupLimit.Global
? "Lots of accounts are being created right now. Please try again " + when + "."
: "Too many accounts have been created from your network recently. Please try again " + when + ".";
return StatusCode(429, new { ok = false, error = msg });
}
}
// ---- uniqueness (re-checked inside CreateWebAccount too, this gives a clean 409) ----
if (PlayerDB.IsUsernameTaken(username))
return Conflict(new { ok = false, error = "That username is already taken." });
try
{
if (!PlayerDB.CreateWebAccount(username, display, pass,
email.Length == 0 ? null : email, out var acc, out string createErr,
isJunior: isJunior, birthday: birthdayIso))
{
return BadRequest(new { ok = false, error = string.IsNullOrEmpty(createErr) ? "Could not create account." : createErr });
}
PlayerDB.RecordSignup(ip, acc.accountId);
Console.WriteLine($"[web-signup] account {acc.accountId} created from IP {ip}");
IssueSiteCookie(acc.accountId);
var full = PlayerDB.GetFullAccount(acc.accountId);
return Ok(new { ok = true, user = full == null ? null : PublicUser(full) });
}
catch (Exception ex)
{
Console.WriteLine($"[web-signup] failed: {ex}");
return StatusCode(500, new { ok = false, error = "Could not create account. Please try again." });
}
}
// Names nobody may register (impersonation / reserved). The owner's own accounts already
// exist, so IsUsernameTaken blocks re-registering them without needing them listed here.
private static readonly HashSet<string> _reserved = new(StringComparer.OrdinalIgnoreCase)
{
"admin", "administrator", "moderator", "mod", "campus", "system", "root",
"staff", "support", "null", "undefined", "everyone", "here", "recroom", "official"
};
private static bool IsReservedName(string name) => _reserved.Contains(name.Trim());
// A random, non-identifying @name for an under-13 (junior) account, e.g. "FunkyHippo1293"
// - Adjective + Noun (from the same word lists the in-game name generator uses) + a few
// digits. Loops until it finds one not already taken; the digits make a collision after a
// few tries astronomically unlikely, but the uniqueness check keeps it correct regardless.
private static readonly Random _rng = new();
private static string GenerateJuniorUsername()
{
var adjectives = Campus_live_rewrite.api.server.Utils.Adjectives;
var nouns = Campus_live_rewrite.api.server.Utils.Nouns;
for (int attempt = 0; attempt < 50; attempt++)
{
string adj, noun;
lock (_rng)
{
adj = adjectives[_rng.Next(adjectives.Count)];
noun = nouns[_rng.Next(nouns.Count)];
}
// Keep it to the same charset the site allows (letters/digits/underscore).
string baseName = System.Text.RegularExpressions.Regex.Replace(adj + noun, "[^A-Za-z0-9]", "");
if (baseName.Length == 0) baseName = "Camper";
if (baseName.Length > 14) baseName = baseName.Substring(0, 14);
int digits;
lock (_rng) digits = _rng.Next(1000, 10000);
string candidate = baseName + digits;
if (!PlayerDB.IsUsernameTaken(candidate)) return candidate;
}
// Extremely unlikely fallback: timestamp-based, guaranteed unique enough.
return "Camper" + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString().Substring(6);
}
// The real browser IP, for the signup rate limit. The app sits behind Caddy
// (`reverse_proxy localhost:8080`), so HttpContext.Connection.RemoteIpAddress is always
// 127.0.0.1 (Caddy) - which the rate limit treats as the exempt localhost, so every
// signup slipped through. Caddy APPENDS the true connecting IP as the LAST
// X-Forwarded-For entry; that entry is authoritative and cannot be spoofed by a client
// sending its own X-Forwarded-For / CF-Connecting-IP (Caddy passes those through but
// always adds the real peer after them), so we take the LAST entry. CF-Connecting-IP is
// deliberately NOT trusted here: there is no Cloudflare in front, so it would be a free
// spoof to rotate past the limit.
private string? ClientIp()
{
string xff = Request.Headers["X-Forwarded-For"].ToString();
if (!string.IsNullOrWhiteSpace(xff))
{
var parts = xff.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length > 0) return parts[parts.Length - 1];
}
return HttpContext.Connection.RemoteIpAddress?.ToString();
}
// POST /api/site/logout
// Clears this browser's session. The JWT itself stays valid until it expires - there is
// no server-side session store yet, so this cannot revoke a token already copied out.
[HttpPost("/api/site/logout")]
public IActionResult Logout()
{
ClearSiteCookie();
return Ok(new { ok = true });
}
// ---- Support tickets --------------------------------------------------------------
// A signed-in player opens a ticket (Support page bot -> "talk to a real person"); STAFF
// (dev / mod / owner) see every ticket and can reply / close / reopen. See SupportDB.
private static bool IsStaffFlag(DevFlag f) => (f & (DevFlag.dev | DevFlag.mod | DevFlag.owner | DevFlag.support)) != 0;
private bool IsStaff(ulong id) => IsStaffFlag(PlayerDB.GetFullAccount(id)?.dev_Flag ?? DevFlag.none);
public class SupportCreateReq { public string? subject { get; set; } public string? message { get; set; } }
public class SupportReplyReq { public string? message { get; set; } }
private static object TicketSummary(SupportDB.Ticket t) => new
{
id = t.Id,
subject = t.Subject,
status = t.Status,
username = t.Username,
accountId = t.AccountId,
createdAt = t.CreatedAt,
updatedAt = t.UpdatedAt,
messageCount = t.Messages?.Count ?? 0,
lastMessage = (t.Messages != null && t.Messages.Count > 0) ? t.Messages[^1].Body : ""
};
// Resolve an account's profile picture to a website URL (/img/<name>), or null.
// Pass through values that are already a URL or an absolute path (legacy accounts).
private static string? AvatarUrl(string? imageName) =>
string.IsNullOrEmpty(imageName) ? null
: (imageName.StartsWith("http", StringComparison.OrdinalIgnoreCase) || imageName.StartsWith("/")) ? imageName
: "/img/" + imageName;
private object TicketDetail(SupportDB.Ticket t, ulong viewerId)
{
// Cache lookups so a person who sent several messages is only fetched once.
var cache = new Dictionary<ulong, string?>();
string? avatarFor(ulong id)
{
if (!cache.TryGetValue(id, out var url))
{
url = AvatarUrl(PlayerDB.GetFullAccount(id)?.player?.profileImage);
cache[id] = url;
}
return url;
}
return new
{
id = t.Id,
subject = t.Subject,
status = t.Status,
username = t.Username,
accountId = t.AccountId,
avatar = avatarFor(t.AccountId),
createdAt = t.CreatedAt,
updatedAt = t.UpdatedAt,
messages = (t.Messages ?? new()).Select(m => new
{
fromName = m.FromName,
staff = m.Staff,
body = m.Body,
at = m.At,
mine = m.FromAccountId == viewerId,
avatar = avatarFor(m.FromAccountId)
})
};
}
// Does the caller get the staff panel? Also tells the page whether to show ticket UI at all.
[HttpGet("/api/site/support/role")]
public IActionResult SupportRole()
{
var account = CurrentUser();
if (account == null) return Ok(new { signedIn = false, staff = false });
bool staff = IsStaff(account.Id);
return Ok(new { signedIn = true, staff, username = account.Username, openCount = staff ? SupportDB.OpenCount() : 0 });
}
[HttpPost("/api/site/support/create")]
public IActionResult SupportCreate([FromBody] SupportCreateReq? body)
{
var account = CurrentUser();
if (account == null) return Unauthorized(new { ok = false, error = "Log in to open a ticket." });
string msg = (body?.message ?? "").Trim();
if (msg.Length < 3) return BadRequest(new { ok = false, error = "Please describe your issue." });
if (msg.Length > 4000) msg = msg.Substring(0, 4000);
string subject = (body?.subject ?? "").Trim();
if (subject.Length > 120) subject = subject.Substring(0, 120);
var t = SupportDB.Create(account.Id, account.Username ?? $"Player{account.Id}", subject, msg);
try
{
Webhook.Send(account.Id, "mod", $"🎫 New support ticket #{t.Id}",
$"From @{t.Username} (id {account.Id})\nSubject: {t.Subject}\n\n{msg}", 15844367);
}
catch (Exception ex) { Console.WriteLine($"[support] webhook failed: {ex.Message}"); }
Console.WriteLine($"[support] ticket #{t.Id} opened by @{t.Username} ({account.Id})");
return Ok(new { ok = true, id = t.Id });
}
[HttpGet("/api/site/support/mine")]
public IActionResult SupportMine()
{
var account = CurrentUser();
if (account == null) return Unauthorized(new { ok = false, error = "not signed in" });
return Ok(new { ok = true, tickets = SupportDB.ForAccount(account.Id).Select(TicketSummary) });
}
[HttpGet("/api/site/support/all")]
public IActionResult SupportAll([FromQuery] string? status = null)
{
var account = CurrentUser();
if (account == null) return Unauthorized(new { ok = false, error = "not signed in" });
if (!IsStaff(account.Id)) return StatusCode(403, new { ok = false, error = "staff only" });
return Ok(new { ok = true, tickets = SupportDB.All(status).Select(TicketSummary) });
}
[HttpGet("/api/site/support/ticket/{id:int}")]
public IActionResult SupportTicket(int id)
{
var account = CurrentUser();
if (account == null) return Unauthorized(new { ok = false, error = "not signed in" });
var t = SupportDB.Get(id);
if (t == null) return NotFound(new { ok = false, error = "no such ticket" });
if (t.AccountId != account.Id && !IsStaff(account.Id)) return StatusCode(403, new { ok = false, error = "not your ticket" });
return Ok(new { ok = true, ticket = TicketDetail(t, account.Id), staff = IsStaff(account.Id) });
}
[HttpPost("/api/site/support/ticket/{id:int}/reply")]
public IActionResult SupportReply(int id, [FromBody] SupportReplyReq? body)
{
var account = CurrentUser();
if (account == null) return Unauthorized(new { ok = false, error = "not signed in" });
var t = SupportDB.Get(id);
if (t == null) return NotFound(new { ok = false, error = "no such ticket" });
bool staff = IsStaff(account.Id);
if (t.AccountId != account.Id && !staff) return StatusCode(403, new { ok = false, error = "not your ticket" });
string msg = (body?.message ?? "").Trim();
if (msg.Length < 1) return BadRequest(new { ok = false, error = "empty reply" });
if (msg.Length > 4000) msg = msg.Substring(0, 4000);
SupportDB.AddMessage(id, account.Id, account.Username ?? $"Player{account.Id}", staff, msg);
var updated = SupportDB.Get(id)!;
return Ok(new { ok = true, ticket = TicketDetail(updated, account.Id), staff });
}
[HttpPost("/api/site/support/ticket/{id:int}/close")]
public IActionResult SupportClose(int id)
{
var account = CurrentUser();
if (account == null) return Unauthorized(new { ok = false, error = "not signed in" });
var t = SupportDB.Get(id);
if (t == null) return NotFound(new { ok = false, error = "no such ticket" });
if (t.AccountId != account.Id && !IsStaff(account.Id)) return StatusCode(403, new { ok = false, error = "not your ticket" });
SupportDB.SetStatus(id, "closed");
Console.WriteLine($"[support] ticket #{id} closed by @{account.Username} ({account.Id})");
return Ok(new { ok = true });
}
[HttpPost("/api/site/support/ticket/{id:int}/reopen")]
public IActionResult SupportReopen(int id)
{
var account = CurrentUser();
if (account == null) return Unauthorized(new { ok = false, error = "not signed in" });
var t = SupportDB.Get(id);
if (t == null) return NotFound(new { ok = false, error = "no such ticket" });
if (t.AccountId != account.Id && !IsStaff(account.Id)) return StatusCode(403, new { ok = false, error = "not your ticket" });
SupportDB.SetStatus(id, "open");
return Ok(new { ok = true });
}
// GET /api/site/me
// 200 with the account when signed in, 401 when not. Every page calls this on load to
// decide whether to render the profile chip or the "Log in" button.
[HttpGet("/api/site/me")]
public IActionResult Me()
{
var account = CurrentUser();
if (account == null) return Unauthorized(new { ok = false, error = "not signed in" });
var full = PlayerDB.GetFullAccount(account.Id);
if (full == null) return NotFound(new { ok = false, error = "account not found" });
// The owner sees their own email (it is never in PublicUser, which other people get).
return Ok(new { ok = true, user = PublicUser(full), email = full.player?.Email ?? "" });
}
public class EmailBody { public string? email { get; set; } }
// Set or clear the account's email from the website's Email settings. Stored on the same
// field the signup form and the in-game acc/account/me/email write, so the game's own
// account screen shows it too (SelfAccountUpdate carries it live). Blank clears it. One
// address per account: a different account already using it is refused, so an email can
// later serve as a recovery / login identifier without ambiguity.
[HttpPost("/api/site/email")]
public IActionResult SetEmail([FromBody] EmailBody? body)
{
var account = CurrentUser();
if (account == null) return Unauthorized(new { ok = false, error = "not signed in" });
string email = (body?.email ?? "").Trim();
if (email.Length > 0)
{
if (email.Length > 254 ||
!System.Text.RegularExpressions.Regex.IsMatch(email, @"^[^@\s]+@[^@\s]+\.[^@\s]+$"))
return BadRequest(new { ok = false, error = "Enter a valid email address." });
ulong other = PlayerDB.GetAccountIdByEmail(email);
if (other != 0 && other != account.Id)
return Conflict(new { ok = false, error = "That email is already used by another account." });
}
if (!PlayerDB.SetPlayerEmail(email.Length == 0 ? null : email, account.Id))
return StatusCode(500, new { ok = false, error = "Could not save your email." });
Console.WriteLine($"[site] {account.Id} {(email.Length == 0 ? "cleared their email" : "set their email")}");
Campus_live_rewrite.api.server.Notifications.FireAndForget(
Campus_live_rewrite.api.server.Notifications.PushAccountUpdate(account.Id), $"email-update {account.Id}");
return Ok(new { ok = true, email });
}
// ---- Discord account linking (Settings > Discord tab) ------------------------------
// A dedicated, ISOLATED flow: this hands the signed-in player a short code; they run
// `/link <code>` with the Campus Discord bot, which binds their Discord id via
// PlayerDB.SetDiscordLink. It shares NOTHING with the admin session gate (AdminGate) - a
// Discord link records only an association and can never grant admin/dev access. See
// DiscordLinkCodes + BotCommands./link.
// GET /api/site/discord/status -> current link state for the signed-in account.
[HttpGet("/api/site/discord/status")]
public IActionResult DiscordStatus()
{
var account = CurrentUser();
if (account == null) return Unauthorized(new { ok = false, error = "not signed in" });
var full = PlayerDB.GetFullAccount(account.Id);
ulong did = full?.player_Extra?.DiscordId ?? 0;
return Ok(new
{
ok = true,
linked = did != 0,
discordId = did == 0 ? "" : did.ToString(),
discordUsername = full?.player_Extra?.DiscordUsername ?? ""
});
}
// POST /api/site/discord/link/start -> a fresh 10-minute code to type into the bot's /link.
[HttpPost("/api/site/discord/link/start")]
public IActionResult DiscordLinkStart()
{
var account = CurrentUser();
if (account == null) return Unauthorized(new { ok = false, error = "not signed in" });
var (code, ttl) = Campus_live_rewrite.api.server.DiscordLinkCodes.Generate(account.Id);
Console.WriteLine($"[discord-link] account {account.Id} started a link (code issued, {ttl}s)");
return Ok(new { ok = true, code, ttlSeconds = ttl });
}
// GET /api/site/discord/oauth/start -> the "Link Discord" button. Sends the signed-in user
// to Discord's authorize page; the callback (DiscordController, state="link:") links their
// Discord to THIS account and returns them to /settings. Reuses the same Discord app +
// registered redirect URI as the admin login, but the "link:" state routes it to the
// link branch, so it never mints an admin session.
[HttpGet("/api/site/discord/oauth/start")]
public IActionResult DiscordOAuthStart()
{
var account = CurrentUser();
if (account == null) return Redirect("/login");
var (code, _) = Campus_live_rewrite.api.server.DiscordLinkCodes.Generate(account.Id);
string state = "link:" + code;
string url = "https://discord.com/api/oauth2/authorize"
+ "?client_id=" + DiscordController.ClientId
+ "&redirect_uri=" + Uri.EscapeDataString(DiscordController.RedirectUri)
+ "&response_type=code"
+ "&scope=" + Uri.EscapeDataString("identify")
+ "&state=" + Uri.EscapeDataString(state);
return Redirect(url);
}
// POST /api/site/discord/unlink -> remove the Discord link from the signed-in account.
[HttpPost("/api/site/discord/unlink")]
public IActionResult DiscordUnlink()
{
var account = CurrentUser();
if (account == null) return Unauthorized(new { ok = false, error = "not signed in" });
PlayerDB.SetDiscordLink(account.Id, 0, null);
return Ok(new { ok = true });
}
// ---- Spotify linking (Settings > Services > Spotify) --------------------------------
// OAuth against the Spotify app in Data/spotify_app.json (see SpotifyLink). The link
// records a refresh token + the player's "show my Spotify to" choice; the profile page and
// friends list show what they are listening to, to whoever that choice allows.
// ---- linked socials (Settings > Services) ----------------------------------------
// GET /api/site/social/status every provider's state for the signed-in account
// GET /api/site/social/{provider}/start sign in with the service (redirect)
// GET /api/site/social/{provider}/callback the service sends the player back here
// POST /api/site/social/{provider}/unlink
// POST /api/site/social/{provider}/show {show:bool} the "show on my profile" switch
[HttpGet("/api/site/social/status")]
public IActionResult SocialStatus()
{
var account = CurrentUser();
if (account == null) return Unauthorized(new { ok = false, error = "not signed in" });
var full = PlayerDB.GetFullAccount(account.Id);
return Ok(new { ok = true, providers = SocialLinks.Status(account.Id, full?.player_Extra), preview = SocialLinks.ProfileRows(account.Id, full?.player_Extra) });
}
[HttpGet("/api/site/social/{provider}/start")]
public IActionResult SocialStart(string provider)
{
var account = CurrentUser();
if (account == null) return Redirect("/login");
provider = (provider ?? "").ToLowerInvariant();
var p = SocialLinks.Find(provider);
if (p == null) return NotFound();
if (provider == "discord") return Redirect("/api/site/discord/oauth/start");
if (provider == "spotify") return Redirect("/api/site/spotify/oauth/start");
string? url = SocialLinks.StartUrl(account.Id, provider);
if (url == null) return Redirect("/settings?social=" + provider + ":unconfigured");
return Redirect(url);
}
[HttpGet("/api/site/social/{provider}/callback")]
public async Task<IActionResult> SocialCallback(string provider)
{
provider = (provider ?? "").ToLowerInvariant();
if (SocialLinks.Find(provider) == null) return NotFound();
string outcome = await SocialLinks.Complete(provider, Request.Query, CurrentUser()?.Id ?? 0);
return Redirect("/settings?social=" + provider + ":" + outcome);
}
[HttpPost("/api/site/social/{provider}/unlink")]
public IActionResult SocialUnlink(string provider)
{
var account = CurrentUser();
if (account == null) return Unauthorized(new { ok = false, error = "not signed in" });
provider = (provider ?? "").ToLowerInvariant();
if (SocialLinks.Find(provider) == null || provider == "discord" || provider == "spotify") return BadRequest(new { ok = false, error = "unknown provider" });
SocialLinks.Unlink(account.Id, provider);
SiteNotifications.ProfileChanged(account.Id);
return Ok(new { ok = true });
}
public class SocialShowBody { public bool? show { get; set; } }
[HttpPost("/api/site/social/{provider}/show")]
public IActionResult SocialShow(string provider, [FromBody] SocialShowBody? body)
{
var account = CurrentUser();
if (account == null) return Unauthorized(new { ok = false, error = "not signed in" });
provider = (provider ?? "").ToLowerInvariant();
if (SocialLinks.Find(provider) == null) return BadRequest(new { ok = false, error = "unknown provider" });
SocialLinks.SetShow(account.Id, provider, body?.show ?? true);
SiteNotifications.ProfileChanged(account.Id);
return Ok(new { ok = true, show = body?.show ?? true });
}
[HttpGet("/api/site/spotify/status")]
public async Task<IActionResult> SpotifyStatus()
{
var account = CurrentUser();
if (account == null) return Unauthorized(new { ok = false, error = "not signed in" });
var link = SpotifyLink.Get(account.Id);
object? now = link == null ? null : await SpotifyLink.PayloadFor(account.Id, account.Id);
return Ok(new
{
ok = true,
configured = SpotifyLink.Configured,
linked = link != null,
broken = link?.Broken ?? false,
notAllowed = link?.NotAllowed ?? false,
displayName = link?.DisplayName ?? "",
profileUrl = link?.ProfileUrl ?? "",
visibility = link?.Visibility ?? SpotifyLink.VisFriends,
now_playing = now
});
}
[HttpGet("/api/site/spotify/oauth/start")]
public IActionResult SpotifyOAuthStart()
{
var account = CurrentUser();
if (account == null) return Redirect("/login");
string? url = SpotifyLink.StartUrl(account.Id);
if (url == null) return Redirect("/settings?spotify=unconfigured");
return Redirect(url);
}
[HttpGet("/api/site/spotify/callback")]
public async Task<IActionResult> SpotifyCallback([FromQuery] string? code, [FromQuery] string? state, [FromQuery] string? error)
{
string outcome = await SpotifyLink.Complete(code, state, error);
return Redirect("/settings?spotify=" + outcome);
}
[HttpPost("/api/site/spotify/unlink")]
public IActionResult SpotifyUnlink()
{
var account = CurrentUser();
if (account == null) return Unauthorized(new { ok = false, error = "not signed in" });
SpotifyLink.Unlink(account.Id);
return Ok(new { ok = true });
}
// POST /api/site/spotify/visibility {visibility: 0 everyone | 1 friends | 2 favourites | 3 no one}
[HttpPost("/api/site/spotify/visibility")]
public async Task<IActionResult> SpotifyVisibility()
{
var account = CurrentUser();
if (account == null) return Unauthorized(new { ok = false, error = "not signed in" });
int vis = -1;
try
{
var f = RoomModeration.Fields(Request); // form, query or JSON body
string raw = RoomModeration.Str(f, "visibility", "value") ?? "";
if (!int.TryParse(raw, out vis))
vis = raw.ToLowerInvariant() switch { "everyone" => 0, "friends" => 1, "favourites" or "favorites" => 2, "none" or "noone" or "nobody" => 3, _ => -1 };
}
catch { }
await Task.CompletedTask;
if (vis < 0 || vis > 3) return BadRequest(new { ok = false, error = "visibility must be 0-3" });
SpotifyLink.SetVisibility(account.Id, vis);
return Ok(new { ok = true, visibility = vis });
}
// GET /api/site/spotify/nowplaying/{handle} - what that player is listening to, if their
// setting lets the signed-in viewer see it. {ok, now_playing: null|{...}}. Polled by the
// profile page; Spotify itself is asked at most every 10 s per player.
[HttpGet("/api/site/spotify/nowplaying/{handle}")]
public async Task<IActionResult> SpotifyNowPlaying(string handle)
{
var viewer = CurrentUser();
if (viewer == null) return Ok(new { ok = true, now_playing = (object?)null });
ulong owner = ulong.TryParse(handle, out var n) ? n : PlayerDB.GetAccountIdByUsernameCI(handle.TrimStart('@'));
if (owner == 0) return NotFound(new { ok = false, error = "no such player" });
return Ok(new { ok = true, now_playing = await SpotifyLink.PayloadFor(owner, viewer.Id) });
}
// ---- search (ported from server.py) -----------------------------------------------
// GET /api/search/rooms?query=
// Flask fetched https://gameauth.ddns.net/rooms/search with a stored bearer token. We
// are that server, so read RoomDB directly.
//
// Visibility goes through RoomDB.CanPlayerSeeRoom with the site visitor's account id
// (0 when signed out) rather than a hand-rolled filter, so the website can never show
// a room the game would hide. Dorms are dropped on top of that: they pass the check
// for their owner, and a personal dorm has no business in a public site search.
[HttpGet("/api/search/rooms")]
public IActionResult SearchRooms(string? query)
{
query = (query ?? "").Trim();
if (query.Length == 0) return Ok(new { ok = true, results = Array.Empty<object>() });
try
{
ulong viewerId = CurrentUser()?.Id ?? 0UL;
var rooms = RoomDB.Search(query) ?? new List<RoomDBClasses.RoomRoot>();
var visible = rooms
.Where(r => r != null && !r.IsDorm && RoomDB.CanPlayerSeeRoom(r, viewerId))
.Take(30)
.Select(r => new
{
RoomId = r.RoomId,
Name = r.Name,
ImageName = r.ImageName,
Description = r.Description,
Stats = new { VisitCount = r.Stats?.VisitCount ?? 0 }
})
.ToList();
return Ok(new { ok = true, results = visible });
}
catch (Exception ex)
{
Console.WriteLine($"[site] room search failed: {ex.Message}");
return StatusCode(500, new { ok = false, error = "search failed" });
}
}
// GET /api/search/players?name=
[HttpGet("/api/search/players")]
public IActionResult SearchPlayers(string? name)
{
name = (name ?? "").Trim();
if (name.Length == 0) return Ok(new { ok = true, results = Array.Empty<object>() });
try
{
var matches = PlayerDB.Search(name) ?? new List<account_data>();
var results = matches
.Take(30)
.Select(a => new
{
accountId = a.accountId,
username = a.username,
displayName = a.displayName,
profileImage = a.profileImage
})
.ToList();
return Ok(new { ok = true, results });
}
catch (Exception ex)
{
Console.WriteLine($"[site] player search failed: {ex.Message}");
return StatusCode(500, new { ok = false, error = "search failed" });
}
}
// ---- profile image ----------------------------------------------------------------
// POST /api/profile-image (multipart field "image")
//
// Flask re-encoded the upload and pushed it to an EXTERNAL image host, signing the
// request with an HMAC key it kept in token.json on disk. That whole path is gone:
// Campus already stores and serves player images itself out of Data/Images, so the
// file is written locally and the account row is pointed at it.
//
// Requires a signed-in site session - without it anyone could rewrite any avatar.
[HttpPost("/api/profile-image")]
[RequestSizeLimit(8 * 1024 * 1024)]
public async Task<IActionResult> UploadProfileImage(IFormFile? image)
{
var account = CurrentUser();
if (account == null) return Unauthorized(new { ok = false, error = "not signed in" });
if (image == null || image.Length == 0)
return BadRequest(new { ok = false, error = "no image uploaded" });
if (image.Length > 8 * 1024 * 1024)
return BadRequest(new { ok = false, error = "image is larger than 8MB" });
// Rate limit BEFORE decoding: a 8MB image decode is the expensive part, so a
// rejected caller must never reach it. Two limits, because either alone is weak -
// the cooldown stops bursts, the quota stops a patient script uploading every
// 11 seconds forever. Keyed on account id, not IP: the endpoint already requires
// a session, and an IP key would punish everyone behind one NAT.
string rlKey = $"pfp:{account.Id}";
if (!RateLimit.TryConsume(rlKey, cooldownSeconds: 10, out int retryAfter))
{
Response.Headers["Retry-After"] = retryAfter.ToString();
return StatusCode(429, new { ok = false, error = $"Slow down - try again in {retryAfter}s." });
}
if (!RateLimit.TryConsumeQuota(rlKey, maxInWindow: 10, windowSeconds: 3600, out int quotaRetry))
{
Response.Headers["Retry-After"] = quotaRetry.ToString();
return StatusCode(429, new
{
ok = false,
error = $"Too many profile picture changes. Try again in {quotaRetry / 60 + 1} minutes."
});
}
try
{
// Decode + re-encode rather than trusting the upload. This is the check that
// matters: a file is only written if ImageSharp could actually parse it as an
// image, so a renamed .exe or a polyglot never lands in the images folder. It
// also strips EXIF (location data) as a side effect.
using var src = await Image.LoadAsync<Rgba32>(image.OpenReadStream());
// Cap the stored size so one upload can't fill the disk with a 10000x10000 PNG,
// but keep it large enough to look crisp on the profile page (1024, up from 512 -
// 512 was the visible "compression" on website-uploaded pictures). Re-encoding is
// to PNG, which is lossless; this Max resize is the only quality-affecting step,
// and it only ever DOWNscales (never upscales a small in-game capture).
if (src.Width > 1024 || src.Height > 1024)
{
src.Mutate(x => x.Resize(new ResizeOptions
{
Mode = ResizeMode.Max,
Size = new Size(1024, 1024)
}));
}
// Name is server-generated - never derived from the uploaded filename, which
// is attacker-controlled and would otherwise be a path-traversal write.
string imageName = $"pfp_{account.Id}_{Guid.NewGuid():N}.png";
string dir = Path.Combine(Environment.CurrentDirectory, "Data", "Images", "player_images");
Directory.CreateDirectory(dir);
await src.SaveAsPngAsync(Path.Combine(dir, imageName));
// Note what is NOT happening here: no row is written to ImageMetadataDB. A
// profile picture is not a photo, so it must not appear in the photo feeds.
// (The feeds also filter to ShareCamera, so this is belt and braces.)
string? previous = PlayerDB.GetFullAccount(account.Id)?.player?.profileImage;
PlayerDB.SetPlayerImageName(imageName, account.Id);
// Live push: redraw the player's profile in-game (and for anyone viewing it) with
// the new picture - no relog. AccountUpdate is the redraw notification; immediate is
// safe here because the GAME client didn't make this request, so there's no in-flight
// image load to double-complete (the self-service double-load dorm-drop [385120]).
Campus_live_rewrite.api.server.Notifications.FireAndForget(
Campus_live_rewrite.api.server.Notifications.PushAccountUpdate(account.Id), "site pfp refresh");
// Delete the picture this one replaces, so changing your avatar does not leave
// a copy behind every time. Strictly scoped to files this endpoint generated
// for THIS account (the "pfp_<id>_" prefix): a profile picture set in-game
// points at a shared saved-image that other records may still reference, and
// deleting one of those would blank it elsewhere.
if (!string.IsNullOrWhiteSpace(previous)
&& previous != imageName
&& previous.StartsWith($"pfp_{account.Id}_", StringComparison.Ordinal)
&& !previous.Contains('/') && !previous.Contains('\\'))
{
try
{
string old = Path.Combine(dir, previous);
if (System.IO.File.Exists(old)) System.IO.File.Delete(old);
}
catch (Exception ex)
{
// Never fail the upload over cleanup - the new picture is already live.
Console.WriteLine($"[site] could not remove old pfp '{previous}': {ex.Message}");
}
}
// ImagesController serves Data/Images/player_images at /img/<name>.
return Ok(new { ok = true, imageName, url = "/img/" + imageName });
}
catch (UnknownImageFormatException)
{
return BadRequest(new { ok = false, error = "that file is not a supported image" });
}
catch (Exception ex)
{
Console.WriteLine($"[site] profile image upload failed: {ex.Message}");
return StatusCode(500, new { ok = false, error = "could not save the image" });
}
}
// GET /api/health - kept so anything that probed the Flask health route still works.
[HttpGet("/api/health")]
public IActionResult Health() => Ok(new { ok = true });
public class SiteCreds
{
public string? username { get; set; }
public string? password { get; set; }
}
public class SiteRegister
{
public string? username { get; set; }
public string? display_name { get; set; }
public string? password { get; set; }
public string? confirm_password { get; set; }
public string? email { get; set; }
// Date of birth (YYYY-MM-DD from the form's <input type=date>). Required: under-13 gets a
// random @name + a junior account.
public string? birthday { get; set; }
// Hidden honeypot field - must stay empty. Bots that autofill everything trip it.
public string? website { get; set; }
}
}
}
@@ -0,0 +1,39 @@
using Microsoft.AspNetCore.Mvc;
using Campus_live_rewrite.api.dbs;
namespace Campus_live_rewrite.Controllers
{
// ---------------------------------------------------------------------------------
// Room lookup for the Tachyon voice server.
//
// Tachyon has NO room id on its wire - the client's voice ConnectionData carries only
// the AccountId (plaintext) plus opaque RSA blobs. So Tachyon resolves which room a
// speaker is in by asking us: GET /tachyon?id=<AccountId>, expecting a BARE numeric
// roomInstanceId in text/plain (see tachyon-server-master/src/room_lookup.cpp - it
// strtoull's the body and treats 0 / non-200 / non-numeric as "isolate this client",
// i.e. they hear no one). It scopes its Dissonance relay per returned room id.
//
// Set Tachyon's config room_url = https://<host>/tachyon (it appends ?id=<n>).
//
// Unauthenticated on purpose: room_lookup.cpp issues a plain GET with no token, exactly
// as the RecFlare reference does. The only thing exposed is which room-instance a given
// account is in, keyed on a numeric id we already hand out publicly; the id is validated
// as an integer before use. Returns "0" (nowhere) for anything we can't positively place,
// which fails safe: Tachyon isolates the client rather than leaking cross-room voice.
// ---------------------------------------------------------------------------------
[ApiController]
public class TachyonController : ControllerBase
{
[HttpGet("/tachyon")]
public IActionResult ResolveRoom([FromQuery] string? id)
{
if (string.IsNullOrWhiteSpace(id) || !ulong.TryParse(id, out var accountId))
return Content("0", "text/plain");
var (online, room) = HeartbeatDB.PeekPresence(accountId);
long instance = (online && room != null) ? room.roomInstanceId : 0;
return Content(instance.ToString(), "text/plain");
}
}
}
@@ -0,0 +1,426 @@
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Campus_live_rewrite.api.server.Classes.db_class;
using Campus_live_rewrite.api.server;
using Campus_live_rewrite.api.dbs;
using System.ComponentModel.DataAnnotations;
namespace Campus_live_rewrite.Controllers
{
public class WebController : ControllerBase
{
// "/" is shared between two callers that want completely different things:
//
// - the GAME CLIENT treats "/" as the NameServer endpoint and rejects anything that
// isn't a valid NS DTO with FAILED_TO_CONNECT_CODE_LOCKER (=4, "Failed to connect
// to RecNet, error code: locker"). This is load-bearing: get it wrong and NOBODY
// can connect.
// - a BROWSER hitting gameauth.ddns.net should get the Campus website.
//
// They are told apart by the request itself: BestHTTP (the client's HTTP stack) does
// not ask for text/html. The test is deliberately biased - anything that isn't clearly
// a browser gets the NS JSON, so an unrecognised client fails safe rather than being
// handed a web page it cannot parse. /home and /site still serve the old landing page.
//
// NOTE: this is also why the site is NOT mounted with UseDefaultFiles in Program.cs.
// That would serve Website/index.html for "/" inside the static-file middleware,
// before routing ever reaches this action, and break every client.
[Route("/")]
public IActionResult NsRoot()
{
if (WantsHtml(Request))
{
var index = Path.Combine(Environment.CurrentDirectory, "Website", "index.html");
if (System.IO.File.Exists(index))
return PhysicalFile(index, "text/html");
}
return ns(Version: "20250731");
}
/// <summary>
/// True only for a request that is unambiguously a web browser asking for a page.
/// Anything else - the game client, a probe, a bare curl - is treated as the game.
/// </summary>
private static bool WantsHtml(HttpRequest req)
{
if (!HttpMethods.IsGet(req.Method)) return false;
string ua = req.Headers.UserAgent.ToString();
if (ua.Contains("BestHTTP", StringComparison.OrdinalIgnoreCase)) return false;
return req.Headers.Accept.ToString()
.Contains("text/html", StringComparison.OrdinalIgnoreCase);
}
[Route("/home")]
[Route("/site")]
public IActionResult Index()
{
var filePath = Path.Combine(Environment.CurrentDirectory, "Web", "Index.html");
if (!System.IO.File.Exists(filePath))
return NotFound();
return PhysicalFile(filePath, "text/html");
}
// ---- Clean website URLs (no .html) --------------------------------------------------
// The public site is served as static files (profile.html, room.html, …), but the links
// people see and share should be clean: /user/kyle, /room/{id}, /image/{id}, /news, etc.
// Each clean path just serves the matching HTML file; the page's JS reads the id/handle
// from location.pathname (see app.js routeParam()). The game client never requests these
// paths (they carry no NS meaning), so there is no browser-vs-game split to make here.
private IActionResult SitePage(string file)
{
var path = Path.Combine(Environment.CurrentDirectory, "Website", file);
if (!System.IO.File.Exists(path)) return NotFound();
// These pages are served at a CLEAN path (/user/kyle, /room/{id}, …). Their asset refs
// (app.js, style.css, assets/…) are RELATIVE, so at /user/kyle the browser would look
// for /user/app.js and 404 - the page couldn't even load its own JS. Inject
// <base href="/"> right after <head> so every relative URL resolves from the site root,
// regardless of the clean path's depth. (Static /profile.html serving is untouched.)
var html = System.IO.File.ReadAllText(path);
if (html.IndexOf("<base ", StringComparison.OrdinalIgnoreCase) < 0)
{
int i = html.IndexOf("<head>", StringComparison.OrdinalIgnoreCase);
if (i >= 0) html = html.Insert(i + "<head>".Length, "\n <base href=\"/\">");
}
return Content(html, "text/html; charset=utf-8");
}
// True when the request carries a valid website session cookie (campus_site). Mirrors
// SiteContentController.CurrentUser - a lightweight decode, no DB hit.
private bool IsSignedIn()
{
string token = Request.Cookies["campus_site"] ?? "";
if (string.IsNullOrEmpty(token)) return false;
// Require the "website" surface (the site cookie is minted with it). Without this the
// default DecodeToken now refuses website tokens, so /profile and /settings would
// redirect to /login even when the visitor is signed in.
string data = ClientSecurity.DecodeToken(token, requireSurface: "website");
if (string.IsNullOrEmpty(data) || data.StartsWith("error_")) return false;
try { var u = JsonConvert.DeserializeObject<player_info>(data); return u != null && u.Id != 0; }
catch { return false; }
}
private const string SiteBase = "https://gameauth.ddns.net";
// Serve a site page with resource-specific OpenGraph/Twitter-card meta injected, so pasting
// the link in Discord/social embeds the image/profile/room. Reads the page, drops any
// existing og:/twitter: meta (so ours win - a duplicate og:image confuses crawlers), and
// injects title/description/image + a <base href="/"> for the clean path. Humans still get
// the full SPA; crawlers (which don't run JS) read the injected meta.
private IActionResult SitePageOg(string file, string title, string? description, string? imageAbsUrl, string urlPath, bool largeImage)
{
var path = Path.Combine(Environment.CurrentDirectory, "Website", file);
if (!System.IO.File.Exists(path)) return NotFound();
var html = System.IO.File.ReadAllText(path);
if (html.IndexOf("<base ", StringComparison.OrdinalIgnoreCase) < 0)
{
int b = html.IndexOf("<head>", StringComparison.OrdinalIgnoreCase);
if (b >= 0) html = html.Insert(b + "<head>".Length, "\n <base href=\"/\">");
}
html = System.Text.RegularExpressions.Regex.Replace(
html,
"\\s*<meta[^>]*(property=\"og:[^\"]*\"|name=\"twitter:[^\"]*\")[^>]*>",
"",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
string Esc(string? s) => System.Net.WebUtility.HtmlEncode(s ?? "");
var sb = new System.Text.StringBuilder();
sb.Append("\n <meta property=\"og:site_name\" content=\"Campus\">");
sb.Append($"\n <meta property=\"og:title\" content=\"{Esc(title)}\">");
sb.Append($"\n <meta property=\"og:url\" content=\"{Esc(SiteBase + urlPath)}\">");
sb.Append("\n <meta property=\"og:type\" content=\"website\">");
if (!string.IsNullOrWhiteSpace(description))
sb.Append($"\n <meta property=\"og:description\" content=\"{Esc(description)}\">");
if (!string.IsNullOrWhiteSpace(imageAbsUrl))
{
sb.Append($"\n <meta property=\"og:image\" content=\"{Esc(imageAbsUrl)}\">");
sb.Append($"\n <meta name=\"twitter:image\" content=\"{Esc(imageAbsUrl)}\">");
}
sb.Append($"\n <meta name=\"twitter:card\" content=\"{(largeImage ? "summary_large_image" : "summary")}\">");
sb.Append($"\n <meta name=\"twitter:title\" content=\"{Esc(title)}\">");
if (!string.IsNullOrWhiteSpace(description))
sb.Append($"\n <meta name=\"twitter:description\" content=\"{Esc(description)}\">");
int i = html.IndexOf("<head>", StringComparison.OrdinalIgnoreCase);
if (i >= 0) html = html.Insert(i + "<head>".Length, sb.ToString());
return Content(html, "text/html; charset=utf-8");
}
private static string? ImgAbs(string? name) =>
string.IsNullOrWhiteSpace(name) || name == "DefaultPFP.png"
? null
: SiteBase + "/img/" + name;
// A small styled "X does not exist" page (404) for a slug that resolves to nothing.
private IActionResult DoesNotExist(string entity)
{
string e = System.Net.WebUtility.HtmlEncode(entity);
string html =
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">" +
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">" +
$"<title>{e} not found - Campus</title>" +
$"<meta property=\"og:title\" content=\"{e} does not exist\">" +
"<meta property=\"og:site_name\" content=\"Campus\">" +
"<style>body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;" +
"background:#14121a;color:#eee;font-family:Inter,system-ui,sans-serif;text-align:center}" +
".c{padding:40px}h1{font-size:28px;margin:0 0 8px}p{opacity:.75;margin:0 0 20px}" +
"a{color:#ff5a3c;text-decoration:none;font-weight:600}</style></head>" +
$"<body><div class=\"c\"><h1>{e} does not exist</h1>" +
$"<p>We couldn't find that {e.ToLower()} on Campus.</p>" +
"<a href=\"/\">&larr; Back to home</a></div></body></html>";
return new ContentResult { Content = html, ContentType = "text/html; charset=utf-8", StatusCode = 404 };
}
// Profile URLs are by USERNAME, never the account id:
// - a real username -> the profile (with OG embed);
// - a bare account id -> 302 REDIRECT to that account's /user/<username>, so the URL
// bar shows the name, not the id;
// - anything else -> "Player does not exist".
[HttpGet("/user/{handle}")]
public IActionResult UserPage(string handle)
{
var full = PlayerDB.GetFullAccountByName(handle);
if (full?.player != null)
{
string title = $"{full.player.DisplayName} (@{full.player.Username})";
string? bio = full.player_Extra?.Bio;
string desc = string.IsNullOrWhiteSpace(bio) ? "View this player's Campus profile." : bio!;
string img = ImgAbs(full.player.profileImage) ?? (SiteBase + "/img/DefaultProfileImage.jpg");
return SitePageOg("profile.html", title, desc, img, "/user/" + Uri.EscapeDataString(handle), largeImage: false);
}
// Not a username. If it's a real account id, send them to the canonical @name URL.
if (ulong.TryParse(handle, out var byId))
{
var acc = PlayerDB.GetFullAccount(byId);
if (!string.IsNullOrEmpty(acc?.player?.Username))
return Redirect("/user/" + Uri.EscapeDataString(acc.player.Username));
}
return DoesNotExist("Player");
}
// Your OWN profile requires a session - signed out it would only show placeholder
// ("Username" / "@handle"), so send it to the login page instead.
[HttpGet("/profile")]
public IActionResult ProfilePage() => IsSignedIn() ? SitePage("profile.html") : Redirect("/login");
// Room URLs are by NAME, never the room id:
// - a real room name -> the room page (with OG embed);
// - a bare room id -> 302 REDIRECT to that room's /room/<name>, so the URL shows the name;
// - anything else -> "Room does not exist".
[HttpGet("/room/{id}")]
public IActionResult RoomPage(string id)
{
var room = RoomDB.GetRoomByName(id);
if (room != null && room.IsDorm) return DoesNotExist("Room"); // dorms are never web pages
if (room != null)
{
string title = (room.Name ?? "Room").TrimStart('^');
return SitePageOg("room.html", title, room.Description, ImgAbs(room.ImageName),
"/room/" + Uri.EscapeDataString(id), largeImage: true);
}
if (ulong.TryParse(id, out var rid))
{
var byId = RoomDB.GetRoom(rid);
if (byId != null && byId.IsDorm) return DoesNotExist("Room");
if (!string.IsNullOrEmpty(byId?.Name))
return Redirect("/room/" + Uri.EscapeDataString(byId.Name.TrimStart('^')));
}
return DoesNotExist("Room");
}
[HttpGet("/image/{id}")]
public IActionResult ImagePage(string id)
{
string title = "Campus Photo";
string? desc = null, img = null;
// ids can be a bare number or an old "user__id" compound - take the numeric tail.
var tail = id.Contains("__") ? id.Substring(id.LastIndexOf("__") + 2) : id;
if (int.TryParse(tail, out int imageId))
{
var meta = ImageMetadataDB.GetImagesByIds(new List<int> { imageId })?.FirstOrDefault();
if (meta != null)
{
img = ImgAbs(meta.ImageName);
desc = meta.Description;
var author = PlayerDB.GetAccountById(meta.PlayerId);
if (author != null) title = $"Photo by @{author.Username}";
}
}
return SitePageOg("image.html", title, desc, img, "/image/" + Uri.EscapeDataString(id), largeImage: true);
}
[HttpGet("/news")]
public IActionResult NewsPage() => SitePage("news.html");
[HttpGet("/rooms")]
public IActionResult RoomsPage() => SitePage("rooms.html");
[HttpGet("/patch-notes")]
public IActionResult PatchNotesPage() => SitePage("patch-notes.html");
// Support replaced the old Weekly page. /weekly redirects so any old link still lands here.
// Deep links (/support/ticket/{id}, /support/logs) all serve the same page; support.html's
// JS reads the path and opens the right ticket / the staff logs view.
[HttpGet("/support")]
[HttpGet("/support/ticket/{id:int}")]
[HttpGet("/support/logs")]
public IActionResult SupportPage() => SitePage("support.html");
[HttpGet("/weekly")]
public IActionResult WeeklyPage() => Redirect("/support");
// Settings is entirely your-account controls - useless signed out, so gate it too.
[HttpGet("/settings")]
public IActionResult SettingsPage() => IsSignedIn() ? SitePage("settings.html") : Redirect("/login");
// Stats is the signed-in creator's own numbers - signed out there is nothing to show.
[HttpGet("/stats")]
public IActionResult StatsPage() => IsSignedIn() ? SitePage("stats.html") : Redirect("/login");
[HttpGet("/login")]
public IActionResult LoginPage() => SitePage("login.html");
[HttpGet("/download")]
public IActionResult DownloadPage() => SitePage("download.html");
[HttpGet("/accessibility")]
public IActionResult AccessibilityPage() => SitePage("accessibility.html");
[HttpGet("/signup")]
public IActionResult SignupPage() => IsSignedIn() ? Redirect("/profile") : SitePage("signup.html");
// The old ".html" addresses stay reachable but bounce to the clean path, so a shared
// /signup.html link lands on /signup and the bar never shows a file name.
private static readonly Dictionary<string, string> CleanPaths = new(StringComparer.OrdinalIgnoreCase)
{
["index.html"] = "/", ["signup.html"] = "/signup", ["login.html"] = "/login",
["download.html"] = "/download", ["news.html"] = "/news", ["patch-notes.html"] = "/patch-notes", ["rooms.html"] = "/rooms",
["accessibility.html"] = "/accessibility", ["support.html"] = "/support", ["weekly.html"] = "/weekly",
["settings.html"] = "/settings", ["profile.html"] = "/profile", ["stats.html"] = "/stats",
};
[HttpGet("/{page}.html")]
public IActionResult HtmlRedirect(string page)
{
if (!CleanPaths.TryGetValue(page + ".html", out var clean)) return NotFound();
// profile.html?u=kyle -> /user/kyle ; keep any other query string / fragment as-is
if (clean == "/profile" && !string.IsNullOrEmpty(Request.Query["u"]))
return RedirectPermanent("/user/" + Uri.EscapeDataString(Request.Query["u"].ToString()));
return RedirectPermanent(clean + Request.QueryString.Value);
}
// The Windows launcher download: https://gameauth.ddns.net/campus.exe
// Drop the built launcher on the server at Data/Downloads/campus.exe (preferred) or
// Website/campus.exe and it is served here as a file download. enableRangeProcessing lets
// browsers resume/segment the download of a large exe.
[HttpGet("/campus.exe")]
public IActionResult CampusExe()
{
foreach (var p in new[]
{
Path.Combine(Environment.CurrentDirectory, "Data", "Downloads", "campus.exe"),
Path.Combine(Environment.CurrentDirectory, "Website", "campus.exe"),
})
{
if (System.IO.File.Exists(p))
return PhysicalFile(p, "application/octet-stream", "campus.exe", enableRangeProcessing: true);
}
return NotFound("Launcher not uploaded yet - place campus.exe in Data/Downloads/ on the server.");
}
[Route("/robots.txt")]
public IActionResult Robots()
{
var content = string.Join(Environment.NewLine, new[]
{
"User-agent: *",
"Disallow: /admin/v4/login",
"Disallow: /admin/v4/home",
"Disallow: /admin/v5/login",
"Disallow: /admin/v5/home"
});
return Content(content, "text/plain");
}
[HttpGet("/favicon.ico")]
public IActionResult Favicon()
{
// Serve the Campus AnimatedIcon as the browser-tab icon for gameauth.ddns.net.
// Falls back to Web/favicon.png if the admin-assets file isn't shipped for some reason.
var animated = Path.Combine(Environment.CurrentDirectory, "AdminPanel", "Assets", "AnimatedIcon.png");
if (System.IO.File.Exists(animated))
return PhysicalFile(animated, "image/png");
var legacy = Path.Combine(Environment.CurrentDirectory, "Web", "favicon.png");
if (System.IO.File.Exists(legacy))
return PhysicalFile(legacy, "image/png");
return NotFound();
}
[HttpGet("/ns")]
public IActionResult ns([FromQuery(Name = "v")] string? Version = null)
{
// Reference server only served for "20250731" (July 31 2025 build). We serve the same
// body for any/no version so older + newer client builds don't 400 here.
{
return Ok(new
{
Accounts = "https://gameauth.ddns.net/Accounts",
API = "https://gameauth.ddns.net",
AI = "https://gameauth.ddns.net/Ai",
Auth = "https://gameauth.ddns.net/Auth",
BugReporting = "https://gameauth.ddns.net",
Cards = "https://gameauth.ddns.net",
// Was `https://cdn.rec.net` (a leftover in the E12354 base that pointed at
// real Rec Room's CDN to "save on our own bandwidth"). The July 19 2025
// client's `summer` NS-validation gate checks every URL's host and fails
// when they don't all resolve back to us - mate's working server puts CDN
// on his host, so we do the same. 2026-08-13.
CDN = "https://gameauth.ddns.net/cdn",
Chat = "https://gameauth.ddns.net/chat",
Clubs = "https://gameauth.ddns.net/clubs",
CMS = "https://gameauth.ddns.net/cms",
Commerce = "https://gameauth.ddns.net/Commerce",
Data = "https://gameauth.ddns.net",
DataCollection = "https://gameauth.ddns.net",
Discovery = "https://gameauth.ddns.net",
Econ = "https://gameauth.ddns.net",
GameLogs = "https://gameauth.ddns.net",
Geo = "https://gameauth.ddns.net",
Images = "https://gameauth.ddns.net/img/",
Leaderboard = "https://gameauth.ddns.net/server/Leaderboard",
Link = "https://gameauth.ddns.net/link",
Lists = "https://gameauth.ddns.net/lists",
Matchmaking = "https://gameauth.ddns.net/Matchmaking",
Moderation = "https://gameauth.ddns.net",
Notifications = "https://gameauth.ddns.net/Notifications",
PlatformNotifications = "https://gameauth.ddns.net/Notifications",
PlayerSettings = "https://gameauth.ddns.net/server/PlayerSettings",
RoomComments = "https://gameauth.ddns.net/RoomComments",
RoomieIntegrations = "https://gameauth.ddns.net/Roomie",
Rooms = "https://gameauth.ddns.net/Room_server",
Storage = "https://gameauth.ddns.net/server/Storage",
Strings = "https://gameauth.ddns.net/Strings",
StringsCDN = "https://gameauth.ddns.net/StringsCDN",
Studio = "https://gameauth.ddns.net/Studio",
Thorn = "https://gameauth.ddns.net/Thorn",
Videos = "https://gameauth.ddns.net/videodir",
WWW = "https://gameauth.ddns.net"
});
}
}
}
}