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,1350 @@
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<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),
// 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) });
}
/// <summary>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).</summary>
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<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());
// 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<string>? attachments { get; set; }
}
public class SupportReplyReq
{
public string? message { get; set; }
public bool @internal { get; set; }
public List<string>? attachments { get; set; }
}
public class SupportAssignReq { public bool assign { get; set; } = true; }
private static readonly Dictionary<string, string> 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: <label>" line of their opening message.
private static string CategoryOf(SupportDB.Ticket t)
{
if (!string.IsNullOrEmpty(t.Category)) return t.Category!;
string first = t.Messages?.FirstOrDefault()?.Body ?? "";
var m = System.Text.RegularExpressions.Regex.Match(first, @"^Issue type:\s*(.+)$", System.Text.RegularExpressions.RegexOptions.Multiline);
if (m.Success)
{
string label = m.Groups[1].Value.Trim();
foreach (var kv in SupportCategories) if (string.Equals(kv.Value, label, StringComparison.OrdinalIgnoreCase)) return kv.Key;
}
return "other";
}
private static string AttachmentUrl(string name) => "/api/site/support/attachment/" + name;
private object TicketSummary(SupportDB.Ticket t, bool forStaff)
{
var last = (t.Messages ?? new()).LastOrDefault(m => forStaff || !m.Internal);
return new
{
id = t.Id,
subject = t.Subject,
status = t.Status,
category = CategoryOf(t),
categoryLabel = SupportCategories.TryGetValue(CategoryOf(t), out var cl) ? cl : "Something else",
username = t.Username,
accountId = t.AccountId,
createdAt = t.CreatedAt,
updatedAt = t.UpdatedAt,
messageCount = (t.Messages ?? new()).Count(m => !m.System && (forStaff || !m.Internal)),
lastMessage = last == null ? "" : (last.Internal ? "[note] " : "") + (last.Body.Length > 0 ? last.Body : "(screenshot)"),
awaiting = SupportDB.AwaitingOf(t),
unread = forStaff ? SupportDB.UnreadForStaff(t) : SupportDB.UnreadForPlayer(t),
assignedTo = t.AssignedTo,
assignedName = t.AssignedName,
};
}
// 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, bool forStaff)
{
// Cache lookups so a person who sent several messages is only fetched once.
var cache = new Dictionary<ulong, string?>();
string? avatarFor(ulong id)
{
if (id == 0) return null;
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,
category = CategoryOf(t),
categoryLabel = SupportCategories.TryGetValue(CategoryOf(t), out var cl) ? cl : "Something else",
username = t.Username,
accountId = t.AccountId,
avatar = avatarFor(t.AccountId),
createdAt = t.CreatedAt,
updatedAt = t.UpdatedAt,
awaiting = SupportDB.AwaitingOf(t),
assignedTo = t.AssignedTo,
assignedName = t.AssignedName,
messages = (t.Messages ?? new()).Where(m => forStaff || !m.Internal).Select(m => new
{
fromName = m.FromName,
staff = m.Staff,
body = m.Body,
at = m.At,
mine = m.FromAccountId == viewerId && !m.System,
@internal = m.Internal,
system = m.System,
avatar = m.System ? null : avatarFor(m.FromAccountId),
attachments = (m.Attachments ?? new()).Select(AttachmentUrl).ToList()
}),
// Staff see who they're helping: account age, bans, their other tickets.
player = forStaff ? SupportPlayerContext(t) : null
};
}
private static object? SupportPlayerContext(SupportDB.Ticket t)
{
var full = PlayerDB.GetFullAccount(t.AccountId);
if (full?.player == null) return null;
var ban = PlayerDB.GetSiteBan(t.AccountId);
var mic = PlayerDB.GetActiveMicBan(t.AccountId);
var others = SupportDB.ForAccount(t.AccountId).Where(x => x.Id != t.Id).ToList();
return new
{
id = t.AccountId,
username = full.player.Username,
displayName = full.player.DisplayName,
createdAt = full.player.created_at,
level = full.player.Level,
junior = full.player.IsJunior,
banned = ban != null,
banPermanent = ban != null && ban.RemainingSeconds == null,
banRemainingSeconds = ban?.RemainingSeconds,
banReason = ban?.Reason,
micBanned = mic != null,
micBanPermanent = mic != null && mic.Duration == int.MaxValue,
micBanRemainingSeconds = mic == null || mic.Duration == int.MaxValue ? (int?)null : mic.Duration,
otherTickets = others.Count,
otherOpen = others.Count(x => x.Status == "open"),
};
}
// 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);
var mine = SupportDB.ForAccount(account.Id);
return Ok(new
{
signedIn = true,
staff,
username = account.Username,
openCount = staff ? SupportDB.OpenCount() : 0,
awaitingStaff = staff ? SupportDB.AwaitingStaffCount() : 0,
unreadMine = mine.Count(SupportDB.UnreadForPlayer),
openMine = mine.Count(x => x.Status == "open"),
maxOpen = SupportDB.MaxOpenTicketsPerPlayer,
// The assistant's "I'm banned" answers read this.
ban = PlayerDB.GetSiteBan(account.Id) is { } b ? new { permanent = b.RemainingSeconds == null, remainingSeconds = b.RemainingSeconds, reason = b.Reason } : null,
micBan = PlayerDB.GetActiveMicBan(account.Id) is { } mb ? new { permanent = mb.Duration == int.MaxValue, remainingSeconds = mb.Duration == int.MaxValue ? (int?)null : mb.Duration } : null,
});
}
[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);
string category = body?.category is { } c && SupportCategories.ContainsKey(c) ? c : "other";
// Anti-spam: a few open tickets at a time, and a short gap between new ones.
if (!IsStaff(account.Id))
{
if (SupportDB.OpenCountFor(account.Id) >= SupportDB.MaxOpenTicketsPerPlayer)
return StatusCode(429, new { ok = false, error = $"You already have {SupportDB.MaxOpenTicketsPerPlayer} open tickets. Add to one of them in My tickets, or close one you don't need." });
var lastAt = SupportDB.LastCreatedBy(account.Id);
if (lastAt != null && DateTime.UtcNow - lastAt.Value < SupportDB.NewTicketCooldown)
return StatusCode(429, new { ok = false, error = "You just opened a ticket - please wait a minute before opening another." });
}
var files = SupportDB.OwnUnclaimed(account.Id, body?.attachments);
var t = SupportDB.Create(account.Id, account.Username ?? $"Player{account.Id}", subject, msg, category, files);
try
{
Webhook.Send(account.Id, "mod", $"🎫 New support ticket #{t.Id} ({SupportCategories[category]})",
$"From @{t.Username} (id {account.Id})\nSubject: {t.Subject}\n\n{msg}{(files.Count > 0 ? $"\n\n[{files.Count} screenshot(s)]" : "")}", 15844367);
}
catch (Exception ex) { Console.WriteLine($"[support] webhook failed: {ex.Message}"); }
Console.WriteLine($"[support] ticket #{t.Id} ({category}) opened by @{t.Username} ({account.Id})");
NotifyStaff();
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(t => TicketSummary(t, false)) });
}
// Staff list. status: open | closed | all ; category: a topic key ; q: #id, @username or
// words in the subject ; mine: only tickets claimed by the caller ; unclaimed: only unclaimed ;
// waiting: only tickets waiting on staff.
[HttpGet("/api/site/support/all")]
public IActionResult SupportAll([FromQuery] string? status = null, [FromQuery] string? category = null, [FromQuery] string? q = null,
[FromQuery] bool mine = false, [FromQuery] bool unclaimed = false, [FromQuery] bool waiting = false)
{
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" });
IEnumerable<SupportDB.Ticket> rows = SupportDB.All(status);
if (!string.IsNullOrWhiteSpace(category) && category != "all") rows = rows.Where(t => CategoryOf(t) == category);
if (mine) rows = rows.Where(t => t.AssignedTo == account.Id);
if (unclaimed) rows = rows.Where(t => t.AssignedTo == 0);
if (waiting) rows = rows.Where(t => SupportDB.AwaitingOf(t) == "staff");
string term = (q ?? "").Trim();
if (term.Length > 0)
{
string lower = term.ToLowerInvariant().TrimStart('@');
bool isNum = int.TryParse(term.TrimStart('#'), out int num);
rows = rows.Where(t => (isNum && (t.Id == num || t.AccountId == (ulong)num))
|| t.Username.ToLowerInvariant().Contains(lower)
|| t.Subject.ToLowerInvariant().Contains(lower));
}
// Waiting on staff first, then newest activity.
var list = rows.OrderBy(t => SupportDB.AwaitingOf(t) == "staff" ? 0 : 1).ThenByDescending(t => t.UpdatedAt).ToList();
return Ok(new { ok = true, tickets = list.Select(t => TicketSummary(t, true)) });
}
[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" });
bool staff = IsStaff(account.Id);
bool owner = t.AccountId == account.Id;
if (!owner && !staff) return StatusCode(403, new { ok = false, error = "not your ticket" });
// Opening it is reading it (the owner's view counts as the player's even for staff).
bool asStaffView = staff && !owner;
if (asStaffView ? SupportDB.UnreadForStaff(t) : SupportDB.UnreadForPlayer(t))
{
SupportDB.MarkRead(id, asStaffView);
t = SupportDB.Get(id)!;
if (asStaffView) NotifyStaff(); else SiteNotifications.Notify(account.Id);
}
return Ok(new { ok = true, ticket = TicketDetail(t, account.Id, staff), staff });
}
[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();
var files = SupportDB.OwnUnclaimed(account.Id, body?.attachments);
if (msg.Length < 1 && files.Count == 0) return BadRequest(new { ok = false, error = "empty reply" });
if (msg.Length > 4000) msg = msg.Substring(0, 4000);
bool isInternal = staff && body?.@internal == true;
if (!staff)
{
if (!RateLimit.TryConsumeQuota($"supreply:{account.Id}", maxInWindow: 15, windowSeconds: 600, out int retry))
{
Response.Headers["Retry-After"] = retry.ToString();
return StatusCode(429, new { ok = false, error = $"You're sending replies too fast. Try again in {(retry + 59) / 60} minute(s)." });
}
if (files.Count > 0 && SupportDB.PlayerAttachmentCount(t, account.Id) + files.Count > SupportDB.MaxPlayerAttachmentsPerTicket)
return StatusCode(429, new { ok = false, error = $"This ticket already has the most screenshots allowed ({SupportDB.MaxPlayerAttachmentsPerTicket}). Send your reply without them." });
}
var updated = SupportDB.AddMessage(id, account.Id, account.Username ?? $"Player{account.Id}", staff, msg, isInternal, files)!;
if (!isInternal)
{
if (staff && account.Id != t.AccountId)
{
// The player's bell lights up: "Staff replied to your ticket #N".
SiteNotifications.Notify(t.AccountId);
}
else
{
try
{
Webhook.Send(account.Id, "mod", $"💬 Reply on ticket #{id}",
$"@{t.Username}: {(msg.Length > 600 ? msg.Substring(0, 600) + "…" : msg)}{(files.Count > 0 ? $"\n[{files.Count} screenshot(s)]" : "")}", 3447003);
}
catch { }
NotifyStaff();
}
}
return Ok(new { ok = true, ticket = TicketDetail(updated, account.Id, staff), staff });
}
[HttpPost("/api/site/support/ticket/{id:int}/close")]
public IActionResult SupportClose(int id) => SupportSetStatus(id, "closed");
[HttpPost("/api/site/support/ticket/{id:int}/reopen")]
public IActionResult SupportReopen(int id) => SupportSetStatus(id, "open");
private IActionResult SupportSetStatus(int id, string status)
{
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" });
SupportDB.SetStatus(id, status, account.Id, account.Username ?? $"Player{account.Id}", staff);
Console.WriteLine($"[support] ticket #{id} {(status == "closed" ? "closed" : "reopened")} by @{account.Username} ({account.Id})");
if (staff && account.Id != t.AccountId) SiteNotifications.Notify(t.AccountId); else NotifyStaff();
return Ok(new { ok = true });
}
// Claim (assign to yourself) or release a ticket. Staff only.
[HttpPost("/api/site/support/ticket/{id:int}/assign")]
public IActionResult SupportAssign(int id, [FromBody] SupportAssignReq? body)
{
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" });
string name = account.Username ?? $"Player{account.Id}";
var t = SupportDB.Assign(id, body?.assign == false ? 0 : account.Id, name, account.Id, name);
if (t == null) return NotFound(new { ok = false, error = "no such ticket" });
return Ok(new { ok = true, ticket = TicketDetail(t, account.Id, true), staff = true });
}
// Screenshot upload (multipart field "file"): returns a name to send with the ticket or
// reply. Images only (decoded and re-encoded server-side), up to SupportDB.MaxAttachmentBytes.
//
// Anti-spam, cheapest check first and all BEFORE the body is read or decoded:
// * burst quota per account (10 per 10 min) and per IP (20 per 10 min, so a pile of
// accounts on one connection can't multiply it) - in memory;
// * persisted caps in SupportDB.UploadRefusal: 8 unsent uploads, 30 uploads / 40 MB a
// day per player, and a hard cap on the whole folder;
// and unsent uploads are deleted after 2 hours, so uploading without ever sending the
// ticket can't pile files up either. Staff are exempt from the quotas (not the caps).
[HttpPost("/api/site/support/upload")]
[RequestSizeLimit(8 * 1024 * 1024)]
public async Task<IActionResult> SupportUpload()
{
var account = CurrentUser();
if (account == null) return Unauthorized(new { ok = false, error = "not signed in" });
if (!IsStaff(account.Id))
{
string? ip = ClientIp();
int retry;
if (!RateLimit.TryConsumeQuota($"supup:{account.Id}", maxInWindow: 10, windowSeconds: 600, out retry)
|| (!string.IsNullOrEmpty(ip) && !RateLimit.TryConsumeQuota($"supupip:{ip}", maxInWindow: 20, windowSeconds: 600, out retry)))
{
Response.Headers["Retry-After"] = retry.ToString();
return StatusCode(429, new { ok = false, error = $"You're adding screenshots too fast. Try again in {(retry + 59) / 60} minute(s)." });
}
}
string? refusal = SupportDB.UploadRefusal(account.Id);
if (refusal != null) return StatusCode(429, new { ok = false, error = refusal });
if (!Request.HasFormContentType || Request.Form.Files.Count == 0) return BadRequest(new { ok = false, error = "No file." });
var file = Request.Form.Files[0];
if (file.Length > SupportDB.MaxAttachmentBytes)
return BadRequest(new { ok = false, error = $"That image is too big (max {SupportDB.MaxAttachmentBytes / 1024 / 1024} MB)." });
using var ms = new MemoryStream();
await file.CopyToAsync(ms);
string? name = SupportDB.SaveAttachment(account.Id, ms.ToArray());
if (name == null) return BadRequest(new { ok = false, error = "That isn't a PNG, JPG, GIF or WebP image we can read (or it's too large)." });
return Ok(new { ok = true, name, url = AttachmentUrl(name) });
}
// The × on an unsent screenshot: frees it (and its slot in the unsent-upload cap) right away.
[HttpDelete("/api/site/support/upload/{name}")]
public IActionResult SupportUploadDelete(string name)
{
var account = CurrentUser();
if (account == null) return Unauthorized(new { ok = false, error = "not signed in" });
return Ok(new { ok = SupportDB.DeleteUnsent(account.Id, name ?? "") });
}
// A screenshot: its uploader, the ticket's owner, or staff.
[HttpGet("/api/site/support/attachment/{name}")]
public IActionResult SupportAttachment(string name)
{
var account = CurrentUser();
if (account == null) return Unauthorized();
if (!System.Text.RegularExpressions.Regex.IsMatch(name, @"^[0-9a-f]{32}\.(png|jpg|gif|webp)$")) return NotFound();
var a = SupportDB.GetAttachment(name);
if (a == null) return NotFound();
bool allowed = a.UploaderId == account.Id || IsStaff(account.Id)
|| (a.TicketId != 0 && SupportDB.Get(a.TicketId)?.AccountId == account.Id);
if (!allowed) return StatusCode(403);
string path = Path.Combine(SupportDB.UploadDir, name);
if (!System.IO.File.Exists(path)) return NotFound();
Response.Headers["Cache-Control"] = "private, max-age=86400";
return PhysicalFile(path, a.ContentType);
}
// Every staff member's open site tabs re-read their bell ("N tickets waiting for staff").
private static List<ulong>? _staffIds;
private static DateTime _staffIdsAt;
private static void NotifyStaff()
{
try
{
if (_staffIds == null || (DateTime.UtcNow - _staffIdsAt).TotalMinutes > 5)
{
_staffIds = PlayerDB.PlayerDBFile.GetCollection<player_data>("players").FindAll()
.Where(p => IsStaffFlag(p.dev_Flag)).Select(p => p.playerid).ToList();
_staffIdsAt = DateTime.UtcNow;
}
foreach (var id in _staffIds) SiteNotifications.Notify(id);
}
catch { }
}
public static bool IsSupportStaff(ulong id) => IsStaffFlag(PlayerDB.GetFullAccount(id)?.dev_Flag ?? DevFlag.none);
// 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).
// `admin`: a dev account on an IP that can reach the admin panel - shows the quick-admin
// button on profiles and rooms (SiteAdminController).
return Ok(new { ok = true, user = PublicUser(full), email = full.player?.Email ?? "",
admin = SiteAdminController.ShowsAdmin(HttpContext, account.Id) });
}
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 });
}
public class PasswordBody
{
public string? current_password { get; set; }
public string? new_password { get; set; }
public string? confirm_password { get; set; }
}
// POST /api/site/password {current_password, new_password, confirm_password} - Settings > Password.
// This used to be a button that only said "saved". The current password is checked with the same helper
// the login uses (so wrong guesses count towards the same lockout), then the new one is hashed into the
// players db by PlayerDB.SetPlayerPassword - the SAME field the game logs in with, so it changes there
// too. Same 6-64 length rule as signup.
[HttpPost("/api/site/password")]
public IActionResult ChangeMyPassword([FromBody] PasswordBody? body)
{
var account = CurrentUser();
if (account == null) return Unauthorized(new { ok = false, error = "not signed in" });
string cur = body?.current_password ?? "";
string pw = body?.new_password ?? "";
string confirm = body?.confirm_password ?? "";
if (cur.Length == 0) return BadRequest(new { ok = false, error = "Enter your current password." });
if (pw.Length < 6 || pw.Length > 64) return BadRequest(new { ok = false, error = "Password must be 6-64 characters." });
if (pw != confirm) return BadRequest(new { ok = false, error = "The passwords don't match." });
if (pw == cur) return BadRequest(new { ok = false, error = "Your new password is the same as the current one." });
string? username = PlayerDB.GetFullAccount(account.Id)?.player?.Username;
if (string.IsNullOrEmpty(username)) return StatusCode(500, new { ok = false, error = "Could not find your account." });
var check = PlayerDB.GetAccountPassword(username, cur, isAdminPanel: false);
if (!check.success || check.accid != account.Id)
return BadRequest(new { ok = false, error = "Your current password is wrong." });
PlayerDB.SetPlayerPassword(pw, account.Id, log: true);
return Ok(new { ok = true });
}
// ---- 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(ConfigCampus.SiteUrl + "/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(ConfigCampus.SiteUrl + "/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://camprec.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 (PlayerDB.IsProfileLocked(account.Id, PlayerDB.ProfileLock.Picture))
return BadRequest(new { ok = false, error = PlayerDB.ProfileLockMessage(PlayerDB.ProfileLock.Picture) });
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; }
}
}
}