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"); } /// /// 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. /// 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 // right after 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("", StringComparison.OrdinalIgnoreCase); if (i >= 0) html = html.Insert(i + "".Length, "\n "); } 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(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 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("", StringComparison.OrdinalIgnoreCase); if (b >= 0) html = html.Insert(b + "".Length, "\n "); } html = System.Text.RegularExpressions.Regex.Replace( html, "\\s*]*(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 "); sb.Append($"\n "); sb.Append($"\n "); sb.Append("\n "); if (!string.IsNullOrWhiteSpace(description)) sb.Append($"\n "); if (!string.IsNullOrWhiteSpace(imageAbsUrl)) { sb.Append($"\n "); sb.Append($"\n "); } sb.Append($"\n "); sb.Append($"\n "); if (!string.IsNullOrWhiteSpace(description)) sb.Append($"\n "); int i = html.IndexOf("", StringComparison.OrdinalIgnoreCase); if (i >= 0) html = html.Insert(i + "".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 = "" + "" + $"{e} not found - Campus" + $"" + "" + "" + $"

{e} does not exist

" + $"

We couldn't find that {e.ToLower()} on Campus.

" + "← Back to home
"; 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/, 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/, 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 { 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 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" }); } } } }