using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using DorkNet.Server.Data; using DorkNet.Server.Data.Entities; using DorkNet.Server.Services; namespace DorkNet.Server.Controllers.Site; /// /// Apex-host API for the public-facing site at localhost / /// rec.net. Everything here is anonymous-safe — the site reads /// the photo feed, looks up players, and browses public rooms without /// any login. Mirrors the wire shape the SPA expects (camelCase JSON /// thanks to the default ASP.NET serializer; admin / game endpoints /// override with PascalCase, but the public site has no legacy clients /// to placate so we ship modern conventions). /// /// Lives at the apex so the SPA can same-origin fetch /api/site/v1 /// from the bare domain without CORS gymnastics. Routing is path-only /// (post [Host]-strip refactor) — every subdomain reaches every /// controller and the right handler is picked by URL alone. /// [ApiController] [AllowAnonymous] [Route("api/site/v1")] public class PublicSiteController( DorkNetDbContext db, PlayerService players, SignupCodeService signupCodes, DomainConfig domain) : ControllerBase { // ── Players ────────────────────────────────────────────────────── /// GET /api/site/v1/players/search?q=…&take=20 — typeahead /// account search by display name OR username. [HttpGet("players/search")] public async Task SearchPlayers([FromQuery] string? q, [FromQuery] int take = 20) { if (string.IsNullOrWhiteSpace(q)) return Ok(Array.Empty()); var rows = await players.SearchAsync(q.Trim(), Math.Clamp(take, 1, 50)); return Ok(rows.Select(PlayerCard).ToList()); } /// GET /api/site/v1/players/{id} — public profile card. [HttpGet("players/{id:long}")] public async Task GetPlayer(long id) { var p = await db.Players .Where(x => x.Id == id) .Select(x => new { x.Id, x.Username, x.DisplayName, x.Bio, x.Level, x.XP, x.IsAdmin, x.IsDeveloper, x.IsVerified, x.IsJunior, x.CreatedAt, x.ProfileImageName, }) .FirstOrDefaultAsync(); if (p is null) return NotFound(); var photoCount = await db.Photos .CountAsync(ph => ph.UploaderPlayerId == id && ph.IsPublic && ph.DeletedAt == null); return Ok(new { id = p.Id, username = p.Username, displayName = p.DisplayName, bio = p.Bio ?? string.Empty, level = p.Level, xp = p.XP, isAdmin = p.IsAdmin, isDeveloper = p.IsDeveloper, isVerified = p.IsVerified, isJunior = p.IsJunior, createdAt = p.CreatedAt, profileImageName = p.ProfileImageName, photoCount, }); } /// GET /api/site/v1/players/{id}/photos — photos uploaded /// by the player. Excludes private + soft-deleted. [HttpGet("players/{id:long}/photos")] public async Task GetPlayerPhotos(long id, [FromQuery] int take = 24, [FromQuery] int skip = 0) { take = Math.Clamp(take, 1, 100); skip = Math.Max(0, skip); var rows = await db.Photos .Where(p => p.UploaderPlayerId == id && p.IsPublic && p.DeletedAt == null) .OrderByDescending(p => p.CreatedAt) .Skip(skip).Take(take) .ToListAsync(); return Ok(await EnrichPhotosAsync(rows)); } // ── Photos ─────────────────────────────────────────────────────── /// GET /api/site/v1/feed — global public feed, newest first. [HttpGet("feed")] public async Task Feed([FromQuery] int take = 24, [FromQuery] int skip = 0) { take = Math.Clamp(take, 1, 100); skip = Math.Max(0, skip); var rows = await db.Photos .Where(p => p.IsPublic && p.DeletedAt == null) .OrderByDescending(p => p.CreatedAt) .Skip(skip).Take(take) .ToListAsync(); return Ok(await EnrichPhotosAsync(rows)); } /// GET /api/site/v1/photos/{id} — single photo + metadata. /// Bumps the ViewCount as a side effect (denormalised trending sort). [HttpGet("photos/{id:long}")] public async Task GetPhoto(long id) { var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == id); if (photo is null || !photo.IsPublic || photo.DeletedAt is not null) return NotFound(); photo.ViewCount += 1; await db.SaveChangesAsync(); var enriched = await EnrichPhotosAsync(new[] { photo }); return Ok(enriched.First()); } // ── Rooms ──────────────────────────────────────────────────────── /// GET /api/site/v1/rooms/search?q=… — rooms matching name. /// Mostly used by the public site's room directory. [HttpGet("rooms/search")] public async Task SearchRooms([FromQuery] string? q, [FromQuery] int take = 24) { var clamped = Math.Clamp(take, 1, 50); var qq = (q ?? string.Empty).Trim(); // Public rooms list: hide dorms (per-player private space), and // hide rooms that have only ever been visited by their creator // (VisitorCount < 2). This keeps "^dwadwad" style throwaway // builds off the public-facing site without an explicit // "publish" toggle. Admins can still see them via the Rooms // admin page and delete them from there if they want. var rows = await db.Rooms .Where(r => !r.IsDormRoom && r.VisitorCount >= 2 && (qq == "" || r.Name.Contains(qq) || r.Description.Contains(qq))) .OrderByDescending(r => r.HotScore) .Take(clamped) .Select(r => new { id = r.Id, name = r.Name, description = r.Description, creatorPlayerId = r.CreatorPlayerId, isDormRoom = r.IsDormRoom, isAGRoom = r.IsAGRoom, visitCount = r.VisitCount, visitorCount = r.VisitorCount, cheerCount = r.CheerCount, imageName = r.ImageName, }) .ToListAsync(); return Ok(rows); } // ── Site stats (home-page hero) ────────────────────────────────── /// GET /api/site/v1/stats — coarse counters for the /// public site's marketing hero (player count, room count, photo /// count). Anonymous-safe. [HttpGet("stats")] public async Task Stats() { var playerCount = await db.Players.CountAsync(); var roomCount = await db.Rooms.CountAsync(r => !r.IsDormRoom); var photoCount = await db.Photos.CountAsync(p => p.IsPublic && p.DeletedAt == null); var inventionCount = await db.Inventions.CountAsync(i => !i.IsDeleted); return Ok(new { playerCount, roomCount, photoCount, inventionCount }); } // ── Helpers ────────────────────────────────────────────────────── /// Public-facing player card. Mirrors what the site needs /// for the search results list / friend-list row: id, name, /// profileImageName (the SPA stitches the img.* URL). private static object PlayerCard(PlayerEntity p) => new { id = p.Id, username = p.Username, displayName = p.DisplayName, level = p.Level, isAdmin = p.IsAdmin, isDeveloper = p.IsDeveloper, isVerified = p.IsVerified, isJunior = p.IsJunior, profileImageName = p.ProfileImageName, }; /// Materialise photo rows into wire DTOs with display names /// + room names + an absolute image URL. The URL apex switches /// based on the request host so a localhost visitor gets localhost /// CDN links and a rec.net visitor gets rec.net. private async Task> EnrichPhotosAsync(IEnumerable photos) { var list = photos.ToList(); if (list.Count == 0) return new(); var uploaderIds = list.Select(p => p.UploaderPlayerId).Distinct().ToList(); var roomIds = list.Select(p => p.RoomId).Where(id => id > 0).Distinct().ToList(); var uploaders = await db.Players .Where(p => uploaderIds.Contains(p.Id)) .Select(p => new { p.Id, p.Username, p.DisplayName, p.ProfileImageName }) .ToListAsync(); var rooms = await db.Rooms .Where(r => roomIds.Contains(r.Id)) .Select(r => new { r.Id, r.Name }) .ToListAsync(); var uMap = uploaders.ToDictionary(u => u.Id); var rMap = rooms.ToDictionary(r => r.Id); var cdnHost = domain.Sub("cdn"); return list.Select(p => { uMap.TryGetValue(p.UploaderPlayerId, out var u); rMap.TryGetValue(p.RoomId, out var r); return (object)new { id = p.Id, uploaderPlayerId = p.UploaderPlayerId, uploaderUsername = u?.Username ?? string.Empty, uploaderDisplayName = u?.DisplayName ?? $"Player_{p.UploaderPlayerId}", uploaderProfileImageName = u?.ProfileImageName, blobName = p.BlobName, imageUrl = $"https://{cdnHost}/{p.BlobName}", caption = p.Caption, roomId = p.RoomId, roomName = r?.Name ?? string.Empty, isPublic = p.IsPublic, cheerCount = p.CheerCount, viewCount = p.ViewCount, createdAt = p.CreatedAt, }; }).ToList(); } // ── Signup (code redemption) ───────────────────────────────────── /// GET /api/site/v1/join/pending-devices — device ids that /// were refused account creation while signups are disabled, seen /// from THIS caller's IP. Lets the /join page show a player the /// device their own game client just reported so they don't have to /// dig it out of Player.log. Best-effort: behind a proxy that /// collapses client IPs this can be empty, in which case the player /// pastes the id manually. [HttpGet("join/pending-devices")] public async Task JoinPendingDevices() { var ip = SignupCodeService.ClientIp(HttpContext); var rows = await signupCodes.RecentPendingByIpAsync(ip); return Ok(rows.Select(d => new { deviceId = d.DeviceId, platform = d.Platform, lastSeenAt = d.LastSeenAt, }).ToList()); } public sealed record JoinRedeemRequest(string? Code, string? Username, string? Password); /// POST /api/site/v1/join/redeem — redeem a signup code, /// minting a username/password account. Returns {ok, error?, /// username?}. [HttpPost("join/redeem")] public async Task JoinRedeem([FromBody] JoinRedeemRequest body) { var result = await signupCodes.RedeemAsync(body.Code, body.Username, body.Password); if (!result.Ok) return BadRequest(new { ok = false, error = result.Error }); return Ok(new { ok = true, username = result.Username, playerId = result.PlayerId }); } }