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 camprec.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), // The site-wide ban banner reads this: null when not banned, else the time left // (remaining_seconds null = permanent) and the reason. SiteBanGateMiddleware refuses // every change a banned session tries to make. ban = SiteBanDto(full.playerid), }; private static object? SiteBanDto(ulong playerid) { var b = PlayerDB.GetSiteBan(playerid); if (b == null) return null; return new { permanent = b.RemainingSeconds == null, remaining_seconds = b.RemainingSeconds, until = b.Until, reason = b.Reason }; } // ---- 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." }); // Correct password. If this account turned 2FA on (Settings -> Security), that's only half the login - hold // off on the site cookie and send back a ticket naming the account instead. The page then asks for a code and // posts it to /api/site/login/2fa, which is the only place the cookie actually gets issued for a 2FA account. if (TwoFactorDB.IsEnabled(result.accid)) return Ok(new { ok = false, need2fa = true, pending = PendingTwoFactorLogin.Issue(result.accid, "website") }); // A banned account CAN sign in - every page then shows the ban banner (time left + // reason) - but it can't change anything: SiteBanGateMiddleware refuses its writes. IssueSiteCookie(result.accid); var full = PlayerDB.GetFullAccount(result.accid); return Ok(new { ok = true, user = full == null ? null : PublicUser(full) }); } /// Step 2 of a 2FA account's website login: the ticket from Login, plus a code from their authenticator /// app (or a recovery code). Covered by the same /api/site/login flood ceiling as the password step /// (TrafficGuardMiddleware.IsAuthPath matches by prefix). public class SiteLogin2FA { public string? pending { get; set; } public string? code { get; set; } } [HttpPost("/api/site/login/2fa")] public IActionResult Login2FA([FromBody] SiteLogin2FA? body) { ulong accountId = PendingTwoFactorLogin.Validate(body?.pending, "website"); if (accountId == 0) return Unauthorized(new { ok = false, error = "That login attempt expired - log in again." }); if (!TwoFactorDB.VerifyLogin(accountId, body?.code)) return Unauthorized(new { ok = false, need2fa = true, pending = body!.pending, error = "Wrong code." }); IssueSiteCookie(accountId); var full = PlayerDB.GetFullAccount(accountId); return Ok(new { ok = true, user = full == null ? null : PublicUser(full) }); } // ---- 2FA enrollment (the signed-in player managing THEIR OWN account) ------------------------------------- [HttpGet("/api/site/2fa/status")] public IActionResult TwoFactorStatusSite() { var account = CurrentUser(); if (account == null) return Unauthorized(new { ok = false, error = "not signed in" }); return Ok(new { ok = true, enabled = TwoFactorDB.IsEnabled(account.Id) }); } [HttpPost("/api/site/2fa/setup")] public IActionResult TwoFactorSetupSite() { var account = CurrentUser(); if (account == null) return Unauthorized(new { ok = false, error = "not signed in" }); string secret = TwoFactorDB.BeginEnroll(account.Id); return Ok(new { ok = true, secret, otpauth = Totp.OtpAuthUri(secret, account.Username ?? ("player" + account.Id), "Campus") }); } public class SiteTwoFactorCode { public string? code { get; set; } } [HttpPost("/api/site/2fa/confirm")] public IActionResult TwoFactorConfirmSite([FromBody] SiteTwoFactorCode? body) { var account = CurrentUser(); if (account == null) return Unauthorized(new { ok = false, error = "not signed in" }); var codes = TwoFactorDB.ConfirmEnroll(account.Id, body?.code ?? ""); if (codes == null) return BadRequest(new { ok = false, error = "Wrong code - try the current 6 digits from your app." }); return Ok(new { ok = true, recoveryCodes = codes }); } [HttpPost("/api/site/2fa/disable")] public IActionResult TwoFactorDisableSite() { var account = CurrentUser(); if (account == null) return Unauthorized(new { ok = false, error = "not signed in" }); TwoFactorDB.Disable(account.Id); return Ok(new { ok = true }); } // 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 = PlayerDB.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()); // 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 (the Support page's form); STAFF (dev / mod / owner / // support flag) see every ticket and can reply, write internal notes, claim, close and // reopen. Topics, read markers, screenshots, limits and auto-close live in 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 string? category { get; set; } public List? attachments { get; set; } } public class SupportReplyReq { public string? message { get; set; } public bool @internal { get; set; } public List? attachments { get; set; } } public class SupportAssignReq { public bool assign { get; set; } = true; } private static readonly Dictionary SupportCategories = new() { ["account"] = "Account / login issue", ["join"] = "Joining & invites", ["econ"] = "Currency, rewards & gift boxes", ["report_player"] = "Report a player", ["report_room"] = "Report a room", ["bug"] = "Bug report", ["appeal"] = "Ban appeal", ["other"] = "Something else", }; // A ticket's topic key: stored since this change; older tickets carry it as the // "Issue type: