Files
Campus-25/server/Server/2025-RecRoom-Server-E12354-main/Controllers/SiteContentController.cs
T
2026-10-14 06:59:51 +02:00

1597 lines
85 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.
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 = 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 ? room?.name?.TrimStart('^', '@') : 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 ? HeartbeatDB.GetPlayerCurrentRoomName(r.PlayerID).TrimStart('^', '@') : 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 ? HeartbeatDB.GetPlayerCurrentRoomName(pid).TrimStart('^', '@') : 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);
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 ? room?.name?.TrimStart('^', '@') : 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 { }
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 });
}
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);
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 = 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 (dorms are personal - not linked)
RoomDBClasses.RoomRoot? photoRoom = null;
try { photoRoom = img.RoomId > 0 ? RoomDB.GetRoom((ulong)img.RoomId) : null; } catch { }
if (photoRoom != null && photoRoom.IsDorm) photoRoom = null;
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 = img.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" });
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) });
}
// 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 = HeartbeatDB.GetPlayerCurrentRoomName(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) || where == "not in a room."
? "Online now"
: where.TrimStart('^', '@'),
});
}
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 ? HeartbeatDB.GetPlayerCurrentRoomName(other).TrimStart('^', '@') : 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)
{
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; }
}
}
}