using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using System.Text.RegularExpressions; 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 { /// /// The Campus launcher (CampusPatch2025\Launcher). /// /// UPDATES - public, so a signed-out launcher still updates: GET /api/launcher/v1/app answers /// latest.json ({version, build, sha256, size}) and /app/{sha256} the exe. Data/Launcher/app is /// staged by Launcher\tools\release_app.py and uploaded by UploadUpdate.bat (exe first). /// /campus.exe (the website download) serves the same latest exe. /// /// ACCOUNT - sign-in with a Campus username + password mints a token with surface "launcher" /// (30 days). DecodeToken refuses that surface everywhere else, so a launcher token can only /// read the launcher's own account summary; it can't play, post or reach the admin panel. /// public class LauncherController : ControllerBase { private static readonly TimeSpan TokenTtl = TimeSpan.FromDays(30); private static readonly Regex Sha = new("^[0-9a-f]{64}$", RegexOptions.Compiled); private static string AppDir => Path.Combine(Environment.CurrentDirectory, "Data", "Launcher", "app"); // ---- updates ------------------------------------------------------------------------------ /// The newest released launcher exe on this server, or null. internal static (string path, string version, int build, string sha, long size)? Latest() { try { var j = JObject.Parse(System.IO.File.ReadAllText(Path.Combine(AppDir, "latest.json"))); string sha = (string?)j["sha256"] ?? ""; string path = Path.Combine(AppDir, sha + ".exe"); if (!Sha.IsMatch(sha) || !System.IO.File.Exists(path)) return null; return (path, (string?)j["version"] ?? "", (int?)j["build"] ?? 0, sha, (long?)j["size"] ?? 0); } catch { return null; } } [HttpGet("/api/launcher/v1/app")] public IActionResult AppLatest() { var l = Latest(); if (l == null) return Ok(new { ok = true, build = 0 }); // nothing released (or the exe isn't up yet) return Ok(new { ok = true, version = l.Value.version, build = l.Value.build, sha256 = l.Value.sha, size = l.Value.size }); } [HttpGet("/api/launcher/v1/app/{id}")] public IActionResult AppDownload(string id) { if (!Sha.IsMatch(id ?? "")) return BadRequest(new { ok = false, error = "Bad version id." }); string path = Path.Combine(AppDir, id + ".exe"); if (!System.IO.File.Exists(path)) return NotFound(new { ok = false, error = "The server doesn't have this version of the launcher." }); return PhysicalFile(path, "application/octet-stream", enableRangeProcessing: true); } // ---- the Campus patch ---------------------------------------------------------------------- // The launcher no longer ships CampusPatch.dll: at every Play it downloads it (signed in only), // checks the SHA-256, loads it into the game and deletes it once the game closes - so the // patch only reaches people with a Campus account, and only through the launcher. // Data/Launcher/patch holds latest.json ({sha256, size}) and .dll, staged by // Launcher\tools\release_patch.py and uploaded by UploadUpdate.bat. private static string PatchDir => Path.Combine(Environment.CurrentDirectory, "Data", "Launcher", "patch"); // Release channels. "" = public (Data/Launcher/patch, everyone). "beta", "dev" and "2026" (Data/Launcher/patch/beta, /dev, /2026) are // for developer accounts only: the launcher shows the drop-down to them, and the patch routes below refuse anyone else. // "2026" is the patch for the Rec Room 2026 client (a different game; same Campus servers, same proof / launch-token gates). // Pushed by PushChannel.bat. Each channel has its own latest.json, .dll and .key. internal static readonly string[] Channels = { "", "beta", "dev", "2026" }; internal static string NormChannel(string? c) { c = (c ?? "").Trim().ToLowerInvariant(); return c == "beta" || c == "dev" || c == "2026" ? c : ""; } internal static string PatchDirFor(string channel) => channel.Length == 0 ? PatchDir : Path.Combine(PatchDir, channel); /// Owner / dev accounts: the ones who may use the beta and dev channels. internal static bool IsDevAccount(ulong id) => ((PlayerDB.GetFullAccount(id)?.dev_Flag ?? DevFlag.none) & (DevFlag.owner | DevFlag.dev)) != 0; /// The SHA-256 of the patch the launcher serves now (latest.json), or "" when none is uploaded. internal static string CurrentPatchSha(string channel = "") { try { var j = JObject.Parse(System.IO.File.ReadAllText(Path.Combine(PatchDirFor(channel), "latest.json"))); string sha = ((string?)j["sha256"] ?? "").ToLowerInvariant(); return Sha.IsMatch(sha) ? sha : ""; } catch { return ""; } } [HttpGet("/api/launcher/v1/patch")] public IActionResult PatchLatest([FromQuery] string? channel = null) { var (u, err) = Caller(); if (err != null) return err; string ch = NormChannel(channel); if (ch.Length > 0 && !IsDevAccount(u!.Id)) { Console.WriteLine($"[launcher] @{u.Username} ({u.Id}) asked for the {ch} patch but is not a developer - refused"); return StatusCode(403, new { ok = false, error = "The beta, dev and 2026 patches are for developer accounts." }); } try { string dir = PatchDirFor(ch); var j = JObject.Parse(System.IO.File.ReadAllText(Path.Combine(dir, "latest.json"))); string sha = (string?)j["sha256"] ?? ""; if (Sha.IsMatch(sha) && System.IO.File.Exists(Path.Combine(dir, sha + ".dll"))) return Ok(new { ok = true, sha256 = sha, size = (long?)j["size"] ?? 0, channel = ch.Length == 0 ? "public" : ch }); } catch { } Console.WriteLine($"[launcher] {u!.Id} asked for the {(ch.Length == 0 ? "public" : ch)} patch, but none is uploaded (Data/Launcher/patch{(ch.Length == 0 ? "" : "/" + ch)})"); return StatusCode(503, new { ok = false, error = ch.Length == 0 ? "The Campus patch isn't on the server right now - try again later." : $"No {ch} patch has been pushed yet (PushChannel.bat)." }); } [HttpGet("/api/launcher/v1/patch/{id}")] public IActionResult PatchDownload(string id, [FromQuery] string? channel = null) { var (u, err) = Caller(); if (err != null) return err; if (!Sha.IsMatch(id ?? "")) return BadRequest(new { ok = false, error = "Bad patch id." }); string ch = NormChannel(channel); if (ch.Length > 0 && !IsDevAccount(u!.Id)) return StatusCode(403, new { ok = false, error = "The beta, dev and 2026 patches are for developer accounts." }); string path = Path.Combine(PatchDirFor(ch), id + ".dll"); if (!System.IO.File.Exists(path)) return NotFound(new { ok = false, error = "The server doesn't have this version of the patch." }); Console.WriteLine($"[launcher] @{u!.Username} ({u.Id}) downloaded the patch {id[..12]}"); return PhysicalFile(path, "application/octet-stream", enableRangeProcessing: true); } // ---- the game itself -------------------------------------------------------------------------- // Data/Launcher/game holds latest.json ({sha256, size, unpacked}) and .zip - the whole // game as one zip (Campus.zip), staged by Launcher\tools\release_app.py game and uploaded by // UploadUpdate.bat. The launcher's INSTALL / UPDATE downloads it (signed in only), resuming // with Range requests, checks the SHA-256 and unpacks it. private static string GameDir => Path.Combine(Environment.CurrentDirectory, "Data", "Launcher", "game"); [HttpGet("/api/launcher/v1/game")] public IActionResult GameLatest() { var (u, err) = Caller(); if (err != null) return err; try { var j = JObject.Parse(System.IO.File.ReadAllText(Path.Combine(GameDir, "latest.json"))); string sha = ((string?)j["sha256"] ?? "").ToLowerInvariant(); if (Sha.IsMatch(sha) && System.IO.File.Exists(Path.Combine(GameDir, sha + ".zip"))) return Ok(new { ok = true, sha256 = sha, size = (long?)j["size"] ?? 0, unpacked = (long?)j["unpacked"] ?? 0 }); } catch { } return StatusCode(503, new { ok = false, error = "The game download isn't on the server right now." }); } [HttpGet("/api/launcher/v1/game/{id}")] public IActionResult GameDownload(string id) { var (u, err) = Caller(); if (err != null) return err; if (!Sha.IsMatch(id ?? "")) return BadRequest(new { ok = false, error = "Bad game id." }); string path = Path.Combine(GameDir, id + ".zip"); if (!System.IO.File.Exists(path)) return NotFound(new { ok = false, error = "The server doesn't have this version of the game." }); string range = Request.Headers["Range"].ToString(); Console.WriteLine($"[launcher] @{u!.Username} ({u.Id}) downloading the game {id[..12]}{(range.Length > 0 ? " (resuming " + range + ")" : "")}"); return PhysicalFile(path, "application/zip", enableRangeProcessing: true); } // ---- account ------------------------------------------------------------------------------ public class LoginBody { public string? username { get; set; } public string? password { get; set; } } private (player_info? user, IActionResult? error) Caller() { string auth = Request.Headers["Authorization"].ToString(); string token = auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) ? auth.Substring(7).Trim() : ""; if (token.Length == 0) return (null, StatusCode(401, new { ok = false, error = "Sign in first." })); string data = ClientSecurity.DecodeToken(token, requireSurface: "launcher"); if (string.IsNullOrEmpty(data) || data.StartsWith("error_")) return (null, StatusCode(401, new { ok = false, error = data == "error_token_expired" ? "Your sign-in has expired - sign in again." : "Sign in again." })); player_info? u; try { u = JsonConvert.DeserializeObject(data); } catch { u = null; } if (u == null || PlayerDB.GetFullAccount(u.Id) == null) return (null, StatusCode(401, new { ok = false, error = "Sign in again." })); return (u, null); } /// POST /api/launcher/v1/login {username, password} -> a launcher token (30 days). [HttpPost("/api/launcher/v1/login")] public IActionResult Login([FromBody] LoginBody? body) { if (body == null || string.IsNullOrWhiteSpace(body.username) || string.IsNullOrWhiteSpace(body.password)) return BadRequest(new { ok = false, error = "Enter your username and password." }); var result = PlayerDB.GetAccountPassword(body.username.Trim().TrimStart('@'), body.password, isAdminPanel: false); if (!result.success) return StatusCode(401, new { ok = false, error = result.error ?? "Incorrect username or password." }); string token = ClientSecurity.RefreshToken_Web(result.accid, TokenTtl, "launcher"); var full = PlayerDB.GetFullAccount(result.accid); Console.WriteLine($"[launcher] @{full?.player?.Username} ({result.accid}) signed in to the launcher"); return Ok(new { ok = true, token, id = result.accid, username = full?.player?.Username, expires = DateTimeOffset.UtcNow.Add(TokenTtl).ToUnixTimeSeconds() }); } /// What the launcher's account menu shows: who you are, level, tokens and gifts waiting in game. [HttpGet("/api/launcher/v1/me")] public IActionResult Me() { var (u, err) = Caller(); if (err != null) return err; ulong id = u!.Id; var full = PlayerDB.GetFullAccount(id)!; var lvl = Progress.GetPlayerLevel(id); int need = lvl.Level >= 0 && lvl.Level < Progress.LEVEL_REQUIRED_XP.Length ? Progress.LEVEL_REQUIRED_XP[lvl.Level] : 0; bool banned = PlayerDB.HasActiveModerationBlock(id); // Same shape the website's /me uses so the launcher can show the same banner and // block PLAY instead of just saying "you can only go to your dorm". Null when the // account isn't banned; remaining_seconds null = permanent. var siteBan = PlayerDB.GetSiteBan(id); object? ban = siteBan == null ? null : new { permanent = siteBan.RemainingSeconds == null, remaining_seconds = siteBan.RemainingSeconds, until = siteBan.Until, reason = siteBan.Reason }; return Ok(new { ok = true, id, username = full.player?.Username ?? "", display_name = full.player?.DisplayName ?? "", image = string.IsNullOrWhiteSpace(full.player?.profileImage) ? "" : "/img/" + full.player!.profileImage, level = lvl.Level, xp = lvl.XP, xp_needed = need, tokens = PlayerDB.GetBalance(id, (int)CurrencyType.RecCenterTokens), gifts = PlayerDB.GetPendingGiftBoxes(id).Count, banned, ban, // strings: a SteamID64 is past what JavaScript-style number parsers keep exactly steam_ids = PlayerDB.GetPlatformIds(id, Platforms.Steam).Where(s => s != 0).Select(s => s.ToString()), // the Steam ids this account whitelisted through the launcher (LauncherPlayers) launcher_steam = LauncherPlayers.ForAccount(id), // developer / owner accounts get the release-channel drop-down (public / beta / dev) in the launcher dev = IsDevAccount(id), }); } public class SteamBody { public string? ticket { get; set; } } /// /// POST /api/launcher/v1/steam {ticket: hex} - link the signed-in account to the Steam account /// the launcher is running under. The launcher gets the ticket from Steam itself (the game's /// steam_api64, app 471710 - the same ticket the game logs in with), and SteamTicket checks /// Steam's signature OFFLINE, so the id linked is one Steam vouched for - a posted SteamID /// alone is public and proves nothing. Linking goes through the same one-account-per-Steam /// lock as a game login (AccountLock.TryEnforceAndBind, with no device: the launcher isn't a /// game device, so it doesn't take the account's PC slot). /// [HttpPost("/api/launcher/v1/steam")] public IActionResult LinkSteam([FromBody] SteamBody? body) { var (u, err) = Caller(); if (err != null) return err; string hex = (body?.ticket ?? "").Trim(); if (hex.Length == 0 || hex.Length > 8192) return BadRequest(new { ok = false, error = "No Steam ticket." }); var proof = SteamTicket.Verify(new JObject { ["Ticket"] = hex }.ToString(Formatting.None)); if (!proof.Ok) { Console.WriteLine($"[launcher] Steam link for {u!.Id} refused: {proof.Reason}"); return BadRequest(new { ok = false, error = "Steam couldn't confirm your account (" + proof.Reason + ")." }); } // Steam vouched for this id and it came through our launcher: it may play on PC // (LauncherPlayers - the normal-player whitelist), whatever the account link says below. LauncherPlayers.Add(proof.SteamId, u!.Id, u.Username ?? ""); if (PlayerDB.GetPlatformIds(u!.Id, Platforms.Steam).Contains(proof.SteamId)) return Ok(new { ok = true, steam_id = proof.SteamId.ToString(), already = true }); if (!AccountLock.TryEnforceAndBind(u.Id, Platforms.Steam, proof.SteamId, null, out string lockErr)) return StatusCode(409, new { ok = false, error = lockErr }); Console.WriteLine($"[launcher] @{u.Username} ({u.Id}) linked Steam {proof.SteamId}"); return Ok(new { ok = true, steam_id = proof.SteamId.ToString() }); } } }