Files
DorkNet/DorkNet.Server/Controllers/Site/PublicSiteController.cs
T
Alexa d471b418b9 Import march-2020-03-10 from private repo (scrubbed 2026-05-27)
Targets Rec Room 2020.03.10 client (also serves 2020.03.06).

Re-import after adding:
- DorkNet.ClientMod/dorknet-clientmod.json.template — config schema
  the launcher's manifest-driven patcher renders into UserData/.
- tools/build-patcher-zip.ps1 — produces dorknet-clientpatch-{key}.zip
  for v1-march-* Release uploads.
- .github/workflows/release.yml — on tag push publishes both
  dorknet-server-march_2020_03_10-win-x64.zip and
  dorknet-clientpatch-march_2020_03_10.zip as Release assets.

Same scrub rules as december; tools/build-patcher-zip.ps1 added to
the maintainer-side allow-list so it survives.
2026-05-27 01:06:12 +03:00

247 lines
10 KiB
C#

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;
/// <summary>
/// Apex-host API for the public-facing site at <c>localhost</c> /
/// <c>rec.net</c>. 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 <c>/api/site/v1</c>
/// 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.
/// </summary>
[ApiController]
[AllowAnonymous]
[Route("api/site/v1")]
public class PublicSiteController(
DorkNetDbContext db,
PlayerService players,
DomainConfig domain) : ControllerBase
{
// ── Players ──────────────────────────────────────────────────────
/// <summary>GET /api/site/v1/players/search?q=…&amp;take=20 — typeahead
/// account search by display name OR username.</summary>
[HttpGet("players/search")]
public async Task<IActionResult> SearchPlayers([FromQuery] string? q, [FromQuery] int take = 20)
{
if (string.IsNullOrWhiteSpace(q)) return Ok(Array.Empty<object>());
var rows = await players.SearchAsync(q.Trim(), Math.Clamp(take, 1, 50));
return Ok(rows.Select(PlayerCard).ToList());
}
/// <summary>GET /api/site/v1/players/{id} — public profile card.</summary>
[HttpGet("players/{id:long}")]
public async Task<IActionResult> 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,
});
}
/// <summary>GET /api/site/v1/players/{id}/photos — photos uploaded
/// by the player. Excludes private + soft-deleted.</summary>
[HttpGet("players/{id:long}/photos")]
public async Task<IActionResult> 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 ───────────────────────────────────────────────────────
/// <summary>GET /api/site/v1/feed — global public feed, newest first.</summary>
[HttpGet("feed")]
public async Task<IActionResult> 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));
}
/// <summary>GET /api/site/v1/photos/{id} — single photo + metadata.
/// Bumps the ViewCount as a side effect (denormalised trending sort).</summary>
[HttpGet("photos/{id:long}")]
public async Task<IActionResult> 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 ────────────────────────────────────────────────────────
/// <summary>GET /api/site/v1/rooms/search?q=… — rooms matching name.
/// Mostly used by the public site's room directory.</summary>
[HttpGet("rooms/search")]
public async Task<IActionResult> 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) ──────────────────────────────────
/// <summary>GET /api/site/v1/stats — coarse counters for the
/// public site's marketing hero (player count, room count, photo
/// count). Anonymous-safe.</summary>
[HttpGet("stats")]
public async Task<IActionResult> 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 ──────────────────────────────────────────────────────
/// <summary>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).</summary>
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,
};
/// <summary>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.</summary>
private async Task<List<object>> EnrichPhotosAsync(IEnumerable<PhotoEntity> 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();
}
}