Files
2026-10-14 06:59:51 +02:00

1889 lines
103 KiB
C#

using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server;
using Campus_live_rewrite.api.server.Classes;
using Campus_live_rewrite.api.server.Classes.db_class;
// SavedImageTypeEnum / ImageAccessibility are nested in ImageMetadataDB, same as APIController.
using static ImageMetadataDB;
using MessageType = Campus_live_rewrite.api.WebsocketEvents.MessageType;
namespace Campus_live_rewrite.Controllers
{
// Real data for the public website - the endpoints that replace data.js's demo content
// (six invented rooms, picsum photo URLs, dicebear avatars, hardcoded "online friends").
//
// These are deliberately SEPARATE from the game endpoints rather than the site calling
// /rooms/hot and /api/images/v3/... directly:
// - the game routes all sit behind the patch-header gate, which a browser cannot pass;
// - they return the client's DTOs (huge - room DataBlobs, save payloads, platform ids)
// where the site needs a handful of display fields;
// - they leak more than an anonymous web visitor should see.
// So this projects the same DB reads down to a small, public-safe shape.
//
// Everything here treats "signed out" as valid: the site is public. Endpoints that need
// an account (friends, notifications, "my" anything) say so and 401 instead.
[ApiController]
public class SiteContentController : ControllerBase
{
private const string SiteCookie = "campus_site";
// Mirrors SiteController.CurrentUser. Kept local rather than shared through a base
// class so the two controllers can be read independently.
private player_info? CurrentUser()
{
string token = Request.Cookies[SiteCookie] ?? "";
if (string.IsNullOrEmpty(token)) return null;
// MUST require the "website" surface, exactly like SiteController.CurrentUser - the
// website token is minted with surface="website" and DecodeToken now refuses website
// tokens on every path that doesn't ask for them. Without this, every endpoint in
// this controller (friends, notifications, profile, photos, rooms) 401s the site.
string data = ClientSecurity.DecodeToken(token, requireSurface: "website");
if (string.IsNullOrEmpty(data) || data.StartsWith("error_")) return null;
try { return JsonConvert.DeserializeObject<player_info>(data); }
catch { return null; }
}
private ulong ViewerId() => CurrentUser()?.Id ?? 0UL;
// ---- shared projections -----------------------------------------------------------
// Everything image-shaped on the site goes through /img/<name>, which ImagesController
// already serves. Null when the account/room has no image, so the front-end can fall
// back to its generated avatar rather than requesting a 404.
// ---- what the website may say about WHERE something is ---------------------------
// A dorm is a personal space and an unpublished (Private) room is a draft: the site never
// names either - not in "Online · ^Room" status lines, not on a photo. Checked live on
// every request, so a room that gets published starts showing up by name straight away.
private static bool RoomIsShowable(RoomDBClasses.RoomRoot? room) =>
room != null && !room.IsDorm && RoomDB.IsRoomPublished(room);
/// <summary>The room a player is in, named for the site - null for a dorm / unpublished room.</summary>
private static string? ShowableRoom(HeartbeatDB.RoomInstance? inst)
{
if (inst == null || string.IsNullOrWhiteSpace(inst.name) || inst.name == "not in a room.") return null;
RoomDBClasses.RoomRoot? room = null;
try { room = inst.roomId > 0 ? RoomDB.GetRoom((ulong)inst.roomId) : RoomDB.GetRoomByName(inst.name.TrimStart('^', '@')); } catch { }
return RoomIsShowable(room) ? inst.name.TrimStart('^', '@') : null;
}
/// <summary>Where this player is, for the site - null offline, in a dorm or in an unpublished room.</summary>
internal static string? ShowableRoomOf(ulong playerId)
{
try
{
var (online, inst) = HeartbeatDB.PeekPresence(playerId);
return online ? ShowableRoom(inst) : null;
}
catch { return null; }
}
/// <summary>The room a photo was taken in, if the site may name it (published, not a dorm).</summary>
private static RoomDBClasses.RoomRoot? PhotoRoom(long roomId)
{
if (roomId <= 0) return null;
try { var r = RoomDB.GetRoom((ulong)roomId); return RoomIsShowable(r) ? r : null; } catch { return null; }
}
/// <summary>A photo's roomId as the site may send it: 0 while its room is a dorm or unpublished.</summary>
private static long PhotoRoomId(long roomId) => PhotoRoom(roomId)?.RoomId is ulong id ? (long)id : 0;
private static string? ImgUrl(string? name) =>
string.IsNullOrWhiteSpace(name) ? null : "/img/" + name;
private static object RoomCard(RoomDBClasses.RoomRoot r, string? creatorName) => new
{
id = r.RoomId,
name = r.Name,
description = r.Description ?? "",
image = ImgUrl(r.ImageName),
creator = creatorName,
creatorId = r.CreatorAccountId,
visits = r.Stats?.VisitCount ?? 0,
cheers = r.Stats?.CheerCount ?? 0,
favorites = r.Stats?.FavoriteCount ?? 0,
maxPlayers = r.MaxPlayers,
approved = r.IsRecRoomApproved,
// who's in the room right now - the "N playing now" chips on the site
players = HeartbeatDB.CountInRoom(r.RoomId),
};
// Room rows carry a CreatorAccountId, not a name. Resolving one account per row would
// be N queries per page, so the caller batches the distinct ids through this first.
private static Dictionary<ulong, string> CreatorNames(IEnumerable<ulong> ids)
{
var map = new Dictionary<ulong, string>();
foreach (var id in ids.Distinct())
{
if (id == 0) continue;
var acc = PlayerDB.GetAccountById(id);
if (acc != null)
map[id] = acc.DisplayName ?? acc.Username ?? "";
}
return map;
}
private List<object> ProjectRooms(IEnumerable<RoomDBClasses.RoomRoot?> rooms, int take)
{
ulong viewer = ViewerId();
var visible = rooms
.Where(r => r != null && !r.IsDorm && RoomDB.CanPlayerSeeRoom(r, viewer))
.Select(r => r!)
.Take(take)
.ToList();
var names = CreatorNames(visible.Select(r => r.CreatorAccountId));
return visible
.Select(r => RoomCard(r, names.TryGetValue(r.CreatorAccountId, out var n) ? n : null))
.ToList();
}
// ---- rooms ------------------------------------------------------------------------
// GET /api/site/rooms/hot?take=12
// Replaces data.js `rooms` - the six hand-written Whisker Heights / Midnight Mews
// entries that every visitor saw regardless of what was actually on the server.
[HttpGet("/api/site/rooms/hot")]
public IActionResult HotRooms(int take = 12)
{
take = Math.Clamp(take, 1, 50);
try
{
return Ok(new { ok = true, results = ProjectRooms(RoomDB.GetHotRooms(), take) });
}
catch (Exception ex)
{
Console.WriteLine($"[site] hot rooms failed: {ex.Message}");
return StatusCode(500, new { ok = false, error = "could not load rooms" });
}
}
// GET /api/site/rooms/browse?tab=hot|rro|new&q=&skip=0&take=24
// The website's Rooms page. hot = most visited, rro = the Rec Room Originals, new = latest
// created; q filters by name (and creator). Public/visible rooms only, paged.
[HttpGet("/api/site/rooms/browse")]
public IActionResult BrowseRooms(string tab = "hot", string q = "", int skip = 0, int take = 24)
{
take = Math.Clamp(take, 1, 60);
skip = Math.Max(0, skip);
try
{
ulong viewer = ViewerId();
IEnumerable<RoomDBClasses.RoomRoot> rooms = RoomDB.GetHotRooms()
.Where(r => r != null && !r.IsDorm && RoomDB.CanPlayerSeeRoom(r, viewer));
tab = (tab ?? "hot").Trim().ToLowerInvariant();
if (tab == "rro") rooms = rooms.Where(RoomDB.IsRecRoomOriginal);
else if (tab == "new") rooms = rooms.Where(r => !RoomDB.IsRecRoomOriginal(r));
q = (q ?? "").Trim().TrimStart('^');
if (q.Length > 0)
rooms = rooms.Where(r => (r.Name ?? "").Contains(q, StringComparison.OrdinalIgnoreCase)
|| (r.Description ?? "").Contains(q, StringComparison.OrdinalIgnoreCase));
rooms = tab == "new"
? rooms.OrderByDescending(r => r.CreatedAt)
: rooms.OrderByDescending(r => r.Stats?.VisitCount ?? 0).ThenByDescending(r => r.Stats?.CheerCount ?? 0);
var all = rooms.ToList();
var page = all.Skip(skip).Take(take).ToList();
var names = CreatorNames(page.Select(r => r.CreatorAccountId));
var results = page.Select(r => RoomCard(r, names.TryGetValue(r.CreatorAccountId, out var n) ? n : null)).ToList();
return Ok(new { ok = true, results, total = all.Count, skip, take });
}
catch (Exception ex)
{
Console.WriteLine($"[site] browse rooms failed: {ex.Message}");
return StatusCode(500, new { ok = false, error = "could not load rooms" });
}
}
// GET /api/site/rooms/featured
// The SAME admin-curated featured rooms the game shows (FeaturedRoomsStore), in the same
// order. Public/visible rooms only. Empty list when nothing is featured (the section hides).
[HttpGet("/api/site/rooms/featured")]
public IActionResult FeaturedRooms(int take = 12)
{
try
{
ulong viewer = ViewerId();
var rooms = new List<RoomDBClasses.RoomRoot>();
foreach (var id in FeaturedRoomsStore.GetIds())
{
var r = RoomDB.GetRoom(id);
if (r != null && !r.IsDorm && RoomDB.CanPlayerSeeRoom(r, viewer))
rooms.Add(r);
if (rooms.Count >= take) break;
}
var names = CreatorNames(rooms.Select(r => r.CreatorAccountId));
var results = rooms
.Select(r => RoomCard(r, names.TryGetValue(r.CreatorAccountId, out var n) ? n : null))
.ToList();
return Ok(new { ok = true, results });
}
catch (Exception ex)
{
Console.WriteLine($"[site] featured rooms failed: {ex.Message}");
return StatusCode(500, new { ok = false, error = "could not load featured rooms" });
}
}
// GET /api/site/rooms/{id} - the room.html detail page. {id} is the room NAME (how rooms
// are shared/searched); a numeric slug still works as an account-... er, room id fallback.
[HttpGet("/api/site/rooms/{id}")]
public IActionResult Room(string id)
{
try
{
var room = RoomDB.GetRoomByName(id);
if (room == null && ulong.TryParse(id, out var rid)) room = RoomDB.GetRoom(rid);
// Dorms are personal spaces, not published rooms: never shown on the website.
if (room == null || room.IsDorm || !RoomDB.CanPlayerSeeRoom(room, ViewerId()))
return NotFound(new { ok = false, error = "room not found" });
var names = CreatorNames(new[] { room.CreatorAccountId });
names.TryGetValue(room.CreatorAccountId, out var creator);
// Photos taken in this room, newest first - the room page's gallery.
// Same two filters as the profile feed: real camera photos only, and public
// only - this page is reachable signed out, so it must never surface a
// private photo regardless of what GetRoomImages returns.
var photos = ImageMetadataDB.GetRoomPublicPhotos((int)room.RoomId, 48) ?? new();
ulong viewerId = ViewerId();
var photoAuthors = CreatorNames(photos.Select(i => i.PlayerId));
var photoCards = photos.Select(i =>
{
var acc = PlayerDB.GetAccountById(i.PlayerId);
return new
{
id = i.Id,
image = ImgUrl(i.ImageName),
description = i.Description,
cheers = i.CheerCount,
cheered = viewerId != 0 && ImageMetadataDB.HasUserCheered(i.Id, viewerId),
comments = i.CommentCount,
createdAt = i.CreatedAt,
taggedPlayers = ResolveTagged(i.TaggedPlayerIds),
author = acc == null ? null : new { id = acc.Id, username = acc.Username, display_name = acc.DisplayName, profile_image = ImgUrl(acc.profileImage) },
};
}).ToList();
return Ok(new
{
ok = true,
room = RoomCard(room, creator),
subrooms = room.SubRooms?.Select(s => new { id = s.SubRoomId, name = s.Name }).ToList(),
photos = photoCards,
});
}
catch (Exception ex)
{
Console.WriteLine($"[site] room {id} failed: {ex.Message}");
return StatusCode(500, new { ok = false, error = "could not load the room" });
}
}
// ---- profiles ---------------------------------------------------------------------
// GET /api/site/profile/{handle} (handle = username, or a numeric account id)
//
// One call for the whole profile page. In-game this is 8 separate per-id fetches
// (account, bio, privacy, progression, reputation, photo feed, showcase, visited) -
// see the profile-panel notes. The site has no reason to pay eight round trips.
[HttpGet("/api/site/profile/{handle}")]
public IActionResult Profile(string handle)
{
try
{
// Resolve by USERNAME first, THEN fall back to a numeric account id. A purely
// numeric USERNAME (e.g. "123", whose account id is actually 221) must resolve to
// that user, not to account id 123 - the old id-first check got them confused.
player_data? full = PlayerDB.GetFullAccountByName(handle);
if (full == null && ulong.TryParse(handle, out var byId))
full = PlayerDB.GetFullAccount(byId);
if (full?.player == null)
return NotFound(new { ok = false, error = "player not found" });
ulong id = full.playerid;
ulong viewer = ViewerId();
// Block: if the profile owner has this viewer blocked (their own Ignored flag set
// toward the viewer), the viewer may not see the profile. GetRelationships(owner)
// projects PlayerID = the OTHER player and Ignored = the owner's own value, so a
// set Ignored toward the viewer means the owner blocked them. Owner and the not-
// signed-in (viewer 0) case are never blocked.
if (viewer != 0 && viewer != id)
{
// Only the OWNER's own block hides their profile. (Ignored != None also matched
// Remote - i.e. the viewer having blocked the owner - which locked the blocker
// out of the profile they'd need to unblock from.)
var ownerRel = FriendsDB.GetRelationships(id)?
.FirstOrDefault(r => r.PlayerID == viewer);
if (ownerRel != null && FriendsDB.IsOwnBlock(ownerRel.Ignored))
return StatusCode(403, new
{
ok = false,
blocked = true,
error = "This Profile cannot be viewed at this time",
});
}
var progress = Progress.GetPlayerLevel(id);
var rep = full.player.PlayerReputation ?? new mPlayerReputation();
// Photo feed: their photos plus ones they're tagged in, filtered by the
// requester so a private photo isn't exposed to a stranger.
// GetPlayerFeed handles the accessibility filter (public-only unless you
// are looking at yourself) but returns every image TYPE. Restrict to
// ShareCamera so the photo tab shows photos the player actually took, not
// their profile picture and room thumbnails repeated back at them.
bool viewerIsFriend = viewer != 0 && viewer != id && AreFriends(viewer, id);
var rawFeed = ImageMetadataDB.GetPlayerFeed(viewer, id, 0, 200, viewerIsFriend) ?? new();
Console.WriteLine($"[site] profile {id} feed types: " +
string.Join(", ", rawFeed.GroupBy(i => i.SavedImageType).Select(g => $"{g.Key}={g.Count()}")));
// "Photos the player took" = actual camera photos (ShareCamera), plus any untyped
// (None) image, which is what the photo tab shows. Thumbnails (outfit/room/profile/
// invention/event/loadscreen) are deliberately excluded so the tab isn't the
// avatar and room covers repeated back. isPrivate drives the lock badge; for your
// OWN profile private photos are included (GetPlayerFeed only hides others'
// private), so a lock marks the ones only you can see.
// Each photo carries two flags so the profile can split them across tabs:
// taken = this player took it (PlayerId == id)
// tagged = this player is tagged in it (theirs OR someone else's photo)
var photos = rawFeed
.Where(i => i.SavedImageType == SavedImageTypeEnum.ShareCamera
|| i.SavedImageType == SavedImageTypeEnum.None)
.Take(96)
.Select(i => new
{
id = i.SavedImageId,
image = ImgUrl(i.ImageName),
cheers = i.CheerCount,
comments = i.CommentCount,
roomId = PhotoRoomId(i.RoomId),
description = i.Description,
createdAt = i.CreatedAt,
isPrivate = i.Accessibility != ImageAccessibility.Public,
visibility = i.FriendsOnly ? "friends" : (i.Accessibility == ImageAccessibility.Public ? "public" : "private"),
taken = i.PlayerId == id,
tagged = i.TaggedPlayerIds != null && i.TaggedPlayerIds.Contains(id),
})
.ToList();
var rooms = ProjectRooms(RoomDB.GetRoomsByCreator(id) ?? new(), 12);
// Presence is disclosed ONLY to a signed-in viewer. A signed-out visitor (viewer 0)
// gets null, and the site hides the status line entirely - you can't see anyone's
// online/offline state unless you're logged in.
object? status = null;
if (viewer != 0)
{
var (isOnline, room) = HeartbeatDB.PeekPresence(id);
// when they were last seen heartbeating, so the site can say "last online 3h ago"
string? lastOnline = null;
try { lastOnline = HeartbeatDB.LastSeen(id); } catch { }
status = new
{
online = isOnline,
where = isOnline ? ShowableRoom(room) : null,
lastOnline = isOnline ? null : lastOnline,
// What they're listening to on Spotify, if their setting lets THIS viewer see it.
spotify = SpotifyLink.PayloadFor(id, viewer).GetAwaiter().GetResult(),
};
}
return Ok(new
{
ok = true,
status,
user = new
{
id,
username = full.player.Username,
display_name = full.player.DisplayName,
profile_image = ImgUrl(full.player.profileImage),
banner_image = ImgUrl(string.IsNullOrEmpty(full.player.bannerImage) ? ConfigCampus.DefaultBannerImage : full.player.bannerImage),
bio = full.player_Extra?.Bio ?? "",
socials = SocialLinks.ProfileRows(id, full.player_Extra),
level = progress?.Level ?? full.player.Level,
xp = progress?.XP ?? full.player.XP,
created_at = full.player.created_at,
is_junior = full.player.IsJunior,
},
reputation = rep,
subscribers = PlayerDB.GetSubscriberIds(id).Count,
friends = FriendsDB.GetRelationships(id)?
.Count(r => r.RelationshipType == RelationshipType.Friend) ?? 0,
photos,
rooms,
// Only meaningful when signed in; null tells the front-end to render the
// "Add friend" button in its neutral state rather than guessing.
relationship = viewer == 0 || viewer == id ? null : RelationshipWith(viewer, id),
// The viewer's own state toward this profile, so the buttons render truthfully.
subscribed = viewer != 0 && viewer != id && PlayerDB.GetSubscribedToIds(viewer).Contains(id),
favourite = viewer != 0 && viewer != id && (FriendsDB.GetRelationships(viewer)?
.Any(r => r.PlayerID == id && r.Favorited != ReciprocalStatus.None) ?? false),
// "I have blocked this player" (the viewer's own part of the pair)
blockedByMe = viewer != 0 && viewer != id && FriendsDB.HasBlocked(viewer, id),
// the viewer's website-side extras for this friend
nickname = viewer != 0 && viewer != id ? SitePrefsDB.NicknameFor(viewer, id) : null,
pinned = viewer != 0 && viewer != id && SitePrefsDB.IsPinned(viewer, id),
});
}
catch (Exception ex)
{
Console.WriteLine($"[site] profile {handle} failed: {ex.Message}");
return StatusCode(500, new { ok = false, error = "could not load the profile" });
}
}
// The caller's OWN side of the pair - never the target's row. Reading the wrong side
// inverts pending-request direction (a request you sent shows as received).
private static string? RelationshipWith(ulong viewer, ulong target)
{
var rel = FriendsDB.GetRelationships(viewer)
?.FirstOrDefault(r => r.PlayerID == target || r.OtherPlayerID == target);
return rel?.RelationshipType.ToString();
}
// ---- friend actions from the website ----------------------------------------------
// These run the SAME FriendsDB operations the game's /api/relationships/v2/* endpoints do,
// but authenticate with the WEBSITE session (campus_site cookie via CurrentUser) instead
// of the game bearer token - so a friend request sent from the site shows up in-game, and
// vice-versa. Arg order mirrors the game controllers exactly: (otherPlayerId, myId).
public class FriendActionBody { public ulong id { get; set; } }
private IActionResult FriendAction(FriendActionBody? body, Func<ulong, ulong, RelationshipDetail?> op, string what)
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
if (body == null || body.id == 0 || body.id == me.Id)
return BadRequest(new { ok = false, error = "invalid target" });
// Light anti-spam: one friend action per account per 2s.
if (!RateLimit.TryConsume($"sitefriend:{me.Id}", cooldownSeconds: 2, out int retry))
{
Response.Headers["Retry-After"] = retry.ToString();
return StatusCode(429, new { ok = false, error = "Slow down a moment." });
}
try
{
// Caller is the FROM player (the sender), target is the TO player - the order the
// reference/client expects. SendFriendRequest marks the FROM row FriendRequestSent,
// so the sender's own relationship reads "Sent" and the profile stays on "Pending"
// after a refresh. (Accept/Remove are symmetric, so the order is harmless there.)
var rel = op(me.Id, body.id);
Console.WriteLine($"[site] {me.Id} {what} {body.id} -> {rel?.RelationshipType.ToString() ?? "null"}");
SiteNotifications.Notify(me.Id);
SiteNotifications.Notify(body.id);
return Ok(new { ok = true, relationship = rel?.RelationshipType.ToString() });
}
catch (Exception ex)
{
Console.WriteLine($"[site] friend {what} {me.Id}->{body.id} failed: {ex.Message}");
return StatusCode(500, new { ok = false, error = "could not update the friendship" });
}
}
// The friends of a profile: All / Favourites, plus pending requests when it is your own.
// Presence comes from the game hub (online = socket connected), so the list is the same
// truth the in-game friends list shows. Other people's lists show accepted friends only.
[HttpGet("/api/site/profile/{handle}/friends")]
public IActionResult ProfileFriends(string handle)
{
try
{
player_data? full = PlayerDB.GetFullAccountByName(handle);
if (full == null && ulong.TryParse(handle, out var byId)) full = PlayerDB.GetFullAccount(byId);
if (full?.player == null) return NotFound(new { ok = false, error = "player not found" });
ulong id = full.playerid;
ulong viewer = ViewerId();
bool own = viewer != 0 && viewer == id;
var rels = FriendsDB.GetRelationships(id) ?? new List<RelationshipDetail>();
var online = CampusLiveSource.Controllers.NotificationsController.WebSockets.Keys.ToHashSet();
var viewerRels = own ? rels : (viewer != 0 ? FriendsDB.GetRelationships(viewer) ?? new List<RelationshipDetail>() : new List<RelationshipDetail>());
object Row(RelationshipDetail r, bool favourite)
{
var acc = PlayerDB.GetAccountById(r.PlayerID);
if (acc == null) return null!;
bool isOnline = online.Contains(r.PlayerID);
return new
{
id = acc.Id,
username = acc.Username,
display_name = acc.DisplayName,
profile_image = ImgUrl(acc.profileImage),
favourite,
online = isOnline,
where = isOnline ? ShowableRoomOf(r.PlayerID) : null,
spotify = SpotifyLink.CachedPayloadFor(r.PlayerID, viewer),
};
}
var friends = rels.Where(r => r.RelationshipType == RelationshipType.Friend)
// "favourite" is always from the VIEWER's point of view.
.Select(r => Row(r, own ? r.Favorited != ReciprocalStatus.None
: viewerRels.Any(v => v.PlayerID == r.PlayerID && v.Favorited != ReciprocalStatus.None)))
.Where(x => x != null).ToList();
var pending = own
? rels.Where(r => r.RelationshipType == RelationshipType.FriendRequestReceived).Select(r => Row(r, false)).Where(x => x != null).ToList()
: new List<object>();
var sent = own
? rels.Where(r => r.RelationshipType == RelationshipType.FriendRequestSent).Select(r => Row(r, false)).Where(x => x != null).ToList()
: new List<object>();
return Ok(new { ok = true, own, friends, pending, sent });
}
catch (Exception ex)
{
Console.WriteLine($"[site] profile friends {handle} failed: {ex.Message}");
return StatusCode(500, new { ok = false, error = "could not load friends" });
}
}
// The people subscribed to a profile, and the creators that profile subscribes to. Both
// read the same store the in-game Subscribe button writes. Public, like the counts.
[HttpGet("/api/site/profile/{handle}/subscribers")]
public IActionResult ProfileSubscribers(string handle) => SubscriptionList(handle, subscribers: true);
[HttpGet("/api/site/profile/{handle}/subscriptions")]
public IActionResult ProfileSubscriptions(string handle) => SubscriptionList(handle, subscribers: false);
private IActionResult SubscriptionList(string handle, bool subscribers)
{
try
{
player_data? full = PlayerDB.GetFullAccountByName(handle);
if (full == null && ulong.TryParse(handle, out var byId)) full = PlayerDB.GetFullAccount(byId);
if (full?.player == null) return NotFound(new { ok = false, error = "player not found" });
ulong id = full.playerid;
ulong viewer = ViewerId();
var ids = subscribers ? PlayerDB.GetSubscriberIds(id) : PlayerDB.GetSubscribedToIds(id);
var mine = viewer != 0 ? PlayerDB.GetSubscribedToIds(viewer) : new List<ulong>();
var online = CampusLiveSource.Controllers.NotificationsController.WebSockets.Keys.ToHashSet();
var results = new List<object>();
foreach (var pid in ids.Distinct())
{
var acc = PlayerDB.GetAccountById(pid);
if (acc == null) continue;
bool isOnline = online.Contains(pid);
results.Add(new
{
id = acc.Id,
username = acc.Username,
display_name = acc.DisplayName,
profile_image = ImgUrl(acc.profileImage),
online = isOnline,
where = isOnline ? ShowableRoomOf(pid) : null,
subscribers = PlayerDB.GetSubscriberIds(pid).Count,
// whether the VIEWER is subscribed to this person (so the row can offer a button)
subscribed = viewer != 0 && viewer != pid && mine.Contains(pid),
});
}
return Ok(new { ok = true, own = viewer != 0 && viewer == id, results });
}
catch (Exception ex)
{
Console.WriteLine($"[site] subscription list {handle} failed: {ex.Message}");
return StatusCode(500, new { ok = false, error = "could not load the list" });
}
}
public class FavouriteBody { public ulong id { get; set; } public bool on { get; set; } }
// Favourite / unfavourite a friend from the website. Same store and same realtime pushes as
// the in-game star (FriendsDB.SetRelationshipFlag), so the game updates the moment it's clicked.
[HttpPost("/api/site/friends/favourite")]
public IActionResult SiteFavourite([FromBody] FavouriteBody? body)
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
if (body == null || body.id == 0 || body.id == me.Id) return BadRequest(new { ok = false, error = "invalid target" });
if (!AreFriends(me.Id, body.id)) return BadRequest(new { ok = false, error = "you can only favourite a friend" });
var rel = FriendsDB.SetRelationshipFlag(me.Id, body.id, FriendInfo.Favorited, body.on);
return Ok(new { ok = true, favourite = rel.Favorited != ReciprocalStatus.None });
}
public class BlockBody { public ulong id { get; set; } public bool on { get; set; } }
// POST /api/site/friends/block {id, on} - block / unblock from the website. The SAME
// FriendsDB.SetBlock the in-game button uses, so the block (and its direction) shows in
// the game instantly and only the blocker's own part is lifted by an unblock.
[HttpPost("/api/site/friends/block")]
public IActionResult SiteBlock([FromBody] BlockBody? body)
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
if (body == null || body.id == 0 || body.id == me.Id) return BadRequest(new { ok = false, error = "invalid target" });
if (PlayerDB.GetAccountById(body.id) == null) return NotFound(new { ok = false, error = "player not found" });
var rel = FriendsDB.SetBlock(me.Id, body.id, body.on);
return Ok(new { ok = true, blocked = FriendsDB.IsOwnBlock(rel.Ignored), blockedMe = rel.Ignored == ReciprocalStatus.Remote || rel.Ignored == ReciprocalStatus.Mutual });
}
public class NicknameBody { public ulong id { get; set; } public string? nickname { get; set; } }
public class PinBody { public ulong id { get; set; } public bool on { get; set; } }
// POST /api/site/friends/nickname {id, nickname} - what YOU call this friend on the site
// (empty removes it). Website-only; stored per owner in SitePrefsDB.
[HttpPost("/api/site/friends/nickname")]
public IActionResult SiteNickname([FromBody] NicknameBody? body)
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
if (body == null || body.id == 0 || body.id == me.Id) return BadRequest(new { ok = false, error = "invalid target" });
if (!AreFriends(me.Id, body.id)) return BadRequest(new { ok = false, error = "you can only nickname a friend" });
string? nick = (body.nickname ?? "").Trim();
if (nick.Length > 0) nick = Campus_live_rewrite.api.server.Classes.Sanitize.SanitizeMessage(nick);
nick = SitePrefsDB.SetNickname(me.Id, body.id, nick);
// the game shows this nickname too (only to the player who set it): redraw that friend on their client now
Campus_live_rewrite.api.server.Notifications.FireAndForget(Campus_live_rewrite.api.server.Notifications.PushAccountUpdateToViewer(me.Id, body.id), "nickname push");
return Ok(new { ok = true, nickname = nick });
}
// POST /api/site/friends/pin {id, on} - pin / unpin a friend on your home page rail.
[HttpPost("/api/site/friends/pin")]
public IActionResult SitePin([FromBody] PinBody? body)
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
if (body == null || body.id == 0 || body.id == me.Id) return BadRequest(new { ok = false, error = "invalid target" });
if (body.on && !AreFriends(me.Id, body.id)) return BadRequest(new { ok = false, error = "you can only pin a friend" });
return Ok(new { ok = true, pinned = SitePrefsDB.SetPinned(me.Id, body.id, body.on) });
}
// GET /api/site/me/pinned - the friends pinned to my home page, with their live status.
[HttpGet("/api/site/me/pinned")]
public IActionResult MyPinned()
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
var prefs = SitePrefsDB.Get(me.Id);
var rows = new List<object>();
foreach (var pid in prefs.Pinned)
{
var acc = PlayerDB.GetAccountById(pid);
if (acc == null || !AreFriends(me.Id, pid)) continue;
var (isOnline, room) = HeartbeatDB.PeekPresence(pid);
rows.Add(new
{
id = pid, username = acc.Username, display_name = acc.DisplayName, profile_image = ImgUrl(acc.profileImage),
nickname = SitePrefsDB.NicknameFor(me.Id, pid),
online = isOnline, where = isOnline ? ShowableRoom(room) : null,
});
}
return Ok(new { ok = true, pinned = rows });
}
public class SubscribeBody { public ulong id { get; set; } public bool on { get; set; } }
// Subscribe / unsubscribe to a creator from the website - the same store the in-game
// Subscribe button writes (ClubsController), so both places agree instantly.
[HttpPost("/api/site/subscribe")]
public IActionResult SiteSubscribe([FromBody] SubscribeBody? body)
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
if (body == null || body.id == 0 || body.id == me.Id) return BadRequest(new { ok = false, error = "invalid target" });
if (!RateLimit.TryConsume($"sitesub:{me.Id}", cooldownSeconds: 2, out int retry))
{
Response.Headers["Retry-After"] = retry.ToString();
return StatusCode(429, new { ok = false, error = "Slow down a moment." });
}
try
{
if (body.on) PlayerDB.AddSubscription(me.Id, body.id); else PlayerDB.RemoveSubscription(me.Id, body.id);
Console.WriteLine($"[site] {me.Id} {(body.on ? "subscribed to" : "unsubscribed from")} {body.id}");
SiteNotifications.Notify(me.Id);
SiteNotifications.Notify(body.id);
return Ok(new { ok = true, subscribed = body.on, subscribers = PlayerDB.GetSubscriberIds(body.id).Count });
}
catch (Exception ex)
{
Console.WriteLine($"[site] subscribe {me.Id}->{body.id} failed: {ex.Message}");
return StatusCode(500, new { ok = false, error = "could not update the subscription" });
}
}
[HttpPost("/api/site/friends/request")]
public IActionResult SiteSendFriendRequest([FromBody] FriendActionBody? body)
=> FriendAction(body, FriendsDB.SendFriendRequest, "sent request to");
[HttpPost("/api/site/friends/accept")]
public IActionResult SiteAcceptFriend([FromBody] FriendActionBody? body)
=> FriendAction(body, FriendsDB.AcceptFriendRequest, "accepted");
[HttpPost("/api/site/friends/remove")]
public IActionResult SiteRemoveFriend([FromBody] FriendActionBody? body)
=> FriendAction(body, FriendsDB.RemoveFriend, "removed/cancelled");
// ---- notifications (website inbox) ------------------------------------------------
// A real, persistent, game-synced inbox. Aggregates the two server-side sources of
// truth so the website shows the same notifications the game does:
// 1. Incoming friend requests - pending FriendRequestReceived rows in the relationship
// DB. Actionable here (Accept -> /friends/accept, Decline -> /friends/remove) and
// they live until actioned, exactly like in-game.
// 2. Saved messages - player_Extra.Messages (cheers, coach messages, ...), the same
// store /api/messages/v2/get serves the game, dismissed via the same delete.
// Game / party / join invites can't be actioned from a browser, so they render as
// "Launch the game to view." Read fresh every call, so web and game stay in sync.
// The invite kinds a browser cannot act on - they need the running game/session.
private static readonly HashSet<MessageType> LaunchGameTypes = new()
{
MessageType.GameInvite, MessageType.GameInviteV2, MessageType.GameInviteDeclined,
MessageType.PartyActivitySwitch, MessageType.PartyActivitySwitchV2, MessageType.FriendInvite,
MessageType.RequestGameInvite, MessageType.RequestGameInviteDeclined,
};
public class NotifDismissBody { public string? id { get; set; } }
// Project a player to {id, username, display_name, profile_image} plus a display name.
private static (object? proj, string name) WhoNamed(ulong pid)
{
if (pid == 0) return (null, "Someone");
var a = PlayerDB.GetAccountById(pid);
if (a == null) return (null, "Someone");
string nm = !string.IsNullOrWhiteSpace(a.DisplayName) ? a.DisplayName
: (!string.IsNullOrWhiteSpace(a.Username) ? a.Username : "Someone");
return (new { id = a.Id, username = a.Username, display_name = a.DisplayName, profile_image = ImgUrl(a.profileImage) }, nm);
}
// Aggregates a player's inbox from the two server-side sources of truth. Static so a
// diagnostic/self-test can call it directly with a player id (no cookie needed).
public static List<object> BuildNotifications(ulong meId)
{
var items = new List<object>();
// 1) incoming friend requests (actionable, persist until accepted/declined)
try
{
var rels = FriendsDB.GetRelationships(meId) ?? new List<RelationshipDetail>();
foreach (var r in rels.Where(r => r.RelationshipType == RelationshipType.FriendRequestReceived))
{
ulong other = r.PlayerID; // GetRelationships projects PlayerID = the other player
var (proj, name) = WhoNamed(other);
items.Add(new
{
id = "fr:" + other,
kind = "friend_request",
actionable = true,
launchGame = false,
fromPlayer = proj,
targetId = other,
text = $"{name} sent you a friend request",
time = (DateTime?)null
});
}
}
catch { }
// 2) saved messages (cheers, coach messages, and any launch-game invites if present)
try
{
var msgs = PlayerDB.GetMessages(meId) ?? new List<MessageData>();
foreach (var m in msgs.OrderByDescending(m => m.SentTime))
{
// The stored FriendInvite message is the GAME's copy of a pending request; the
// website already lists the request itself (friend_request rows above).
if (m.Type == MessageType.FriendInvite) continue;
bool launch = LaunchGameTypes.Contains(m.Type);
bool anon = m.Type == MessageType.PlayerCheerAnonymous;
var (proj, name) = anon ? ((object?)null, "Someone") : WhoNamed(m.FromPlayerId);
string text;
if (launch) text = "Launch the game to view.";
else text = m.Type switch
{
MessageType.PlayerCheer => $"{name} cheered you",
MessageType.PlayerCheerAnonymous => "Someone cheered you",
MessageType.FriendRequestAccepted => $"{name} accepted your friend request",
MessageType.CoachMessage => string.IsNullOrWhiteSpace(m.Data) ? "Message from the Campus team" : m.Data!,
MessageType.TextMessage => $"{name} sent you a message",
_ => "New notification"
};
items.Add(new
{
id = "msg:" + m.Id,
kind = launch ? "launch" : "message",
actionable = false,
launchGame = launch,
fromPlayer = proj,
text,
time = (DateTime?)m.SentTime
});
}
}
catch { }
// 3) support: staff replied on one of my tickets (cleared by opening the ticket), and for
// staff, one line counting the tickets waiting on staff (cleared as they're answered).
try
{
foreach (var t in SupportDB.ForAccount(meId).Where(SupportDB.UnreadForPlayer))
{
var lastStaff = t.Messages.LastOrDefault(m => m.Staff && !m.Internal);
items.Add(new
{
id = "sup:" + t.Id,
kind = "support",
actionable = false,
launchGame = false,
fromPlayer = (object?)null,
link = "/support/ticket/" + t.Id,
text = lastStaff != null && lastStaff.System
? $"Your support ticket #{t.Id} was updated: {lastStaff.Body}"
: $"Staff replied to your support ticket #{t.Id} \u201c{t.Subject}\u201d",
time = (DateTime?)(lastStaff?.At ?? t.UpdatedAt)
});
}
if (SiteController.IsSupportStaff(meId))
{
int waiting = SupportDB.AwaitingStaffCount();
if (waiting > 0)
items.Add(new
{
id = "supq",
kind = "support",
actionable = false,
launchGame = false,
fromPlayer = (object?)null,
link = "/support/logs",
text = waiting == 1 ? "1 support ticket is waiting for staff" : $"{waiting} support tickets are waiting for staff",
time = (DateTime?)null
});
}
}
catch { }
return items;
}
[HttpGet("/api/site/notifications")]
public IActionResult GetSiteNotifications()
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
var items = BuildNotifications(me.Id);
return Ok(new { ok = true, count = items.Count, items });
}
// Realtime push: the browser opens this websocket; whenever a notification is created for
// this player (SiteNotifications.Notify, fired from Notifications.SendToPlayer and
// PlayerDB.AddMessage) the socket receives a {"type":"refresh"} and the page re-fetches
// the inbox above. Separate from the game hub so it never disturbs the player's game
// socket. Cookie-authed like the rest of the site endpoints.
[HttpGet("/api/site/notifications/ws")]
public async Task SiteNotificationsSocket()
{
if (!HttpContext.WebSockets.IsWebSocketRequest)
{
HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
return;
}
var me = CurrentUser();
if (me == null)
{
HttpContext.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
using var socket = await HttpContext.WebSockets.AcceptWebSocketAsync();
await Campus_live_rewrite.api.server.SiteNotifications.HandleAsync(me.Id, socket);
}
// Dismiss a SAVED message (msg:<id>) - deletes it from the same store the game reads,
// so it clears in both places. Friend requests are not dismissed here; they are accepted
// or declined through /api/site/friends/*.
[HttpPost("/api/site/notifications/dismiss")]
public IActionResult SiteDismissNotification([FromBody] NotifDismissBody? body)
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
if (body == null || string.IsNullOrWhiteSpace(body.id))
return BadRequest(new { ok = false, error = "no id" });
if (body.id.StartsWith("msg:") && ulong.TryParse(body.id.Substring(4), out var mid))
{
DismissMessageEverywhere(me.Id, mid);
return Ok(new { ok = true });
}
// "Staff replied to your ticket": dismissing it marks the ticket read.
if (body.id.StartsWith("sup:") && int.TryParse(body.id.Substring(4), out var tid)
&& SupportDB.Get(tid)?.AccountId == me.Id)
{
SupportDB.MarkRead(tid, asStaff: false);
SiteNotifications.Notify(me.Id);
return Ok(new { ok = true });
}
if (body.id == "supq") return Ok(new { ok = true }); // clears itself as tickets are answered
return BadRequest(new { ok = false, error = "not a dismissable notification" });
}
// Delete the stored message AND tell the running game to drop it (MessageDeleted frame),
// so "mark as read" on the website removes the notification in-game right away instead of
// on the next notifications fetch.
private static void DismissMessageEverywhere(ulong playerId, ulong messageId)
{
PlayerDB.DeleteMessage(playerId, messageId);
try
{
Notifications.FireAndForget(
Notifications.SendToPlayer(playerId, Newtonsoft.Json.JsonConvert.SerializeObject(Campus_live_rewrite.api.WebsocketEvents.CreateMessageDeleted(messageId))),
"site dismiss -> game");
}
catch { }
}
// POST /api/site/notifications/dismissall - "Mark all as read": every dismissable
// notification (friend requests stay - they are actioned, not read) goes in one call.
[HttpPost("/api/site/notifications/dismissall")]
public IActionResult SiteDismissAllNotifications()
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
var full = PlayerDB.GetFullAccount(me.Id);
var msgs = full?.player_Extra?.Messages?.Select(m => m.Id).ToList() ?? new List<ulong>();
foreach (var id in msgs) DismissMessageEverywhere(me.Id, id);
foreach (var t in SupportDB.ForAccount(me.Id).Where(SupportDB.UnreadForPlayer)) SupportDB.MarkRead(t.Id, asStaff: false);
SiteNotifications.Notify(me.Id);
return Ok(new { ok = true, dismissed = msgs.Count });
}
// ---- photos -----------------------------------------------------------------------
// GET /api/site/photos/recent?take=24
// The home feed. Replaces the picsum.photos seed URLs and the invented post list.
[HttpGet("/api/site/photos/recent")]
public IActionResult RecentPhotos(int take = 24)
{
take = Math.Clamp(take, 1, 60);
try
{
// GetRecentPublicPhotos, not GetGlobalImages/GetSlideshowImages:
// - GetGlobalImages returns EVERY public image type (profile pictures, room
// and outfit thumbnails showed up as if they were photos) and sorts by
// CheerCount, so "Recent Photos" was neither photos-only nor recent.
// - GetSlideshowImages is Public + ShareCamera only, but THIS server saves
// camera photos as Type None, so it matched nothing and the feed was empty.
// GetRecentPublicPhotos is Public + (None|ShareCamera), newest first - the actual
// camera photos, excluding thumbnails, and unauthenticated-safe.
var rows = ImageMetadataDB.GetRecentPublicPhotos(take) ?? new();
// Batch the author lookups the same way rooms do.
var authors = new Dictionary<ulong, player_info>();
foreach (var pid in rows.Select(r => r.PlayerId).Distinct())
{
var acc = PlayerDB.GetAccountById(pid);
if (acc != null) authors[pid] = acc;
}
ulong viewerId = ViewerId();
var results = rows.Select(i =>
{
authors.TryGetValue(i.PlayerId, out var a);
return new
{
id = i.Id,
image = ImgUrl(i.ImageName),
description = i.Description,
cheers = i.CheerCount,
cheered = viewerId != 0 && ImageMetadataDB.HasUserCheered(i.Id, viewerId),
comments = i.CommentCount,
roomId = PhotoRoomId(i.RoomId),
createdAt = i.CreatedAt,
author = a == null ? null : new
{
id = a.Id,
username = a.Username,
display_name = a.DisplayName,
profile_image = ImgUrl(a.profileImage),
},
};
}).ToList();
return Ok(new { ok = true, results });
}
catch (Exception ex)
{
Console.WriteLine($"[site] recent photos failed: {ex.Message}");
return StatusCode(500, new { ok = false, error = "could not load photos" });
}
}
// True when a and b are friends (a's relationship list contains b as a Friend).
private bool AreFriends(ulong a, ulong b)
{
if (a == 0 || b == 0 || a == b) return false;
var rels = FriendsDB.GetRelationships(a);
return rels != null && rels.Any(r => r.PlayerID == b && r.RelationshipType == RelationshipType.Friend);
}
// The website "visibility" of a photo, projected from the two stored fields.
private static string VisibilityOf(ImageMetadataDB.FullSavedImage img) =>
img.FriendsOnly ? "friends"
: (img.Accessibility == ImageMetadataDB.ImageAccessibility.Public ? "public" : "private");
// Resolve a list of tagged account ids to display rows (skips ids with no account).
private List<object> ResolveTagged(List<ulong>? ids)
{
var outp = new List<object>();
if (ids == null) return outp;
foreach (var pid in ids.Distinct())
{
var acc = PlayerDB.GetAccountById(pid);
if (acc == null) continue;
outp.Add(new
{
id = acc.Id,
username = acc.Username,
display_name = acc.DisplayName,
profile_image = ImgUrl(acc.profileImage),
});
}
return outp;
}
// GET /api/site/photos/{id} - one photo, for the image detail page (/image/{id}).
[HttpGet("/api/site/photos/{id:int}")]
public IActionResult Photo(int id)
{
try
{
var img = ImageMetadataDB.GetById(id);
if (img == null)
return NotFound(new { ok = false, error = "photo not found" });
ulong viewer = ViewerId();
bool isOwner = viewer != 0 && viewer == img.PlayerId;
bool isPublic = img.Accessibility == ImageMetadataDB.ImageAccessibility.Public && !img.FriendsOnly;
bool canView = isOwner || isPublic
|| (img.FriendsOnly && AreFriends(img.PlayerId, viewer));
if (!canView)
return StatusCode(403, new { ok = false, error = "This photo is private." });
var a = PlayerDB.GetAccountById(img.PlayerId);
// the room it was taken in, for the "^Room" link - only once that room is published
// (never a dorm or an unpublished draft; see PhotoRoom)
var photoRoom = PhotoRoom(img.RoomId);
return Ok(new
{
ok = true,
photo = new
{
id = img.Id,
image = ImgUrl(img.ImageName),
description = img.Description,
cheers = img.CheerCount,
cheered = viewer != 0 && ImageMetadataDB.HasUserCheered(img.Id, viewer),
comments = img.CommentCount,
commentList = CommentRows(ImageMetadataDB.GetComments(img.Id)),
roomId = photoRoom == null ? 0 : (long)photoRoom.RoomId,
room = photoRoom == null ? null : new { id = photoRoom.RoomId, name = photoRoom.Name },
createdAt = img.CreatedAt,
isOwner,
visibility = VisibilityOf(img),
taggedPlayers = ResolveTagged(img.TaggedPlayerIds),
author = a == null ? null : new
{
id = a.Id,
username = a.Username,
display_name = a.DisplayName,
profile_image = ImgUrl(a.profileImage),
},
},
});
}
catch (Exception ex)
{
Console.WriteLine($"[site] photo {id} failed: {ex.Message}");
return StatusCode(500, new { ok = false, error = "could not load the photo" });
}
}
public class PhotoVisibilityBody { public string? visibility { get; set; } }
public class PhotoTextBody { public string? text { get; set; } public string? description { get; set; } }
private List<object> CommentRows(List<ImageMetadataDB.ImageComment> list)
{
var outp = new List<object>();
foreach (var c in list)
{
var acc = PlayerDB.GetAccountById(c.PlayerId);
outp.Add(new
{
id = c.Id,
playerId = c.PlayerId,
username = acc?.Username ?? $"Player{c.PlayerId}",
display_name = acc?.DisplayName ?? acc?.Username ?? $"Player{c.PlayerId}",
profile_image = ImgUrl(acc?.profileImage),
text = c.Text,
createdAt = c.CreatedAt,
});
}
return outp;
}
private static bool CanSeePhoto(ImageMetadataDB.FullSavedImage img, ulong viewer, Func<ulong, ulong, bool> friends)
=> (viewer != 0 && viewer == img.PlayerId)
|| (img.Accessibility == ImageMetadataDB.ImageAccessibility.Public && !img.FriendsOnly)
|| (img.FriendsOnly && friends(img.PlayerId, viewer));
// GET /api/site/me/photos?taken=1&tagged=1 - every camera photo of mine (private ones
// included - it's my own export), for Settings > Export > Export Photos. Only the list:
// the browser fetches each /img/ file itself and builds the zip client-side, so nothing
// is assembled or stored on the server.
[HttpGet("/api/site/me/photos")]
public IActionResult MyPhotos([FromQuery] bool taken = true, [FromQuery] bool tagged = true)
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
var rows = (ImageMetadataDB.GetPlayerFeed(me.Id, me.Id, 0, 5000) ?? new())
.Where(i => i.SavedImageType == SavedImageTypeEnum.ShareCamera || i.SavedImageType == SavedImageTypeEnum.None)
.Select(i => new
{
id = i.SavedImageId,
image = ImgUrl(i.ImageName),
file = i.ImageName,
description = i.Description,
createdAt = i.CreatedAt,
taken = i.PlayerId == me.Id,
tagged = i.TaggedPlayerIds != null && i.TaggedPlayerIds.Contains(me.Id),
})
.Where(x => (taken && x.taken) || (tagged && x.tagged))
.ToList();
return Ok(new { ok = true, photos = rows });
}
// ---- reports ----------------------------------------------------------------------
//
// POST /api/site/report {type: player|photo|comment, id, photoId?, reason, details}
// The site's Report buttons. Lands in the SAME queue as in-game reports (ReportsDB), so
// the admin panel's Reports tab shows both and the same ban / warn / dismiss actions
// apply. The reported player is resolved server-side from what was reported, never
// trusted from the body. Anonymous to the reported player; the reporter is recorded.
public class SiteReportBody
{
public string? type { get; set; }
public string? id { get; set; }
public int? photoId { get; set; }
public string? reason { get; set; }
public string? details { get; set; }
}
private static readonly Dictionary<ulong, List<DateTime>> _reportRate = new();
private static readonly string[] ReportReasons = { "Spam or scam", "Harassment or bullying", "Inappropriate content", "Hate speech", "Impersonation", "Other" };
[HttpPost("/api/site/report")]
public IActionResult SiteReport([FromBody] SiteReportBody? body)
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "Log in to send a report." });
string type = (body?.type ?? "").Trim().ToLowerInvariant();
string reason = (body?.reason ?? "").Trim();
if (!ReportReasons.Contains(reason)) return BadRequest(new { ok = false, error = "Pick a reason." });
string details = (body?.details ?? "").Trim();
if (details.Length > 300) details = details[..300];
// 5 reports per 10 minutes per account - same limit the page enforces, but here
// so it can't be skipped.
lock (_reportRate)
{
if (!_reportRate.TryGetValue(me.Id, out var times)) _reportRate[me.Id] = times = new();
times.RemoveAll(t => t < DateTime.UtcNow.AddMinutes(-10));
if (times.Count >= 5) return StatusCode(429, new { ok = false, error = "You've sent too many reports. Please try again later." });
times.Add(DateTime.UtcNow);
}
ulong reported = 0; long subjectId = 0; string link = ""; string subjectText = "";
switch (type)
{
case "player":
{
string handle = (body?.id ?? "").Trim().TrimStart('@');
reported = ulong.TryParse(handle, out var pid) ? pid : PlayerDB.GetAccountIdByUsernameCI(handle);
var acc = reported != 0 ? PlayerDB.GetAccountById(reported) : null;
if (acc == null) return NotFound(new { ok = false, error = "player not found" });
subjectId = (long)reported; link = "/user/" + Uri.EscapeDataString(acc.Username ?? handle);
subjectText = "profile @" + acc.Username;
break;
}
case "photo":
{
if (!int.TryParse(body?.id, out var imgId)) return BadRequest(new { ok = false, error = "bad photo id" });
var img = ImageMetadataDB.GetById(imgId);
if (img == null) return NotFound(new { ok = false, error = "photo not found" });
reported = img.PlayerId; subjectId = imgId; link = "/image/" + imgId;
subjectText = "photo #" + imgId + (string.IsNullOrWhiteSpace(img.Description) ? "" : " \"" + img.Description + "\"");
break;
}
case "comment":
{
if (!int.TryParse(body?.id, out var cid)) return BadRequest(new { ok = false, error = "bad comment id" });
var c = ImageMetadataDB.GetComment(cid);
if (c == null) return NotFound(new { ok = false, error = "comment not found" });
reported = c.PlayerId; subjectId = cid; link = "/image/" + c.SavedImageId;
subjectText = "comment on photo #" + c.SavedImageId + ": \"" + c.Text + "\"";
break;
}
default:
return BadRequest(new { ok = false, error = "bad report type" });
}
if (reported == me.Id) return BadRequest(new { ok = false, error = "You can't report yourself." });
// one open report per reporter per thing - a second click doesn't stack the queue
if (ReportsDB.HasOpenDuplicate(me.Id, type, subjectId, reported))
return Ok(new { ok = true, duplicate = true });
var category = reason switch
{
"Harassment or bullying" => Moderation_Detail.ReportCategory.Harassment,
"Hate speech" => Moderation_Detail.ReportCategory.CoC_Discrimination,
"Inappropriate content" => Moderation_Detail.ReportCategory.CoC_Trolling,
"Impersonation" => Moderation_Detail.ReportCategory.CoC_NameOrProfile,
_ => Moderation_Detail.ReportCategory.Misc,
};
var r = ReportsDB.Create(new ReportsDB.Report
{
ReporterPlayerId = me.Id,
ReportedPlayerId = reported,
ReportCategory = (int)category,
Details = (subjectText + (details.Length > 0 ? "\n\n" + details : "")).Trim(),
Source = "website",
Subject = type,
SubjectId = subjectId,
SubjectLink = link,
Reason = reason,
});
return Ok(new { ok = true, id = r.Id });
}
// POST /api/site/photos/{id}/cheer -> toggles the SAME cheer the game records
// (ImageMetadataDB cheers), so a cheer on the site shows in-game and vice versa.
[HttpPost("/api/site/photos/{id:int}/cheer")]
public IActionResult CheerPhoto(int id)
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
var img = ImageMetadataDB.GetById(id);
if (img == null) return NotFound(new { ok = false, error = "photo not found" });
if (!CanSeePhoto(img, me.Id, AreFriends)) return StatusCode(403, new { ok = false, error = "This photo is private." });
bool now = ImageMetadataDB.ToggleCheer(id, me.Id, !ImageMetadataDB.HasUserCheered(id, me.Id));
int count = ImageMetadataDB.GetCheerCount(id);
ImageMetadataDB.UpdateImageCheerCount(id, count);
return Ok(new { ok = true, cheered = now, cheers = count });
}
// GET /api/site/photos/{id}/comments ; POST {text} ; DELETE /comments/{commentId}
[HttpGet("/api/site/photos/{id:int}/comments")]
public IActionResult PhotoComments(int id)
{
var img = ImageMetadataDB.GetById(id);
if (img == null) return NotFound(new { ok = false, error = "photo not found" });
if (!CanSeePhoto(img, ViewerId(), AreFriends)) return StatusCode(403, new { ok = false, error = "This photo is private." });
return Ok(new { ok = true, comments = CommentRows(ImageMetadataDB.GetComments(id)) });
}
[HttpPost("/api/site/photos/{id:int}/comments")]
public IActionResult AddPhotoComment(int id, [FromBody] PhotoTextBody? body)
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
var img = ImageMetadataDB.GetById(id);
if (img == null) return NotFound(new { ok = false, error = "photo not found" });
if (!CanSeePhoto(img, me.Id, AreFriends)) return StatusCode(403, new { ok = false, error = "This photo is private." });
string text = (body?.text ?? "").Trim();
if (text.Length == 0) return BadRequest(new { ok = false, error = "Say something first." });
if (text.Length > 200) text = text[..200];
text = Campus_live_rewrite.api.server.Classes.Sanitize.SanitizeMessage(text);
var c = ImageMetadataDB.AddComment(id, me.Id, text);
if (c == null) return NotFound(new { ok = false, error = "photo not found" });
Console.WriteLine($"[site] {me.Id} commented on photo {id}");
return Ok(new { ok = true, comment = CommentRows(new List<ImageMetadataDB.ImageComment> { c })[0], comments = ImageMetadataDB.GetById(id)?.CommentCount ?? 0 });
}
[HttpDelete("/api/site/photos/{id:int}/comments/{commentId:int}")]
[HttpPost("/api/site/photos/{id:int}/comments/{commentId:int}/delete")]
public IActionResult DeletePhotoComment(int id, int commentId)
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
if (!ImageMetadataDB.DeleteComment(commentId, me.Id)) return StatusCode(403, new { ok = false, error = "not your comment" });
return Ok(new { ok = true, comments = ImageMetadataDB.GetById(id)?.CommentCount ?? 0 });
}
// POST /api/site/photos/{id}/description {description} - the photo's title/caption,
// the same field the game shows (modifydescription).
[HttpPost("/api/site/photos/{id:int}/description")]
public IActionResult SetPhotoDescription(int id, [FromBody] PhotoTextBody? body)
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
var img = ImageMetadataDB.GetById(id);
if (img == null) return NotFound(new { ok = false, error = "photo not found" });
if (img.PlayerId != me.Id && !PlayerDB.CheckDevFlag(me.Id)) return StatusCode(403, new { ok = false, error = "not your photo" });
string text = (body?.description ?? body?.text ?? "").Trim();
if (text.Length > 120) text = text[..120];
text = Campus_live_rewrite.api.server.Classes.Sanitize.SanitizeMessage(text);
ImageMetadataDB.SetDescription(id, text);
return Ok(new { ok = true, description = text });
}
public class BannerBody { public int photoId { get; set; } public bool reset { get; set; } }
// POST /api/site/me/banner {photoId} | {reset:true} - use one of your (or a public) photos
// as your profile banner; reset puts the starting banner.png back. Pushed to the game.
[HttpPost("/api/site/me/banner")]
public IActionResult SetMyBanner([FromBody] BannerBody? body)
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
if (PlayerDB.IsProfileLocked(me.Id, PlayerDB.ProfileLock.Banner))
return BadRequest(new { ok = false, error = PlayerDB.ProfileLockMessage(PlayerDB.ProfileLock.Banner) });
string name;
if (body == null || body.reset || body.photoId <= 0) name = ConfigCampus.DefaultBannerImage;
else
{
var img = ImageMetadataDB.GetById(body.photoId);
if (img == null) return NotFound(new { ok = false, error = "photo not found" });
if (!CanSeePhoto(img, me.Id, AreFriends)) return StatusCode(403, new { ok = false, error = "This photo is private." });
name = img.ImageName;
}
PlayerDB.SetPlayerBanner(name, me.Id);
Notifications.FireAndForget(Notifications.PushAccountUpdate(me.Id), "site banner refresh");
return Ok(new { ok = true, banner_image = ImgUrl(name) });
}
// ---- events on the site: a player's profile (Events tab) and a room's page ----------------------------------
// An event card: what the profile / room page draws. Times are UTC ISO (the page shows them in the visitor's
// own timezone), status is live / upcoming / ended, going / interested are the RSVP counts, myResponse is the
// signed-in visitor's own answer.
private static object EventCard(EventDBClasses.FullPlayerEvent e, ulong viewer)
{
RoomDBClasses.RoomRoot? room = null;
try { room = e.RoomId > 0 ? RoomDB.GetRoom(e.RoomId) : null; } catch { }
player_data? host = null;
try { host = e.CreatorPlayerId is ulong c && c > 0 ? PlayerDB.GetFullAccount(c) : null; } catch { }
var now = DateTime.UtcNow;
var start = e.StartTime.ToUniversalTime();
var end = e.EndTime.ToUniversalTime();
string status = start <= now && end >= now ? "live" : start > now ? "upcoming" : "ended";
var resp = e.Responses ?? new List<EventDBClasses.FullEventResponses>();
var mine = viewer == 0 ? null : resp.FirstOrDefault(r => r.PlayerId == viewer);
return new
{
id = e.PlayerEventId,
name = string.IsNullOrWhiteSpace(e.Name) ? "Untitled event" : e.Name,
description = e.Description ?? "",
image = ImgUrl(e.ImageName),
start,
end,
status,
room = RoomIsShowable(room) ? new { id = room!.RoomId, name = room.Name } : null,
host = host?.player == null ? null : new { id = host.playerid, username = host.player.Username, display_name = host.player.DisplayName, profile_image = ImgUrl(host.player.profileImage) },
going = resp.Count(r => r.Type == EventDBClasses.PlayerEventResponseType.Yes),
interested = resp.Count(r => r.Type == EventDBClasses.PlayerEventResponseType.Interested),
accessibility = e.Accessibility.ToString().ToLowerInvariant(),
canJoin = viewer != 0 && e.EndTime.ToUniversalTime() >= now && EventDB.CanPlayerJoin(e, viewer, out _),
myResponse = mine == null ? null : mine.Type == EventDBClasses.PlayerEventResponseType.Yes ? "going"
: mine.Type == EventDBClasses.PlayerEventResponseType.Interested ? "interested"
: mine.Type == EventDBClasses.PlayerEventResponseType.No ? "not_interested" : null,
};
}
// Public events for everyone; private / unlisted ones only for the host. Live first, then what is coming up
// (soonest first), then the last few that ended in the past two weeks.
private static List<object> EventCards(IEnumerable<EventDBClasses.FullPlayerEvent> events, ulong viewer, int max = 30)
{
var now = DateTime.UtcNow;
var shown = events
.Where(e => e.Accessibility == EventDBClasses.PlayerEventAccessibility.Public || (viewer != 0 && e.CreatorPlayerId == viewer))
.Where(e => e.EndTime.ToUniversalTime() >= now.AddDays(-14))
.ToList();
var current = shown.Where(e => e.EndTime.ToUniversalTime() >= now).OrderBy(e => e.StartTime.ToUniversalTime() > now ? 1 : 0).ThenBy(e => e.StartTime).ToList();
var past = shown.Where(e => e.EndTime.ToUniversalTime() < now).OrderByDescending(e => e.EndTime).Take(6).ToList();
return current.Concat(past).Take(max).Select(e => EventCard(e, viewer)).ToList();
}
// GET /api/site/profile/{handle}/events - the events this player is hosting.
[HttpGet("/api/site/profile/{handle}/events")]
public IActionResult ProfileEvents(string handle)
{
try
{
player_data? full = PlayerDB.GetFullAccountByName(handle);
if (full == null && ulong.TryParse(handle, out var byId)) full = PlayerDB.GetFullAccount(byId);
if (full?.player == null) return NotFound(new { ok = false, error = "player not found" });
return Ok(new { ok = true, events = EventCards(EventDB.GetEventsByCreator(full.playerid), ViewerId()) });
}
catch (Exception ex)
{
Console.WriteLine($"[site] profile events failed: {ex.Message}");
return StatusCode(500, new { ok = false, error = "could not load events" });
}
}
// GET /api/site/rooms/{id}/events - the events booked in this room (only for a room the site shows).
[HttpGet("/api/site/rooms/{roomId:long}/events")]
public IActionResult RoomEvents(long roomId)
{
try
{
var room = roomId > 0 ? RoomDB.GetRoom((ulong)roomId) : null;
if (!RoomIsShowable(room)) return Ok(new { ok = true, events = new List<object>() });
return Ok(new { ok = true, events = EventCards(EventDB.GetEventsForRoom((ulong)roomId), ViewerId()) });
}
catch (Exception ex)
{
Console.WriteLine($"[site] room events failed: {ex.Message}");
return StatusCode(500, new { ok = false, error = "could not load events" });
}
}
// GET /api/site/events/{id} - one event for its page: the card plus who is going (up to 30, with pictures).
[HttpGet("/api/site/events/{eventId:long}")]
public IActionResult GetEvent(long eventId)
{
try
{
var ev = eventId > 0 ? EventDB.GetFullEventById((ulong)eventId) : null;
ulong viewer = ViewerId();
if (ev == null || (ev.Accessibility != EventDBClasses.PlayerEventAccessibility.Public && !(viewer != 0 && ev.CreatorPlayerId == viewer)))
return NotFound(new { ok = false, error = "event not found" });
var going = (ev.Responses ?? new List<EventDBClasses.FullEventResponses>())
.Where(r => r.Type == EventDBClasses.PlayerEventResponseType.Yes)
.OrderBy(r => r.CreatedAt).Select(r => r.PlayerId).Distinct().Take(30)
.Select(pid => { var a = PlayerDB.GetAccountById(pid); return a == null ? null : new { id = a.Id, username = a.Username, display_name = a.DisplayName, profile_image = ImgUrl(a.profileImage) }; })
.Where(x => x != null).ToList();
return Ok(new { ok = true, @event = EventCard(ev, viewer), going_players = going });
}
catch (Exception ex)
{
Console.WriteLine($"[site] event {eventId} failed: {ex.Message}");
return StatusCode(500, new { ok = false, error = "could not load the event" });
}
}
// POST /api/site/events/{id}/join - the website's JOIN button. If you are online in the game right now, your
// game is pointed at the event's room instance (the same decision the game's own Join button makes) and walks
// there on its next heartbeat - up to ~20 s. Not online -> it says so and does nothing.
[HttpPost("/api/site/events/{eventId:long}/join")]
public IActionResult JoinEventFromSite(long eventId)
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "Log in first." });
if (!RateLimit.TryConsume($"eventjoin:{me.Id}", cooldownSeconds: 5, out int retryAfter))
return StatusCode(429, new { ok = false, error = $"Slow down - try again in {retryAfter}s." });
var ev = eventId > 0 ? EventDB.GetFullEventById((ulong)eventId) : null;
if (ev == null || (ev.Accessibility != EventDBClasses.PlayerEventAccessibility.Public && ev.CreatorPlayerId != me.Id))
return NotFound(new { ok = false, error = "event not found" });
if (ev.EndTime.ToUniversalTime() < DateTime.UtcNow) return BadRequest(new { ok = false, error = "This event has ended." });
var (online, _) = HeartbeatDB.PeekPresence(me.Id);
if (!online)
return BadRequest(new { ok = false, offline = true, error = "You're not online in Campus right now. Start the game, then press Join again." });
var outcome = EventJoin.Resolve((ulong)eventId, me.Id);
if (outcome.Session == null)
{
string msg = outcome.Code switch
{
HeartbeatDB.MatchmakingErrorCode.EventNotStarted => string.IsNullOrWhiteSpace(outcome.Why) ? "This event hasn't started yet." : char.ToUpper(outcome.Why[0]) + outcome.Why[1..] + (outcome.Why.EndsWith(".") ? "" : "."),
HeartbeatDB.MatchmakingErrorCode.Banned => "Your account can't join rooms right now.",
HeartbeatDB.MatchmakingErrorCode.RoomIsPrivate => "This event's room is private.",
HeartbeatDB.MatchmakingErrorCode.BlockedFromRoom => "You can't join this event.",
HeartbeatDB.MatchmakingErrorCode.NoSuchRoom => "This event's room no longer exists.",
_ => "Couldn't join the event right now. Try again in a moment."
};
return BadRequest(new { ok = false, error = msg });
}
// the client acts on its next heartbeat (the same move the admin panel's "force join" makes)
HeartbeatDB.UpdatePlayerHeartbeat(me.Id, outcome.Session, online: true);
Console.WriteLine($"[site] {me.Id} pressed Join on event {eventId} '{ev.Name}' -> instance {outcome.Session.roomInstanceId}");
return Ok(new { ok = true, room = outcome.RoomName, message = $"Sending you to {ev.Name}. It can take up to 20 seconds - keep Campus open." });
}
public class EventRsvpBody { public string? response { get; set; } }
// POST /api/site/events/{id}/respond {response: "going" | "interested" | "not_interested" | "none"} - RSVP from the site; the same
// answer the game's event page records (one per player, answering again replaces it).
[HttpPost("/api/site/events/{eventId:long}/respond")]
public IActionResult RespondToEvent(long eventId, [FromBody] EventRsvpBody? body)
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
var ev = eventId > 0 ? EventDB.GetFullEventById((ulong)eventId) : null;
if (ev == null) return NotFound(new { ok = false, error = "event not found" });
if (ev.Accessibility != EventDBClasses.PlayerEventAccessibility.Public && ev.CreatorPlayerId != me.Id)
return StatusCode(403, new { ok = false, error = "This event is private." });
if (ev.EndTime.ToUniversalTime() < DateTime.UtcNow) return BadRequest(new { ok = false, error = "This event has ended." });
var type = (body?.response ?? "").Trim().ToLowerInvariant() switch
{
"going" or "yes" => EventDBClasses.PlayerEventResponseType.Yes,
"interested" or "maybe" => EventDBClasses.PlayerEventResponseType.Interested,
"not_interested" or "notinterested" or "no" => EventDBClasses.PlayerEventResponseType.No, // an answer of its own
_ => EventDBClasses.PlayerEventResponseType.None, // "none": take the answer back
};
var updated = EventDB.SetResponse((ulong)eventId, me.Id, type);
if (updated == null) return NotFound(new { ok = false, error = "event not found" });
return Ok(new { ok = true, @event = EventCard(updated, me.Id) });
}
public class PfpBody { public int photoId { get; set; } }
// POST /api/site/me/pfp {photoId} - use one of your (or a public) photos as your PROFILE PICTURE, the same
// way the banner works and the way the game's own "set as profile picture" does (the account points at the
// saved photo). The website used to download the photo in the browser and upload it back (/api/profile-image):
// any hiccup there - the 10 s / 10 per hour limit, a picture lock, a dropped request - only showed a toast and
// the picture stayed as it was on the profile. Pushed to the game too.
[HttpPost("/api/site/me/pfp")]
public IActionResult SetMyPfp([FromBody] PfpBody? body)
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
if (PlayerDB.IsProfileLocked(me.Id, PlayerDB.ProfileLock.Picture))
return BadRequest(new { ok = false, error = PlayerDB.ProfileLockMessage(PlayerDB.ProfileLock.Picture) });
if (body == null || body.photoId <= 0) return BadRequest(new { ok = false, error = "no photo chosen" });
var img = ImageMetadataDB.GetById(body.photoId);
if (img == null || string.IsNullOrWhiteSpace(img.ImageName)) return NotFound(new { ok = false, error = "photo not found" });
if (!CanSeePhoto(img, me.Id, AreFriends)) return StatusCode(403, new { ok = false, error = "This photo is private." });
PlayerDB.SetPlayerImageName(img.ImageName, me.Id);
Notifications.FireAndForget(Notifications.PushAccountUpdate(me.Id), "site pfp-from-photo refresh");
Console.WriteLine($"[site] {me.Id} set photo {body.photoId} ({img.ImageName}) as their profile picture");
return Ok(new { ok = true, profile_image = ImgUrl(img.ImageName) });
}
// DELETE /api/site/photos/{id} (owner or staff) - the same delete the game does.
[HttpDelete("/api/site/photos/{id:int}")]
[HttpPost("/api/site/photos/{id:int}/delete")]
public IActionResult DeletePhoto(int id)
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
var img = ImageMetadataDB.GetById(id);
if (img == null) return NotFound(new { ok = false, error = "photo not found" });
if (img.PlayerId != me.Id && !PlayerDB.CheckDevFlag(me.Id)) return StatusCode(403, new { ok = false, error = "not your photo" });
ImageMetadataDB.DeleteImage(id);
Console.WriteLine($"[site] {me.Id} deleted photo {id}");
return Ok(new { ok = true });
}
public class PhotoUntagBody { public ulong playerId { get; set; } }
// POST /api/site/photos/{id}/visibility {visibility:"public"|"friends"|"private"}
// Owner-only. public/private map straight to the in-game Accessibility (0/1); "friends"
// stores Private + FriendsOnly so it never leaks in-game but shows to friends on the site.
[HttpPost("/api/site/photos/{id:int}/visibility")]
public IActionResult SetPhotoVisibility(int id, [FromBody] PhotoVisibilityBody? body)
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
var img = ImageMetadataDB.GetById(id);
if (img == null) return NotFound(new { ok = false, error = "photo not found" });
if (img.PlayerId != me.Id) return StatusCode(403, new { ok = false, error = "not your photo" });
ImageMetadataDB.ImageAccessibility acc;
bool friendsOnly;
switch ((body?.visibility ?? "").Trim().ToLowerInvariant())
{
case "public": acc = ImageMetadataDB.ImageAccessibility.Public; friendsOnly = false; break;
case "friends": acc = ImageMetadataDB.ImageAccessibility.Private; friendsOnly = true; break;
case "private": acc = ImageMetadataDB.ImageAccessibility.Private; friendsOnly = false; break;
default: return BadRequest(new { ok = false, error = "visibility must be public, friends or private" });
}
ImageMetadataDB.SetVisibility(id, acc, friendsOnly);
Console.WriteLine($"[site] photo {id} visibility -> {body!.visibility} by {me.Id}");
return Ok(new { ok = true, visibility = friendsOnly ? "friends" : (acc == ImageMetadataDB.ImageAccessibility.Public ? "public" : "private") });
}
// POST /api/site/photos/{id}/untag {playerId}
// Owner-only: remove a tagged player from the photo. Returns the updated tag list.
[HttpPost("/api/site/photos/{id:int}/untag")]
public IActionResult UntagPhoto(int id, [FromBody] PhotoUntagBody? body)
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
var img = ImageMetadataDB.GetById(id);
if (img == null) return NotFound(new { ok = false, error = "photo not found" });
if (img.PlayerId != me.Id) return StatusCode(403, new { ok = false, error = "not your photo" });
if (body == null || body.playerId == 0) return BadRequest(new { ok = false, error = "playerId is required" });
var updated = ImageMetadataDB.RemoveTag(id, body.playerId);
Console.WriteLine($"[site] photo {id} untag {body.playerId} by {me.Id}");
return Ok(new { ok = true, taggedPlayers = ResolveTagged(updated) });
}
// ---- presence ---------------------------------------------------------------------
// GET /api/site/online
// Who is actually in-game right now, and where. Replaces data.js `onlineFriends`.
// Public (the site already has an "online players" surface), so it exposes only
// display name, avatar and room NAME - never room ids or instance data.
[HttpGet("/api/site/online")]
public IActionResult Online(int take = 20)
{
take = Math.Clamp(take, 1, 100);
try
{
var results = new List<object>();
foreach (var (playerId, _) in CampusLiveSource.Controllers.NotificationsController.WebSockets)
{
if (results.Count >= take) break;
var acc = PlayerDB.GetAccountById(playerId);
if (acc == null) continue;
string? where = ShowableRoomOf(playerId);
results.Add(new
{
id = acc.Id,
username = acc.Username,
display_name = acc.DisplayName,
profile_image = ImgUrl(acc.profileImage),
// The DB sentinel is the literal string "not in a room." - map it to
// something a person would read rather than showing it verbatim.
where = string.IsNullOrWhiteSpace(where) ? "Online now" : where,
});
}
return Ok(new { ok = true, results, total = CampusLiveSource.Controllers.NotificationsController.WebSockets.Count });
}
catch (Exception ex)
{
Console.WriteLine($"[site] online list failed: {ex.Message}");
return StatusCode(500, new { ok = false, error = "could not load online players" });
}
}
// GET /api/site/friends - the signed-in user's friends, with live presence.
// Replaces the localStorage-only friends list, which was per-browser and invisible
// to the game.
// GET /api/site/stats - the signed-in creator's numbers: room visits, what sold, tokens
// pending and earned, per item (with its picture). Drives the website's Stats page.
[HttpGet("/api/site/stats")]
public IActionResult SiteStats()
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
try
{
var rooms = RoomDB.GetRoomsByCreator(me.Id).Where(r => !r.IsDorm).ToList();
long visits = rooms.Sum(r => (long)(r.Stats?.VisitCount ?? 0));
long cheers = rooms.Sum(r => (long)(r.Stats?.CheerCount ?? 0));
var inventions = InventionDB.GetByCreator(me.Id);
var (pending, paid, next) = EarningsDB.Summary(me.Id);
var sales = EarningsDB.ForPlayer(me.Id, 5000);
string? Image(string kind, string itemId)
{
try
{
switch (kind)
{
case "invention": return int.TryParse(itemId, out var iid) ? ImgUrl(InventionDB.GetById(iid)?.ImageName) : null;
case "ugc": return ImgUrl((string?)CustomItemsCache.Get(itemId)?["ImageName"]);
case "roomkey": return long.TryParse(itemId, out var kid) ? ImgUrl(RoomEconDB.GetKey(kid)?.ImageName) : null;
case "roomcurrency": return Guid.TryParse(itemId, out var cid) ? ImgUrl(RoomEconDB.GetCurrency(cid)?.ImageName) : null;
case "roomconsumable": return Guid.TryParse(itemId, out var xid) ? ImgUrl(RoomEconDB.GetConsumable(xid)?.ImageName) : null;
}
}
catch { }
return null;
}
var items = sales.GroupBy(e => (e.Kind, e.ItemId)).Select(g => new
{
kind = g.Key.Kind,
itemId = g.Key.ItemId,
name = g.OrderByDescending(e => e.BoughtAt).First().ItemName,
image = Image(g.Key.Kind, g.Key.ItemId),
sold = g.Count(),
revenue = g.Sum(e => e.Spent),
pending = g.Where(e => e.PaidAt == null).Sum(e => e.Share),
earned = g.Where(e => e.PaidAt != null).Sum(e => e.Share),
lastSold = g.Max(e => e.BoughtAt).ToString("o"),
nextPayout = g.Where(e => e.PaidAt == null).Select(e => (DateTime?)e.PayoutAt).Min()?.ToString("o")
}).OrderByDescending(x => x.sold).ThenByDescending(x => x.lastSold).ToList();
return Ok(new
{
ok = true,
username = me.Username,
tokens = PlayerDB.GetBalance(me.Id, (int)Campus_live_rewrite.api.server.Classes.CurrencyType.RecCenterTokens),
level = Campus_live_rewrite.api.server.Classes.Progress.GetPlayerLevel(me.Id)?.Level ?? 1,
rooms = rooms.Count,
roomVisits = visits,
roomCheers = cheers,
inventions = inventions.Count,
inventionsPublished = inventions.Count(i => i.IsPublished),
inventionsBought = inventions.Sum(i => (long)i.NumDownloads),
customItems = CustomItemsCache.ByCreatorRecs(me.Id).Count,
keys = rooms.Sum(r => RoomEconDB.KeysForRoom((long)r.RoomId).Count),
currencies = rooms.Sum(r => RoomEconDB.CurrenciesForRoom((long)r.RoomId).Count),
consumables = rooms.Sum(r => RoomEconDB.ConsumablesForRoom((long)r.RoomId).Count),
salesCount = sales.Count,
revenue = sales.Sum(e => e.Spent),
tokensPending = pending,
tokensEarned = paid,
nextPayout = next?.ToString("o"),
percent = ConfigCampus.CreatorEarningsPercent,
pendDays = ConfigCampus.CreatorEarningsPendDays,
// Per-day totals for the earnings graph (share = your cut, spent = what buyers paid).
series = sales.GroupBy(e => e.BoughtAt.ToString("yyyy-MM-dd"))
.Select(g => new { d = g.Key, share = g.Sum(e => e.Share), spent = g.Sum(e => e.Spent), n = g.Count() })
.OrderBy(x => x.d).ToList(),
items,
recent = sales.Take(50).Select(e => new
{
e.Kind, item = e.ItemName, e.Spent, e.Share, buyer = PlayerDB.GetAccountById(e.BuyerId)?.Username ?? e.BuyerId.ToString(),
at = e.BoughtAt.ToString("o"), payoutAt = e.PayoutAt.ToString("o"), paid = e.PaidAt != null
})
});
}
catch (Exception ex)
{
Console.WriteLine($"[site] stats failed: {ex.Message}");
return StatusCode(500, new { ok = false, error = "could not load stats" });
}
}
[HttpGet("/api/site/friends")]
public IActionResult Friends()
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
try
{
var rels = FriendsDB.GetRelationships(me.Id) ?? new List<RelationshipDetail>();
var online = CampusLiveSource.Controllers.NotificationsController.WebSockets.Keys.ToHashSet();
var results = new List<object>();
foreach (var r in rels)
{
// GetRelationships already projects from the caller's point of view, so
// PlayerID is the OTHER player here.
ulong other = r.PlayerID;
var acc = PlayerDB.GetAccountById(other);
if (acc == null) continue;
bool isOnline = online.Contains(other);
results.Add(new
{
id = acc.Id,
username = acc.Username,
display_name = acc.DisplayName,
profile_image = ImgUrl(acc.profileImage),
status = r.RelationshipType.ToString(),
favourite = r.Favorited != ReciprocalStatus.None,
online = isOnline,
where = isOnline ? ShowableRoomOf(other) : null,
spotify = SpotifyLink.CachedPayloadFor(other, me.Id),
});
}
return Ok(new { ok = true, results });
}
catch (Exception ex)
{
Console.WriteLine($"[site] friends failed: {ex.Message}");
return StatusCode(500, new { ok = false, error = "could not load friends" });
}
}
// ---- me ---------------------------------------------------------------------------
// POST /api/site/profile {display_name?, bio?}
// Settings page. These used to be written to localStorage only ('campus-mynick',
// 'campus-mybio'), so they never reached the game or another browser.
[HttpPost("/api/site/profile")]
public IActionResult UpdateProfile([FromBody] ProfileEdit? body)
{
var me = CurrentUser();
if (me == null) return Unauthorized(new { ok = false, error = "not signed in" });
if (body == null) return BadRequest(new { ok = false, error = "nothing to update" });
bool changed = false;
// Name lock (admin panel): neither name may change while it is on. Checked before
// anything is written so a locked player can still edit their bio.
bool nameLocked = PlayerDB.IsNameLocked(me.Id);
if (nameLocked && body.username != null && !string.Equals(body.username.Trim(), me.Username, StringComparison.OrdinalIgnoreCase))
return BadRequest(new { ok = false, error = "Your name has been locked by a moderator and cannot be changed." });
if (nameLocked && body.display_name != null && body.display_name.Trim() != (me.DisplayName ?? ""))
return BadRequest(new { ok = false, error = "Your name has been locked by a moderator and cannot be changed." });
// @ name (username). Unique, so this can fail; check it FIRST before writing anything.
if (body.username != null)
{
string un = body.username.Trim();
if (!string.Equals(un, me.Username, StringComparison.OrdinalIgnoreCase))
{
if (un.Length is < 3 or > 24)
return BadRequest(new { ok = false, error = "@ name must be 3-24 characters." });
if (!System.Text.RegularExpressions.Regex.IsMatch(un, "^[A-Za-z0-9_.]+$"))
return BadRequest(new { ok = false, error = "@ name can only use letters, numbers, _ and ." });
if (!PlayerDB.SetPlayerUsername(un, me.Id))
return BadRequest(new { ok = false, error = "That @ name is already taken." });
changed = true;
}
}
if (body.display_name != null)
{
string dn = body.display_name.Trim();
if (dn.Length is < 1 or > 24)
return BadRequest(new { ok = false, error = "Display name must be 1-24 characters." });
PlayerDB.SetDisplayName(dn, me.Id);
changed = true;
}
if (body.bio != null && PlayerDB.IsProfileLocked(me.Id, PlayerDB.ProfileLock.Bio)
&& body.bio.Trim() != (PlayerDB.GetFullAccount(me.Id)?.player_Extra?.Bio ?? "").Trim())
return BadRequest(new { ok = false, error = PlayerDB.ProfileLockMessage(PlayerDB.ProfileLock.Bio) });
if (body.bio != null)
{
string bio = body.bio.Trim();
if (bio.Length > 300)
return BadRequest(new { ok = false, error = "Bio must be 300 characters or fewer." });
PlayerDB.SetPlayerBio(bio, me.Id);
changed = true;
}
// Push the change to the game (and anyone viewing this profile) so a new @name /
// display name shows without a relog. AccountUpdate carries name fields; bio isn't in
// that DTO, so a bio change still persists and shows on the next profile open in-game.
if (changed)
Notifications.FireAndForget(Notifications.PushAccountUpdate(me.Id), "site profile edit");
var full = PlayerDB.GetFullAccount(me.Id);
return Ok(new
{
ok = true,
username = full?.player?.Username,
display_name = full?.player?.DisplayName,
bio = full?.player_Extra?.Bio ?? "",
});
}
public class ProfileEdit
{
public string? username { get; set; }
public string? display_name { get; set; }
public string? bio { get; set; }
}
}
}