1597 lines
85 KiB
C#
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; }
|
|
}
|
|
|
|
}
|
|
}
|