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(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 _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/), 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(); 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 ` 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 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 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 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 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 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() }); try { ulong viewerId = CurrentUser()?.Id ?? 0UL; var rooms = RoomDB.Search(query) ?? new List(); 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() }); try { var matches = PlayerDB.Search(name) ?? new List(); 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 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(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__" 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/. 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 ). 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; } } } }