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.
This commit is contained in:
Alexa
2026-05-27 01:06:12 +03:00
commit d471b418b9
389 changed files with 104767 additions and 0 deletions
@@ -0,0 +1,267 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Account;
/// <summary>
/// api.rec.net/api/account/* — same endpoints as accounts.rec.net but routed
/// through the API service. The Accounts class in dump.cs doesn't expose its
/// base URL constant so the game may use either host; we cover both.
/// </summary>
[ApiController]
public class AccountApiController(
PlayerService playerService,
AuthService authService,
DorkNetDbContext db,
NotificationService notifications,
ServerSettingsService settings,
ILogger<AccountApiController> logger) : ControllerBase
{
[HttpPost("api/account/v1/create")]
[HttpPost("api/account/v1")]
[HttpPost("api/account/v2/create")]
[HttpPost("api/createaccount")]
public async Task<IActionResult> CreateAccount(
[FromForm] int platform,
[FromForm] string? platformId,
[FromForm] string? deviceId,
[FromForm] string? Username,
[FromForm] string? DisplayName)
{
if (await settings.AreSignupsDisabledAsync())
{
logger.LogInformation(
"[api-account] api/account/v1/create refused — signups disabled (device={Device} platform={Platform})",
deviceId, platform);
return Ok(new CreateAccountResponse
{
Success = false,
Error = "Account creation is currently disabled by the server admin.",
});
}
var player = await playerService.GetOrCreateByDeviceAsync(
deviceId: deviceId,
platform: platform,
platformId: platformId,
displayName: DisplayName ?? Username);
return Ok(new CreateAccountResponse
{
Success = true,
Error = string.Empty,
Account = new RecNetAccount
{
AccountId = (int)player.Id,
RawUsername = player.Username,
Username = player.Username,
DisplayName = player.DisplayName ?? player.Username,
ProfileImage = string.Empty,
TreatAsJunior = false,
HasBirthday = true,
Platforms = 1,
},
});
}
[HttpGet("api/account/v1/me")]
[HttpGet("api/account/v2/me")]
public async Task<IActionResult> GetMe()
{
var player = await GetCurrentPlayerAsync();
if (player is null) return Unauthorized();
return Ok(new RecNetSelfAccount
{
AccountId = (int)player.Id,
RawUsername = player.Username,
Username = player.Username,
DisplayName = player.DisplayName ?? player.Username,
ProfileImage = player.ProfileImageName ?? string.Empty,
TreatAsJunior = false,
HasBirthday = true,
Platforms = 1,
Email = player.Email ?? string.Empty,
Phone = string.Empty,
Birthday = new DateTime(2000, 1, 1),
JuniorState = 0,
ParentAccountId = null,
});
}
/// <summary>
/// Resolves the player tied to the bearer token on the request. Returns
/// null when no/invalid bearer is supplied — caller decides whether
/// that's a 401 or a softer fallback. Anonymous-account creation was
/// previously the fallback path; deleted in Phase 1c because every
/// caller has gone through `/account/create` (deviceId-bound) before
/// reaching us.
/// </summary>
private async Task<PlayerEntity?> GetCurrentPlayerAsync()
{
var auth = Request.Headers.Authorization.ToString();
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return null;
var token = auth["Bearer ".Length..].Trim();
var id = authService.ValidateToken(token);
if (id is not long playerId) return null;
return await playerService.GetByIdAsync(playerId);
}
// Modification endpoints — POSTs land on api.* host with form body
// carrying the new value. AuthAccountController on auth.* handles
// the same shape with real persistence; we mirror that here so
// either host works.
[HttpPost("api/account/v1/displayname")]
public async Task<IActionResult> SetDisplayName([FromForm(Name = "displayName")] string? displayName,
[FromForm(Name = "value")] string? value)
{
var p = await GetCurrentPlayerAsync();
if (p is null) return Unauthorized();
var name = (displayName ?? value ?? string.Empty).Trim();
if (name.Length == 0) return BadRequest(new RecNetResult { Success = false, Error = "empty" });
await playerService.UpdateDisplayNameAsync(p.Id, name);
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
[HttpPost("api/account/v1/bio")]
public async Task<IActionResult> SetBio([FromForm(Name = "bio")] string? bio,
[FromForm(Name = "value")] string? value)
{
var p = await GetCurrentPlayerAsync();
if (p is null) return Unauthorized();
await playerService.UpdateBioAsync(p.Id, (bio ?? value ?? string.Empty));
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
[HttpPost("api/account/v1/username")]
public async Task<IActionResult> SetUsername([FromForm(Name = "username")] string? username,
[FromForm(Name = "value")] string? value)
{
var p = await GetCurrentPlayerAsync();
if (p is null) return Unauthorized();
var newName = (username ?? value ?? string.Empty).Trim();
if (newName.Length == 0) return BadRequest(new RecNetResult { Success = false, Error = "empty" });
var taken = await db.Players.AnyAsync(x => x.Username == newName && x.Id != p.Id);
if (taken) return Ok(new RecNetResult { Success = false, Error = "username_taken" });
p.Username = newName;
await db.SaveChangesAsync();
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
[HttpPost("api/account/v1/birthday")]
public async Task<IActionResult> SetBirthday([FromForm(Name = "birthday")] string? birthday,
[FromForm(Name = "value")] string? value)
{
var p = await GetCurrentPlayerAsync();
if (p is null) return Unauthorized();
var raw = birthday ?? value ?? string.Empty;
if (DateTime.TryParse(raw, out var parsed))
{
p.Birthday = DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
await db.SaveChangesAsync();
}
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
[HttpPost("api/account/v1/email")]
public async Task<IActionResult> SetEmail([FromForm(Name = "email")] string? email,
[FromForm(Name = "value")] string? value)
{
var p = await GetCurrentPlayerAsync();
if (p is null) return Unauthorized();
var addr = (email ?? value ?? string.Empty).Trim();
if (addr.Length > 0) p.Email = addr;
await db.SaveChangesAsync();
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
[HttpPost("api/account/v1/phone")]
[HttpPost("api/account/v1/confirmphone")]
public async Task<IActionResult> SetPhone([FromForm(Name = "phone")] string? phone,
[FromForm(Name = "value")] string? value)
{
var p = await GetCurrentPlayerAsync();
if (p is null) return Unauthorized();
var num = (phone ?? value ?? string.Empty).Trim();
if (num.Length > 0) p.Phone = num;
await db.SaveChangesAsync();
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
[HttpPost("api/account/v1/profileimage")]
[HttpPost("api/account/me/profileimage")]
[HttpPost("account/me/profileimage")]
[HttpPost("profileimage")]
[HttpPut("api/account/v1/profileimage")]
[HttpPut("api/account/me/profileimage")]
[HttpPut("account/me/profileimage")]
[HttpPut("profileimage")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> SetProfileImage(
[FromForm(Name = "imageName")] string? imageName,
[FromForm(Name = "fileName")] string? fileName,
[FromForm(Name = "image")] string? imageAlt)
{
var player = await GetCurrentPlayerAsync();
if (player is null) return Unauthorized();
var name = imageName ?? fileName ?? imageAlt
?? Request.Query["imageName"].ToString()
?? Request.Query["fileName"].ToString();
player.ProfileImageName = string.IsNullOrWhiteSpace(name) ? null : name.Trim();
await db.SaveChangesAsync();
logger.LogInformation(
"[api-account] profile image set: player={PlayerId} -> {Name}",
player.Id,
player.ProfileImageName ?? "<cleared>");
await NotifyAccountImageChangedAsync(player);
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
private Task NotifyAccountImageChangedAsync(PlayerEntity player)
{
var account = new RecNetAccount
{
AccountId = (int)player.Id,
RawUsername = player.Username,
Username = player.Username,
DisplayName = player.DisplayName ?? player.Username,
ProfileImage = player.ProfileImageName ?? string.Empty,
TreatAsJunior = player.IsJunior,
HasBirthday = true,
Platforms = 1,
};
var selfAccount = new RecNetSelfAccount
{
AccountId = account.AccountId,
RawUsername = account.RawUsername,
Username = account.Username,
DisplayName = account.DisplayName,
ProfileImage = account.ProfileImage,
TreatAsJunior = account.TreatAsJunior,
HasBirthday = account.HasBirthday,
Platforms = account.Platforms,
Email = player.Email ?? string.Empty,
Phone = player.Phone ?? string.Empty,
Birthday = player.Birthday,
JuniorState = player.IsJunior ? 1 : 0,
ParentAccountId = null,
};
return Task.WhenAll(
notifications.NotifyTypedAsync(player.Id, "AccountUpdate", account),
notifications.NotifyTypedAsync(player.Id, "SelfAccountUpdate", selfAccount));
}
[HttpGet("api/account/v1/namegen")]
[HttpGet("api/account/v1/namegeneration")]
public IActionResult NameGen() => Ok(new
{
Nouns = new[] { "Player", "Hero", "Star", "Fox", "Wolf" },
Adjectives = new[] { "Brave", "Swift", "Clever", "Bold", "Bright" },
});
}
@@ -0,0 +1,16 @@
using Microsoft.AspNetCore.Mvc;
namespace DorkNet.Server.Controllers.API.Activities;
/// <summary>
/// api.{rec.net,localhost}/api/activities/* — per-activity content
/// (charades words, party-game prompts, etc.). The 2020 watch
/// fetches these as a static list at room-join time; we ship an
/// empty list so the activity's bundled defaults kick in.
/// </summary>
[ApiController]
public class ActivitiesController : ControllerBase
{
[HttpGet("api/activities/charades/v1/words")]
public IActionResult CharadesWords() => Ok(Array.Empty<object>());
}
@@ -0,0 +1,45 @@
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Announcements;
/// <summary>
/// api.{rec.net,localhost}/api/announcement/v{1,2}/get — fetches the
/// current server announcement (shown on the watch's home tab). The
/// payload is sourced from <see cref="CommunityBoardService"/> so the
/// admin SPA's "edit announcement" form is the single place to
/// update what players see.
///
/// Wire shape per <c>RecNet/AnnouncementDTO.cs</c> — AnnouncementId,
/// AnnouncementType, LinkType, Platform are all required via
/// <c>Util.GetKey</c> (throws on miss); Title/Body/ImageName/LinkName/
/// LinkUri/CreatedAt are GetKeyOrDefault.
/// </summary>
[ApiController]
public class AnnouncementsController(CommunityBoardService communityBoard) : ControllerBase
{
[HttpGet("api/announcement/v1/get")]
[HttpGet("api/announcement/v2/get")]
public async Task<IActionResult> Current()
{
var board = await communityBoard.GetAsync();
var ann = board.CurrentAnnouncement;
if (ann is null) return Ok(Array.Empty<object>());
return Ok(new[]
{
new
{
AnnouncementId = 1L,
AnnouncementType = 0,
Title = "Server Announcement",
Body = ann.Message ?? string.Empty,
ImageName = "",
LinkType = 0,
LinkName = "",
LinkUri = ann.MoreInfoUrl ?? string.Empty,
Platform = 0,
CreatedAt = DateTime.UtcNow,
}
});
}
}
@@ -0,0 +1,43 @@
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Auth;
/// <summary>
/// Photon custom-auth webhook (api.rec.net).
///
/// Configure in Photon dashboard:
/// Authentication → Custom → URL = https://api.rec.net/api/auth/v1/photon
/// Query params sent by client: token=&lt;our_jwt&gt;
///
/// Photon ResultCode: 1 = success, 2 = fail, 3 = param error
/// </summary>
[ApiController]
public class PhotonAuthController(AuthService authService, PlayerService playerService) : ControllerBase
{
[HttpGet("api/auth/v1/photon")]
[HttpPost("api/auth/v1/photon")]
public async Task<IActionResult> Authenticate([FromQuery] string? token, [FromQuery] string? Token)
{
var jwt = token ?? Token;
if (string.IsNullOrWhiteSpace(jwt))
return Ok(new { ResultCode = 3, Message = "Missing token parameter" });
var playerId = authService.ValidateToken(jwt);
if (playerId is null)
return Ok(new { ResultCode = 2, Message = "Invalid or expired token" });
var player = await playerService.GetByIdAsync(playerId.Value);
if (player is null)
return Ok(new { ResultCode = 2, Message = "Player not found" });
return Ok(new
{
ResultCode = 1,
Message = "ok",
UserId = player.Id.ToString(),
Nickname = player.DisplayName ?? player.Username,
});
}
}
@@ -0,0 +1,281 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Avatar.V2;
/// <summary>
/// api.rec.net/api/avatar/v2/gifts — wardrobe gift inbox. Mirrors
/// the client's <c>RecNet.Avatars.DowloadGiftPackages</c> +
/// <c>LocalRequestGiftPackage</c> + <c>LocalConsumeGiftPackage</c>
/// flow. Wire DTO matches <c>RecNet.Avatars+GiftPackage</c>
/// (<c>Cpp2IL_CS/.../RecNet/Avatars.cs:192-555</c>) field-for-field
/// so the watch's deserialiser doesn't crash.
///
/// Server semantics:
/// - <c>GET v2/gifts</c> returns unconsumed gifts for caller.
/// - <c>POST v2/gifts/generate</c> rolls a random unlocked-avatar
/// item and stamps a <see cref="GiftPackageEntity"/> for caller —
/// used by drop tiles and "free daily" prompts.
/// - <c>POST v2/gifts/consume/{id}</c> marks consumed and appends
/// the <see cref="GiftPackageEntity.AvatarItemDescOrHairDyeDesc"/>
/// to the player's <see cref="AvatarEntity.InventoryJson"/> so it
/// shows up in their backpack on next load.
/// </summary>
[ApiController]
[Authorize]
public class AvatarGiftsController(
DorkNetDbContext db,
NotificationService notifications,
LevelService level) : ControllerBase
{
[HttpGet("api/avatar/v2/gifts")]
[HttpGet("api/avatar/v3/gifts")]
[HttpGet("api/avatar/v4/gifts")]
public async Task<IActionResult> List()
{
var pid = this.RequireCurrentPlayerId();
var rows = await db.GiftPackages
.Where(g => g.RecipientPlayerId == pid && !g.Consumed)
.OrderByDescending(g => g.CreatedAt)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
public sealed record GenerateGiftRequest(int? GiftContext);
/// <summary>POST <c>api/avatar/v2/gifts/generate</c> — server picks
/// a random unlocked-avatar item and queues a gift package. The
/// 2020 client posts to this when the user clicks a free-gift
/// tile or hits a context the client maps to <c>GiftContext</c>.</summary>
[HttpPost("api/avatar/v2/gifts/generate")]
public async Task<IActionResult> Generate([FromBody] GenerateGiftRequest? req)
{
var pid = this.RequireCurrentPlayerId();
// Pick a random wardrobe StoreItem (Slug starts with
// "wardrobe-" — that's the convention StoreService uses for
// catalogue rows derived from outfits_assets bundles).
var pool = await db.StoreItems
.Where(s => s.IsActive && s.Slug.StartsWith("wardrobe-"))
.Select(s => new { s.Slug, s.DisplayName })
.ToListAsync();
if (pool.Count == 0) return StatusCode(503, "no wardrobe items in catalogue");
var rng = new Random();
var pick = pool[rng.Next(pool.Count)];
var guid = pick.Slug["wardrobe-".Length..];
// Bare GUIDs crash AvatarItem.FromRecNetString — append `,,,`
// so the watch's parse-and-iterate doesn't blow up in
// GiftManager.DequeueGift. See AvatarItemsController.cs:31-40
// for the canonical wire-format note.
var gift = new GiftPackageEntity
{
RecipientPlayerId = pid,
FromPlayerId = null,
AvatarItemType = 0, // Outfit
AvatarItemDescOrHairDyeDesc = guid + ",,,",
CurrencyType = 0, // Invalid (no currency in this gift)
GiftContext = req?.GiftContext ?? 0,
GiftRarity = RollRarity(rng),
Message = $"Enjoy your new {pick.DisplayName}!",
Platform = -1,
PackageVariant = "Standard",
PackageMaterial = string.Empty,
IsValid = true,
SupportsCurrentPlatform = true,
};
db.GiftPackages.Add(gift);
await db.SaveChangesAsync();
// Push so the watch shows the new-gift toast.
await notifications.NotifyAsync(pid,
PushNotificationId.GiftPackageReceived, ToWire(gift));
return Ok(ToWire(gift));
}
[HttpPost("api/avatar/v2/gifts/consume/{id:long}")]
public Task<IActionResult> ConsumeViaPath(long id) => ConsumeImpl(id);
/// <summary>POST <c>/api/avatar/v2/gifts/consume/</c> (note trailing
/// slash, no id in path). The watch's <c>LocalConsumeGiftPackage</c>
/// (verified at <c>Cpp2IL_ISIL/.../RecNet/Avatars_NestedType__LocalConsumeGiftPackage_d__19.txt:276-301</c>)
/// posts to this exact URL with a form-urlencoded body containing
/// <c>Id</c> and <c>UnlockedLevel</c> fields. The previous
/// implementation took <c>[FromBody] ConsumeGiftRequest</c> which
/// only accepts <c>application/json</c> — the watch's form-encoded
/// body got 415 Unsupported Media Type
/// (<c>output_log.txt:'Failed to consume gift … HTTP Error 415'</c>),
/// which left the gift unconsumed → the avatar item never landed
/// in the player's inventory → mirror-unequip rendered a missing
/// item and crashed.
///
/// Accept both JSON and form-urlencoded by reading the raw body
/// and pulling <c>Id</c> from whichever container we got.</summary>
[HttpPost("api/avatar/v2/gifts/consume")]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data", "text/plain")]
public async Task<IActionResult> ConsumeViaBody()
{
long id = 0;
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
var raw = form["Id"].ToString();
if (string.IsNullOrWhiteSpace(raw)) raw = form["id"].ToString();
long.TryParse(raw, out id);
}
else
{
using var reader = new System.IO.StreamReader(Request.Body);
var body = await reader.ReadToEndAsync();
if (!string.IsNullOrWhiteSpace(body))
{
try
{
using var doc = System.Text.Json.JsonDocument.Parse(body);
if (doc.RootElement.TryGetProperty("Id", out var v) ||
doc.RootElement.TryGetProperty("id", out v))
{
if (v.ValueKind == System.Text.Json.JsonValueKind.Number)
v.TryGetInt64(out id);
else if (v.ValueKind == System.Text.Json.JsonValueKind.String)
long.TryParse(v.GetString(), out id);
}
}
catch (System.Text.Json.JsonException) { /* fall through; id stays 0 */ }
}
}
if (id == 0)
{
var raw = Request.Query["Id"].ToString();
if (string.IsNullOrWhiteSpace(raw)) raw = Request.Query["id"].ToString();
long.TryParse(raw, out id);
}
if (id == 0) return BadRequest("missing Id");
return await ConsumeImpl(id);
}
private async Task<IActionResult> ConsumeImpl(long id)
{
var pid = this.RequireCurrentPlayerId();
var gift = await db.GiftPackages.FirstOrDefaultAsync(g => g.Id == id);
if (gift is null) return NotFound();
if (gift.RecipientPlayerId != pid) return Forbid();
if (gift.Consumed) return Ok(ToWire(gift)); // idempotent
// Append avatar item to player's inventory list (JSON list of
// GUID strings). Defensive parse — corrupted JSON resets to
// empty list rather than 500.
if (!string.IsNullOrEmpty(gift.AvatarItemDescOrHairDyeDesc))
{
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid);
if (avatar is null)
{
avatar = new AvatarEntity { PlayerId = pid };
db.Avatars.Add(avatar);
}
try
{
var list = System.Text.Json.JsonSerializer
.Deserialize<List<string>>(avatar.InventoryJson) ?? new();
if (!list.Contains(gift.AvatarItemDescOrHairDyeDesc))
{
list.Add(gift.AvatarItemDescOrHairDyeDesc);
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(list);
avatar.UpdatedAt = DateTime.UtcNow;
}
}
catch
{
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(
new[] { gift.AvatarItemDescOrHairDyeDesc });
avatar.UpdatedAt = DateTime.UtcNow;
}
}
// Apply currency / XP / level rewards bundled with the gift.
// The previous implementation only handled the avatar item path,
// so token gifts from the admin SPA marked themselves consumed
// but never moved the player's wallet — the player tapped the
// gift, the box played its animation, and the balance stayed
// unchanged. CurrencyType is the canonical RecNet enum
// (1 = Tokens, 2 = Coins; 0 means "no currency in this gift").
if (gift.CurrencyType > 0 && gift.Currency > 0)
{
await level.GrantCurrencyAsync(pid, gift.CurrencyType, gift.Currency,
$"gift_consumed:{gift.Id}");
}
if (gift.Xp > 0)
{
await level.AwardXpAsync(pid, gift.Xp, $"gift_consumed:{gift.Id}");
}
// Direct level grants bypass XP curves — same pattern
// /api/admin/v1/players/{id}/levelup uses. We don't surface
// that as a separate helper; bump the column inline.
if (gift.Level > 0)
{
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == pid);
if (player is not null) player.Level += gift.Level;
}
gift.Consumed = true;
gift.ConsumedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
// Profile-update push so the watch's wallet readout refreshes
// without waiting for the next /balance poll. Mirrors what
// /api/admin/v1/players/{id}/gift does after the inbox push so
// the toast number animates up the moment the box closes.
if (gift.CurrencyType > 0 || gift.Xp > 0 || gift.Level > 0)
{
await notifications.NotifyAsync(pid,
PushNotificationId.SubscriptionUpdateProfile,
new { Reason = "GiftConsumed", gift.CurrencyType, gift.Currency, gift.Xp, gift.Level });
}
return Ok(ToWire(gift));
}
/// <summary>Weighted rarity roll — Common 65, Uncommon 25,
/// Rare 8, Epic 1.8, Legendary 0.2 percent.</summary>
private static int RollRarity(Random rng) => rng.NextDouble() switch
{
< 0.65 => 0, // Common
< 0.90 => 10, // Uncommon
< 0.98 => 20, // Rare
< 0.998 => 30, // Epic
_ => 50, // Legendary
};
private static object ToWire(GiftPackageEntity g) => new
{
g.Id,
g.FromPlayerId,
g.ConsumableItemDesc,
g.AvatarItemType,
AvatarItemDesc = g.AvatarItemDescOrHairDyeDesc,
g.AvatarItemDescOrHairDyeDesc,
g.EquipmentPrefabName,
g.EquipmentModificationGuid,
g.CurrencyType,
g.Currency,
g.Xp,
g.Level,
g.GiftContext,
g.GiftRarity,
g.Message,
g.Platform,
g.PackageMaterial,
g.PackageVariant,
g.Consumed,
g.IsValid,
g.ErrorMessage,
g.SupportsCurrentPlatform,
g.IsGifted,
};
}
@@ -0,0 +1,193 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using System.Text.Json;
using System.Text.Json.Serialization;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.Avatar.V2;
/// <summary>
/// api.rec.net/api/avatar/v2 — older Avatar settings endpoint used by
/// the 2020 client (the V3 endpoint is for newer builds and uses a
/// different shape entirely).
///
/// Wire shape verified by disassembling RecNet.Avatar.Deserialize at
/// RVA 0xFA3C30. Required keys (Util.GetKey&lt;string&gt;): OutfitSelections,
/// HairColor, SkinColor, FaceFeatures. Empty strings satisfy the
/// deserializer; the client interprets them as "use defaults" and falls
/// back to its built-in starter avatar.
///
/// This implementation persists each set/get to <see cref="AvatarEntity"/>
/// instead of stubbing — round-trip across sessions, multi-user-safe
/// (one row per player keyed by PlayerId).
/// </summary>
[ApiController]
[Authorize]
public class AvatarV2Controller(DorkNetDbContext db, ILogger<AvatarV2Controller> logger) : ControllerBase
{
[HttpGet("api/avatar/v2")]
public async Task<ActionResult<AvatarV2Dto>> GetAvatar()
{
var pid = this.RequireCurrentPlayerId();
var a = await db.Avatars.FirstOrDefaultAsync(x => x.PlayerId == pid);
return Ok(ToDto(a));
}
/// <summary>
/// POST /api/avatar/v2/set — replace the player's avatar settings.
///
/// CRITICAL: do NOT use <c>[FromBody] AvatarV2Dto</c> here. The
/// watch's <c>RecNet.Avatar.Serialize()</c> emits each of
/// OutfitSelections / HairColor / SkinColor / FaceFeatures as
/// dictionary values whose runtime types are determined by the
/// IRecNetObject serializer it uses — FaceFeatures in particular
/// sometimes ships as a nested JSON OBJECT instead of a JSON
/// string, and the watch may also omit fields entirely when
/// they're empty.
///
/// System.Text.Json's strict type binder responds to either of those
/// with a 400 (model state invalid → ApiController auto-400) and
/// the watch then retries every frame, accumulating "Failed to
/// upload Rec Room player avatar changes: HTTP Error 400" errors
/// (output_log.txt:852+, 1000+ repeats) until the player object
/// gets destroyed.
///
/// Parse the body manually as JsonElement so we accept any shape
/// the watch sends: string-or-object for FaceFeatures (re-encode
/// the object as a string before persisting), missing fields (keep
/// existing values), null values (treat as empty).
/// </summary>
[HttpPost("api/avatar/v2")]
[HttpPost("api/avatar/v2/set")]
[Consumes("application/json", "application/x-www-form-urlencoded", "text/plain")]
public async Task<ActionResult<AvatarV2Dto>> SetAvatar()
{
var pid = this.RequireCurrentPlayerId();
// Read the raw body so we control parsing — bypasses the
// model binder's strict type matching that was causing 400s.
string raw;
using (var reader = new System.IO.StreamReader(Request.Body))
raw = await reader.ReadToEndAsync();
string? outfitSelections = null;
string? hairColor = null;
string? skinColor = null;
string? faceFeatures = null;
if (!string.IsNullOrWhiteSpace(raw))
{
try
{
using var doc = JsonDocument.Parse(raw);
var root = doc.RootElement;
if (root.ValueKind == JsonValueKind.Object)
{
outfitSelections = ReadStringField(root, "OutfitSelections");
hairColor = ReadStringField(root, "HairColor");
skinColor = ReadStringField(root, "SkinColor");
faceFeatures = ReadStringField(root, "FaceFeatures");
}
}
catch (JsonException ex)
{
// Fall through with all-null updates if the body isn't
// JSON. Log so we can see what the watch actually sends.
logger.LogWarning(
"[avatar-set] could not parse body as JSON ({Msg}); raw={Raw}",
ex.Message, raw.Length > 500 ? raw.Substring(0, 500) + "…" : raw);
}
}
var a = await db.Avatars.FirstOrDefaultAsync(x => x.PlayerId == pid);
if (a is null)
{
a = new AvatarEntity { PlayerId = pid };
db.Avatars.Add(a);
}
// Only overwrite fields the watch actually sent; missing keys
// keep the previously-stored value so a partial PATCH-style
// update doesn't accidentally wipe HairColor/SkinColor/etc.
if (outfitSelections is not null) a.OutfitSelections = outfitSelections;
if (hairColor is not null) a.HairColor = hairColor;
if (skinColor is not null) a.SkinColor = skinColor;
if (faceFeatures is not null) a.FaceFeatures = faceFeatures;
a.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToDto(a));
}
/// <summary>Read a field that should be a string but may arrive
/// as <c>null</c>, a JSON object/array (re-encoded back to a JSON
/// string), or be missing entirely (return null → caller keeps
/// the existing DB value).</summary>
private static string? ReadStringField(JsonElement root, string name)
{
if (!root.TryGetProperty(name, out var v)) return null;
return v.ValueKind switch
{
JsonValueKind.String => v.GetString() ?? string.Empty,
JsonValueKind.Null => string.Empty,
JsonValueKind.Object => v.GetRawText(),
JsonValueKind.Array => v.GetRawText(),
JsonValueKind.Number => v.GetRawText(),
JsonValueKind.True or JsonValueKind.False => v.GetRawText(),
_ => null,
};
}
/// <summary>Public lookup of another player's equipped avatar — the
/// client uses this to render remote players' avatars in shared
/// rooms. No auth requirement on read so anonymous calls in the
/// boot path don't 401.</summary>
[HttpGet("api/avatar/v2/equipped/{playerId:long}")]
[HttpGet("api/avatar/v3/equipped/{playerId:long}")]
[HttpGet("api/avatar/v4/equipped/{playerId:long}")]
[AllowAnonymous]
public async Task<ActionResult<AvatarV2Dto>> GetEquippedFor(long playerId)
{
var a = await db.Avatars.FirstOrDefaultAsync(x => x.PlayerId == playerId);
return Ok(ToDto(a));
}
/// <summary>Bare <c>api/avatar/v{2,3}/equipped</c> returns the
/// caller's own equipped avatar. Without the bare path the watch's
/// "what am I wearing" query falls through to the catch-all and
/// gets <c>[]</c>.</summary>
[HttpGet("api/avatar/v2/equipped")]
[HttpGet("api/avatar/v3/equipped")]
[HttpGet("api/avatar/v4/equipped")]
public async Task<ActionResult<AvatarV2Dto>> GetEquippedSelf()
{
var pid = this.RequireCurrentPlayerId();
var a = await db.Avatars.FirstOrDefaultAsync(x => x.PlayerId == pid);
return Ok(ToDto(a));
}
private static AvatarV2Dto ToDto(AvatarEntity? a) => new()
{
OutfitSelections = a?.OutfitSelections ?? string.Empty,
FaceFeatures = a?.FaceFeatures ?? string.Empty,
HairColor = a?.HairColor ?? string.Empty,
SkinColor = a?.SkinColor ?? string.Empty,
};
}
public class AvatarV2Dto
{
[JsonPropertyName("OutfitSelections")]
public string OutfitSelections { get; set; } = string.Empty;
[JsonPropertyName("HairColor")]
public string HairColor { get; set; } = string.Empty;
[JsonPropertyName("SkinColor")]
public string SkinColor { get; set; } = string.Empty;
[JsonPropertyName("FaceFeatures")]
public string FaceFeatures { get; set; } = string.Empty;
}
@@ -0,0 +1,103 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using System.Text.Json.Serialization;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.Avatar.V3;
/// <summary>
/// api.rec.net/api/avatar/v{2,3}/saved — saved-outfit slots. Wire
/// type <c>RecNet.SavedOutfit</c> (per agent ISIL extraction):
/// <c>Slot(int), PreviewImageName(string), OutfitSelections(string),
/// FaceFeatures(string), SkinColor(string), HairColor(string)</c>.
///
/// State persists in <see cref="AvatarEntity.SavedOutfitsJson"/> as
/// a JSON list of <see cref="SavedOutfitDto"/> records keyed by
/// Slot. Caps at 20 entries per player to keep the column under
/// the SQLite text-blob comfort zone.
/// </summary>
[ApiController]
[Authorize]
public class AvatarSavedOutfitsController(DorkNetDbContext db) : ControllerBase
{
private const int MaxSlots = 20;
[HttpGet("api/avatar/v2/saved")]
[HttpGet("api/avatar/v3/saved")]
[HttpGet("api/avatar/v4/saved")]
public async Task<IActionResult> List()
{
var pid = this.RequireCurrentPlayerId();
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid);
var list = ParseList(avatar?.SavedOutfitsJson);
return Ok(list);
}
/// <summary>POST <c>v3/saved/set</c> — upsert one outfit by Slot.
/// Body is the SavedOutfit shape directly.</summary>
[HttpPost("api/avatar/v3/saved/set")]
[HttpPost("api/avatar/v2/saved/set")]
public async Task<IActionResult> Set([FromBody] SavedOutfitDto body)
{
if (body is null) return BadRequest("missing body");
var pid = this.RequireCurrentPlayerId();
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid);
if (avatar is null)
{
avatar = new AvatarEntity { PlayerId = pid };
db.Avatars.Add(avatar);
}
var list = ParseList(avatar.SavedOutfitsJson);
var idx = list.FindIndex(o => o.Slot == body.Slot);
if (idx >= 0) list[idx] = body;
else
{
if (list.Count >= MaxSlots) return BadRequest($"max {MaxSlots} saved outfits per player");
list.Add(body);
}
avatar.SavedOutfitsJson = JsonSerializer.Serialize(list);
avatar.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(body);
}
[HttpDelete("api/avatar/v3/saved/{slot:int}")]
[HttpDelete("api/avatar/v2/saved/{slot:int}")]
public async Task<IActionResult> Delete(int slot)
{
var pid = this.RequireCurrentPlayerId();
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid);
if (avatar is null) return NotFound();
var list = ParseList(avatar.SavedOutfitsJson);
var removed = list.RemoveAll(o => o.Slot == slot);
if (removed == 0) return NotFound();
avatar.SavedOutfitsJson = JsonSerializer.Serialize(list);
avatar.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok();
}
private static List<SavedOutfitDto> ParseList(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new();
try
{
return JsonSerializer.Deserialize<List<SavedOutfitDto>>(json) ?? new();
}
catch { return new(); }
}
}
public sealed class SavedOutfitDto
{
[JsonPropertyName("Slot")] public int Slot { get; set; }
[JsonPropertyName("PreviewImageName")] public string PreviewImageName { get; set; } = string.Empty;
[JsonPropertyName("OutfitSelections")] public string OutfitSelections { get; set; } = string.Empty;
[JsonPropertyName("FaceFeatures")] public string FaceFeatures { get; set; } = string.Empty;
[JsonPropertyName("SkinColor")] public string SkinColor { get; set; } = string.Empty;
[JsonPropertyName("HairColor")] public string HairColor { get; set; } = string.Empty;
}
@@ -0,0 +1,411 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using System.Text.Json;
using System.Text.Json.Serialization;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Avatar.V4;
/// <summary>
/// api.rec.net/api/avatar/v{2,3,4}/items — list of avatar items the
/// caller has UNLOCKED. The watch's
/// <c>RecNet.Avatars+&lt;DowloadUnlockedAvatarItems&gt;</c> coroutine
/// (Cpp2IL_ISIL/.../Avatars_NestedType__DowloadUnlockedAvatarItems_d__21.txt:149
/// hits <c>"{api/avatar/}v4/items"</c>) parses the result as
/// <c>List&lt;UnlockedAvatarItem&gt;</c>; the wardrobe UI then
/// renders a "buy" button for any item NOT in this list and an
/// "equip" affordance for items that are.
///
/// Wire shape per
/// Cpp2IL_ISIL/.../Avatars_NestedType_UnlockedAvatarItem.txt:186-212:
/// "AvatarItemType" (int — Outfit=0 / HairDye=1, NOT slot-based),
/// "AvatarItemDesc" (string — the item GUID with three empty
/// customizations appended, see below),
/// "PlatformMask" (int), "FriendlyName" (string),
/// "Tooltip" (string), "Rarity" (int).
///
/// IMPORTANT — <c>AvatarItemDesc</c> format: the client passes this
/// string to <c>AvatarItem.FromRecNetString(string)</c>
/// (Cpp2IL_ISIL/.../RecRoom/Avatar/Data/Runtime/AvatarItem.txt:81-257).
/// The desc is <c>String.Split(',')</c> into <c>guid + customization[0..3]</c>
/// — the inner overload then indexes <c>customization[0]</c>,
/// <c>customization[1]</c>, <c>customization[2]</c> and throws
/// <c>ArgumentException</c> at <c>ArraySegment.get_Current</c> if the
/// segment is shorter than 3. Every desc we ship MUST therefore have
/// at least 4 comma-separated parts, so we suffix bare GUIDs with
/// <c>",,,"</c> (three empty customizations).
///
/// CATALOG SOURCE — <c>data/avatar_item_lookup.json</c>. Generated by
/// the manual/CI-only BepInEx patch under
/// <c>tools/AvatarCatalogDumpPatch</c>. That patch reads the watch's bundled
/// <c>AvatarItemWardrobeRuntimeConfig.avatarItemDataLookup</c>
/// SerializedDictionary at boot and writes every (guid, AvatarItemData)
/// pair to JSON. THIS is the authoritative safe list — any GUID NOT
/// in this file throws <c>InvalidOperationException("Missing data
/// for guid …")</c> inside the watch's wardrobe parser, which aborts
/// the entire coroutine on the first bad item and leaves the drawer
/// empty. Items extracted from <c>outfits_assets_*.bundle</c>
/// MonoBehaviours (the older <c>data/wardrobe_items.json</c>) are
/// MASK + SWATCH asset GUIDs, NOT AvatarItem GUIDs, so that file
/// can't be used as a safelist.
///
/// PER-PLAYER ownership: the watch intersects each store tile's
/// <c>AvatarItemDesc</c> against THIS list to flip the buy-button
/// to "Already Owned." Returning the global wardrobe here therefore
/// makes every store item appear owned. We instead intersect the
/// caller's <see cref="AvatarEntity.InventoryJson"/> with the master
/// catalog so the watch sees only items the player actually owns.
/// Anonymous calls return [].
/// </summary>
[ApiController]
public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) : ControllerBase
{
/// <summary>Master catalog keyed by GUID. Loaded once on first
/// request from <c>data/avatar_item_lookup.json</c>. Same
/// lazy-load idiom as before but the catalog itself is now a
/// dump of the watch's bundled
/// <c>avatarItemDataLookup</c> instead of the asset-bundle
/// MonoBehaviour scan in the previous wardrobe_items.json.</summary>
private static readonly Lazy<CatalogData> _catalog = new(LoadCatalog);
[HttpGet("api/avatar/v2/items")]
[HttpGet("api/avatar/v3/items")]
[HttpGet("api/avatar/v4/items")]
[Authorize]
public async Task<ActionResult<List<UnlockedAvatarItemDto>>> Get()
{
// Keep this switch as a fast kill-switch, but default it on now that
// the response is bounded below. The risky path is not the parser
// itself; it is feeding the 2020 watch too many BrowsableAvatarItem /
// DynamicAvatarItemImposter rows at once.
if (!config.GetValue("Avatar:EnableUnlockedItems", true))
return Ok(new List<UnlockedAvatarItemDto>());
var maxItems = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItems", 30), 1, 120);
var maxPerSlot = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItemsPerSlot", 4), 1, 20);
var pid = this.CurrentPlayerId();
if (pid is not long me) return Ok(new List<UnlockedAvatarItemDto>());
var avatar = await db.Avatars
.Where(a => a.PlayerId == me)
.Select(a => new { a.InventoryJson, a.OutfitSelections })
.FirstOrDefaultAsync();
var owned = ParseInventory(avatar?.InventoryJson);
var ownedHairDyeSlugs = await db.PlayerInventory
.Where(i => i.PlayerId == me && i.Quantity > 0)
.Select(i => i.ItemSlug)
.ToListAsync();
var ownedHairDyes = ownedHairDyeSlugs
.Where(slug => StoreService.TryGetHairDyePayload(slug, out _))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (owned.Count == 0 && ownedHairDyes.Count == 0) return Ok(new List<UnlockedAvatarItemDto>());
var catalog = _catalog.Value;
var equipped = ParseEquippedItemGuids(avatar?.OutfitSelections);
var ordered = owned
.OrderByDescending(guid => equipped.Contains(guid))
.ThenBy(guid => catalog.SlotByGuid.TryGetValue(guid, out var slot) ? slot : int.MaxValue)
.ThenBy(guid => guid, StringComparer.OrdinalIgnoreCase);
var maxOutfitItems = Math.Max(0, maxItems - ownedHairDyes.Count);
var perSlot = new Dictionary<int, int>();
var result = new List<UnlockedAvatarItemDto>(Math.Min(owned.Count, maxItems));
foreach (var guid in ordered)
{
if (result.Count >= maxOutfitItems) break;
// Intersect against the safelist. Items not in the bundled
// lookup would crash the watch's wardrobe parser, so they're
// silently dropped here.
if (!catalog.Items.TryGetValue(guid, out var item)) continue;
var slot = catalog.SlotByGuid.TryGetValue(guid, out var value) ? value : -1;
perSlot.TryGetValue(slot, out var count);
if (count >= maxPerSlot && !equipped.Contains(guid)) continue;
perSlot[slot] = count + 1;
result.Add(item);
if (result.Count >= maxItems) break;
}
foreach (var slug in ownedHairDyes)
{
if (result.Count >= maxItems) break;
if (!StoreService.TryGetHairDyePayload(slug, out var hairColorGuid)) continue;
StoreService.TryGetHairDyeName(slug, out var friendlyName);
result.Add(new UnlockedAvatarItemDto
{
AvatarItemType = AvatarItemType.HairDye,
AvatarItemDesc = hairColorGuid,
PlatformMask = unchecked((int)0xFFFFFFFF),
FriendlyName = friendlyName,
Tooltip = string.Empty,
Rarity = 0,
});
}
return Ok(result);
}
/// <summary>The set of GUIDs the bundled
/// <c>AvatarItemWardrobeRuntimeConfig.avatarItemDataLookup</c>
/// contains. Exposed so other controllers (StoreService seed,
/// PlayerService starter inventory) can validate their seed
/// data against the same safe list.</summary>
public static IReadOnlySet<string> SafeGuids => _catalog.Value.SafeGuids;
/// <summary>Master catalog of all renderable items, keyed by
/// GUID. Used by callers that need item metadata (slot type,
/// name, hair-color flags) outside of the per-player ownership
/// check.</summary>
public static IReadOnlyDictionary<string, UnlockedAvatarItemDto> Catalog
=> _catalog.Value.Items;
/// <summary>Per-OutfitType slot index → list of GUIDs in that slot.
/// Used by <see cref="DorkNet.Server.Services.PlayerService"/> to
/// build a starter loadout (one per slot) and a starter inventory
/// (a few per slot).</summary>
public static IReadOnlyDictionary<int, List<string>> BySlot
=> _catalog.Value.BySlot;
public static bool TryGetOutfitType(string guid, out int outfitType) =>
_catalog.Value.SlotByGuid.TryGetValue(guid, out outfitType);
private static List<string> ParseInventory(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new();
try
{
var strings = JsonSerializer.Deserialize<List<string>>(json);
if (strings is not null) return strings;
}
catch { }
try
{
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Array) return new();
var result = new List<string>();
foreach (var element in doc.RootElement.EnumerateArray())
{
string? value = null;
if (element.ValueKind == JsonValueKind.String)
value = element.GetString();
else if (element.ValueKind == JsonValueKind.Object &&
element.TryGetProperty("itemId", out var itemId) &&
itemId.ValueKind == JsonValueKind.String)
value = itemId.GetString();
if (string.IsNullOrWhiteSpace(value)) continue;
const string wardrobePrefix = "wardrobe-";
if (value.StartsWith(wardrobePrefix, StringComparison.Ordinal))
value = value[wardrobePrefix.Length..];
var comma = value.IndexOf(',');
if (comma >= 0)
value = value[..comma];
result.Add(value);
}
return result
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
catch { return new(); }
}
private static HashSet<string> ParseEquippedItemGuids(string? outfitSelections)
{
var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(outfitSelections)) return result;
foreach (var selection in outfitSelections.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var comma = selection.IndexOf(',');
var guid = comma >= 0 ? selection[..comma] : selection;
if (!string.IsNullOrWhiteSpace(guid))
result.Add(guid.Trim());
}
return result;
}
private sealed record CatalogData(
IReadOnlyDictionary<string, UnlockedAvatarItemDto> Items,
IReadOnlySet<string> SafeGuids,
IReadOnlyDictionary<int, List<string>> BySlot,
IReadOnlyDictionary<string, int> SlotByGuid);
private static CatalogData LoadCatalog()
{
// Try both lowercase 'data/' (csproj None+Link target) and
// uppercase 'Data/' (git-tracked source folder) so case-sensitivity
// mismatches between Windows (where the dev wrote the path) and
// Linux (where Coolify builds the container) can't lose the lookup
// file. Without this fallback, a single missed file → empty
// SafeGuids → StoreService.SeedAsync skips its stale-row cleanup
// → 418 swatch-GUID rows ship live → every store tile renders as
// Hat_Angler.
var candidates = new[]
{
Path.Combine(AppContext.BaseDirectory, "data", "avatar_item_lookup.json"),
Path.Combine(AppContext.BaseDirectory, "Data", "avatar_item_lookup.json"),
Path.Combine(Directory.GetCurrentDirectory(), "data", "avatar_item_lookup.json"),
Path.Combine(Directory.GetCurrentDirectory(), "Data", "avatar_item_lookup.json"),
};
var path = candidates.FirstOrDefault(System.IO.File.Exists);
Console.WriteLine(path is null
? $"[avatar-catalog] avatar_item_lookup.json not found in any of: {string.Join(", ", candidates)}"
: $"[avatar-catalog] loading from {path}");
if (path is null) return new CatalogData(
new Dictionary<string, UnlockedAvatarItemDto>(),
new HashSet<string>(),
new Dictionary<int, List<string>>(),
new Dictionary<string, int>());
using var fs = System.IO.File.OpenRead(path);
var dump = JsonSerializer.Deserialize<LookupFile>(fs,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? new LookupFile();
var items = new Dictionary<string, UnlockedAvatarItemDto>(dump.Items.Count, StringComparer.OrdinalIgnoreCase);
var safe = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var bySlot = new Dictionary<int, List<string>>();
var slotByGuid = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var row in dump.Items)
{
if (string.IsNullOrEmpty(row.Guid)) continue;
safe.Add(row.Guid);
// Rec Room's bundled item names are wrapped in parens like
// "(Hat_Wizard)" — strip those and the underscores for a
// friendlier UI label.
var pretty = PrettyName(row.Name);
items[row.Guid] = new UnlockedAvatarItemDto
{
// AvatarItemType is the wire enum the watch parses
// (Outfit=0 / HairDye=1), not the slot. Rows in
// avatarItemDataLookup are wearable avatar items, including
// hair meshes that *use* hair color. They must still be sent
// as Outfit; sending HairDye makes the client treat the item
// GUID as a color GUID and log "Can not find hair dye color".
AvatarItemType = AvatarItemType.Outfit,
AvatarItemDesc = row.Guid + ",,,",
PlatformMask = unchecked((int)0xFFFFFFFF),
FriendlyName = pretty,
Tooltip = string.Empty,
Rarity = 0,
};
if (!bySlot.TryGetValue(row.OutfitType, out var lst))
bySlot[row.OutfitType] = lst = new List<string>();
lst.Add(row.Guid);
slotByGuid[row.Guid] = row.OutfitType;
}
return new CatalogData(items, safe, bySlot, slotByGuid);
}
private static string PrettyName(string raw)
{
if (string.IsNullOrEmpty(raw)) return string.Empty;
// Strip leading/trailing parens that the bundled config wraps
// names in, then turn underscores into spaces. Keep it simple —
// the watch shows whatever string we send.
var trimmed = raw.Trim().TrimStart('(').TrimEnd(')');
// Drop common asset-name suffixes. Items in the runtime config
// use the raw asset name (e.g. "Hat_Wizard", "shirt_sherlock");
// the watch never normalises these, so we do it server-side.
var s = trimmed.Replace('_', ' ').Trim();
return s;
}
/// <summary>Shape of <c>data/avatar_item_lookup.json</c> as written
/// by <c>tools/AvatarCatalogDumpPatch</c>.</summary>
private sealed class LookupFile
{
[JsonPropertyName("items")]
public List<LookupItem> Items { get; set; } = new();
// Auxiliary GUID lists (prefab/mask/swatch/decal). Not used
// server-side yet — they'd matter for resolving asset
// references the watch might subsequently request, but the
// wire protocol only ever ships the AvatarItem GUID.
[JsonPropertyName("prefab_guids")]
public List<string> PrefabGuids { get; set; } = new();
[JsonPropertyName("mask_guids")]
public List<string> MaskGuids { get; set; } = new();
[JsonPropertyName("swatch_guids")]
public List<string> SwatchGuids { get; set; } = new();
[JsonPropertyName("decal_guids")]
public List<string> DecalGuids { get; set; } = new();
}
private sealed class LookupItem
{
[JsonPropertyName("guid")] public string Guid { get; set; } = string.Empty;
[JsonPropertyName("name")] public string Name { get; set; } = string.Empty;
[JsonPropertyName("outfit_type")] public int OutfitType { get; set; }
[JsonPropertyName("outfit_set")] public int OutfitSet { get; set; }
[JsonPropertyName("drawer_index")] public int DrawerIndex { get; set; }
[JsonPropertyName("hides_hair")] public bool HidesHair { get; set; }
[JsonPropertyName("hides_hand")] public bool HidesHand { get; set; }
[JsonPropertyName("uses_hair_color")] public bool UsesHairColor { get; set; }
[JsonPropertyName("uses_team_color")] public bool UsesTeamColor { get; set; }
[JsonPropertyName("is_team_item")] public bool IsTeamItem { get; set; }
}
/// <summary>Mirrors <c>RecNet.Avatars+AvatarItemType</c>
/// (Avatars.cs:186-190).</summary>
private static class AvatarItemType
{
public const int Outfit = 0;
public const int HairDye = 1;
}
/// <summary>OutfitType slot enum mirrored from
/// <c>RecRoom.Avatar.Data.Shared.OutfitType</c>. Used by other
/// callers (PlayerService, StoreService) for slot-aware seeding.</summary>
public static class Slot
{
public const int Hat = 0;
public const int BackHead = 1;
public const int Hair = 2;
public const int Eye = 10;
public const int Mouth = 20;
public const int Neck = 100;
public const int Shirt = 101;
public const int Belt = 102;
public const int Pocket = 103;
public const int TeamShirt = 104;
public const int Wrist = 200;
public const int Glove = 201;
public const int Watch = 202;
public const int TeamWrist = 203;
}
}
public class UnlockedAvatarItemDto
{
[JsonPropertyName("AvatarItemType")]
public int AvatarItemType { get; set; }
[JsonPropertyName("AvatarItemDesc")]
public string AvatarItemDesc { get; set; } = string.Empty;
[JsonPropertyName("PlatformMask")]
public int PlatformMask { get; set; }
[JsonPropertyName("FriendlyName")]
public string FriendlyName { get; set; } = string.Empty;
[JsonPropertyName("Tooltip")]
public string Tooltip { get; set; } = string.Empty;
[JsonPropertyName("Rarity")]
public int Rarity { get; set; }
}
@@ -0,0 +1,88 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.BugReporting;
/// <summary>
/// api.rec.net/api/bugreporting/* — in-game "Report a Bug" UI.
/// Wire request DTO matches <c>RecNet.BugReporting+BugReportDTO</c>
/// (<c>Cpp2IL_CS/.../RecNet/BugReporting.cs</c>): <c>Summary,
/// Description, TestCaseKey, BuildVersion, BuildTimestamp,
/// BundleVersionCode</c>.
///
/// Plus <c>POST /api/bugreporting/v1/submit</c> (legacy v1 path) and
/// <c>POST /api/bugreporting/v2/submit</c> (renamed v2). The
/// <c>ReportBug</c> client API also includes screenshot + log byte
/// arrays as multipart parts; we accept-and-discard those (no
/// storage flow yet) but persist the text fields.
/// </summary>
[ApiController]
[Authorize]
public class BugReportsController(DorkNetDbContext db) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
public sealed class BugReportRequest
{
public string? Summary { get; set; }
public string? Description { get; set; }
public string? TestCaseKey { get; set; }
public string? BuildVersion { get; set; }
public long? BuildTimestamp { get; set; }
public int? BundleVersionCode { get; set; }
}
[HttpPost("api/bugreporting/v1/submit")]
[HttpPost("api/bugreporting/v2/submit")]
[HttpPost("api/bugreporting/v1/reportbug")]
[HttpPost("api/bugreporting/v2/reportbug")]
[Consumes("application/json")]
public async Task<IActionResult> SubmitJson([FromBody] BugReportRequest req)
=> await PersistAsync(req);
/// <summary>Multipart form variant — the watch posts screenshot
/// + log as separate parts. We pull the text-only fields and
/// drop the binary attachments.</summary>
[HttpPost("api/bugreporting/v1/submit-multipart")]
[HttpPost("api/bugreporting/v2/reportbug-multipart")]
[Consumes("multipart/form-data")]
public async Task<IActionResult> SubmitMultipart(
[FromForm(Name = "Summary")] string? summary,
[FromForm(Name = "Description")] string? description,
[FromForm(Name = "TestCaseKey")] string? testCaseKey,
[FromForm(Name = "BuildVersion")] string? buildVersion,
[FromForm(Name = "BuildTimestamp")] long? buildTimestamp)
=> await PersistAsync(new BugReportRequest
{
Summary = summary,
Description = description,
TestCaseKey = testCaseKey,
BuildVersion = buildVersion,
BuildTimestamp = buildTimestamp,
});
private async Task<IActionResult> PersistAsync(BugReportRequest req)
{
var pid = Me;
var summary = (req.Summary ?? string.Empty).Trim();
var body = (req.Description ?? string.Empty).Trim();
if (summary.Length == 0 && body.Length == 0)
return BadRequest(new { Error = "summary or description required" });
db.BugReports.Add(new BugReportEntity
{
ReporterPlayerId = pid,
Title = summary[..Math.Min(128, summary.Length)],
Body = body[..Math.Min(4000, body.Length)],
ClientVersion = req.BuildVersion ?? string.Empty,
Platform = HttpContext.Request.Headers.UserAgent.ToString()[..Math.Min(32, HttpContext.Request.Headers.UserAgent.ToString().Length)],
Category = string.IsNullOrEmpty(req.TestCaseKey) ? "bug" : $"bug:{req.TestCaseKey}",
});
await db.SaveChangesAsync();
return Ok(new { Submitted = true });
}
}
@@ -0,0 +1,40 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
namespace DorkNet.Server.Controllers.API.Cards;
/// <summary>
/// api.{rec.net,localhost}/api/cards/v1/all — home-screen card panel
/// (announcements, promos, daily-bonus tiles). Returns global cards
/// PLUS any cards targeted at the caller, sorted by Priority then
/// CreatedAt. Expired cards filtered out server-side.
/// </summary>
[ApiController]
public class CardsController(DorkNetDbContext db) : ControllerBase
{
[HttpGet("api/cards/v1/all")]
public async Task<IActionResult> All()
{
var pid = this.CurrentPlayerId();
var now = DateTime.UtcNow;
var rows = await db.Cards
.Where(c => (c.PlayerId == null || c.PlayerId == pid) &&
(c.ExpiresAt == null || c.ExpiresAt > now))
.OrderByDescending(c => c.Priority).ThenByDescending(c => c.CreatedAt)
.Take(50)
.Select(c => new
{
c.Id,
c.Title,
c.Body,
c.ImageName,
c.ActionUrl,
c.Category,
c.CreatedAt,
})
.ToListAsync();
return Ok(rows);
}
}
@@ -0,0 +1,89 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Data;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.CommunityBoard;
/// <summary>
/// api.rec.net/api/communityboard/v1/current — backs the dorm's
/// community board. The watch's
/// <c>RecNet.CommunityBoard.GetCurrent</c> hits
/// <c>"{api/communityboard/}v1/current"</c>
/// (Cpp2IL_ISIL/.../CommunityBoard.txt:902) and parses the response as
/// a <c>CommunityBoardDTO</c> with FeaturedPlayer / FeaturedRoomGroup /
/// CurrentAnnouncement / InstagramImages / Videos
/// (CommunityBoard_NestedType_CommunityBoardDTO.txt:460-480).
///
/// State lives in <see cref="CommunityBoardService"/> as a single
/// DB row keyed at <c>CommunityBoardRows.Id = 1</c>. Admins update it
/// via <c>POST /api/admin/v1/communityboard</c> from the admin UI.
/// </summary>
[ApiController]
public class CommunityBoardController(
CommunityBoardService board,
DorkNetDbContext db,
DomainConfig domain) : ControllerBase
{
[HttpGet("/api/communityboard/v1/current")]
[HttpGet("/api/communityboard/v2/current")]
public async Task<IActionResult> Current()
{
var state = await board.GetAsync();
// InstagramImage URL backfill — the watch's
// InstagramImageDTO.Deserialize calls a string-typed GetKey for
// both ImageName AND ImageUrl
// (CommunityBoard_NestedType_InstagramImageDTO.txt:85-91); an
// empty URL yields a broken tile. The admin SPA persists only
// ImageName, so we derive the URL on read against the configured
// deployment apex (DORKNET_DOMAIN).
var instagram = state.InstagramImages.Select(img => new
{
ImageName = img.ImageName,
ImageUrl = string.IsNullOrEmpty(img.ImageUrl) && !string.IsNullOrEmpty(img.ImageName)
? $"https://{domain.Sub("img")}/{img.ImageName}"
: img.ImageUrl,
}).ToArray();
// FeaturedRoomGroup expansion — the watch's FeaturedRoomGroupDTO
// (Rooms_NestedType_FeaturedRoomGroupDTO.txt:75-93) reads
// FeaturedRooms as <c>List&lt;FeaturedRoom&gt;</c> where each
// FeaturedRoom is <c>{RoomName, RoomId, ImageName}</c> with
// RoomId required (Util.GetKey at FeaturedRoom.txt:415-417).
// The admin SPA persists the list as a bare <c>List&lt;long&gt;</c>
// of room ids — which the watch can't deserialize, throwing
// KeyNotFoundException for "RoomId" on each item. With the list
// throwing, GetCurrentCommunityBoardData fails and the whole
// community-board panel renders empty. Expand the ids to full
// FeaturedRoom shapes here so the wire payload deserialises.
object? featuredRoomGroup = null;
if (state.FeaturedRoomGroup is { } group)
{
var ids = group.FeaturedRooms.ToList();
var rooms = ids.Count == 0
? new()
: await db.Rooms
.Where(r => ids.Contains(r.Id))
.Select(r => new { r.Id, r.Name, r.ImageName })
.ToDictionaryAsync(r => r.Id);
featuredRoomGroup = new
{
Name = group.Name ?? string.Empty,
FeaturedRooms = ids.Select(id => rooms.TryGetValue(id, out var r)
? new { RoomName = r.Name, RoomId = r.Id, ImageName = r.ImageName ?? string.Empty }
: new { RoomName = string.Empty, RoomId = id, ImageName = string.Empty })
.ToArray(),
};
}
return Ok(new
{
FeaturedPlayer = state.FeaturedPlayer,
FeaturedRoomGroup = featuredRoomGroup,
CurrentAnnouncement = state.CurrentAnnouncement,
InstagramImages = instagram,
Videos = state.Videos,
});
}
}
@@ -0,0 +1,72 @@
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Config.V1;
[ApiController]
[Route("api/[controller]/v1")]
public class ConfigController(ConfigService configService) : ControllerBase
{
[HttpGet("motd")]
public ActionResult<string> GetMotd()
{
var cfg = configService.GetConfig($"{Request.Scheme}://{Request.Host}");
return Ok(cfg.MessageOfTheDay);
}
[HttpGet("objectives")]
public ActionResult<List<object>> GetObjectives() => Ok(new List<object>());
// AmplitudeConfig.Deserialize expects key "AmplitudeKey"
[HttpGet("amplitude")]
public ActionResult<object> GetAmplitude() => Ok(new { AmplitudeKey = string.Empty });
/// <summary>
/// `Config.GetCohortNUXButtonConfigs(cohortId)` — returns the four
/// "What would you like to do first?" buttons shown in the OOBE
/// (RegistrationSceneNUX → ChooseCohortScreen). Each button maps to
/// a room the new player can teleport into.
///
/// Wire shape per CohortNUXButtonConfig (TypeDefIndex 11516) — list of:
/// Version (int)
/// ButtonNumber (int, 0..3 → Button1..Button4)
/// Override (int, 0=None, 1=Hotlist, 2=Custom)
/// CustomRoomName (string)
/// CustomTitle (string)
/// DefaultRoomName (string)
/// DefaultTitle (string)
///
/// The screen picks `CustomRoomName` if Override=Custom, else the
/// `DefaultRoomName` (resolved by Rooms.GetByName). For simplicity all
/// four use Override=None and route to Rec Room Original rooms we
/// already seeded — picking the most newcomer-friendly ones.
/// </summary>
[HttpGet("cohortnux/{cohortId:int}")]
public IActionResult GetCohortNux(int cohortId) => Ok(new[]
{
new {
Version = 1, ButtonNumber = 0, Override = 0,
CustomRoomName = "", CustomTitle = "",
DefaultRoomName = "RecCenter",
DefaultTitle = "Hang out at the Rec Center",
},
new {
Version = 1, ButtonNumber = 1, Override = 0,
CustomRoomName = "", CustomTitle = "",
DefaultRoomName = "Paintball",
DefaultTitle = "Play Paintball",
},
new {
Version = 1, ButtonNumber = 2, Override = 0,
CustomRoomName = "", CustomTitle = "",
DefaultRoomName = "Dodgeball",
DefaultTitle = "Play Dodgeball",
},
new {
Version = 1, ButtonNumber = 3, Override = 0,
CustomRoomName = "", CustomTitle = "",
DefaultRoomName = "GoldenTrophy",
DefaultTitle = "Quest for the Golden Trophy",
},
});
}
@@ -0,0 +1,19 @@
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Config.V1;
/// <summary>
/// Returns the full Rec Room service URL map. Some builds fetch this standalone
/// at /api/config/v1/services to discover where the other subdomains live.
/// Apex comes from <see cref="DomainConfig"/> (DORKNET_DOMAIN), so a single
/// env var controls every URL emitted.
/// </summary>
[ApiController]
[Route("api/config/v1")]
public class ServiceUrlsController(DomainConfig domain) : ControllerBase
{
[HttpGet("services")]
public ActionResult<Dictionary<string, string>> Get()
=> Ok(ConfigService.BuildServiceUrlMap(domain.Apex));
}
@@ -0,0 +1,17 @@
using Microsoft.AspNetCore.Mvc;
using DorkNet.Models.Config;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Config.V2;
[ApiController]
[Route("api/[controller]/v2")]
public class ConfigController(ConfigService configService) : ControllerBase
{
[HttpGet]
public ActionResult<RecRoomConfig> GetConfig()
{
var baseUrl = $"{Request.Scheme}://{Request.Host}";
return Ok(configService.GetConfig(baseUrl));
}
}
@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Mvc;
namespace DorkNet.Server.Controllers.API.Events.V3;
[ApiController]
[Route("api/[controller]/v3")]
public class EventsController : ControllerBase
{
[HttpGet("list")]
public ActionResult<List<object>> GetActiveEvents() => Ok(new List<object>());
}
@@ -0,0 +1,15 @@
using Microsoft.AspNetCore.Mvc;
namespace DorkNet.Server.Controllers.API.GameConfigurations;
[ApiController]
public class GameConfigurationsController : ControllerBase
{
// Actual path used by the 2020 build
[HttpGet("api/gameconfigs/v1/all")]
[HttpGet("api/gameconfigs/v1")]
// Legacy path we originally assumed
[HttpGet("api/gameconfigurations/v1/all")]
[HttpGet("api/gameconfigurations/v1")]
public ActionResult<List<object>> GetAll() => Ok(new List<object>());
}
@@ -0,0 +1,67 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using DorkNet.Models.GameSessions;
using DorkNet.Server.Auth;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.GameSessions.V2;
[ApiController]
[Route("api/[controller]/v2")]
[Authorize]
public class GameSessionsController(GameSessionService sessionService) : ControllerBase
{
private long CurrentPlayerId => this.RequireCurrentPlayerId();
[HttpPost("joinRandom")]
public async Task<ActionResult<JoinGameSessionResponse>> JoinRandom([FromBody] JoinRandomGameSessionRequest req)
{
var session = await sessionService.JoinOrCreateAsync(req.RoomId, req.ActivityLevelId, req.RegionId);
return Ok(new JoinGameSessionResponse
{
Result = JoinGameErrorCode.Success,
GameSession = session,
});
}
[HttpPost("joinById")]
public async Task<ActionResult<JoinGameSessionResponse>> JoinById([FromBody] JoinByIdRequest req)
{
var session = await sessionService.GetByIdAsync(req.GameSessionId);
if (session is null)
{
return Ok(new JoinGameSessionResponse { Result = JoinGameErrorCode.NotFound });
}
if (session.IsFull)
{
return Ok(new JoinGameSessionResponse { Result = JoinGameErrorCode.Full });
}
return Ok(new JoinGameSessionResponse
{
Result = JoinGameErrorCode.Success,
GameSession = session,
});
}
[HttpPost("leave")]
public async Task<ActionResult> Leave([FromBody] long sessionId)
{
await sessionService.PlayerLeftAsync(sessionId);
return Ok();
}
[HttpGet("{id:long}")]
public async Task<ActionResult<GameSession>> GetSession(long id)
{
var session = await sessionService.GetByIdAsync(id);
if (session is null) return NotFound();
return Ok(session);
}
}
public class JoinByIdRequest
{
public long GameSessionId { get; set; }
}
@@ -0,0 +1,240 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.Groups;
/// <summary>
/// api.rec.net/api/groups/* — clubs / groups. Wire types from
/// <c>RecNet.Group</c> + <c>RecNet.GroupMembership</c>:
///
/// • Group: <c>GroupId, Name, Description, CreatedAt, ImageName,
/// BanStatus, CreatorId, NumMembers, Members</c>.
/// • GroupMembership: <c>GroupId, PlayerId, Permissions</c>
/// (GroupMembershipPermissions flags).
///
/// URL surface (verb + template) verified against
/// <c>Cpp2IL_ISIL/.../RecNet/Groups.txt</c>:
/// GET api/groups/v1/{id}
/// GET api/groups/v1/name/{name}
/// GET api/groups/v1/memberships/{playerId}
/// POST api/groups/v1 (create — body CreateGroupRequest)
/// POST api/groups/v1/delete/{id}
/// </summary>
[ApiController]
public class GroupsController(DorkNetDbContext db) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
private long? MeOrNull => this.CurrentPlayerId();
[HttpGet("api/groups/v1/{id:long}")]
public async Task<IActionResult> GetById(long id)
{
var g = await db.Clubs.FirstOrDefaultAsync(c => c.Id == id);
if (g is null) return NotFound();
var members = await db.ClubMemberships
.Where(m => m.ClubId == id)
.ToListAsync();
return Ok(ToWire(g, members));
}
[HttpGet("api/groups/v1/name/{name}")]
public async Task<IActionResult> GetByName(string name)
{
var g = await db.Clubs.FirstOrDefaultAsync(c => c.Name == name);
if (g is null) return NotFound();
var members = await db.ClubMemberships
.Where(m => m.ClubId == g.Id)
.ToListAsync();
return Ok(ToWire(g, members));
}
[HttpGet("api/groups/v1/memberships/{playerId:long}")]
public async Task<IActionResult> Memberships(long playerId)
{
var rows = await (from m in db.ClubMemberships
join c in db.Clubs on m.ClubId equals c.Id
where m.PlayerId == playerId
select new { m, c }).ToListAsync();
return Ok(rows.Select(r => new
{
GroupId = r.c.Id,
PlayerId = (int)r.m.PlayerId,
Permissions = r.m.Permissions,
Group = ToWire(r.c, new List<ClubMembershipEntity>()),
}));
}
public sealed class CreateGroupRequest
{
public string? Name { get; set; }
public string? Description { get; set; }
public string? ImageName { get; set; }
}
[HttpPost("api/groups/v1")]
[Authorize]
public async Task<IActionResult> Create([FromBody] CreateGroupRequest req)
{
if (string.IsNullOrWhiteSpace(req.Name))
return BadRequest(new { Result = 1, Error = "name required" });
var pid = Me;
var dupe = await db.Clubs.AnyAsync(c => c.Name == req.Name);
if (dupe) return Conflict(new { Result = 2, Error = "name taken" });
var club = new ClubEntity
{
Name = req.Name.Trim(),
Description = req.Description ?? string.Empty,
ImageName = req.ImageName ?? string.Empty,
CreatorPlayerId = pid,
BanStatus = 0,
};
db.Clubs.Add(club);
await db.SaveChangesAsync();
// Owner membership uses GroupMembershipPermissions.Owner = 127.
db.ClubMemberships.Add(new ClubMembershipEntity
{
ClubId = club.Id,
PlayerId = pid,
Permissions = 127,
});
await db.SaveChangesAsync();
return Ok(new
{
Result = 0,
Group = ToWire(club, new List<ClubMembershipEntity>
{
new() { ClubId = club.Id, PlayerId = pid, Permissions = 127 },
}),
});
}
public sealed class UpdateGroupRequest
{
public string? Name { get; set; }
public string? Description { get; set; }
public string? ImageName { get; set; }
}
/// <summary>POST <c>api/groups/v1/{id}</c> — owner-only update of
/// description / image (per <c>RecNet.Groups.UpdateGroup</c> body
/// shape). Mirrors the create response so the watch's
/// <c>CreateModifyGroupResponse</c> deserialiser parses both.</summary>
[HttpPost("api/groups/v1/{id:long}")]
[Authorize]
public async Task<IActionResult> Update(long id, [FromBody] UpdateGroupRequest req)
{
var pid = Me;
var g = await db.Clubs.FirstOrDefaultAsync(c => c.Id == id);
if (g is null) return NotFound();
if (g.CreatorPlayerId != pid) return Forbid();
if (req.Description is not null) g.Description = req.Description;
if (req.ImageName is not null) g.ImageName = req.ImageName;
// Name updates are routed to the dedicated endpoint below
// (uniqueness check + name index has to round-trip).
g.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
var members = await db.ClubMemberships.Where(m => m.ClubId == id).ToListAsync();
return Ok(new { Result = 0, Group = ToWire(g, members) });
}
public sealed class UpdateGroupNameRequest { public string? Name { get; set; } }
/// <summary>POST <c>api/groups/v1/name/{id}</c> — owner-only
/// rename. Unique-name collision returns Result=2 to match the
/// client's <c>CreateModifyGroupStatus</c> enum.</summary>
[HttpPost("api/groups/v1/name/{id:long}")]
[Authorize]
public async Task<IActionResult> UpdateName(long id, [FromBody] UpdateGroupNameRequest req)
{
if (string.IsNullOrWhiteSpace(req.Name))
return BadRequest(new { Result = 1, Error = "name required" });
var pid = Me;
var g = await db.Clubs.FirstOrDefaultAsync(c => c.Id == id);
if (g is null) return NotFound();
if (g.CreatorPlayerId != pid) return Forbid();
var dupe = await db.Clubs.AnyAsync(c => c.Id != id && c.Name == req.Name);
if (dupe) return Conflict(new { Result = 2, Error = "name taken" });
g.Name = req.Name.Trim();
g.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
var members = await db.ClubMemberships.Where(m => m.ClubId == id).ToListAsync();
return Ok(new { Result = 0, Group = ToWire(g, members) });
}
[HttpPost("api/groups/v1/delete/{id:long}")]
[HttpDelete("api/groups/v1/delete/{id:long}")]
[Authorize]
public async Task<IActionResult> Delete(long id)
{
var pid = Me;
var g = await db.Clubs.FirstOrDefaultAsync(c => c.Id == id);
if (g is null) return NotFound();
if (g.CreatorPlayerId != pid) return Forbid();
var memberships = await db.ClubMemberships.Where(m => m.ClubId == id).ToListAsync();
db.ClubMemberships.RemoveRange(memberships);
db.Clubs.Remove(g);
await db.SaveChangesAsync();
return Ok(new { Deleted = true });
}
[HttpPost("api/groups/v1/{id:long}/join")]
[Authorize]
public async Task<IActionResult> Join(long id)
{
var pid = Me;
var g = await db.Clubs.FirstOrDefaultAsync(c => c.Id == id);
if (g is null) return NotFound();
var existing = await db.ClubMemberships
.FirstOrDefaultAsync(m => m.ClubId == id && m.PlayerId == pid);
if (existing is not null) return Ok(new { Joined = true });
db.ClubMemberships.Add(new ClubMembershipEntity
{
ClubId = id,
PlayerId = pid,
Permissions = 0, // Member
});
await db.SaveChangesAsync();
return Ok(new { Joined = true });
}
[HttpPost("api/groups/v1/{id:long}/leave")]
[Authorize]
public async Task<IActionResult> Leave(long id)
{
var pid = Me;
var g = await db.Clubs.FirstOrDefaultAsync(c => c.Id == id);
if (g is null) return NotFound();
if (g.CreatorPlayerId == pid)
return BadRequest(new { Error = "owner cannot leave; transfer ownership first" });
var row = await db.ClubMemberships
.FirstOrDefaultAsync(m => m.ClubId == id && m.PlayerId == pid);
if (row is null) return NotFound();
db.ClubMemberships.Remove(row);
await db.SaveChangesAsync();
return Ok(new { Left = true });
}
private static object ToWire(ClubEntity g, List<ClubMembershipEntity> members) => new
{
GroupId = g.Id,
g.Name,
g.Description,
g.CreatedAt,
g.ImageName,
BanStatus = g.BanStatus,
CreatorId = (int)g.CreatorPlayerId,
NumMembers = members.Count,
Members = members.Select(m => new
{
GroupId = g.Id,
PlayerId = (int)m.PlayerId,
Permissions = m.Permissions,
}),
};
}
@@ -0,0 +1,849 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Http;
using System.Text.Json;
using DorkNet.Models.Auth;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Images;
/// <summary>
/// api.rec.net/api/images/v* — in-game camera photo endpoints. The 2020
/// watch's camera UI ("save photo to album", "share photo") posts here.
///
/// Wire shape (observed from server logs — the 2020 watch hits these
/// without a corresponding storage.rec.net/upload, meaning the bytes
/// come straight in this request body):
/// POST api/images/v4/uploadsaved
/// - body: multipart/form-data with the image file plus optional
/// fields (Caption, Description, RoomId, IsPublic, TaggedPlayerIds).
/// On the 2020 client the field name varies — we accept "File",
/// "Image", and any *.png/*.jpg upload for safety.
/// - response: an ImageInfo-shaped object (PascalCase keys). The
/// watch's success path calls a JSON parser whose strict keys are
/// AccountId, ImageName, CreatedAt — so all three are required.
/// Returning [] (the previous catch-all) made the watch silently
/// drop the photo.
///
/// Saved photos are stored as <see cref="PhotoEntity"/> rows with
/// IsPublic=false by default. A separate Share button (POST
/// api/images/v4/share/{id}) flips IsPublic=true and surfaces them on
/// feed.rec.net.
/// </summary>
[ApiController]
public class ImagesController(
DorkNetDbContext db,
PlayerPresenceService presence,
LevelService level,
IObjectStorage storage,
ImageSignatureService signatures,
NotificationService notifications,
DomainConfig domain,
ILogger<ImagesController> logger) : ControllerBase
{
private static readonly string ImageDir =
Path.Combine(AppContext.BaseDirectory, "data", "images");
private static readonly byte[] TransparentPng = new byte[]
{
0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A,0x00,0x00,0x00,0x0D,
0x49,0x48,0x44,0x52,0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x01,
0x08,0x06,0x00,0x00,0x00,0x1F,0x15,0xC4,0x89,0x00,0x00,0x00,
0x0D,0x49,0x44,0x41,0x54,0x78,0x9C,0x63,0x00,0x01,0x00,0x00,
0x05,0x00,0x01,0x0D,0x0A,0x2D,0xB4,0x00,0x00,0x00,0x00,0x49,
0x45,0x4E,0x44,0xAE,0x42,0x60,0x82,
};
/// <summary>POST api/images/v{2-5}/uploadsaved — receive an image
/// from the in-game camera "save to album" button. Persists the
/// bytes in <see cref="RoomDataBlobEntity"/> and creates a
/// <see cref="PhotoEntity"/> row keyed by the uploader. The photo
/// defaults to IsPublic=false (saved-only) so it doesn't surface
/// on the public feed unless the user explicitly hits Share.</summary>
[HttpPost("api/images/v2/uploadsaved")]
[HttpPost("api/images/v3/uploadsaved")]
[HttpPost("api/images/v4/uploadsaved")]
[HttpPost("api/images/v5/uploadsaved")]
[Authorize]
public async Task<IActionResult> UploadSaved()
{
var pid = this.RequireCurrentPlayerId();
var bytes = await ReadImageBytesAsync();
if (bytes is null || bytes.Length == 0)
{
logger.LogWarning("[images] uploadsaved by {Pid}: no file bytes received", pid);
return BadRequest(new { error = "missing_file" });
}
// Pull optional metadata from the form (the 2020 watch sometimes
// includes a caption / room context, sometimes posts bytes only).
var form = Request.HasFormContentType ? await Request.ReadFormAsync() : null;
var caption = form?["Caption"].ToString() ?? form?["Description"].ToString() ?? string.Empty;
var taggedIds = form?["TaggedPlayerIds"].ToString() ?? string.Empty;
long roomId = 0;
if (form is not null && long.TryParse(form["RoomId"].ToString(), out var rid)) roomId = rid;
if (roomId == 0) roomId = presence.GetRoom(pid)?.RoomId ?? 0;
var isPublic = string.Equals(form?["IsPublic"].ToString(), "true", StringComparison.OrdinalIgnoreCase);
var isProfileThumbnail = IsProfileThumbnail(form);
LogUploadForm("uploadsaved", pid, form, isProfileThumbnail);
// Detect format. The watch uses PNG; if a JPEG slips through we
// tag it accordingly so the cdn serves the right MIME.
var extension = SniffExtension(bytes);
var blobName = $"img_p{pid}_{Guid.NewGuid():N}.{extension}";
// Dual-write during the migration window: S3 is the canonical
// store going forward, but we keep the DB Bytes column populated
// so reads from older code paths still work. PR 4 drops the
// column once every blob has a confirmed S3 copy.
try
{
using var s3Timeout = new CancellationTokenSource(TimeSpan.FromSeconds(4));
var (bucket, key) = BlobRouter.Route(blobName);
await storage.PutAsync(
bucket, key, bytes,
extension == "png" ? "image/png" : "image/jpeg",
s3Timeout.Token);
}
catch (Exception ex)
{
logger.LogError(
ex,
"[images] S3 upload failed for {Blob}; keeping DB fallback so the client does not fail",
blobName);
}
db.RoomDataBlobs.Add(new RoomDataBlobEntity
{
RoomId = 0,
BlobName = blobName,
UploadedByPlayerId = pid,
UploadedAt = DateTime.UtcNow,
ReferencedFilenamesCsv = string.Empty,
});
var photo = new PhotoEntity
{
UploaderPlayerId = pid,
BlobName = blobName,
Caption = caption.Trim(),
TaggedPlayerIdsCsv = NormaliseTaggedIds(taggedIds, exceptId: pid),
RoomId = roomId,
IsPublic = isPublic,
};
db.Photos.Add(photo);
if (isProfileThumbnail)
{
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == pid);
if (player is not null)
{
player.ProfileImageName = blobName;
logger.LogInformation(
"[images] profile thumbnail upload set profile image: player={PlayerId} blob={Blob}",
pid,
blobName);
}
}
await db.SaveChangesAsync();
if (isProfileThumbnail)
{
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == pid);
if (player is not null)
await NotifyAccountImageChangedAsync(player);
}
// Small XP bump for taking a photo — encourages camera use.
await level.AwardXpAsync(pid, LevelService.RoomVisitXp, $"photo_saved:{photo.Id}");
logger.LogInformation(
"[images] uploaded saved photo {Id} by {Pid} ({Bytes} bytes) → {Blob}",
photo.Id, pid, bytes.Length, blobName);
return Ok(BuildImageInfo(photo, pid));
}
/// <summary>POST api/images/v{1-5}/profile — direct profile image upload.
/// Older clients use this instead of tagging an uploadsaved request as a
/// ProfileThumbnail, so this route must always update Account.ProfileImage.</summary>
[HttpPost("api/images/v1/profile")]
[HttpPost("api/images/v2/profile")]
[HttpPost("api/images/v3/profile")]
[HttpPost("api/images/v4/profile")]
[HttpPost("api/images/v5/profile")]
[Authorize]
public async Task<IActionResult> UploadProfile()
{
var pid = this.RequireCurrentPlayerId();
var bytes = await ReadImageBytesAsync();
if (bytes is null || bytes.Length == 0)
{
logger.LogWarning("[images] profile upload by {Pid}: no file bytes received", pid);
return BadRequest(new { error = "missing_file" });
}
var form = Request.HasFormContentType ? await Request.ReadFormAsync() : null;
LogUploadForm("profile", pid, form, isProfile: true);
var extension = SniffExtension(bytes);
var blobName = $"img_p{pid}_{Guid.NewGuid():N}.{extension}";
var contentType = extension == "png" ? "image/png" : "image/jpeg";
try
{
using var s3Timeout = new CancellationTokenSource(TimeSpan.FromSeconds(4));
var (bucket, key) = BlobRouter.Route(blobName);
await storage.PutAsync(bucket, key, bytes, contentType, s3Timeout.Token);
}
catch (Exception ex)
{
logger.LogError(
ex,
"[images] profile S3 upload failed for {Blob}; keeping DB fallback so the client does not fail",
blobName);
}
db.RoomDataBlobs.Add(new RoomDataBlobEntity
{
RoomId = 0,
BlobName = blobName,
UploadedByPlayerId = pid,
UploadedAt = DateTime.UtcNow,
ReferencedFilenamesCsv = string.Empty,
});
var photo = new PhotoEntity
{
UploaderPlayerId = pid,
BlobName = blobName,
Caption = string.Empty,
TaggedPlayerIdsCsv = string.Empty,
RoomId = presence.GetRoom(pid)?.RoomId ?? 0,
IsPublic = false,
};
db.Photos.Add(photo);
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == pid);
if (player is not null)
player.ProfileImageName = blobName;
await db.SaveChangesAsync();
if (player is not null)
await NotifyAccountImageChangedAsync(player);
logger.LogInformation(
"[images] uploaded profile photo {Id} by {Pid} ({Bytes} bytes) -> {Blob}",
photo.Id, pid, bytes.Length, blobName);
return Ok(new
{
ImageName = blobName,
AccountId = (int)pid,
CreatedAt = photo.CreatedAt,
});
}
/// <summary>GET api/images/v{2-5}/saved — list the caller's saved
/// photos. The watch's "My Photos" tab calls this to populate the
/// album view.</summary>
[HttpGet("api/images/v2/saved")]
[HttpGet("api/images/v3/saved")]
[HttpGet("api/images/v4/saved")]
[HttpGet("api/images/v5/saved")]
[Authorize]
public async Task<IActionResult> GetSaved()
{
var pid = this.RequireCurrentPlayerId();
var rows = await db.Photos
.Where(p => p.UploaderPlayerId == pid && p.DeletedAt == null)
.OrderByDescending(p => p.CreatedAt)
.Take(200)
.ToListAsync();
return Ok(rows.Select(p => BuildImageInfo(p, pid)));
}
/// <summary>GET <c>api/images/v2/named</c> — the watch's
/// <c>Images.DownloadNamedImageMappings</c> endpoint. Returns a
/// list of <c>NamedImageDTO</c> entries; an empty list is a valid
/// response (the watch's <c>ExpectListResponse</c> rejects an
/// object body but tolerates an empty array).</summary>
[HttpGet("api/images/v2/named")]
public IActionResult NamedImages() => Ok(Array.Empty<object>());
/// <summary>GET <c>api/images/v1/slideshow</c> — the watch's lobby
/// slideshow info. Response shape per <c>SlideshowInfoDTO.Deserialize</c>:
/// <c>{ValidTill (DateTime, required), Images (list, required)}</c>.
/// Far-future <c>ValidTill</c> stops the watch from re-polling.</summary>
[HttpGet("api/images/v1/slideshow")]
public IActionResult SlideshowInfo() => Ok(new
{
ValidTill = new DateTime(9999, 12, 31, 0, 0, 0, DateTimeKind.Utc),
Images = Array.Empty<object>(),
});
/// <summary>Binary image fallback for clients that request image bytes
/// through api/images/{name} instead of img.rec.net/{name}. Specific JSON
/// endpoints above still win route matching; this only handles CDN-shaped
/// image paths.</summary>
[HttpGet("api/images/{*path}", Order = 100)]
public async Task<IActionResult> ServeImageFromApiHost(string? path)
{
var fileName = Path.GetFileName(path ?? string.Empty);
// Strip the leading '$' sigil that ImageData.image_name uses for
// hash-addressed assets. Watch sends "%24<hash>.jpg"; route
// decode gives us "$<hash>.jpg"; the bytes are stored at
// "<hash>.jpg". Without the strip, placed in-world polaroids
// miss in S3 and the watch renders the "?" fallback.
if (!string.IsNullOrEmpty(fileName) && fileName[0] == '$') fileName = fileName[1..];
if (string.IsNullOrWhiteSpace(fileName) || !IsImageName(fileName))
return NotFound();
var result = await LoadImageBytesAsync(fileName);
if (result.Bytes is { Length: > 0 })
{
logger.LogInformation(
"[images] api image hit host={Host} path={Path} file={File} source={Source} bytes={Bytes} query='{Query}'",
Request.Host.Host,
path,
fileName,
result.Source,
result.Bytes.Length,
Request.QueryString);
Response.Headers.CacheControl = "public, max-age=300";
signatures.AddContentSignature(Response, result.Bytes);
return File(result.Bytes, MimeFromName(fileName));
}
logger.LogWarning(
"[images] api image MISS host={Host} path={Path} file={File} query='{Query}' -> transparent png",
Request.Host.Host,
path,
fileName,
Request.QueryString);
Response.Headers.CacheControl = "public, max-age=60";
signatures.AddContentSignature(Response, TransparentPng);
return File(TransparentPng, "image/png");
}
/// <summary>GET api/images/v{2-5}/byaccount/{accountId} — list a
/// player's PUBLIC photos. Drives the "their album" tab in
/// the watch's player profile.</summary>
[HttpGet("api/images/v2/byaccount/{accountId:long}")]
[HttpGet("api/images/v3/byaccount/{accountId:long}")]
[HttpGet("api/images/v4/byaccount/{accountId:long}")]
[HttpGet("api/images/v5/byaccount/{accountId:long}")]
public async Task<IActionResult> GetByAccount(long accountId,
[FromQuery] int take = 50, [FromQuery] int skip = 0)
{
take = Math.Clamp(take, 1, 200);
skip = Math.Max(0, skip);
var rows = await db.Photos
.Where(p => p.UploaderPlayerId == accountId && p.IsPublic && p.DeletedAt == null)
.OrderByDescending(p => p.CreatedAt)
.Skip(skip).Take(take)
.ToListAsync();
return Ok(rows.Select(p => BuildImageInfo(p, accountId)));
}
/// <summary>POST api/images/v{2-5}/share/{id} — flip a saved photo
/// to public so it shows up on feed.rec.net. The watch's "Share"
/// button hits this; if the watch posts to a different URL we'll
/// add it when we see it in logs.</summary>
[HttpPost("api/images/v2/share/{id:long}")]
[HttpPost("api/images/v3/share/{id:long}")]
[HttpPost("api/images/v4/share/{id:long}")]
[HttpPost("api/images/v5/share/{id:long}")]
[Authorize]
public async Task<IActionResult> Share(long id)
{
var pid = this.RequireCurrentPlayerId();
var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == id);
if (photo is null) return NotFound();
if (photo.UploaderPlayerId != pid) return Forbid();
photo.IsPublic = true;
await db.SaveChangesAsync();
return Ok(BuildImageInfo(photo, pid));
}
/// <summary>DELETE api/images/v{2-5}/{id} — uploader (or admin)
/// soft-deletes a saved photo.</summary>
[HttpDelete("api/images/v2/{id:long}")]
[HttpDelete("api/images/v3/{id:long}")]
[HttpDelete("api/images/v4/{id:long}")]
[HttpDelete("api/images/v5/{id:long}")]
[Authorize]
public async Task<IActionResult> Delete(long id)
{
var pid = this.RequireCurrentPlayerId();
var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == id);
if (photo is null) return NotFound();
if (photo.UploaderPlayerId != pid)
{
var isAdmin = await db.Players.Where(x => x.Id == pid).Select(x => x.IsAdmin).FirstOrDefaultAsync();
if (!isAdmin) return Forbid();
}
photo.DeletedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(new { deleted = id });
}
// ── Legacy v1 ops (real persistence, was Ack-only stubs) ─────────
public sealed class CheerImageRequest
{
public long ImageId { get; set; }
public long PhotoId { get; set; }
}
/// <summary>POST <c>api/images/v1/cheer</c> — cheer a photo.
/// Was previously Ack-only; now persists a <c>CheerEntity</c> row
/// keyed on <c>TargetPhotoId</c> + bumps <c>Photos.CheerCount</c>.
/// Idempotent per (player, photo).</summary>
[HttpPost("api/images/v1/cheer")]
[Authorize]
public async Task<IActionResult> CheerV1([FromBody] CheerImageRequest? body,
[FromForm(Name = "ImageId")] long? imageIdForm,
[FromForm(Name = "PhotoId")] long? photoIdForm)
{
var pid = this.RequireCurrentPlayerId();
var photoId = body?.PhotoId ?? body?.ImageId
?? photoIdForm ?? imageIdForm ?? 0;
if (photoId <= 0) return BadRequest(new { error = "missing photoId" });
var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == photoId && p.DeletedAt == null);
if (photo is null) return NotFound();
var existing = await db.Cheers.FirstOrDefaultAsync(c =>
c.FromPlayerId == pid && c.TargetPhotoId == photoId);
if (existing is null)
{
db.Cheers.Add(new CheerEntity
{
FromPlayerId = pid,
TargetPhotoId = photoId,
CheeredAt = DateTime.UtcNow,
});
photo.CheerCount += 1;
await db.SaveChangesAsync();
}
return Ok(new { photo.Id, photo.CheerCount });
}
/// <summary>GET <c>api/images/v1/listsaved</c> — the 2020 client
/// deserializes this as SavedImagesListDTO { Images: string[] }.
/// Do not return the richer v2/v4 photo-object list here.</summary>
[HttpGet("api/images/v1/listsaved")]
[Authorize]
public async Task<IActionResult> ListSavedV1()
{
var pid = this.RequireCurrentPlayerId();
var names = await db.Photos
.Where(p => p.UploaderPlayerId == pid && p.DeletedAt == null)
.OrderByDescending(p => p.CreatedAt)
.Select(p => p.BlobName)
.Take(200)
.ToListAsync();
return Ok(new { Images = names });
}
public sealed class ModifyAccessibilityRequest
{
public long PhotoId { get; set; }
public long ImageId { get; set; }
public string? ImageName { get; set; }
public bool IsPublic { get; set; }
}
/// <summary>POST <c>api/images/v1/modifyaccessibility</c> — flip
/// the IsPublic flag on a saved photo. Was Ack-only; now writes
/// the column.</summary>
[HttpPost("api/images/v1/modifyaccessibility")]
[Authorize]
public async Task<IActionResult> ModifyAccessibility(
[FromBody] ModifyAccessibilityRequest? body,
[FromForm(Name = "PhotoId")] long? photoIdForm,
[FromForm(Name = "ImageId")] long? imageIdForm,
[FromForm(Name = "ImageName")] string? imageNameForm,
[FromForm(Name = "IsPublic")] bool? isPublicForm)
{
var pid = this.RequireCurrentPlayerId();
var photoId = body?.PhotoId ?? body?.ImageId
?? photoIdForm ?? imageIdForm ?? 0;
var imageName = body?.ImageName ?? imageNameForm;
if (photoId <= 0 && string.IsNullOrWhiteSpace(imageName))
return BadRequest(new { error = "missing photoId" });
var isPublic = body?.IsPublic ?? isPublicForm ?? true;
var photo = photoId > 0
? await db.Photos.FirstOrDefaultAsync(p => p.Id == photoId)
: await db.Photos.FirstOrDefaultAsync(p => p.BlobName == imageName);
if (photo is null) return NotFound();
if (photo.UploaderPlayerId != pid) return Forbid();
photo.IsPublic = isPublic;
await db.SaveChangesAsync();
return Ok(new { photo.Id, photo.IsPublic });
}
public sealed class DeleteSavedRequest
{
public long PhotoId { get; set; }
public long ImageId { get; set; }
public string? ImageName { get; set; }
}
/// <summary>POST <c>api/images/v1/deletesaved</c> + DELETE alias —
/// soft-delete a saved photo. Was Ack-only; now flips
/// <c>DeletedAt</c>.</summary>
[HttpPost("api/images/v1/deletesaved")]
[HttpDelete("api/images/v1/deletesaved/{id:long}")]
[Authorize]
public async Task<IActionResult> DeleteSavedV1(
long? id,
[FromBody] DeleteSavedRequest? body,
[FromForm(Name = "PhotoId")] long? photoIdForm,
[FromForm(Name = "ImageId")] long? imageIdForm,
[FromForm(Name = "ImageName")] string? imageNameForm)
{
var photoId = id ?? body?.PhotoId ?? body?.ImageId
?? photoIdForm ?? imageIdForm ?? 0;
var imageName = body?.ImageName ?? imageNameForm;
if (photoId <= 0 && string.IsNullOrWhiteSpace(imageName))
return BadRequest(new { error = "missing photoId" });
if (photoId <= 0)
{
var pid = this.RequireCurrentPlayerId();
var photo = await db.Photos.FirstOrDefaultAsync(p =>
p.UploaderPlayerId == pid && p.BlobName == imageName && p.DeletedAt == null);
if (photo is null) return NotFound();
photo.DeletedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
return await Delete(photoId);
}
public sealed class SendLinkRequest
{
public long PhotoId { get; set; }
public long ImageId { get; set; }
public List<long>? RecipientIds { get; set; }
}
/// <summary>POST <c>api/images/v1/sendlink</c> — send a link to a
/// photo as a DM to one or more recipients. Was Ack-only; now
/// inserts one <see cref="MessageEntity"/> per recipient.</summary>
[HttpPost("api/images/v1/sendlink")]
[Authorize]
public async Task<IActionResult> SendLink(
[FromBody] SendLinkRequest? body,
[FromForm(Name = "PhotoId")] long? photoIdForm,
[FromForm(Name = "ImageId")] long? imageIdForm,
[FromForm(Name = "RecipientIds")] string? recipientIdsForm)
{
var pid = this.RequireCurrentPlayerId();
var photoId = body?.PhotoId ?? body?.ImageId ?? photoIdForm ?? imageIdForm ?? 0;
if (photoId <= 0) return BadRequest(new { error = "missing photoId" });
var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == photoId);
if (photo is null) return NotFound();
var recipients = body?.RecipientIds ?? ParseIdList(recipientIdsForm) ?? new();
if (recipients.Count == 0) return Ok(new { Sent = 0 });
var preview = $"shared a photo (id {photoId})";
foreach (var rid in recipients.Distinct())
{
db.Messages.Add(new MessageEntity
{
SenderPlayerId = pid,
RecipientPlayerId = rid,
Body = $"{preview} → /img/{photo.BlobName}",
});
}
await db.SaveChangesAsync();
return Ok(new { Sent = recipients.Count });
}
private static List<long>? ParseIdList(string? csv)
{
if (string.IsNullOrWhiteSpace(csv)) return null;
return csv.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(s => long.TryParse(s.Trim(), out var v) ? v : 0L)
.Where(v => v > 0)
.ToList();
}
// ── Helpers ──────────────────────────────────────────────────────
/// <summary>Read raw image bytes from the request — supports both
/// multipart/form-data (the typical case, with field "File" or
/// "Image") and a raw octet-stream body (some 2020 client paths).</summary>
private async Task<byte[]?> ReadImageBytesAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
// Try common field names; fall back to the first uploaded file.
var file = form.Files["File"]
?? form.Files["Image"]
?? form.Files["image"]
?? form.Files["file"]
?? form.Files.FirstOrDefault();
if (file is null || file.Length == 0) return null;
using var ms = new MemoryStream();
await file.CopyToAsync(ms);
return ms.ToArray();
}
// Raw body fallback — when the watch posts the bytes directly.
if (Request.ContentLength is > 0)
{
using var ms = new MemoryStream();
await Request.Body.CopyToAsync(ms);
return ms.ToArray();
}
return null;
}
/// <summary>Detect PNG vs JPEG by magic bytes — anything else
/// defaults to .png since the 2020 client's camera always saves
/// PNG.</summary>
private static string SniffExtension(byte[] bytes)
{
if (bytes.Length >= 4 &&
bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47)
return "png";
if (bytes.Length >= 3 &&
bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF)
return "jpg";
return "png";
}
private async Task<(byte[]? Bytes, string Source)> LoadImageBytesAsync(string fileName)
{
var (bucket, key) = BlobRouter.Route(fileName);
try
{
using var s3Timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2));
var s3Bytes = await storage.GetAsync(bucket, key, s3Timeout.Token);
if (s3Bytes is { Length: > 0 })
return (s3Bytes, $"s3:{bucket}/{key}");
}
catch (Exception ex)
{
logger.LogWarning(ex,
"[images] API image S3 lookup threw for {File} bucket={Bucket} key={Key}",
fileName, bucket, key);
}
// S3 is the only canonical store. Disk-on-server is a legacy
// surface used by tools/fetch-room-images.py'd thumbnails that
// ship with the server — keep that as a last resort, but DB
// bytes are gone.
var full = Path.Combine(ImageDir, fileName);
if (System.IO.File.Exists(full))
return (await System.IO.File.ReadAllBytesAsync(full), "disk");
return (null, "miss");
}
private static bool TryGetPlayerId(string fileName, string prefix, out long playerId)
{
playerId = 0;
if (!fileName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return false;
var start = prefix.Length;
var end = fileName.IndexOf('_', start);
return end > start && long.TryParse(fileName[start..end], out playerId);
}
private static bool IsImageName(string name)
{
var ext = Path.GetExtension(name).ToLowerInvariant();
return ext is ".png" or ".jpg" or ".jpeg" or ".gif" or ".webp" or ".avif";
}
private static string MimeFromName(string name)
{
var ext = Path.GetExtension(name).ToLowerInvariant();
return ext switch
{
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".gif" => "image/gif",
".webp" => "image/webp",
".avif" => "image/avif",
_ => "image/png",
};
}
private void LogUploadForm(string endpoint, long playerId, IFormCollection? form, bool isProfile)
{
if (form is null)
{
logger.LogInformation(
"[images] {Endpoint} metadata player={PlayerId} form=false profile={Profile}",
endpoint,
playerId,
isProfile);
return;
}
var fileNames = string.Join(
",",
form.Files.Select(f => $"{f.Name}:{f.FileName}:{f.Length}"));
var fieldNames = string.Join(",", form.Keys);
var meta = form["imgMeta"].ToString();
if (string.IsNullOrWhiteSpace(meta))
meta = form["imageMeta"].ToString();
if (meta.Length > 512)
meta = meta[..512];
logger.LogInformation(
"[images] {Endpoint} metadata player={PlayerId} files={Files} fields={Fields} profile={Profile} imgMeta={ImgMeta}",
endpoint,
playerId,
fileNames,
fieldNames,
isProfile,
meta);
}
private static bool IsProfileThumbnail(IFormCollection? form)
{
if (form is null) return false;
static bool IsProfileValue(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return false;
return value.Trim().Equals("4", StringComparison.OrdinalIgnoreCase)
|| value.Contains("ProfileThumbnail", StringComparison.OrdinalIgnoreCase);
}
if (IsProfileValue(form["SavedImageType"].ToString())
|| IsProfileValue(form["savedImageType"].ToString())
|| IsProfileValue(form["ImageType"].ToString())
|| IsProfileValue(form["imageType"].ToString()))
return true;
var meta = form["imgMeta"].ToString();
if (string.IsNullOrWhiteSpace(meta))
meta = form["imageMeta"].ToString();
if (string.IsNullOrWhiteSpace(meta))
return false;
if (IsProfileValue(meta))
return true;
try
{
using var doc = JsonDocument.Parse(meta);
if (doc.RootElement.ValueKind != JsonValueKind.Object)
return false;
foreach (var name in new[] { "savedImageType", "SavedImageType", "imageType", "ImageType" })
{
if (!doc.RootElement.TryGetProperty(name, out var value))
continue;
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var n) && n == 4)
return true;
if (value.ValueKind == JsonValueKind.String && IsProfileValue(value.GetString()))
return true;
}
}
catch (JsonException)
{
}
return false;
}
/// <summary>Build the response shape every images/v* endpoint
/// returns. PascalCase keys + redundant aliases so whichever JSON
/// parser the 2020 watch uses, it finds the field it wants. The
/// most important keys are AccountId, ImageName, CreatedAt — those
/// are what the watch's strict deserialiser reads. <c>Url</c>
/// is built against the configured deployment apex (DORKNET_DOMAIN)
/// so every callsite for the URL string flows through one source
/// of truth.</summary>
private object BuildImageInfo(PhotoEntity p, long accountId)
{
var cdnHost = domain.Sub("cdn");
return new
{
// Primary keys the 2020 watch's ImageInfo deserialiser reads.
ImageName = p.BlobName,
AccountId = (int)accountId,
ImageType = 0,
CreatedAt = p.CreatedAt,
// Bonus fields — useful for other clients (and our own feed UI).
Id = p.Id,
Filename = p.BlobName,
Url = $"https://{cdnHost}/{p.BlobName}",
Caption = p.Caption,
Description = p.Caption,
RoomId = p.RoomId,
IsPublic = p.IsPublic,
CheerCount = p.CheerCount,
ViewCount = p.ViewCount,
TaggedPlayerIds = ParseTagged(p.TaggedPlayerIdsCsv).ToArray(),
};
}
private static IEnumerable<long> ParseTagged(string? csv) =>
string.IsNullOrWhiteSpace(csv)
? Enumerable.Empty<long>()
: csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(s => long.TryParse(s, out var v) ? v : 0L)
.Where(v => v > 0);
private Task NotifyAccountImageChangedAsync(PlayerEntity player)
{
var account = new DorkNet.Models.Auth.RecNetAccount
{
AccountId = (int)player.Id,
RawUsername = player.Username,
Username = player.Username,
DisplayName = player.DisplayName ?? player.Username,
ProfileImage = player.ProfileImageName ?? string.Empty,
TreatAsJunior = player.IsJunior,
HasBirthday = true,
Platforms = 1,
};
var selfAccount = new DorkNet.Models.Auth.RecNetSelfAccount
{
AccountId = account.AccountId,
RawUsername = account.RawUsername,
Username = account.Username,
DisplayName = account.DisplayName,
ProfileImage = account.ProfileImage,
TreatAsJunior = account.TreatAsJunior,
HasBirthday = account.HasBirthday,
Platforms = account.Platforms,
Email = player.Email ?? string.Empty,
Phone = player.Phone ?? string.Empty,
Birthday = player.Birthday,
JuniorState = player.IsJunior ? 1 : 0,
ParentAccountId = null,
};
return Task.WhenAll(
notifications.NotifyTypedAsync(player.Id, "AccountUpdate", account),
notifications.NotifyTypedAsync(player.Id, "SelfAccountUpdate", selfAccount));
}
private static string NormaliseTaggedIds(string? csv, long exceptId)
{
if (string.IsNullOrWhiteSpace(csv)) return string.Empty;
var ids = csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(s => long.TryParse(s, out var v) ? v : 0L)
.Where(v => v > 0 && v != exceptId)
.Distinct()
.ToList();
return string.Join(",", ids);
}
}
@@ -0,0 +1,39 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace DorkNet.Server.Controllers.API.Images.V2;
[ApiController]
[Route("api/[controller]/v2")]
[Authorize]
public class ImagesController : ControllerBase
{
private static readonly string ImageDir =
Path.Combine(AppContext.BaseDirectory, "data", "images");
[HttpPost("upload")]
public async Task<ActionResult<UploadImageResponse>> Upload(IFormFile file)
{
Directory.CreateDirectory(ImageDir);
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (ext is not (".png" or ".jpg" or ".jpeg"))
return BadRequest("Only PNG/JPG accepted");
var name = $"{Guid.NewGuid():N}{ext}";
var path = Path.Combine(ImageDir, name);
await using var stream = System.IO.File.Create(path);
await file.CopyToAsync(stream);
var baseUrl = $"{Request.Scheme}://{Request.Host}";
return Ok(new UploadImageResponse { ImageName = name, Url = $"{baseUrl}/img/{name}" });
}
}
public class UploadImageResponse
{
public string ImageName { get; set; } = string.Empty;
public string Url { get; set; } = string.Empty;
}
@@ -0,0 +1,549 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Inventions;
/// <summary>
/// api.rec.net/api/inventions/* — Maker-Pen creations the player can
/// re-spawn or share. URL surface + verbs verified against
/// <c>Cpp2IL_ISIL/.../RecNet/Inventions.txt</c>: the "update*"
/// routes use <c>Core.Get</c> (GET) despite being mutations, so we
/// expose them as [HttpGet]; only <c>cheer</c>, <c>report</c>,
/// <c>settags</c>, <c>save</c>, <c>addversion</c>, and <c>batch</c>
/// are POST. <c>delete</c>, <c>publish</c>, <c>unpublish</c>,
/// <c>download</c> are also GET.
///
/// Wire DTO matches <c>RecNet.Invention</c>
/// (<c>Cpp2IL_CS/.../RecNet/Invention.cs</c>) byte-for-byte: keys
/// <c>InventionId, ReplicationId, CreatorPlayerId, Name, Description,
/// ImageName, CurrentVersionNumber, IsPublished, ModifiedAt,
/// CreatedAt, FirstPublishedAt, CreationRoomId,
/// NumPlayersHaveUsedInRoom, NumDownloads, CheerCount,
/// CreatorPermission, GeneralPermission, IsAgInvention</c>.
/// </summary>
[ApiController]
public class InventionsController(
DorkNetDbContext db,
LevelService level,
NotificationService notifications) : ControllerBase
{
private long? CurrentPlayerIdOrNull => this.CurrentPlayerId();
private long CurrentPlayerId => this.RequireCurrentPlayerId();
// ── Browse ───────────────────────────────────────────────────────────
[HttpGet("api/inventions/v3/popular")]
public async Task<ActionResult> Popular([FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 100);
var rows = await db.Inventions
.Where(i => !i.IsDeleted && i.IsPublished)
.OrderByDescending(i => i.CheerCount)
.Take(take)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("api/inventions/v3/saved")]
[Authorize]
public async Task<ActionResult> Saved([FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 100);
var pid = CurrentPlayerId;
var rows = await db.Inventions
.Where(i => !i.IsDeleted && i.CreatorPlayerId == pid)
.OrderByDescending(i => i.UpdatedAt)
.Take(take)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("api/inventions/v3/created")]
[Authorize]
public Task<ActionResult> Created([FromQuery] int take = 50) => Saved(take);
/// <summary><c>GetLocalPlayerInventions</c> — same as Saved but
/// at v1 path. Watch hits both depending on tab.</summary>
[HttpGet("api/inventions/v1/mine")]
[Authorize]
public Task<ActionResult> Mine() => Saved(100);
[HttpGet("api/inventions/v1/search")]
public async Task<ActionResult> Search([FromQuery] string value = "")
{
if (string.IsNullOrWhiteSpace(value))
return Ok(Array.Empty<object>());
// The watch's @-prefix means "search by player". Two callers
// use this with different payload shapes — both end up here:
// * PlayerDetailsWatchUIFlow.SetAccount → @<accountId> (numeric)
// loads the Inventions tab on a player profile. Verified
// in Cpp2IL — see PlayerDetailsWatchUIFlow.txt:1867,1950.
// * Inventions search UI → @<username> (text) free-text
// prefix match on Username.
if (value.StartsWith('@'))
{
var tail = value[1..];
List<long> creators;
if (long.TryParse(tail, out var accountId))
{
creators = new List<long> { accountId };
}
else
{
creators = await db.Players
.Where(p => p.Username.StartsWith(tail))
.Select(p => p.Id).Take(50).ToListAsync();
}
var byCreator = await db.Inventions
.Where(i => !i.IsDeleted && i.IsPublished
&& creators.Contains(i.CreatorPlayerId))
.OrderByDescending(i => i.CheerCount)
.Take(50)
.ToListAsync();
return Ok(byCreator.Select(ToWire));
}
var v = value.ToLowerInvariant();
var rows = await db.Inventions
.Where(i => !i.IsDeleted && i.IsPublished
&& (i.Name.ToLower().Contains(v)
|| i.TagsCsv.ToLower().Contains(v)))
.OrderByDescending(i => i.CheerCount)
.Take(50)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
public sealed record InventionBatchRequest(List<long> InventionIds);
[HttpPost("api/inventions/v1/batch")]
public async Task<ActionResult> Batch([FromBody] InventionBatchRequest req)
{
if (req?.InventionIds is null || req.InventionIds.Count == 0)
return Ok(Array.Empty<object>());
var ids = req.InventionIds.Take(200).ToList();
var rows = await db.Inventions
.Where(i => !i.IsDeleted && ids.Contains(i.Id))
.ToListAsync();
return Ok(rows.Select(ToWire));
}
/// <summary>GET <c>api/inventions/v1/tagfilters</c> — drives
/// <c>RecNet.Inventions.GetTags</c>. The watch deserialises the
/// response as <c>RecNet.GetFiltersResponse</c> (an
/// <c>IRecNetObject</c> with <c>PinnedFilters</c> +
/// <c>PopularFilters</c> string-list properties), then a
/// <c>Func&lt;GetFiltersResponse, List&lt;string&gt;&gt;</c>
/// projects whichever list the caller wanted. Verified at
/// Cpp2IL_ISIL/.../RecNet/Inventions.txt:4834-4852 — the URL
/// literal is followed by a <c>typeof(Func`2&lt;GetFiltersResponse,
/// List`1&lt;String&gt;&gt;)</c>. Returning a bare string array
/// (the previous shape) tripped the LitJson importer's
/// <c>List → Dictionary</c> cast inside <c>Util.Deserialize&lt;T&gt;</c>
/// and surfaced as "Failed to get tags: Malformed Response"
/// (output_log.txt:1602+1639) — and that bubbles up as the
/// dorm-load destabilisation we've been hunting.</summary>
[HttpGet("api/inventions/v1/tagfilters")]
public IActionResult TagFilters()
{
var tags = new[]
{
"sport", "game", "vehicle", "weapon", "decor", "tool",
"art", "music", "puzzle", "combat", "build", "race",
};
return Ok(new
{
PinnedFilters = tags,
PopularFilters = tags,
});
}
[HttpGet("api/inventions/v1/creatorIds")]
public IActionResult CreatorIdsInRoom([FromQuery] long roomId)
{
// We don't yet track per-room invention placements; return
// empty list (correct shape: List<int>).
_ = roomId;
return Ok(Array.Empty<int>());
}
// ── Single fetch ─────────────────────────────────────────────────────
[HttpGet("api/inventions/v1")]
public async Task<ActionResult> SingleByQuery([FromQuery] long inventionId)
{
var i = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
if (i is null) return NotFound();
if (!i.IsPublished && i.CreatorPlayerId != CurrentPlayerIdOrNull)
return Forbid();
return Ok(ToWire(i));
}
[HttpGet("api/inventions/v1/details")]
public async Task<ActionResult> Details([FromQuery] long inventionId)
{
var i = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
if (i is null) return NotFound();
if (!i.IsPublished && i.CreatorPlayerId != CurrentPlayerIdOrNull)
return Forbid();
var versions = await db.InventionVersions
.Where(v => v.InventionId == inventionId)
.OrderByDescending(v => v.VersionNumber)
.ToListAsync();
return Ok(new
{
Invention = ToWire(i),
Versions = versions.Select(ToVersionWire),
});
}
[HttpGet("api/inventions/v1/personaldetails/{id:long}")]
[Authorize]
public async Task<ActionResult> PersonalDetails(long id)
{
var i = await db.Inventions.FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted);
if (i is null) return NotFound();
return Ok(new
{
Invention = ToWire(i),
CanEdit = i.CreatorPlayerId == CurrentPlayerId,
});
}
[HttpGet("api/inventions/v1/versions")]
public async Task<ActionResult> Versions([FromQuery] long inventionId)
{
var rows = await db.InventionVersions
.Where(v => v.InventionId == inventionId)
.OrderByDescending(v => v.VersionNumber)
.ToListAsync();
return Ok(rows.Select(ToVersionWire));
}
[HttpGet("api/inventions/v1/version")]
public async Task<ActionResult> Version(
[FromQuery] long inventionId, [FromQuery] int version)
{
var v = await db.InventionVersions
.FirstOrDefaultAsync(x => x.InventionId == inventionId && x.VersionNumber == version);
if (v is null) return NotFound();
return Ok(ToVersionWire(v));
}
// ── Create / version ─────────────────────────────────────────────────
public sealed record SaveInventionRequest(
string Name, string? Description, string? ImageName,
int? Permission, string? Tags, string BlobName,
long? CreationRoomId, int? InstantiationCost, int? LightsCost);
[HttpPost("api/inventions/v3/save")]
[Authorize]
public async Task<ActionResult> Save([FromBody] SaveInventionRequest req)
{
var pid = CurrentPlayerId;
if (string.IsNullOrWhiteSpace(req.Name)) return BadRequest("missing name");
if (string.IsNullOrWhiteSpace(req.BlobName)) return BadRequest("missing BlobName");
var inv = new InventionEntity
{
CreatorPlayerId = pid,
ReplicationId = Guid.NewGuid().ToString("D"),
Name = req.Name.Trim(),
Description = req.Description ?? string.Empty,
ImageName = req.ImageName ?? string.Empty,
Permission = Math.Clamp(req.Permission ?? 0, 0, 2),
CurrentBlobName = req.BlobName,
TagsCsv = req.Tags ?? string.Empty,
CreationRoomId = req.CreationRoomId,
CreatorPermission = 100, // Unlimited for the creator
GeneralPermission = 0, // Unassigned (private) until publish
IsPublished = false,
CurrentVersionNumber = 1,
};
db.Inventions.Add(inv);
await db.SaveChangesAsync();
// Snapshot the v1 version row.
var v1 = new InventionVersionEntity
{
InventionId = inv.Id,
ReplicationId = Guid.NewGuid().ToString("D"),
VersionNumber = 1,
BlobName = req.BlobName,
InstantiationCost = req.InstantiationCost ?? 0,
LightsCost = req.LightsCost ?? 0,
};
db.InventionVersions.Add(v1);
await db.SaveChangesAsync();
await level.AwardXpAsync(pid, LevelService.InventionSavedXp, $"invention_save:{inv.Id}");
return Ok(ToWire(inv));
}
public sealed record AddVersionRequest(
long InventionId, string BlobName,
int? InstantiationCost, int? LightsCost);
[HttpPost("api/inventions/v3/addversion")]
[Authorize]
public async Task<ActionResult> AddVersion([FromBody] AddVersionRequest req)
{
var pid = CurrentPlayerId;
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted);
if (inv is null) return NotFound();
if (inv.CreatorPlayerId != pid) return Forbid();
if (string.IsNullOrWhiteSpace(req.BlobName)) return BadRequest("missing BlobName");
var nextVer = inv.CurrentVersionNumber + 1;
inv.CurrentBlobName = req.BlobName;
inv.CurrentVersionNumber = nextVer;
inv.UpdatedAt = DateTime.UtcNow;
db.InventionVersions.Add(new InventionVersionEntity
{
InventionId = inv.Id,
ReplicationId = Guid.NewGuid().ToString("D"),
VersionNumber = nextVer,
BlobName = req.BlobName,
InstantiationCost = req.InstantiationCost ?? 0,
LightsCost = req.LightsCost ?? 0,
});
await db.SaveChangesAsync();
return Ok(ToWire(inv));
}
// ── GET-based mutations (Core.Get pattern) ───────────────────────────
/// <summary>Watch sends ONE of name/description/imgName/permission
/// per request — `UpdateInvention*` ISIL builds different
/// query strings. Pull whichever ones came in and apply.</summary>
[HttpGet("api/inventions/v1/update")]
[Authorize]
public async Task<ActionResult> Update(
[FromQuery] long inventionId,
[FromQuery] string? name,
[FromQuery] string? description,
[FromQuery] string? imgName,
[FromQuery] int? permission)
{
var pid = CurrentPlayerId;
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
if (inv is null) return NotFound();
if (inv.CreatorPlayerId != pid) return Forbid();
if (!string.IsNullOrWhiteSpace(name)) inv.Name = name.Trim();
if (description is not null) inv.Description = description;
if (imgName is not null) inv.ImageName = imgName;
if (permission.HasValue)
{
inv.GeneralPermission = ClampInventionPermission(permission.Value);
if (inv.GeneralPermission > 0 && !inv.IsPublished)
{
inv.IsPublished = true;
inv.FirstPublishedAt = DateTime.UtcNow;
}
// Mirror legacy 0/1/2 column for back-compat.
inv.Permission = inv.GeneralPermission >= 60 ? 2 : (inv.GeneralPermission >= 20 ? 1 : 0);
}
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(inv));
}
[HttpGet("api/inventions/v1/delete")]
[Authorize]
public async Task<ActionResult> Delete([FromQuery] long inventionId)
{
var pid = CurrentPlayerId;
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId);
if (inv is null) return NotFound();
if (inv.CreatorPlayerId != pid) return Forbid();
inv.IsDeleted = true;
inv.GeneralPermission = 0;
inv.IsPublished = false;
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(inv));
}
[HttpGet("api/inventions/v2/publish")]
[Authorize]
public async Task<ActionResult> Publish(
[FromQuery] long inventionId, [FromQuery] int permissionLevel)
{
var pid = CurrentPlayerId;
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
if (inv is null) return NotFound();
if (inv.CreatorPlayerId != pid) return Forbid();
var perm = ClampInventionPermission(permissionLevel);
if (perm == 0) return BadRequest("permissionLevel must be > 0 (Unassigned)");
inv.GeneralPermission = perm;
inv.IsPublished = true;
inv.FirstPublishedAt ??= DateTime.UtcNow;
inv.Permission = perm >= 60 ? 2 : (perm >= 20 ? 1 : 0);
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(inv));
}
[HttpGet("api/inventions/v1/unpublish")]
[Authorize]
public async Task<ActionResult> Unpublish([FromQuery] long inventionId)
{
var pid = CurrentPlayerId;
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
if (inv is null) return NotFound();
if (inv.CreatorPlayerId != pid) return Forbid();
inv.GeneralPermission = 0;
inv.IsPublished = false;
inv.Permission = 0;
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(inv));
}
[HttpGet("api/inventions/v1/download")]
public async Task<ActionResult> Download([FromQuery] long inventionId)
{
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
if (inv is null) return NotFound();
inv.SpawnCount += 1;
var pid = CurrentPlayerIdOrNull;
if (pid is long me && me != inv.CreatorPlayerId)
{
// NumPlayersHaveUsedInRoom — increment per-distinct-player.
// Approximate by distinct cheer/check; for now bump once
// per download by non-creator.
inv.NumPlayersHaveUsedInRoom += 1;
}
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(inv));
}
// ── POST mutations ───────────────────────────────────────────────────
public sealed record SetTagsRequest(long InventionId, string? AutoTags, string? PlayerAddedTags);
[HttpPost("api/inventions/v1/settags")]
[Authorize]
public async Task<ActionResult> SetTags([FromBody] SetTagsRequest req)
{
var pid = CurrentPlayerId;
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted);
if (inv is null) return NotFound();
if (inv.CreatorPlayerId != pid) return Forbid();
var combined = string.Join(',', new[] { req.AutoTags, req.PlayerAddedTags }
.Where(s => !string.IsNullOrWhiteSpace(s))).Trim(',');
inv.TagsCsv = combined;
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(inv));
}
public sealed record CheerRequest(long InventionId);
[HttpPost("api/inventions/v1/cheer")]
[Authorize]
public async Task<ActionResult> Cheer([FromBody] CheerRequest req)
{
var pid = CurrentPlayerId;
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted);
if (inv is null) return NotFound();
// Idempotent: one cheer per (player, invention).
var existing = await db.Cheers.FirstOrDefaultAsync(c =>
c.FromPlayerId == pid && c.TargetInventionId == req.InventionId);
if (existing is null)
{
db.Cheers.Add(new CheerEntity
{
FromPlayerId = pid,
TargetInventionId = req.InventionId,
CheeredAt = DateTime.UtcNow,
});
inv.CheerCount += 1;
await db.SaveChangesAsync();
if (inv.CreatorPlayerId != pid)
await notifications.NotifyAsync(inv.CreatorPlayerId,
PushNotificationId.InventionModerationStateChanged,
new { Type = "cheer", inv.Id, From = pid, inv.CheerCount });
}
return Ok(new { inv.Id, inv.CheerCount });
}
public sealed record ReportInventionRequest(
long InventionId, int? ReportCategory, string? Details);
[HttpPost("api/inventions/v1/report")]
[Authorize]
public async Task<ActionResult> Report([FromBody] ReportInventionRequest req)
{
var pid = CurrentPlayerId;
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted);
if (inv is null) return NotFound();
db.Reports.Add(new ReportEntity
{
ReporterPlayerId = pid,
TargetPlayerId = inv.CreatorPlayerId,
TargetInventionId = req.InventionId,
Category = req.ReportCategory ?? 5 /* Other */,
Message = (req.Details ?? string.Empty)[..Math.Min(1000, (req.Details ?? string.Empty).Length)],
});
await db.SaveChangesAsync();
return Ok(new { Reported = true });
}
// ── Wire serializers ─────────────────────────────────────────────────
private static int ClampInventionPermission(int v) => v switch
{
0 or 10 or 20 or 40 or 60 or 80 or 100 => v,
_ => 0,
};
private static object ToWire(InventionEntity i) => new
{
InventionId = i.Id,
ReplicationId = string.IsNullOrEmpty(i.ReplicationId)
? Guid.Empty.ToString("D") : i.ReplicationId,
// Wire field is int — IDs in this server are <2^31 so safe.
CreatorPlayerId = (int)i.CreatorPlayerId,
i.Name,
i.Description,
i.ImageName,
i.CurrentVersionNumber,
i.IsPublished,
ModifiedAt = i.UpdatedAt,
i.CreatedAt,
i.FirstPublishedAt,
i.CreationRoomId,
i.NumPlayersHaveUsedInRoom,
NumDownloads = i.SpawnCount,
i.CheerCount,
i.CreatorPermission,
i.GeneralPermission,
i.IsAgInvention,
};
private static object ToVersionWire(InventionVersionEntity v) => new
{
v.InventionId,
ReplicationId = string.IsNullOrEmpty(v.ReplicationId)
? Guid.Empty.ToString("D") : v.ReplicationId,
v.VersionNumber,
v.InstantiationCost,
v.LightsCost,
v.BlobName,
};
}
@@ -0,0 +1,204 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Inventory;
/// <summary>
/// api.rec.net/api/{equipment,consumables}/* — backs the watch's
/// Backpack tab. Wire DTOs match the decompiled client classes:
///
/// • <c>RecNet.Equipment</c> — fields: <c>PrefabName, ModificationGuid,
/// PlatformMask, IsPlatformLocked, FriendlyName, Tooltip, Rarity,
/// Favorited</c>.
/// • <c>RecNet.Consumable</c> — fields: <c>Id(long), ConsumableType,
/// PlatformMask, Count, InitialCount, UnlockedLevel, CreatedAt,
/// ActiveDurationMinutes(int?), ConsumableItem(nested), Category,
/// IsActive, IsPlatformLocked</c>.
///
/// Persistence: one <see cref="PlayerInventoryEntity"/> row per
/// (player, item). For equipment, Quantity=1 + IsActive tracks
/// favourite. For consumables, Quantity is the stack count + IsActive
/// tracks "currently activated".
/// </summary>
[ApiController]
[Authorize]
public class InventoryController(
DorkNetDbContext db,
NotificationService notifications) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
// Categories the StoreItem table uses for equipment vs
// consumables. The seed data tags items per these strings.
private static readonly HashSet<string> EquipmentCategories = new()
{
"equipment", "tool", "maker_pen", "weapon",
};
private static readonly HashSet<string> ConsumableCategories = new()
{
"consumable",
};
// ── Equipment ────────────────────────────────────────────────────────
[HttpGet("api/equipment/v1/getUnlocked")]
[HttpGet("api/equipment/v2/getUnlocked")]
[HttpGet("api/equipment/v3/getUnlocked")]
public async Task<IActionResult> GetUnlockedEquipment()
{
var pid = Me;
var rows = await (from inv in db.PlayerInventory
join item in db.StoreItems on inv.ItemSlug equals item.Slug
where inv.PlayerId == pid
&& EquipmentCategories.Contains(item.Category)
select new { inv, item }).ToListAsync();
return Ok(rows.Select(r => ToEquipmentWire(r.inv, r.item)));
}
public sealed class EquipmentUpdateRequest
{
public string PrefabName { get; set; } = string.Empty;
public string ModificationGuid { get; set; } = string.Empty;
public bool Favorited { get; set; }
}
/// <summary>POST <c>api/equipment/v1/update</c> — toggle the
/// "favorited" flag on a piece of equipment (the watch lets the
/// player pin commonly-used tools).</summary>
[HttpPost("api/equipment/v1/update")]
public async Task<IActionResult> UpdateEquipment([FromBody] EquipmentUpdateRequest req)
{
var pid = Me;
// The slug for equipment is the modification GUID (or prefab
// name fallback when no mod is applied).
var slug = !string.IsNullOrWhiteSpace(req.ModificationGuid)
? req.ModificationGuid
: req.PrefabName;
if (string.IsNullOrWhiteSpace(slug)) return BadRequest("missing prefab/mod identifier");
var row = await db.PlayerInventory
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.ItemSlug == slug);
if (row is null)
{
// Player doesn't own this; reject rather than silently
// creating ownership (avoids letting clients grant items
// to themselves).
return Forbid();
}
row.IsActive = req.Favorited;
await db.SaveChangesAsync();
return Ok(new { Updated = true });
}
// ── Consumables ──────────────────────────────────────────────────────
[HttpGet("api/consumables/v1/getUnlocked")]
public async Task<IActionResult> GetUnlockedConsumables()
{
var pid = Me;
var rows = await (from inv in db.PlayerInventory
join item in db.StoreItems on inv.ItemSlug equals item.Slug
where inv.PlayerId == pid
&& ConsumableCategories.Contains(item.Category)
select new { inv, item }).ToListAsync();
return Ok(rows.Select(r => ToConsumableWire(r.inv, r.item)));
}
public sealed class ConsumableItemRequest
{
public long Id { get; set; }
public int DeltaCount { get; set; }
}
[HttpPost("api/consumables/v1/consume")]
public async Task<IActionResult> Consume([FromBody] ConsumableItemRequest req)
{
var pid = Me;
var row = await db.PlayerInventory
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.Id == req.Id);
if (row is null) return NotFound();
var delta = Math.Max(1, Math.Abs(req.DeltaCount));
row.Quantity = Math.Max(0, row.Quantity - delta);
if (row.Quantity == 0) db.PlayerInventory.Remove(row);
await db.SaveChangesAsync();
await notifications.NotifyAsync(pid,
row.Quantity > 0
? PushNotificationId.ConsumableMappingAdded
: PushNotificationId.ConsumableMappingRemoved,
new { row.Id, row.ItemSlug, row.Quantity });
return Ok(new { row.Id, Count = row.Quantity });
}
public sealed class ActivateConsumableRequest
{
public long Id { get; set; }
public bool IsActive { get; set; }
}
[HttpPost("api/consumables/v1/updateActive")]
public async Task<IActionResult> UpdateActive([FromBody] ActivateConsumableRequest req)
{
var pid = Me;
var row = await db.PlayerInventory
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.Id == req.Id);
if (row is null) return NotFound();
row.IsActive = req.IsActive;
await db.SaveChangesAsync();
return Ok(new { row.Id, row.IsActive });
}
// ── Wire serializers ─────────────────────────────────────────────────
private static object ToEquipmentWire(PlayerInventoryEntity inv, StoreItemEntity item) => new
{
PrefabName = item.Slug, // for store-derived items the slug doubles as prefab name
ModificationGuid = inv.ItemSlug,
PlatformMask = -1, // All
IsPlatformLocked = false,
FriendlyName = item.DisplayName,
Tooltip = item.Description,
Rarity = 0, // Common (no rarity column on StoreItem yet)
Favorited = inv.IsActive,
};
private static object ToConsumableWire(PlayerInventoryEntity inv, StoreItemEntity item)
{
var type = StoreService.TryGetConsumableItemDesc(item.Slug, out var consumableDesc)
? consumableDesc
: item.Slug;
var category = StoreService.TryGetHairDyePayload(item.Slug, out _)
? 6
: 0;
return new
{
inv.Id,
ConsumableItemDesc = type,
ConsumableType = type,
PlatformMask = -1,
Count = inv.Quantity,
InitialCount = inv.Quantity, // we don't track original purchase count
UnlockedLevel = 0,
CreatedAt = inv.AcquiredAt,
ActiveDurationMinutes = (int?)null,
ConsumableItem = new
{
Id = inv.Id,
Type = type,
FriendlyName = item.DisplayName,
Tooltip = item.Description,
ImageName = item.ImageName,
},
Category = category,
inv.IsActive,
IsPlatformLocked = false,
};
}
}
@@ -0,0 +1,301 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Leaderboard;
/// <summary>
/// leaderboard.{rec.net,localhost}/leaderboard/* — per-stat-channel
/// leaderboard reads + writes. URLs verified against
/// <c>Cpp2IL_ISIL/.../RecNet/Leaderboards.txt:1934, 2416, 2700</c>:
///
/// POST leaderboard/SetStat body: SetStatRequestDTO
/// <c>{StatChannel(int), RoomId(long), StatValue(int)}</c>
/// POST leaderboard/GetPlayerRank body: GetPlayerRankData
/// <c>{PlayerId(int), StatChannel(int), RoomId(long)}</c>
///
/// **Subdomain matters**: the client uses
/// <c>Core.SendRequest(method, Service.Leaderboard, requestUri)</c>
/// where <c>Service.Leaderboard = 9</c> resolves via the
/// <c>RecRoomConfig.ServiceUrls</c> map (we serve via
/// <c>api/config/v2</c>) to <c>https://leaderboard.{apex}</c>. So
/// the actual outbound URL is <c>leaderboard.localhost/leaderboard/SetStat</c>
/// — not <c>api.localhost/api/leaderboard/...</c>.
///
/// Response shapes:
/// SetStat → RequestResult ack <c>{success:true,error:""}</c>
/// GetPlayerRank → FullLeaderboard.Entry — <b>camelCase</b> per
/// <c>FullLeaderboard_NestedType_Entry.txt:111-121</c>:
/// <c>{playerId(int), score(long), rank(int)}</c>.
///
/// Without explicit subdomain routing the request fell through to
/// <see cref="GlobalCatchAllController"/> which returns <c>[]</c>;
/// the client's <c>Util.Deserialize&lt;Entry&gt;</c> tried to cast
/// list-as-dictionary →
/// <c>InvalidCastException: Unable to cast 'List' to 'Dictionary'</c>
/// → "Failed to retrieve leaderboard: Malformed Response".
///
/// We bind both the leaderboard subdomain (canonical) AND the api
/// host (defensive — some 2020 builds may proxy via api/) so either
/// path lands here.
/// </summary>
[ApiController]
public class LeaderboardController(
DorkNetDbContext db,
PlayerPresenceService playerPresence) : ControllerBase
{
public sealed class SetStatRequest
{
public int StatChannel { get; set; }
public long RoomId { get; set; }
public int StatValue { get; set; }
}
/// <summary>POST <c>/leaderboard/SetStat</c> on the leaderboard
/// subdomain (canonical client path) AND <c>/api/leaderboard/SetStat</c>
/// on the api host (legacy / proxy fallback).
///
/// **Wire semantics**: the watch's
/// <c>Leaderboards.ConditionalSetStat_ReturningNewValue</c>
/// (Cpp2IL_ISIL/.../RecNet/Leaderboards.txt:645) handles the
/// per-channel direction (high-score vs low-time-better) LOCALLY
/// via a SetMode enum and only POSTs to <c>SetStat</c> when the
/// new value should replace the cached one. The wire request
/// itself carries no direction hint — it's just
/// <c>{StatChannel, RoomId, StatValue}</c>. So the server must
/// trust the client and assign unconditionally; an earlier
/// <c>Math.Max</c> here silently kept the LARGEST value, which
/// broke channels like StuntRunner (channel 7, low elapsed time
/// wins) where the watch posts the new best and we'd discard it
/// for any prior worse value.</summary>
[HttpPost("/leaderboard/SetStat")]
[HttpPost("/api/leaderboard/SetStat")]
[Authorize]
public async Task<IActionResult> SetStat([FromBody] SetStatRequest req)
{
var pid = this.RequireCurrentPlayerId();
var row = await db.LeaderboardStats.FirstOrDefaultAsync(s =>
s.PlayerId == pid && s.StatChannel == req.StatChannel);
if (row is null)
{
row = new LeaderboardStatEntity
{
PlayerId = pid,
StatChannel = req.StatChannel,
Value = req.StatValue,
};
db.LeaderboardStats.Add(row);
}
else
{
row.Value = req.StatValue;
row.UpdatedAt = DateTime.UtcNow;
}
// Auto-attach the channel to whichever room the score came
// from so every room implicitly gets its own leaderboards.
// The watch's <c>SetStatRequestDTO</c> carries
// <c>RoomId</c> (see Cpp2IL_ISIL .../SetStatRequestDTO);
// when it's zero (rare — direct stat report outside a room
// context) fall back to the player's current presence so a
// stray score still gets bucketed somewhere visible.
var roomId = req.RoomId > 0 ? req.RoomId : (playerPresence.GetRoom(pid)?.RoomId ?? 0);
if (roomId > 0)
{
var meta = await db.LeaderboardChannelMeta
.FirstOrDefaultAsync(c => c.Channel == req.StatChannel);
if (meta is null)
{
// First time this channel reports. Stamp a meta row so
// the admin SPA's per-room Leaderboards tab surfaces it
// immediately, with a placeholder name the admin can
// rename later. Direction defaults to higher-is-better;
// admin flips the toggle for time-trial channels.
db.LeaderboardChannelMeta.Add(new LeaderboardChannelMetaEntity
{
Channel = req.StatChannel,
RoomId = roomId,
Name = $"Channel {req.StatChannel}",
LowerIsBetter = false,
ValueFormat = "count",
});
}
else if (meta.RoomId == 0)
{
// Channel registered globally (e.g. from the hardcoded
// KnownStatChannels fallback or an unscoped admin
// entry). Bind it to this room now that we know which
// room it came from. Don't re-bind if it already
// points at a different room — the admin must be the
// one to reassign once a channel has a home.
meta.RoomId = roomId;
meta.UpdatedAt = DateTime.UtcNow;
}
}
await db.SaveChangesAsync();
return Ok(new { success = true, error = "" });
}
public sealed class GetPlayerRankRequest
{
public int PlayerId { get; set; }
public int StatChannel { get; set; }
public long RoomId { get; set; }
}
[HttpPost("/leaderboard/GetPlayerRank")]
[HttpPost("/api/leaderboard/GetPlayerRank")]
public async Task<IActionResult> GetPlayerRank([FromBody] GetPlayerRankRequest req)
{
var pid = req.PlayerId > 0
? (long)req.PlayerId
: this.CurrentPlayerId() ?? 0;
// Empty/no-row case: return a zero entry. The client tolerates
// this — `Entry.Deserialize` reads playerId/score/rank as
// primitives with defaults.
if (pid == 0)
return Ok(new { playerId = 0, score = 0L, rank = 0 });
var row = await db.LeaderboardStats.FirstOrDefaultAsync(s =>
s.PlayerId == pid && s.StatChannel == req.StatChannel);
if (row is null)
return Ok(new { playerId = (int)pid, score = 0L, rank = 0 });
var higherCount = await db.LeaderboardStats
.CountAsync(s => s.StatChannel == req.StatChannel && s.Value > row.Value);
return Ok(new
{
playerId = (int)pid,
score = row.Value,
rank = higherCount + 1,
});
}
public sealed class GetNearbyScoresRequest
{
public int PlayerId { get; set; }
public int StatChannel { get; set; }
public long RoomId { get; set; }
public int WindowSize { get; set; }
}
/// <summary>POST <c>/leaderboard/GetNearbyScores</c> — returns the
/// N entries on either side of the caller's rank. Wire shape per
/// <c>GetNearbyScoresRequestDTO.cs</c>: PlayerId/StatChannel/RoomId
/// inherited from GetRankRequestDTO + WindowSize.</summary>
[HttpPost("/leaderboard/GetNearbyScores")]
[HttpPost("/api/leaderboard/GetNearbyScores")]
public async Task<IActionResult> GetNearbyScores([FromBody] GetNearbyScoresRequest req)
{
var pid = req.PlayerId > 0 ? (long)req.PlayerId : this.CurrentPlayerId() ?? 0;
var window = Math.Clamp(req.WindowSize, 1, 50);
var all = await db.LeaderboardStats
.Where(s => s.StatChannel == req.StatChannel)
.OrderByDescending(s => s.Value)
.Select(s => new { s.PlayerId, s.Value })
.ToListAsync();
if (all.Count == 0)
{
// Empty case must still come back as a SingleLeaderboard
// Dictionary, not a bare array — the 2020 watch's
// SingleLeaderboard.Deserialize reads dict["rows"]
// (Cpp2IL_ISIL/.../RecNet/SingleLeaderboard.txt:60).
// Returning `[]` cast-fails as List→Dictionary inside
// RecNet.Util.Deserialize, logged repeatedly as
// "Received malformed RecNet response: '[]'" and
// eventually starves the StuntRunner UI of a usable
// leaderboard.
return Ok(new { rows = Array.Empty<object>() });
}
var ranked = all.Select((r, i) => new
{
playerId = (int)r.PlayerId,
score = r.Value,
rank = i + 1,
}).ToList();
var myIdx = ranked.FindIndex(r => r.playerId == (int)pid);
if (myIdx < 0)
{
return Ok(new { rows = ranked.Take(window * 2 + 1).ToArray() });
}
var lo = Math.Max(0, myIdx - window);
var hi = Math.Min(ranked.Count - 1, myIdx + window);
return Ok(new { rows = ranked.GetRange(lo, hi - lo + 1).ToArray() });
}
public sealed class GetRanksRequest
{
public int PlayerId { get; set; }
public int StatChannel { get; set; }
public long RoomId { get; set; }
public int RankStart { get; set; }
public int RankEnd { get; set; }
}
/// <summary>POST <c>/leaderboard/GetRanks</c> — returns a slice of
/// the leaderboard between RankStart and RankEnd (inclusive,
/// 1-indexed). Wire shape per <c>GetRanksRequestDTO.cs</c>.</summary>
[HttpPost("/leaderboard/GetRanks")]
[HttpPost("/api/leaderboard/GetRanks")]
public async Task<IActionResult> GetRanks([FromBody] GetRanksRequest req)
{
var start = Math.Max(1, req.RankStart);
var end = Math.Max(start, req.RankEnd);
var skip = start - 1;
var take = Math.Clamp(end - skip, 1, 200);
var rows = await db.LeaderboardStats
.Where(s => s.StatChannel == req.StatChannel)
.OrderByDescending(s => s.Value)
.Skip(skip).Take(take)
.ToListAsync();
var entries = rows.Select((s, i) => new
{
playerId = (int)s.PlayerId,
score = s.Value,
rank = start + i,
}).ToArray();
// SingleLeaderboard wrapper — see GetNearbyScores above.
return Ok(new { rows = entries });
}
/// <summary>Top-N for a channel. Used by the admin tooling and
/// by the watch's full-leaderboard scoreboard view.</summary>
[HttpGet("/leaderboard/Top")]
[HttpGet("/api/leaderboard/Top")]
public async Task<IActionResult> Top([FromQuery] int channel, [FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 200);
var rows = await db.LeaderboardStats
.Where(s => s.StatChannel == channel)
.OrderByDescending(s => s.Value)
.Take(take)
.ToListAsync();
// FullLeaderboard wire shape: GlobalOverall / GlobalPeriodic /
// FriendsOverall / FriendsPeriodic + NextResetUTC. Use the
// same Entry list for all four buckets — friends-vs-global
// can come later.
var entries = rows.Select((s, i) => new
{
playerId = (int)s.PlayerId,
score = s.Value,
rank = i + 1,
}).ToArray();
return Ok(new
{
GlobalOverall = entries,
GlobalPeriodic = entries,
FriendsOverall = Array.Empty<object>(),
FriendsPeriodic = Array.Empty<object>(),
NextResetUTC = DateTime.UtcNow.AddDays(7),
});
}
}
@@ -0,0 +1,586 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Messages.V2;
/// <summary>
/// api.rec.net/api/messages/v2/* — direct-message inbox API. Backed
/// by <see cref="MessageEntity"/>. Sends fan out a SignalR push
/// (<c>MessageReceived</c>) so the recipient's watch refreshes
/// immediately.
/// </summary>
[ApiController]
[Route("api/[controller]/v2")]
[Authorize]
public class MessagesController(
DorkNetDbContext db,
NotificationService notifications,
PrivateInstanceService privateInstances,
OnlinePresenceService onlinePresence,
ILogger<MessagesController> logger) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
/// <summary>List the caller's inbox, newest-first. Response is a
/// flat array of <see cref="MessageDto"/>; the watch groups by
/// sender locally. v1/v3 paths share the same handler — the watch
/// uses whichever URL its <c>RecNet.Messages</c> static helper
/// built from <c>GameConfig.MessageServiceVersion</c>.</summary>
[HttpGet("get")]
[HttpGet("/api/messages/v1/get")]
[HttpGet("/api/messages/v3/get")]
[HttpGet("/api/messages/v1/onlineStatus")]
public async Task<ActionResult<List<MessageDto>>> GetMessages([FromQuery] int take = 100)
{
take = Math.Clamp(take, 1, 200);
var rows = await db.Messages
.Where(m => m.RecipientPlayerId == Me || m.SenderPlayerId == Me)
// Defensive filter for the empty-body ghost rows that
// accumulated before SendFormMessage rejected them. The
// watch's MessagesScreenItem renders TextMessage (type 30)
// as "Message : {body}", so an empty body becomes a
// useless "Message :" entry the user has to scroll past.
// Control-channel types (10 RequestGameInvite, 20
// FriendStatusOnline, etc.) have their own labels and
// legitimately carry no body, so we keep them.
.Where(m => m.Type != 30 || (m.Body != null && m.Body != ""))
.OrderByDescending(m => m.SentAt)
.Take(take)
.ToListAsync();
return Ok(await ToDtosAsync(rows));
}
/// <summary>GET <c>api/messages/v1/unread</c> — list of unread
/// messages addressed to the caller. The watch uses the count for
/// the inbox badge and the entries themselves for the toast
/// preview.</summary>
[HttpGet("/api/messages/v1/unread")]
public async Task<ActionResult<List<MessageDto>>> Unread()
{
var rows = await db.Messages
.Where(m => m.RecipientPlayerId == Me && m.ReadAt == null)
// Same ghost-row filter as GetMessages above — keep the
// unread badge count honest by hiding empty TextMessages.
.Where(m => m.Type != 30 || (m.Body != null && m.Body != ""))
.OrderByDescending(m => m.SentAt)
.Take(100)
.ToListAsync();
return Ok(await ToDtosAsync(rows));
}
public sealed class DeleteRequest
{
public long? Id { get; set; }
public long? MessageId { get; set; }
public List<long>? MessageIds { get; set; }
}
/// <summary>POST <c>api/messages/v3/delete</c> — hard-delete one or
/// more messages addressed to the caller. The 2020 watch's inbox
/// "delete" action posts JSON <c>{"MessageIds":[8]}</c> (plural
/// array, verified from the live trace where the body was
/// <c>{"MessageIds":[8]}</c> against <c>/api/messages/v3/delete</c>);
/// some older / SPA call sites still post <c>id</c> or
/// <c>messageId</c> as a form/query. Accept all three shapes and
/// scope every delete to <c>RecipientPlayerId == Me</c> so the
/// sender can't unsend.</summary>
[HttpPost("/api/messages/v3/delete")]
[HttpPost("/api/messages/v1/delete")]
[Consumes("application/json")]
public async Task<IActionResult> DeleteJson([FromBody] DeleteRequest body)
{
var ids = new List<long>();
if (body.Id is long a && a > 0) ids.Add(a);
if (body.MessageId is long b && b > 0) ids.Add(b);
if (body.MessageIds is { Count: > 0 })
ids.AddRange(body.MessageIds.Where(v => v > 0));
return await DeleteByIds(ids);
}
[HttpPost("/api/messages/v3/delete")]
[HttpPost("/api/messages/v1/delete")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> DeleteForm(
[FromForm(Name = "id")] long? formId,
[FromForm(Name = "messageId")] long? formMessageId,
[FromForm(Name = "messageIds")] string? messageIdsCsv)
{
var ids = new List<long>();
if (formId is long a && a > 0) ids.Add(a);
if (formMessageId is long b && b > 0) ids.Add(b);
if (long.TryParse(Request.Query["id"], out var qi) && qi > 0) ids.Add(qi);
if (!string.IsNullOrWhiteSpace(messageIdsCsv))
{
foreach (var s in messageIdsCsv.Split(',', StringSplitOptions.RemoveEmptyEntries))
if (long.TryParse(s.Trim(), out var v) && v > 0) ids.Add(v);
}
return await DeleteByIds(ids);
}
private async Task<IActionResult> DeleteByIds(List<long> ids)
{
ids = ids.Distinct().ToList();
if (ids.Count == 0) return BadRequest(new { error = "missing_id" });
var deleted = await db.Messages
.Where(m => ids.Contains(m.Id) && m.RecipientPlayerId == Me)
.ExecuteDeleteAsync();
return Ok(new { success = true, error = "", deleted });
}
/// <summary>POST <c>api/messages/v3/markRead</c> — bulk
/// mark-as-read for every unread message addressed to the caller.
/// Optional <c>ids</c> CSV scopes to specific message ids.</summary>
[HttpPost("/api/messages/v3/markRead")]
[HttpPost("/api/messages/v1/markRead")]
public async Task<IActionResult> MarkReadBulk([FromForm(Name = "ids")] string? ids)
{
IQueryable<MessageEntity> q = db.Messages
.Where(m => m.RecipientPlayerId == Me && m.ReadAt == null);
if (!string.IsNullOrWhiteSpace(ids))
{
var idSet = ids
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(s => long.TryParse(s, out var v) ? v : 0L)
.Where(v => v > 0)
.ToHashSet();
if (idSet.Count > 0) q = q.Where(m => idSet.Contains(m.Id));
}
var now = DateTime.UtcNow;
await q.ExecuteUpdateAsync(s => s.SetProperty(m => m.ReadAt, now));
return Ok(new { success = true, error = "" });
}
public sealed record SendRequest(long RecipientId, string Body, long? RoomId = null);
/// <summary>Send a direct message — JSON wire shape used by some
/// SPA/admin callers. The 2020 watch posts to the same path with
/// form-urlencoded fields (<see cref="SendFormMessage"/>); the
/// <c>[Consumes]</c> attribute disambiguates which action MVC
/// picks. Empty bodies are rejected so the inbox doesn't get
/// padded with whitespace pings; messages over 2000 chars are
/// truncated to fit the column.</summary>
[HttpPost("send")]
[HttpPost("/api/messages/v1/send")]
[Consumes("application/json")]
public async Task<ActionResult<MessageDto>> SendMessage([FromBody] SendRequest req)
{
if (req.RecipientId <= 0 || req.RecipientId == Me)
return BadRequest(new { error = "invalid_recipient" });
var trimmed = (req.Body ?? string.Empty).Trim();
if (trimmed.Length == 0) return BadRequest(new { error = "empty_body" });
if (trimmed.Length > 2000) trimmed = trimmed[..2000];
var entry = new MessageEntity
{
SenderPlayerId = Me,
RecipientPlayerId = req.RecipientId,
Body = trimmed,
Type = 30, // TextMessage
RoomId = req.RoomId,
};
db.Messages.Add(entry);
await db.SaveChangesAsync();
await BroadcastMessageAsync(entry);
return Ok(ToDto(entry));
}
/// <summary>Form-urlencoded variant — what the 2020 watch actually
/// posts when the user taps "Send Join Request" or any other
/// <c>RecNet.Messages.SendMessage(player, type, data)</c> caller.
/// Wire shape (verified against client log <c>contentType=
/// application/x-www-form-urlencoded</c>, body <c>ToPlayerId=…&amp;
/// Type=10&amp;Data=</c>): the <c>Type</c> integer is a
/// <c>RecNet.Message.MessageType</c> enum value
/// (<c>Cpp2IL_CS/.../RecNet/Message.cs</c>) — 10 =
/// RequestGameInvite, 30 = TextMessage, etc. Empty <c>Data</c> is
/// allowed for control-channel types (join request, declines)
/// where the message itself is the signal.</summary>
[HttpPost("send")]
[HttpPost("/api/messages/v1/send")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<ActionResult<MessageDto>> SendFormMessage(
[FromForm(Name = "ToPlayerId")] long toPlayerId,
[FromForm(Name = "Type")] int type,
[FromForm(Name = "Data")] string? data,
[FromForm(Name = "RoomId")] long? roomId)
{
if (toPlayerId <= 0 || toPlayerId == Me)
return BadRequest(new { error = "invalid_recipient" });
var trimmed = (data ?? string.Empty);
if (trimmed.Length > 2000) trimmed = trimmed[..2000];
// For TextMessage (type 30) the body IS the message — empty
// is meaningless and renders as a ghost "Message :" row in
// the watch's inbox (MessagesScreenItem format string at
// Cpp2IL_ISIL/.../MessagesScreenItem.txt:2145 is
// "Message : {0}"). Control-channel types (RequestGameInvite,
// declines, FriendStatusOnline, etc.) intentionally carry
// empty Data — they get rendered by their own label string
// in the same screen item.
if (type == 30 && trimmed.Trim().Length == 0)
return BadRequest(new { error = "empty_text_message" });
var entry = new MessageEntity
{
SenderPlayerId = Me,
RecipientPlayerId = toPlayerId,
Body = trimmed,
Type = type,
RoomId = roomId,
};
db.Messages.Add(entry);
await db.SaveChangesAsync();
logger.LogInformation(
"[messages] send from={From} to={To} type={Type} dataLen={Len}",
Me, toPlayerId, type, trimmed.Length);
await BroadcastMessageAsync(entry);
return Ok(ToDto(entry));
}
/// <summary>POST <c>invite</c> — handles the watch's
/// <c>RecNet.Messages.SendGameInvite(playerId, roomInstanceId)</c>
/// (verified at <c>Cpp2IL_ISIL/.../Messages.txt:613-773</c>): form
/// fields <c>playerId</c> (recipient) + <c>roomInstanceId</c>
/// (the inviter's private match id). Two side-effects:
/// 1. Add the recipient to the invite list for that instance so
/// <c>POST /goto/instance/{id}</c> accepts them.
/// 2. Drop a Type-6 (GameInviteV2) message in their inbox carrying the
/// RoomInstanceId so the message UI's "Join" button knows
/// where to send them.
/// Registered at multiple paths because the exact URL the watch
/// builds for "invite" depends on the messages-namespace base —
/// rather than guess wrong and silently fall through to the
/// wildcard, we cover all plausible mounts and log the one that
/// actually fires.</summary>
[HttpPost("/invite")]
[HttpPost("/api/invite")]
[HttpPost("/api/messages/v1/invite")]
[HttpPost("/api/messages/v2/invite")]
[HttpPost("/api/messages/v3/invite")]
public async Task<ActionResult> SendInvite(
[FromForm(Name = "playerId")] long playerId,
[FromForm(Name = "roomInstanceId")] long roomInstanceId)
{
logger.LogInformation(
"[invite] from={From} → to={To} instance={Instance} (path={Path})",
Me, playerId, roomInstanceId, Request.Path);
if (playerId <= 0 || playerId == Me)
return BadRequest(new { error = "invalid_recipient" });
// Only the OWNER of the private instance can invite into it.
// Otherwise an attacker who somehow learned a roomInstanceId
// could invite themselves (or anyone) freely.
var inst = await privateInstances.GetAsync(roomInstanceId);
if (inst is null)
return BadRequest(new { error = "instance_not_found", roomInstanceId });
if (inst.OwnerPlayerId != Me)
return Forbid();
// Persist a real RecNet.Message.MessageType.GameInviteV2 (=6)
// so the watch's GameInviteController calls
// Matchmaking.AcceptRoomInvite(inviteId, roomId), which posts
// goto/invite/{inviteId}. Type 0 follows the sender's current
// presence instead and can land in the wrong instance.
//
// Two saves: the first allocates the message Id (which becomes
// the `inviteId` the watch sends back in /goto/invite/{N}); the
// second backfills the JSON Body once we know it. We can't
// pre-allocate the Id because EF Core's identity columns only
// populate on SaveChanges.
var roomName = await db.Rooms
.Where(r => r.Id == inst.RoomId)
.Select(r => r.Name)
.FirstOrDefaultAsync() ?? "Private match";
var entry = new MessageEntity
{
SenderPlayerId = Me,
RecipientPlayerId = playerId,
// Placeholder so the row passes the NOT NULL constraint
// until we overwrite it post-save with the real JSON.
Body = string.Empty,
Type = 6,
RoomId = inst.RoomId,
};
db.Messages.Add(entry);
await db.SaveChangesAsync();
// InviteAsync AFTER the message save so we can pin the freshly-
// allocated Message.Id onto the invitee row's
// LatestInviteMessageId. This is what /goto/invite/{N} falls
// back to when the watch's accept flow has already raced
// through DELETE /api/messages/v3/delete — the message row is
// gone but the (instance, player) → messageId mapping survives.
await privateInstances.InviteAsync(roomInstanceId, playerId, messageId: entry.Id);
// Body shape MUST be the JSON the watch's Message.Deserialize
// builds RoomInviteDetails from (verified at
// Cpp2IL_ISIL/.../RecNet/Message.txt:786-805 — for Type 6/7
// the parser hits `Json.Deserialize` and reads "inviteId" +
// "name"; for other types it `Int32.Parse`s the body).
//
// inviteId → the message Id; watch echoes it back in
// /goto/invite/{inviteId}, server looks the
// row up to resolve roomInstanceId.
// name → display string in the invite toast / row.
// roomInstanceId → extra field the watch ignores; server uses
// it (via TryParseRoomInstanceId's JSON
// fallback) so /goto/invite/{N} doesn't have
// to re-query the private-instance service.
//
// The roomInstanceId MUST be a JSON STRING, not a number. LitJson
// chokes on the dict-cast when a value is a 64-bit integer larger
// than int32 — confirmed via SaveDebugMod trace: the watch logs
// `data={"inviteId":31,"name":"Dorm_1811750","roomInstanceId":6546545006375796736}`
// `RoomInviteDetails=<null>`
// `System.NullReferenceException at RecNet.Util.GetKey from
// RecNet.Message.Deserialize`
// Json.Deserialize returns a null cast-result on the long value,
// GetKey("inviteId") then NREs and the whole parse aborts before
// RoomInviteDetails is built. Stringifying the value sidesteps
// LitJson's long-int handling entirely; the watch only reads
// inviteId + name so the string form is invisible to it.
entry.Body = System.Text.Json.JsonSerializer.Serialize(new
{
inviteId = entry.Id,
name = roomName,
roomInstanceId = roomInstanceId.ToString(System.Globalization.CultureInfo.InvariantCulture),
});
await db.SaveChangesAsync();
await BroadcastMessageAsync(entry);
return Ok(new { success = true, messageId = entry.Id });
}
/// <summary>Mark a single message as read. No-op if not
/// addressed to the caller — prevents the sender from "reading"
/// the recipient's copy.</summary>
[HttpPost("markread/{id:long}")]
public async Task<ActionResult> MarkRead(long id)
{
var msg = await db.Messages.FirstOrDefaultAsync(m =>
m.Id == id && m.RecipientPlayerId == Me);
if (msg is null) return NotFound();
if (msg.ReadAt is null)
{
msg.ReadAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
return Ok();
}
public sealed class SendMultipleBody
{
public List<long>? ToPlayerIds { get; set; }
public int Type { get; set; }
public string? Data { get; set; }
public long? RoomId { get; set; }
}
/// <summary>POST <c>api/messages/v1/sendMultiple</c> — fan-out a
/// message to many recipients. Persists one MessageEntity per
/// recipient. Wire shape: <c>{ToPlayerIds (int[]), Type (int), Data (string)}</c>.</summary>
[HttpPost("/api/messages/v1/sendMultiple")]
[Consumes("application/json")]
public async Task<IActionResult> SendMultiple([FromBody] SendMultipleBody body)
{
if (body.ToPlayerIds is null || body.ToPlayerIds.Count == 0)
return Ok(Array.Empty<object>());
var entries = new List<MessageEntity>();
foreach (var to in body.ToPlayerIds.Distinct().Where(id => id > 0 && id != Me))
{
var entry = new MessageEntity
{
SenderPlayerId = Me,
RecipientPlayerId = to,
Body = body.Data ?? string.Empty,
Type = body.Type,
RoomId = body.RoomId,
};
entries.Add(entry);
db.Messages.Add(entry);
}
await db.SaveChangesAsync();
foreach (var entry in entries)
{
await BroadcastMessageAsync(entry);
}
return Ok(Array.Empty<object>());
}
/// <summary>POST <c>api/offlineinvite/v1/send</c> — send an invite
/// message to a player who's offline; surfaces in their inbox on
/// next login. Wire shape: form-urlencoded <c>RecipientId</c> +
/// <c>Data</c>. Idempotent per (sender, recipient, body).</summary>
[HttpPost("/api/offlineinvite/v1/send")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> OfflineInviteSend(
[FromForm(Name = "RecipientId")] long? recipientId,
[FromForm(Name = "Data")] string? data)
{
if (recipientId is not long rid || rid <= 0 || rid == Me)
return Ok(new { success = false, error = "invalid_recipient" });
var entry = new MessageEntity
{
SenderPlayerId = Me,
RecipientPlayerId = rid,
Body = data ?? string.Empty,
Type = 0,
};
db.Messages.Add(entry);
await db.SaveChangesAsync();
await BroadcastMessageAsync(entry);
return Ok(new { success = true, messageId = entry.Id });
}
/// <summary>GET <c>api/offlineinvite/v1/all</c> — invites
/// received while the player was offline. Pulls unread
/// MessageEntity rows whose body looks like an invite.</summary>
[HttpGet("/api/offlineinvite/v1/all")]
public async Task<IActionResult> OfflineInviteAll()
{
var rows = await db.Messages
.Where(m => m.RecipientPlayerId == Me && m.ReadAt == null
&& (m.Body.Contains("invite") || m.Body.Contains("event")))
.OrderByDescending(m => m.SentAt)
.Take(50)
.Select(m => new
{
InviteId = m.Id,
FromPlayerId = (int)m.SenderPlayerId,
m.Body,
SentAt = m.SentAt,
})
.ToListAsync();
return Ok(rows);
}
/// <summary>The boot sequence asks for online friends. We
/// derive that from the SignalR hub's connected-player set —
/// the response shape is <c>[ {AccountId, Online}, ... ]</c>.</summary>
[HttpGet("/api/messages/v1/favoriteFriendOnlineStatus")]
public async Task<ActionResult> FavoriteFriendOnlineStatus()
{
var friendIds = await db.Relationships
.Where(r => r.Status == RelationshipStatus.Friend &&
(r.RequesterId == Me || r.TargetId == Me))
.Select(r => r.RequesterId == Me ? r.TargetId : r.RequesterId)
.ToListAsync();
var online = onlinePresence.OnlinePlayerIds().ToHashSet();
return Ok(friendIds.Select(id => new
{
AccountId = id,
Online = online.Contains(id),
}));
}
/// <summary>Wire shape verified against
/// <c>Cpp2IL_ISIL/.../RecNet/Message.txt:591-787</c>.
/// <c>RecNet.Message.Deserialize</c> calls <c>Util.GetKey</c>
/// (throws on miss) for: <c>Id</c> (long), <c>FromPlayerId</c> (int),
/// <c>SentTime</c> (DateTime), <c>Type</c> (int — MessageType enum),
/// and reads <c>Data</c> (string) via <c>Util.GetKeyOrDefault</c>.
/// Optional nullable wrappers: <c>RoomId</c>, <c>PlayerEventId</c>.
/// Previous response used <c>{Id, Sender, Recipient, Body, SentAt, ReadAt}</c>
/// which crashed at <c>Util.GetKey("FromPlayerId")</c>.</summary>
public sealed record MessageDto(
long Id,
long FromPlayerId,
DateTime SentTime,
int Type,
string Data,
long? RoomId,
long? PlayerEventId);
private async Task<List<MessageDto>> ToDtosAsync(List<MessageEntity> rows)
{
var instanceIds = rows
.Where(m => IsInstanceInviteType(m.Type)
&& m.RoomId is null
&& long.TryParse(m.Body, System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture, out _))
.Select(m => long.Parse(m.Body, System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture))
.Distinct()
.ToList();
var roomByInstance = instanceIds.Count == 0
? new Dictionary<long, long>()
: await db.PrivateInstances
.Where(i => instanceIds.Contains(i.Id))
.Select(i => new { i.Id, i.RoomId })
.ToDictionaryAsync(i => i.Id, i => i.RoomId);
return rows.Select(m =>
{
long? roomId = m.RoomId;
if (roomId is null
&& IsInstanceInviteType(m.Type)
&& long.TryParse(m.Body, System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture, out var instanceId)
&& roomByInstance.TryGetValue(instanceId, out var rid))
{
roomId = rid;
}
return ToDto(m, roomId);
}).ToList();
}
private static bool IsInstanceInviteType(int type) => type is 6 or 7;
/// <summary>Push a <c>MessageReceived</c> SignalR notification to BOTH
/// the recipient AND the sender. The recipient half is the obvious one
/// — that's what fires the watch's <c>OnMessageAdded</c> event and
/// gives them the toast / inbox-row update. The sender-half is less
/// obvious but equally needed: the watch's <c>Messages.SendMessage</c>
/// (Messages.txt:1790-1900) just does <c>ExpectHttpStatusSuccess</c>
/// on the response and never touches the local <c>MessageList</c>,
/// so without a self-targeted push the sender's chat thread doesn't
/// know about its own outgoing message until the next
/// <c>RefreshList</c> (which only fires on hub-reconnect or first
/// inbox open). Pushing to both sides keeps both watches in sync
/// in real time. Dedup happens client-side in
/// <c>OnMessageReceived</c> via the "already-in-MessageList by Id"
/// check (Messages.txt:2790-2793), so a player who somehow ends up
/// as both sender and recipient (admin self-messaging) only sees
/// the message once.</summary>
private async Task BroadcastMessageAsync(MessageEntity entry)
{
var dto = ToDto(entry);
await notifications.NotifyAsync(entry.RecipientPlayerId,
PushNotificationId.MessageReceived, dto);
if (entry.SenderPlayerId != entry.RecipientPlayerId)
{
await notifications.NotifyAsync(entry.SenderPlayerId,
PushNotificationId.MessageReceived, dto);
}
}
private static MessageDto ToDto(MessageEntity m, long? roomId = null) =>
new(
Id: m.Id,
FromPlayerId: m.SenderPlayerId,
SentTime: m.SentAt,
// Round-trip the stored Type verbatim. Earlier we coerced
// Type==0 → 30 for "pre-Type-column rows", but the
// Messages.Type Postgres patch (Program.cs:612-614) added
// the column with DEFAULT 30 and backfilled every legacy
// row to 30, so any Type==0 row in the DB now is a real
// GameInvite (e.g. SendInvite above). Coercing it to 30
// routes it through TextMessageController and renders
// "Message : <instanceId>" with no Accept button.
Type: m.Type,
Data: m.Body,
RoomId: roomId ?? m.RoomId,
PlayerEventId: null);
}
@@ -0,0 +1,211 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Moderation;
/// <summary>
/// In-game moderation endpoints invoked by the watch's player-action
/// dialogs. URLs / form-fields verified against the Cpp2IL ISIL dump:
///
/// POST api/PlayerReporting/v3/create
/// PlayerReporting.txt:1696 + 1712-1822
/// Form: PlayerIdReported (int), ReportCategory (int), Details (string),
/// HeightReporter (float "F2"), HeightReported (float "F2"),
/// RoomId (long).
///
/// POST api/PlayerReporting/v3/voteToKick
/// PlayerReporting.txt:3175 + 3215-3237
/// Form: PlayerId (long), Response (bool), GameSessionId (long).
///
/// POST api/PlayerReporting/v1/instantKick
/// PlayerReporting.txt:3417 (single) + 3590 (bulk)
/// Body: PlayerId(s) — one or many. Admin/dev only.
///
/// POST api/PlayerReporting/v1/deviceId
/// PlayerReporting.txt:3736 + 3749
/// Form: oldDeviceId (string).
///
/// POST api/PlayersBanned/v2/ban
/// Moderation.txt:623-702
/// Form: PlayerId (long), Reason (int), BanType (int),
/// DisplayReason (string), BannedUntil (string MM/dd/yyyy HH:mm).
/// Admin only.
///
/// Without these handlers the watch fell through to the JSON catch-all
/// returning <c>{}</c>, which the OkResponse parser tolerates but the
/// admin-action then has no observable effect — i.e. "the admin
/// system doesn't work at all".
/// </summary>
[ApiController]
[Authorize]
public class InGameModerationController(
DorkNetDbContext db,
NotificationService notifications,
ILogger<InGameModerationController> logger) : ControllerBase
{
// ── /v3/create — file a player report ────────────────────────────
[HttpPost("api/PlayerReporting/v3/create")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> CreateReport(
[FromForm(Name = "PlayerIdReported")] long? playerIdReported,
[FromForm(Name = "ReportCategory")] int reportCategory = 0,
[FromForm(Name = "Details")] string? details = null,
[FromForm(Name = "HeightReporter")] float? heightReporter = null,
[FromForm(Name = "HeightReported")] float? heightReported = null,
[FromForm(Name = "RoomId")] long roomId = 0)
{
if (playerIdReported is not long target || target <= 0)
return Ok(new { success = false, error = "missing_target" });
var reporter = this.RequireCurrentPlayerId();
if (reporter == target)
return Ok(new { success = false, error = "cannot_report_self" });
var trimmed = (details ?? string.Empty).Trim();
if (trimmed.Length > 1000) trimmed = trimmed[..1000];
db.Reports.Add(new ReportEntity
{
ReporterPlayerId = reporter,
TargetPlayerId = target,
Category = reportCategory,
Message = trimmed,
RoomId = roomId,
});
await db.SaveChangesAsync();
logger.LogInformation(
"[mod] report filed: reporter={Reporter} target={Target} category={Cat} room={Room}",
reporter, target, reportCategory, roomId);
return Ok(new { success = true, error = "" });
}
// ── /v3/voteToKick — room-vote kick ──────────────────────────────
[HttpPost("api/PlayerReporting/v3/voteToKick")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public IActionResult VoteToKick(
[FromForm(Name = "PlayerId")] long? playerId,
[FromForm(Name = "Response")] bool? response,
[FromForm(Name = "GameSessionId")] long gameSessionId = 0)
{
var caller = this.RequireCurrentPlayerId();
logger.LogInformation(
"[mod] vote-to-kick by {Caller}: target={Target} yes={Yes} session={Session}",
caller, playerId, response, gameSessionId);
// Real implementation would tally votes per (session, target) and
// push a kick once the threshold tripped. For now we accept the
// vote without side-effects so the watch UI's "voted" indicator
// shows.
return Ok(new { success = true, error = "" });
}
// ── /v1/instantKick — admin one-shot ─────────────────────────────
/// <summary>
/// Admin/dev one-shot kick. The watch posts either
/// <c>?playerId=N</c> for a single target (PlayerReporting.txt:3417)
/// or a list body for bulk kicks (3590). Either form lands here.
/// Gated to <see cref="PlayerEntity.IsAdmin"/> /
/// <see cref="PlayerEntity.IsDeveloper"/>.
/// </summary>
[HttpPost("api/PlayerReporting/v1/instantKick")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> InstantKick(
[FromQuery(Name = "playerId")] long? singleId,
[FromForm(Name = "PlayerId")] long? formId,
[FromForm(Name = "PlayerIds")] string? formIds)
{
var caller = this.RequireCurrentPlayerId();
if (!await IsCallerAdminOrDevAsync(caller))
return Forbid();
var ids = new List<long>();
if (singleId is long s && s > 0) ids.Add(s);
if (formId is long f && f > 0) ids.Add(f);
if (!string.IsNullOrEmpty(formIds))
{
foreach (var part in formIds.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
if (long.TryParse(part, out var v) && v > 0) ids.Add(v);
}
ids = ids.Distinct().ToList();
if (ids.Count == 0) return Ok(new { success = false, error = "missing_target" });
foreach (var target in ids)
{
logger.LogInformation("[mod] instant-kick by {Caller}: target={Target}", caller, target);
// Push a SignalR ModerationKick notification — the watch's
// PlayerReporting.OnModerationKick handler boots the player
// back to their dorm.
await notifications.NotifyAsync(
target,
Models.Notification.PushNotificationId.ModerationKick);
}
return Ok(new { success = true, error = "" });
}
// ── /v1/deviceId — log mismatched device id ──────────────────────
[HttpPost("api/PlayerReporting/v1/deviceId")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public IActionResult MismatchedDeviceWarning(
[FromForm(Name = "oldDeviceId")] string? oldDeviceId)
{
logger.LogInformation(
"[mod] mismatched device id reported: caller={Caller} old={Old}",
this.CurrentPlayerId(), oldDeviceId);
return Ok(new { success = true, error = "" });
}
// ── /api/PlayersBanned/v2/ban — admin ban ────────────────────────
[HttpPost("api/PlayersBanned/v2/ban")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> Ban(
[FromForm(Name = "PlayerId")] long? playerId,
[FromForm(Name = "Reason")] int reason = 0,
[FromForm(Name = "BanType")] int banType = 0,
[FromForm(Name = "DisplayReason")] string? displayReason = null,
[FromForm(Name = "BannedUntil")] string? bannedUntil = null)
{
var caller = this.RequireCurrentPlayerId();
if (!await IsCallerAdminOrDevAsync(caller))
return Forbid();
if (playerId is not long target || target <= 0)
return Ok(new { success = false, error = "missing_target" });
// BannedUntil arrives as "MM/dd/yyyy HH:mm" per Moderation.txt:696.
DateTime? until = null;
if (!string.IsNullOrEmpty(bannedUntil) &&
DateTime.TryParseExact(bannedUntil, "MM/dd/yyyy HH:mm",
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal,
out var parsed))
{
until = parsed;
}
var p = await db.Players.FirstOrDefaultAsync(x => x.Id == target);
if (p is null) return Ok(new { success = false, error = "no_such_player" });
p.BannedUntil = until ?? DateTime.UtcNow.AddYears(99);
await db.SaveChangesAsync();
logger.LogInformation(
"[mod] ban by {Caller}: target={Target} reason={Reason} until={Until} display={Display}",
caller, target, reason, p.BannedUntil, displayReason);
// Push a ModerationRoomBan notification so the target watch
// shows the ban dialog and disconnects (PlayerReporting.txt:2253).
await notifications.NotifyAsync(
target,
Models.Notification.PushNotificationId.ModerationRoomBan);
return Ok(new { success = true, error = "" });
}
private async Task<bool> IsCallerAdminOrDevAsync(long callerId) =>
await db.Players
.Where(p => p.Id == callerId)
.Select(p => p.IsAdmin || p.IsDeveloper)
.FirstOrDefaultAsync();
}
@@ -0,0 +1,34 @@
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Notification.V2;
/// <summary>
/// Legacy raw-WebSocket notification endpoint. Replaced by the SignalR
/// hub at <c>notify.{apex}/hub/v1</c>. Returns 410 Gone for any client
/// that still tries the old path.
/// </summary>
[ApiController]
[Route("api/[controller]/v2")]
public class NotificationController(DomainConfig domain) : ControllerBase
{
[HttpGet]
public IActionResult Connect() => StatusCode(StatusCodes.Status410Gone, new
{
error = "use_signalr_hub",
hint = $"connect to {domain.Sub("notify")}/hub/v1 instead",
});
/// <summary>GET <c>api/notification/hub/v1</c> — returns the
/// SignalR hub URL the client should open. Without this the
/// watch falls back to its own default and may try the wrong
/// scheme. URL is built off the configured deployment apex
/// (DORKNET_DOMAIN).</summary>
[HttpGet("/api/notification/hub/v1")]
public IActionResult HubInfo() => Ok(new
{
Url = $"https://{domain.Sub("notify")}/hub/v1",
Protocol = "signalr",
Version = 1,
});
}
@@ -0,0 +1,128 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.NotificationPrefs;
/// <summary>
/// api.rec.net/api/messages/v1/IOSGetNotificationPreferences +
/// IOSModifyNotificationPreferences. Wire DTO matches
/// <c>RecNet.IOSNotificationPreferences</c> (decompiled at
/// <c>Cpp2IL_CS/.../RecNet/IOSNotificationPreferences.cs</c>):
/// <c>ChatMessage(bool), FriendInvite(bool), FavoriteFriendOnline(bool)</c>.
///
/// Persists via <see cref="NotificationPrefsEntity"/> keyed by
/// (PlayerId, Platform). Defaults to all-on when no row exists yet.
/// </summary>
[ApiController]
[Authorize]
public class IOSNotificationPrefsController(DorkNetDbContext db) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
[HttpGet("api/messages/v1/IOSGetNotificationPreferences")]
public async Task<IActionResult> Get()
{
var pid = Me;
var row = await db.NotificationPrefs.FirstOrDefaultAsync(p => p.PlayerId == pid);
return Ok(ToWire(row));
}
public sealed class IOSPrefsRequest
{
public bool ChatMessage { get; set; } = true;
public bool FriendInvite { get; set; } = true;
public bool FavoriteFriendOnline { get; set; } = true;
}
[HttpPost("api/messages/v1/IOSModifyNotificationPreferences")]
public async Task<IActionResult> Modify([FromBody] IOSPrefsRequest body)
{
var pid = Me;
var row = await db.NotificationPrefs.FirstOrDefaultAsync(p => p.PlayerId == pid);
if (row is null)
{
row = new NotificationPrefsEntity
{
PlayerId = pid,
Platform = "ios",
};
db.NotificationPrefs.Add(row);
}
row.AllowMessage = body.ChatMessage;
row.AllowFriendRequest = body.FriendInvite;
row.AllowAnnouncements = body.FavoriteFriendOnline;
row.AllowAll = body.ChatMessage || body.FriendInvite || body.FavoriteFriendOnline;
row.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(row));
}
/// <summary>POST <c>IOSResetNotificationPreferencesBadgeCount</c> —
/// the watch sends this when the user opens the iOS app to clear
/// the badge counter. No persistent state to mutate; ack.</summary>
[HttpPost("api/messages/v1/IOSResetNotificationPreferencesBadgeCount")]
public IActionResult ResetBadge() => Ok(new { Reset = true });
public sealed class SaveTokenRequest
{
public string? Token { get; set; }
public string? DeviceToken { get; set; }
public string? Platform { get; set; }
}
/// <summary>POST <c>api/messages/v1/IOSSaveDeviceToken</c> —
/// upserts the caller's APNS token to
/// <see cref="PushTokenEntity"/> for a future APNS proxy to fan
/// out push notifications.</summary>
[HttpPost("api/messages/v1/IOSSaveDeviceToken")]
public async Task<IActionResult> SaveDeviceToken(
[FromBody] SaveTokenRequest? body,
[FromForm(Name = "Token")] string? tokenForm,
[FromForm(Name = "DeviceToken")] string? deviceTokenForm)
{
var pid = Me;
var token = body?.Token ?? body?.DeviceToken ?? tokenForm ?? deviceTokenForm ?? string.Empty;
var platform = body?.Platform ?? "ios";
if (string.IsNullOrWhiteSpace(token))
return BadRequest(new { error = "missing token" });
var row = await db.PushTokens.FirstOrDefaultAsync(t =>
t.PlayerId == pid && t.Platform == platform);
if (row is null)
{
row = new PushTokenEntity { PlayerId = pid, Platform = platform };
db.PushTokens.Add(row);
}
row.Token = token;
row.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(new { Saved = true });
}
/// <summary>POST <c>api/messages/v1/IOSClearDeviceToken</c> —
/// drops every push-token row for the caller (e.g. on iOS
/// logout).</summary>
[HttpPost("api/messages/v1/IOSClearDeviceToken")]
public async Task<IActionResult> ClearDeviceToken()
{
var pid = Me;
var rows = await db.PushTokens.Where(t => t.PlayerId == pid).ToListAsync();
if (rows.Count > 0)
{
db.PushTokens.RemoveRange(rows);
await db.SaveChangesAsync();
}
return Ok(new { Cleared = rows.Count });
}
private static object ToWire(NotificationPrefsEntity? r) => new
{
ChatMessage = r?.AllowMessage ?? true,
FriendInvite = r?.AllowFriendRequest ?? true,
FavoriteFriendOnline = r?.AllowAnnouncements ?? true,
};
}
@@ -0,0 +1,335 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Photos;
/// <summary>
/// api.rec.net/api/photos/v1/* — social-feed wrapper around the
/// in-game camera. The camera itself uses storage.rec.net/upload with
/// FileType=Image to push the PNG bytes; that returns a Filename. The
/// watch's "Share" panel then POSTs here with that Filename, a caption
/// and any tagged players to create the public-feed photo.
///
/// Public reads (feed, by-player, by-room) are anonymous so the
/// feed.rec.net frontend can browse without an admin token.
/// Mutations (post, cheer, delete) require auth.
/// </summary>
[ApiController]
// Two hosts: api.rec.net is what the in-game watch hits; feed.rec.net
// also serves these endpoints so the public frontend website can do
// same-origin fetches without CORS.
[Route("api/photos/v1")]
public class PhotosController(
DorkNetDbContext db,
PlayerPresenceService presence,
NotificationService notifications,
LevelService level,
DomainConfig domain) : ControllerBase
{
public sealed record PostPhotoRequest(
string ImageName,
string? Caption,
string? TaggedPlayerIds,
long? RoomId,
bool? IsPublic);
/// <summary>POST api/photos/v1/post — promote an uploaded image
/// blob to the photo feed. Body: ImageName (filename returned by
/// storage.rec.net/upload), Caption, TaggedPlayerIds (CSV),
/// optional RoomId (defaults to caller's current room from
/// PlayerPresenceService), IsPublic (defaults true). Returns the
/// created PhotoEntity.</summary>
[HttpPost("post")]
[Authorize]
public async Task<ActionResult> PostPhoto([FromBody] PostPhotoRequest body)
{
var me = this.RequireCurrentPlayerId();
if (string.IsNullOrWhiteSpace(body.ImageName))
return BadRequest(new { error = "missing_image_name" });
// Verify the blob actually exists and was uploaded by us. Without
// this check anyone could cite another player's filename and
// post-attribute the photo to themselves.
var blob = await db.RoomDataBlobs
.Where(b => b.BlobName == body.ImageName)
.Select(b => new { b.UploadedByPlayerId })
.FirstOrDefaultAsync();
if (blob is null)
return NotFound(new { error = "image_blob_not_found" });
if (blob.UploadedByPlayerId != me)
return Forbid();
// Auto-resolve current room if the caller didn't supply one.
var roomId = body.RoomId ?? presence.GetRoom(me)?.RoomId ?? 0;
var photo = new PhotoEntity
{
UploaderPlayerId = me,
BlobName = body.ImageName,
Caption = (body.Caption ?? string.Empty).Trim(),
TaggedPlayerIdsCsv = NormaliseTaggedIds(body.TaggedPlayerIds, exceptId: me),
RoomId = roomId,
IsPublic = body.IsPublic ?? true,
};
db.Photos.Add(photo);
await db.SaveChangesAsync();
// Posting a photo gives a small XP bump — encourages camera use.
await level.AwardXpAsync(me, LevelService.InventionSavedXp, $"photo_posted:{photo.Id}");
// Push to tagged players so their "Photos of me" feed refreshes.
foreach (var taggedId in ParseTagged(photo.TaggedPlayerIdsCsv))
await notifications.NotifyAsync(taggedId,
PushNotificationId.SubscriptionUpdateProfile,
new { Reason = "TaggedInPhoto", PhotoId = photo.Id, From = me });
return Ok(ToDto(photo));
}
/// <summary>GET api/photos/v1/feed — public newest-first feed.
/// Anonymous-safe (so the frontend website can render without a
/// JWT). Soft-deleted and private photos are excluded.</summary>
[HttpGet("feed")]
[AllowAnonymous]
public async Task<ActionResult> Feed([FromQuery] int take = 20, [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 EnrichAsync(rows));
}
/// <summary>GET api/photos/v1/by/{playerId} — photos posted by a
/// specific player. Anonymous-safe. Private photos hidden unless
/// the caller is the uploader (or an admin).</summary>
[HttpGet("by/{playerId:long}")]
[AllowAnonymous]
public async Task<ActionResult> ByPlayer(long playerId,
[FromQuery] int take = 20, [FromQuery] int skip = 0)
{
take = Math.Clamp(take, 1, 100);
skip = Math.Max(0, skip);
var me = this.CurrentPlayerId();
var includePrivate = me == playerId || await IsAdminAsync(me);
var q = db.Photos.Where(p => p.UploaderPlayerId == playerId && p.DeletedAt == null);
if (!includePrivate) q = q.Where(p => p.IsPublic);
var rows = await q
.OrderByDescending(p => p.CreatedAt)
.Skip(skip).Take(take)
.ToListAsync();
return Ok(await EnrichAsync(rows));
}
/// <summary>GET api/photos/v1/of/{playerId} — photos the player
/// is tagged in. Anonymous-safe.</summary>
[HttpGet("of/{playerId:long}")]
[AllowAnonymous]
public async Task<ActionResult> OfPlayer(long playerId,
[FromQuery] int take = 20, [FromQuery] int skip = 0)
{
take = Math.Clamp(take, 1, 100);
skip = Math.Max(0, skip);
var needle = $",{playerId},";
// SQLite EF translates `string.Contains` to LIKE — wrapping the
// CSV with leading/trailing commas avoids "12" matching "112".
var rows = await db.Photos
.Where(p => p.IsPublic && p.DeletedAt == null &&
("," + p.TaggedPlayerIdsCsv + ",").Contains(needle))
.OrderByDescending(p => p.CreatedAt)
.Skip(skip).Take(take)
.ToListAsync();
return Ok(await EnrichAsync(rows));
}
/// <summary>GET api/photos/v1/in/{roomId} — photos taken in a
/// specific room. Anonymous-safe.</summary>
[HttpGet("in/{roomId:long}")]
[AllowAnonymous]
public async Task<ActionResult> InRoom(long roomId,
[FromQuery] int take = 20, [FromQuery] int skip = 0)
{
take = Math.Clamp(take, 1, 100);
skip = Math.Max(0, skip);
var rows = await db.Photos
.Where(p => p.RoomId == roomId && p.IsPublic && p.DeletedAt == null)
.OrderByDescending(p => p.CreatedAt)
.Skip(skip).Take(take)
.ToListAsync();
return Ok(await EnrichAsync(rows));
}
/// <summary>GET api/photos/v1/{id} — single photo detail.
/// Increments ViewCount as a side effect (denormalised for the
/// "trending" sort).</summary>
[HttpGet("{id:long}")]
[AllowAnonymous]
public async Task<ActionResult> Get(long id)
{
var p = await db.Photos.FirstOrDefaultAsync(x => x.Id == id);
if (p is null || p.DeletedAt is not null) return NotFound();
if (!p.IsPublic)
{
var me = this.CurrentPlayerId();
if (me != p.UploaderPlayerId && !await IsAdminAsync(me)) return NotFound();
}
p.ViewCount += 1;
await db.SaveChangesAsync();
var enriched = await EnrichAsync(new[] { p });
return Ok(enriched.First());
}
/// <summary>POST api/photos/v1/{id}/cheer — like a photo. Idempotent
/// per (caller, photo) pair. Pushes a notification to the
/// uploader so their watch can refresh the count.</summary>
[HttpPost("{id:long}/cheer")]
[Authorize]
public async Task<ActionResult> Cheer(long id)
{
var me = this.RequireCurrentPlayerId();
var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == id);
if (photo is null || photo.DeletedAt is not null) return NotFound();
var existing = await db.Cheers.FirstOrDefaultAsync(c =>
c.FromPlayerId == me && c.TargetPhotoId == id &&
c.TargetPlayerId == 0 && c.TargetRoomId == 0);
if (existing is not null) return Ok(new { already_cheered = true });
db.Cheers.Add(new CheerEntity
{
FromPlayerId = me,
TargetPhotoId = id,
});
photo.CheerCount += 1;
await db.SaveChangesAsync();
if (photo.UploaderPlayerId != me)
{
await level.AwardXpAsync(photo.UploaderPlayerId,
LevelService.CheerReceivedXp, $"photo_cheer:{id}");
await notifications.NotifyAsync(photo.UploaderPlayerId,
PushNotificationId.SubscriptionUpdateProfile,
new { Reason = "PhotoCheer", PhotoId = id, From = me });
}
return Ok(new { cheered = true, count = photo.CheerCount });
}
/// <summary>DELETE api/photos/v1/{id} — soft-delete. Allowed for
/// the uploader or an admin. The blob bytes stay in
/// RoomDataBlobs for audit; the row's DeletedAt timestamp hides
/// it from feeds.</summary>
[HttpDelete("{id:long}")]
[Authorize]
public async Task<ActionResult> Delete(long id)
{
var me = this.RequireCurrentPlayerId();
var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == id);
if (photo is null) return NotFound();
if (photo.UploaderPlayerId != me && !await IsAdminAsync(me))
return Forbid();
photo.DeletedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(new { deleted = id });
}
// ── Helpers ──────────────────────────────────────────────────────
/// <summary>Trim/dedupe tagged ids and drop the uploader's own id.
/// CSV in, CSV out.</summary>
private static string NormaliseTaggedIds(string? csv, long exceptId)
{
if (string.IsNullOrWhiteSpace(csv)) return string.Empty;
var ids = csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(s => long.TryParse(s, out var v) ? v : 0L)
.Where(v => v > 0 && v != exceptId)
.Distinct()
.ToList();
return string.Join(",", ids);
}
private static IEnumerable<long> ParseTagged(string? csv) =>
string.IsNullOrWhiteSpace(csv)
? Enumerable.Empty<long>()
: csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(s => long.TryParse(s, out var v) ? v : 0L)
.Where(v => v > 0);
private async Task<bool> IsAdminAsync(long? playerId)
{
if (playerId is not long id) return false;
return await db.Players.Where(p => p.Id == id).Select(p => p.IsAdmin).FirstOrDefaultAsync();
}
/// <summary>Materialise photo rows into the wire DTO shape. One
/// extra DB hit fetches the uploader display names and room names
/// in batch so the JSON has human-readable labels — saves the
/// frontend from a per-row N+1.</summary>
private async Task<List<object>> EnrichAsync(IEnumerable<PhotoEntity> photos)
{
var photoList = photos.ToList();
if (photoList.Count == 0) return new();
var uploaderIds = photoList.Select(p => p.UploaderPlayerId).Distinct().ToList();
var roomIds = photoList.Select(p => p.RoomId).Where(id => id > 0).Distinct().ToList();
var uploaderNames = await db.Players
.Where(p => uploaderIds.Contains(p.Id))
.Select(p => new { p.Id, p.DisplayName, p.Username })
.ToListAsync();
var roomNames = await db.Rooms
.Where(r => roomIds.Contains(r.Id))
.Select(r => new { r.Id, r.Name })
.ToListAsync();
var uMap = uploaderNames.ToDictionary(u => u.Id);
var rMap = roomNames.ToDictionary(r => r.Id);
return photoList.Select(p =>
{
uMap.TryGetValue(p.UploaderPlayerId, out var u);
rMap.TryGetValue(p.RoomId, out var r);
return (object)new
{
p.Id,
p.UploaderPlayerId,
UploaderDisplayName = u?.DisplayName ?? $"Player_{p.UploaderPlayerId}",
UploaderUsername = u?.Username ?? string.Empty,
p.BlobName,
ImageUrl = $"https://{domain.Sub("cdn")}/{p.BlobName}",
p.Caption,
p.RoomId,
RoomName = r?.Name ?? string.Empty,
TaggedPlayerIds = ParseTagged(p.TaggedPlayerIdsCsv).ToArray(),
p.IsPublic,
p.CheerCount,
p.ViewCount,
p.CreatedAt,
};
}).ToList();
}
private object ToDto(PhotoEntity p) => new
{
p.Id,
p.UploaderPlayerId,
p.BlobName,
ImageUrl = $"https://{domain.Sub("cdn")}/{p.BlobName}",
p.Caption,
p.RoomId,
TaggedPlayerIds = ParseTagged(p.TaggedPlayerIdsCsv).ToArray(),
p.IsPublic,
p.CheerCount,
p.ViewCount,
p.CreatedAt,
};
}
@@ -0,0 +1,209 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Auth;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.PlatformLogin.V2;
/// <summary>
/// api.rec.net/api/platformlogin/* — OAuth-style password-grant login.
/// Login.LOGIN_API in dump.cs (line 578249) is "api/platformlogin/" — the
/// version suffix (v2 / v5) is appended by callers. The 2020 build uses v5.
///
/// Request body is application/x-www-form-urlencoded (HTTPUrlEncodedForm in
/// Login.LoginHelper, dump.cs:578319), NOT JSON. Form fields are bound
/// case-insensitively by ASP.NET Core, so the parameter names don't have to
/// match the on-the-wire casing exactly.
///
/// Response is a Login.LoginResponse: { Error, ErrorDescription,
/// AccessToken, RefreshToken } — JSON keys must be PascalCase (the
/// Util.GetKey calls inside Login.LoginResponse.Deserialize pass PascalCase
/// string literals; verified via global-metadata.dat scan).
/// </summary>
[ApiController]
[Route("api/platformlogin")]
public class PlatformLoginController(
PlayerService playerService,
AuthService authService,
ServerSettingsService settings,
DorkNetDbContext db) : ControllerBase
{
// Login.LoginToAccount → POST api/platformlogin/v2 / v5
// Form fields the 2020 client sends are not yet fully confirmed; common
// OAuth-2 password-grant + Rec Room platform fields:
// grant_type, username, password, platform, platformId, deviceId,
// buildTimestamp, version. Bind permissively and ignore extras.
[HttpPost("v2")]
[HttpPost("v5")]
public async Task<ActionResult<LoginResponse>> Login(
[FromForm(Name = "username")] string? Username,
[FromForm(Name = "Name")] string? Name,
[FromForm(Name = "platform")] int Platform = 0,
[FromForm(Name = "platformId")] string? PlatformId = null,
[FromForm(Name = "deviceId")] string? DeviceId = null,
[FromForm(Name = "buildTimestamp")] long BuildTimestamp = 0,
[FromForm(Name = "version")] string? Version = null)
{
var displayName = Username ?? Name ?? PlatformId ?? "Player";
// Admin "signups disabled" gate. Mirrors the same guard on
// /connect/token's device-id fallback in AuthController — if
// the device has no existing account, GetOrCreate would mint a
// new one here, bypassing the toggle. Refuse pre-creation.
if (await settings.AreSignupsDisabledAsync()
&& await playerService.GetByDeviceAsync(DeviceId) is null)
{
return Unauthorized(new LoginResponse
{
Error = "signups_disabled",
ErrorDescription = "Account creation is currently disabled by the server admin.",
});
}
// Account identity is keyed off deviceId. The service handles the
// null/empty case by synthesising a stable id from platformId or a
// GUID — either way, no two unrelated callers ever share an account.
var player = await playerService.GetOrCreateByDeviceAsync(
deviceId: DeviceId,
platform: Platform,
platformId: PlatformId,
displayName: displayName);
var (access, refresh) = authService.GenerateTokenPair(player.Id);
return Ok(new LoginResponse
{
AccessToken = access,
RefreshToken = refresh,
});
}
// Login.DownloadCachedLogins → GET api/platformlogin/cached
// Returns List<Login.CachedLogin>. Empty array means no remembered accounts.
//
// The 2020 client doesn't include a platformId in the path here, but
// BestHTTP appends current-session credentials as query params on these
// bootstrap calls — so we accept platform/platformId via query or form
// and reuse the same lookup as auth.rec.net/cachedlogin/forplatformid.
[HttpGet("cached")]
[HttpGet("cachedlogins")]
[HttpGet("savedlogins")]
[HttpGet("v1/cached")]
[HttpGet("v2/cached")]
public async Task<IActionResult> GetCachedLogins(
[FromQuery] int platform = 0,
[FromQuery] string? platformId = null)
{
var players = await playerService.GetCachedLoginsAsync(platform, platformId);
return Ok(players.Select(p => new CachedLogin
{
Platform = platform,
PlatformId = platformId ?? string.Empty,
AccountId = (int)p.Id,
LastLoginTime = p.LastSeenAt,
RequirePassword = false,
}).ToList());
}
// Login.RemoveCachedLogin → DELETE api/platformlogin/cached
[HttpDelete("cached")]
[HttpDelete("cachedlogins")]
public IActionResult RemoveCachedLogin() => Ok(new { });
// Login.RefreshLogin → POST api/platformlogin/refresh
// Response is Login.RefreshLoginResponse { Token } — single field, the
// new access token. (Note: dump.cs Login.RefreshLoginResponse has only
// "Token", not "AccessToken".)
[HttpPost("refresh")]
public async Task<IActionResult> Refresh()
{
// Refresh requires a valid bearer (access OR refresh JWT); both are
// signed with the same secret so ValidateToken accepts either.
// Refusing to "GetOrCreateAsync(null)" here means a stale install
// can't quietly mint a brand-new account when its refresh token is
// gone — it has to go through the cached-login flow instead.
var auth = Request.Headers.Authorization.ToString();
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return Unauthorized();
var token = auth["Bearer ".Length..].Trim();
var resolved = authService.ValidateToken(token);
if (resolved is not long playerId)
return Unauthorized();
var player = await playerService.GetByIdAsync(playerId);
if (player is null) return Unauthorized();
var (access, _) = authService.GenerateTokenPair(player.Id);
return Ok(new RefreshLoginResponse { Token = access });
}
// Login.LoginToCachedAccount(int accountId) → POST api/platformlogin/cached/{accountId}
// Returns the same Login.LoginResponse shape as the regular login.
[HttpPost("cached/{accountId:long}")]
public async Task<IActionResult> LoginToCachedAccount(
long accountId,
[FromForm] int platform = 0,
[FromForm(Name = "platformId")] string? platformId = null,
[FromForm(Name = "platform_id")] string? platformIdSnake = null)
{
// No GetOrCreateAsync(null) fallback — if the cached id doesn't
// resolve, the cache is stale (another DB instance, deleted account,
// etc.) and we'd rather force the client back to the create-account
// flow than silently mint a new player.
var player = await playerService.GetByIdAsync(accountId);
if (player is null) return Unauthorized();
await playerService.TagPlatformAsync(player.Id, platform, platformId ?? platformIdSnake);
var (access, refresh) = authService.GenerateTokenPair(player.Id);
return Ok(new LoginResponse
{
AccessToken = access,
RefreshToken = refresh,
});
}
// Login.CheckHasPassword → GET api/platformlogin/haspassword
// Client uses ExpectPrimitiveResponse<bool> → Convert.ChangeType
// on the raw response, so we MUST emit a bare boolean literal.
// Real password state lives on PlayerEntity.PasswordHash;
// AuthAccountController on auth.* does the same lookup against the
// authenticated player. We mirror it here so the api.* host also
// returns the truthful value instead of an always-true stub.
[HttpGet("haspassword")]
public async Task<IActionResult> HasPassword()
{
var pid = this.CurrentPlayerId() ?? 0;
if (pid == 0) return Content("false", "application/json");
var player = await playerService.GetByIdAsync(pid);
var has = player?.PasswordHash is { Length: > 0 };
return Content(has ? "true" : "false", "application/json");
}
// Login.CreatePassword / ChangePassword / ResetPassword on auth.*
// already do the real bcrypt write — they're wired in
// AuthAccountController. The api.* path duplicates the route
// without persisting; rather than mirror the full logic here,
// delegate to AuthService.ChangePassword for consistency.
[HttpPost("password")]
[HttpPut("password")]
public async Task<IActionResult> SetPassword([FromForm(Name = "newPassword")] string? newPassword,
[FromForm(Name = "password")] string? legacy)
{
var pid = this.CurrentPlayerId() ?? 0;
if (pid == 0) return Unauthorized();
var pw = newPassword ?? legacy ?? string.Empty;
if (pw.Length < 6) return Ok(new RecNetResult { Success = false, Error = "password_too_short" });
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == pid);
if (player is null) return NotFound();
player.PasswordHash = BCrypt.Net.BCrypt.HashPassword(pw, workFactor: 11);
await db.SaveChangesAsync();
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
[HttpPost("resetpassword")]
public IActionResult ResetPassword()
{
// Real password reset would email a token; on a private
// server with no SMTP this is an explicit no-op (the admin
// SPA "change password" tool is the supported path).
return Ok(new RecNetResult { Success = false, Error = "reset_not_supported" });
}
}
@@ -0,0 +1,99 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.PlayerCheer;
/// <summary>
/// api.{rec.net,localhost}/api/PlayerCheer/v1/* — peer cheer flow.
/// Cheers between players are stored as <see cref="CheerEntity"/>
/// rows keyed by <c>TargetPlayerId</c>. The "selected cheer" badge
/// each player can pin to their profile is stored as a
/// <see cref="PlayerSettingEntity"/> with key <c>SelectedCheer</c>.
/// </summary>
[ApiController]
[Authorize]
public class PlayerCheerController(
DorkNetDbContext db,
NotificationService notifications,
LevelService level) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
/// <summary>POST <c>api/PlayerCheer/v1/create</c> — caller cheers
/// the target player. <c>Type</c> matches the same enum as
/// <c>CheerEntity.Type</c>. Idempotent on (caller, target, type)
/// to match <c>PlayerStateController.CheerPlayer</c>, so the same
/// category click doesn't inflate the count.</summary>
[HttpPost("api/PlayerCheer/v1/create")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> Create(
[FromForm(Name = "TargetAccountId")] long? targetAccountId,
[FromForm(Name = "Type")] int type = 0)
{
if (targetAccountId is not long target || target <= 0)
return Ok(new { success = false, error = "missing_target" });
if (Me == target)
return Ok(new { success = false, error = "cannot_cheer_self" });
var existing = await db.Cheers.FirstOrDefaultAsync(c =>
c.FromPlayerId == Me && c.TargetPlayerId == target &&
c.TargetRoomId == 0 && c.Type == type);
if (existing is not null) return Ok(new { success = true, already_cheered = true });
db.Cheers.Add(new CheerEntity
{
FromPlayerId = Me,
TargetPlayerId = target,
Type = type,
});
await db.SaveChangesAsync();
await level.AwardXpAsync(target, LevelService.CheerReceivedXp, $"cheer_from:{Me}");
await notifications.NotifyAsync(target,
PushNotificationId.SubscriptionUpdateProfile,
new { Reason = "CheerReceived", From = Me, Type = type });
return Ok(new { success = true, error = "" });
}
/// <summary>POST <c>api/PlayerCheer/v1/SetSelectedCheer</c> — pin
/// a cheer category as the caller's selected badge. Stored on the
/// PlayerSetting key <c>SelectedCheer</c> (string-form int).</summary>
[HttpPost("api/PlayerCheer/v1/SetSelectedCheer")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> SetSelectedCheer([FromForm(Name = "Cheer")] int cheer = 0)
{
var existing = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == Me && s.Key == "SelectedCheer");
if (existing is null)
db.PlayerSettings.Add(new PlayerSettingEntity { PlayerId = Me, Key = "SelectedCheer", Value = cheer.ToString() });
else
existing.Value = cheer.ToString();
await db.SaveChangesAsync();
return Ok(new { success = true, error = "" });
}
/// <summary>GET <c>api/PlayerCheer/v1/all</c> — every cheer the
/// caller has received. Drives the profile's "X cheers" badge.</summary>
[HttpGet("api/PlayerCheer/v1/all")]
public async Task<IActionResult> All()
{
var rows = await db.Cheers
.Where(c => c.TargetPlayerId == Me)
.OrderByDescending(c => c.CheeredAt)
.Take(200)
.Select(c => new
{
Id = c.Id,
FromPlayerId = c.FromPlayerId,
Type = c.Type,
CheeredAt = c.CheeredAt,
})
.ToListAsync();
return Ok(rows);
}
}
@@ -0,0 +1,69 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.PlayerElo;
/// <summary>
/// api.rec.net/api/PlayerElo/* — match-result Elo deltas. The 2020
/// client posts after each ranked match (Royale, Paintball, Soccer)
/// with a delta the server applies to the per-player Elo row.
///
/// Wire request matches <c>RecNet.Elo.ReportPlayerElo</c>:
/// <c>{GameMode(int), EloDelta(int), Won(bool)}</c>
/// (we tolerate a few synonym fields). Response: the new Elo as a
/// JSON primitive int.
/// </summary>
[ApiController]
public class PlayerEloController(DorkNetDbContext db) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
public sealed class ReportEloRequest
{
public int GameMode { get; set; }
public int EloDelta { get; set; }
public bool Won { get; set; }
}
[HttpPost("api/PlayerElo/v1/reportPlayerElo")]
[Authorize]
public async Task<IActionResult> ReportPlayerElo([FromBody] ReportEloRequest req)
{
var pid = Me;
var row = await db.PlayerElo.FirstOrDefaultAsync(p =>
p.PlayerId == pid && p.GameMode == req.GameMode);
if (row is null)
{
row = new PlayerEloEntity
{
PlayerId = pid,
GameMode = req.GameMode,
};
db.PlayerElo.Add(row);
}
// Clamp Elo to a reasonable range to avoid overflow exploits.
row.Elo = Math.Clamp(row.Elo + req.EloDelta, 0, 5000);
row.MatchesPlayed += 1;
if (req.Won) row.Wins += 1; else row.Losses += 1;
row.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(row.Elo);
}
[HttpGet("api/PlayerElo/v1/get")]
public async Task<IActionResult> Get(
[FromQuery] long? playerId, [FromQuery] int gameMode = 0)
{
var pid = playerId ?? this.CurrentPlayerId() ?? 0;
if (pid == 0) return Ok(new { Elo = 1000, Wins = 0, Losses = 0 });
var row = await db.PlayerElo.FirstOrDefaultAsync(p =>
p.PlayerId == pid && p.GameMode == gameMode);
return Ok(row is null
? new { Elo = 1000, Wins = 0, Losses = 0 }
: new { row.Elo, row.Wins, row.Losses });
}
}
@@ -0,0 +1,319 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.PlayerEvents;
/// <summary>
/// api.rec.net/api/playerevents/v1/* — create, list, RSVP for
/// player-scheduled gatherings.
/// </summary>
[ApiController]
[Authorize]
public class PlayerEventsController(
DorkNetDbContext db,
NotificationService notifications) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
// ── Browse (v2) ──────────────────────────────────────────────────────
/// <summary>GET <c>api/playerevents/v2</c> — upcoming events list.
/// Returns the next 50 events ordered by start time. Wire shape
/// is the standard PlayerEvent (Id, CreatorPlayerId, RoomId,
/// Title, Description, StartsAt, EndsAt, Capacity).</summary>
[AllowAnonymous]
[HttpGet("api/playerevents/v2")]
public async Task<ActionResult> ListUpcoming(
[FromQuery] int skip = 0, [FromQuery] int take = 50)
{
skip = Math.Max(0, skip);
take = Math.Clamp(take, 1, 100);
var rows = await db.PlayerEvents
.Where(e => e.EndsAt > DateTime.UtcNow)
.OrderBy(e => e.StartsAt)
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[AllowAnonymous]
[HttpGet("api/playerevents/v2/{eventId:long}")]
public async Task<ActionResult> GetOne(long eventId)
{
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId);
if (ev is null) return NotFound();
return Ok(ToWire(ev));
}
/// <summary>GET <c>api/playerevents/v1/all</c> — the watch's
/// <c>LocalPlayerEventInfo</c> fetch (caller's created + RSVP'd
/// events). Response keys (both required, via
/// <c>Util.GetObjectListKey</c>): <c>Created</c>, <c>Responses</c>.</summary>
[AllowAnonymous]
[HttpGet("api/playerevents/v1/all")]
public async Task<ActionResult> AllPlayerEvents()
{
var pid = this.CurrentPlayerId();
if (pid is not long me)
return Ok(new { Created = Array.Empty<object>(), Responses = Array.Empty<object>() });
var created = await db.PlayerEvents
.Where(e => e.CreatorPlayerId == me)
.OrderByDescending(e => e.StartsAt)
.Select(e => new
{
e.Id,
e.Title,
e.Description,
e.RoomId,
e.StartsAt,
e.EndsAt,
e.Capacity,
})
.ToListAsync();
var responses = await db.PlayerEventResponses
.Where(r => r.PlayerId == me)
.Select(r => new
{
r.Id,
r.EventId,
r.Response,
r.CreatedAt,
})
.ToListAsync();
return Ok(new { Created = created, Responses = responses });
}
/// <summary>POST <c>api/playerevents/v1/{eventId}/responses</c> —
/// alternate RSVP route the watch uses for the response-list
/// flow (older / sub-room view). Body is the same RsvpRequest
/// as <c>v1/{id}/rsvp</c>; behaves identically.</summary>
[HttpPost("api/playerevents/v1/{eventId:long}/responses")]
public Task<ActionResult> RespondList(long eventId, [FromBody] RsvpRequest body)
=> Rsvp(eventId, body);
/// <summary>Wire shape verified against
/// <c>Cpp2IL_ISIL/.../RecNet/PlayerEvent.txt:769-815</c>:
/// PascalCase keys <c>PlayerEventId, Name, Description,
/// StartTime, EndTime, CreatorPlayerId, AttendeeCount, RoomId,
/// ImageName, Accessibility</c>. Server-side we store these
/// under different names (Title, StartsAt, EndsAt, Capacity) —
/// remap explicitly here so the watch's
/// <c>PlayerEvent.Deserialize</c> picks them up.</summary>
private static object ToWire(PlayerEventEntity ev) => new
{
PlayerEventId = ev.Id,
Name = ev.Title,
ev.Description,
StartTime = ev.StartsAt,
EndTime = ev.EndsAt,
CreatorPlayerId = (int)ev.CreatorPlayerId,
AttendeeCount = 0, // computed at query time when needed
ev.RoomId,
ImageName = string.Empty,
Accessibility = 1, // Public
};
public sealed record CreateEventRequest(
string Title, string? Description, long RoomId,
DateTime StartsAt, DateTime EndsAt, int Capacity);
[HttpPost("api/playerevents/v1/create")]
[HttpPost("api/playerevents/v1")]
public async Task<ActionResult> Create([FromBody] CreateEventRequest req)
{
if (string.IsNullOrWhiteSpace(req.Title))
return BadRequest("missing_title");
var ev = new PlayerEventEntity
{
CreatorPlayerId = Me,
RoomId = req.RoomId,
Title = req.Title.Trim(),
Description = (req.Description ?? string.Empty).Trim(),
StartsAt = req.StartsAt,
EndsAt = req.EndsAt > req.StartsAt ? req.EndsAt : req.StartsAt.AddHours(1),
Capacity = Math.Max(0, req.Capacity),
};
db.PlayerEvents.Add(ev);
await db.SaveChangesAsync();
return Ok(new
{
ev.Id, ev.Title, ev.Description, ev.RoomId,
ev.StartsAt, ev.EndsAt, ev.Capacity,
});
}
public sealed record RsvpRequest(int Response);
[HttpPost("api/playerevents/v1/{eventId:long}/rsvp")]
public async Task<ActionResult> Rsvp(long eventId, [FromBody] RsvpRequest body)
{
if (body.Response < 0 || body.Response > 2)
return BadRequest("invalid_response");
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId);
if (ev is null) return NotFound();
var row = await db.PlayerEventResponses
.FirstOrDefaultAsync(r => r.EventId == eventId && r.PlayerId == Me);
if (row is null)
{
row = new PlayerEventResponseEntity { EventId = eventId, PlayerId = Me };
db.PlayerEventResponses.Add(row);
}
row.Response = body.Response;
await db.SaveChangesAsync();
// Tell the creator someone RSVP'd. They can ignore but the
// watch will refresh their event detail card.
if (ev.CreatorPlayerId != Me)
await notifications.NotifyAsync(ev.CreatorPlayerId,
PushNotificationId.PlayerEventResponseChanged,
new { ev.Id, From = Me, body.Response });
return Ok(new { row.EventId, row.PlayerId, row.Response });
}
/// <summary>POST <c>api/playerevents/v1/respond</c> — set the
/// caller's RSVP state for an event. <c>Response</c> is the
/// <c>PlayerEventResponseType</c> enum (0=Going, 1=Maybe, 2=Pass).
/// Idempotent — re-responding overwrites.</summary>
[HttpPost("api/playerevents/v1/respond")]
public async Task<IActionResult> RespondForm(
[FromForm(Name = "EventId")] long? eventId,
[FromForm(Name = "Response")] int response = 0)
{
if (eventId is not long evt || evt <= 0) return Ok(new { success = false });
var existing = await db.PlayerEventResponses
.FirstOrDefaultAsync(r => r.PlayerId == Me && r.EventId == evt);
if (existing is null)
db.PlayerEventResponses.Add(new PlayerEventResponseEntity { PlayerId = Me, EventId = evt, Response = response });
else
existing.Response = response;
await db.SaveChangesAsync();
return Ok(new { success = true, error = "" });
}
/// <summary>POST <c>api/playerevents/v1/deleteResponse</c> — drop
/// the caller's RSVP row.</summary>
[HttpPost("api/playerevents/v1/deleteResponse")]
public async Task<IActionResult> DeleteResponseForm([FromForm(Name = "EventId")] long? eventId)
{
if (eventId is not long evt) return Ok(new { success = true });
await db.PlayerEventResponses
.Where(r => r.PlayerId == Me && r.EventId == evt)
.ExecuteDeleteAsync();
return Ok(new { success = true, error = "" });
}
/// <summary>DELETE <c>api/playerevents/v2/delete/{id}</c> — hard
/// delete an event the caller created.</summary>
[HttpDelete("api/playerevents/v2/delete/{id:long}")]
public async Task<IActionResult> DeleteByPath(long id)
{
var evt = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == id && e.CreatorPlayerId == Me);
if (evt is null) return NotFound();
db.PlayerEvents.Remove(evt);
await db.PlayerEventResponses.Where(r => r.EventId == id).ExecuteDeleteAsync();
await db.SaveChangesAsync();
return Ok(new { success = true, error = "" });
}
[HttpDelete("api/playerevents/v1/{eventId:long}")]
public async Task<ActionResult> Delete(long eventId)
{
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId);
if (ev is null) return NotFound();
if (ev.CreatorPlayerId != Me) return Forbid();
db.PlayerEvents.Remove(ev);
// Cascade-clean the responses; SQLite has no FK cascade
// configured here so do it manually.
var responses = db.PlayerEventResponses.Where(r => r.EventId == eventId);
db.PlayerEventResponses.RemoveRange(responses);
await db.SaveChangesAsync();
return Ok();
}
// ── Bulk-invite (Phase 8) ────────────────────────────────────────────
public sealed class BulkInviteRequest
{
public long PlayerEventId { get; set; }
public List<int>? InvitedPlayerIds { get; set; }
}
/// <summary>POST <c>/api/playerevents/v1/bulkInvite</c> — wire
/// type per agent ISIL extraction: <c>BulkInviteRequest{PlayerEventId,
/// InvitedPlayerIds:List&lt;int&gt;}</c>; response
/// <c>BulkInviteResponse{FailedInvites,Result(CreateModifyPlayerEventStatus)}</c>.
/// We insert one MessageEntity per recipient so the watch's inbox
/// shows the invite, and push a per-recipient notification.</summary>
[HttpPost("api/playerevents/v1/bulkInvite")]
public async Task<IActionResult> BulkInvite([FromBody] BulkInviteRequest req)
{
if (req?.InvitedPlayerIds is null || req.InvitedPlayerIds.Count == 0)
return Ok(new { FailedInvites = Array.Empty<object>(), Result = 0 });
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == req.PlayerEventId);
if (ev is null) return NotFound();
if (ev.CreatorPlayerId != Me) return Forbid();
var failed = new List<object>();
var sender = Me;
var inviteText = $"You're invited to '{ev.Title}' starting {ev.StartsAt:u}.";
foreach (var rid in req.InvitedPlayerIds.Distinct())
{
var exists = await db.Players.AnyAsync(p => p.Id == rid);
if (!exists)
{
failed.Add(new { PlayerId = rid, Error = "unknown player" });
continue;
}
db.Messages.Add(new MessageEntity
{
SenderPlayerId = sender,
RecipientPlayerId = rid,
Body = inviteText,
});
await notifications.NotifyAsync(rid,
PushNotificationId.PlayerEventCreated,
new { ev.Id, ev.Title, ev.StartsAt, From = sender });
}
await db.SaveChangesAsync();
return Ok(new { FailedInvites = failed, Result = 0 });
}
// ── Report (Phase 8) ─────────────────────────────────────────────────
public sealed class PlayerEventReportRequest
{
public int ReportCategory { get; set; }
public long PlayerEventId { get; set; }
public string? Details { get; set; }
}
[HttpPost("api/playerevents/v1/report")]
public async Task<IActionResult> Report([FromBody] PlayerEventReportRequest req)
{
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == req.PlayerEventId);
if (ev is null) return NotFound();
var msg = (req.Details ?? string.Empty).Trim();
db.Reports.Add(new ReportEntity
{
ReporterPlayerId = Me,
TargetPlayerId = ev.CreatorPlayerId,
TargetEventId = req.PlayerEventId,
Category = req.ReportCategory,
Message = msg[..Math.Min(1000, msg.Length)],
});
await db.SaveChangesAsync();
return Ok(new { Reported = true });
}
}
@@ -0,0 +1,210 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.PlayerReporting;
/// <summary>
/// api.rec.net/api/PlayerReporting/v1/* — real player report flow.
/// The 2020 client's "report player" UI in the watch posts here.
/// Form fields the watch sends (per the in-game send_report dialog):
/// <c>TargetPlayerId</c> — int account id of the offender
/// <c>Type</c> — RoomieReportCategory enum (1..5)
/// <c>Message</c> — free-form description (capped at 1000)
/// <c>GameSessionId</c> — optional, current session for context
/// <c>RoomId</c> — optional, room the offence happened in
///
/// Anyone authenticated can file a report. Admins read the queue
/// from <c>GET api/admin/v1/reports</c>.
/// </summary>
[ApiController]
[Authorize]
public class ReportsController(DorkNetDbContext db) : ControllerBase
{
[HttpPost("api/PlayerReporting/v1/report")]
[HttpPost("api/PlayerReporting/v1/playerreport")]
[HttpPost("api/PlayerReporting/v2/report")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> SubmitReport(
[FromForm(Name = "TargetPlayerId")] long? targetPlayerId,
[FromForm(Name = "Type")] int type = 0,
[FromForm(Name = "Message")] string? message = null,
[FromForm(Name = "GameSessionId")] long gameSessionId = 0,
[FromForm(Name = "RoomId")] long roomId = 0)
{
if (targetPlayerId is not long target || target <= 0)
return BadRequest(new { success = false, error = "missing_target" });
var reporter = this.RequireCurrentPlayerId();
if (reporter == target)
return BadRequest(new { success = false, error = "cannot_report_self" });
// Cap message length to match the watch UI's enforcement +
// the column max — anything longer is almost certainly junk.
var trimmed = (message ?? string.Empty).Trim();
if (trimmed.Length > 1000) trimmed = trimmed[..1000];
db.Reports.Add(new ReportEntity
{
ReporterPlayerId = reporter,
TargetPlayerId = target,
Category = type,
Message = trimmed,
GameSessionId = gameSessionId,
RoomId = roomId,
});
await db.SaveChangesAsync();
return Ok(new { success = true, error = "" });
}
/// <summary>GET <c>api/PlayerReporting/v2/detail?playerId={id}</c>
/// — moderator detail panel. Lists recent reports against the
/// target player. Public — admin-only mutations are elsewhere.</summary>
[HttpGet("api/PlayerReporting/v2/detail")]
public async Task<IActionResult> ReportDetail(
[FromQuery] long? playerId, [FromQuery] long? accountId)
{
var pid = playerId ?? accountId ?? 0;
if (pid <= 0) return Ok(new { PlayerId = 0L, OpenReports = 0, RecentReports = Array.Empty<object>() });
var reports = await db.Reports
.Where(r => r.TargetPlayerId == pid)
.OrderByDescending(r => r.CreatedAt)
.Take(20)
.ToListAsync();
return Ok(new
{
PlayerId = pid,
OpenReports = reports.Count(r => r.ResolvedAt == null),
RecentReports = reports.Select(r => new
{
r.Id, r.Category, r.Message,
r.ReporterPlayerId, r.RoomId, r.CreatedAt, r.ResolvedAt,
}),
});
}
public sealed class V2BanRequest
{
public long PlayerId { get; set; }
public int? Category { get; set; }
public string? Reason { get; set; }
public DateTime? Until { get; set; }
}
/// <summary>POST <c>api/PlayerReporting/v2/ban</c> — admin-only.
/// Sets <see cref="PlayerEntity.BannedUntil"/> + appends a
/// resolved <see cref="ReportEntity"/> for the audit trail.</summary>
[HttpPost("api/PlayerReporting/v2/ban")]
[Authorize]
public async Task<IActionResult> V2Ban([FromBody] V2BanRequest req)
{
var me = this.RequireCurrentPlayerId();
if (req.PlayerId <= 0) return BadRequest(new { error = "missing playerId" });
var isAdmin = await db.Players
.Where(p => p.Id == me)
.Select(p => p.IsAdmin)
.FirstOrDefaultAsync();
if (!isAdmin) return Forbid();
var target = await db.Players.FirstOrDefaultAsync(p => p.Id == req.PlayerId);
if (target is null) return NotFound();
target.BannedUntil = req.Until ?? DateTime.UtcNow.AddYears(100);
var reason = req.Reason ?? string.Empty;
if (reason.Length > 1000) reason = reason[..1000];
db.Reports.Add(new ReportEntity
{
ReporterPlayerId = me,
TargetPlayerId = req.PlayerId,
Category = req.Category ?? 5,
Message = reason,
ResolvedAt = DateTime.UtcNow,
ResolverAdminId = me,
ResolutionNote = "v2/ban",
});
await db.SaveChangesAsync();
return Ok(new { Banned = true, target.Id, target.BannedUntil });
}
public sealed class HileRequest
{
public string? Detail { get; set; }
public string? Body { get; set; }
public string? Message { get; set; }
public int? Type { get; set; }
public long? GameSessionId { get; set; }
}
/// <summary>POST <c>api/PlayerReporting/v1/hile</c> — anti-cheat
/// heartbeat. Stored as a <see cref="BugReportEntity"/> tagged
/// <c>hile</c> so admins can audit the stream.</summary>
[HttpPost("api/PlayerReporting/v1/hile")]
public async Task<IActionResult> Hile()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
return await SaveHileAsync(
form["Message"].FirstOrDefault() ??
form["Detail"].FirstOrDefault() ??
form["Body"].FirstOrDefault(),
int.TryParse(form["Type"].FirstOrDefault(), out var t) ? t : (int?)null,
long.TryParse(form["GameSessionId"].FirstOrDefault(), out var g) ? g : (long?)null);
}
HileRequest? body = null;
try
{
body = await JsonSerializer.DeserializeAsync<HileRequest>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (JsonException) { }
return await SaveHileAsync(
body?.Message ?? body?.Detail ?? body?.Body,
body?.Type,
body?.GameSessionId);
}
/// <summary>POST <c>api/PlayerReporting/v1/log</c> — bare client
/// telemetry log. Persisted as a low-priority BugReport for admin
/// visibility but never acted on automatically.</summary>
[HttpPost("api/PlayerReporting/v1/log")]
public async Task<IActionResult> ReportLog()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
await SaveHileAsync(
form["Message"].FirstOrDefault() ?? form["Body"].FirstOrDefault(),
null, null);
}
return Ok(new { success = true, error = "" });
}
private async Task<IActionResult> SaveHileAsync(string? rawDetail, int? type, long? gameSessionId)
{
var pid = this.CurrentPlayerId();
if (pid is not long me) return Ok(new { success = true });
var detail = (rawDetail ?? string.Empty).Trim();
if (type is int hileType)
detail = detail.Length == 0 ? $"Type={hileType}" : $"Type={hileType}; {detail}";
if (detail.Length > 4000) detail = detail[..4000];
db.BugReports.Add(new BugReportEntity
{
ReporterPlayerId = me,
Title = "hile heartbeat",
Body = detail,
GameSessionId = gameSessionId ?? 0,
Category = "hile",
});
await db.SaveChangesAsync();
return Ok(new { success = true });
}
}
@@ -0,0 +1,227 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Notification;
using DorkNet.Models.Players;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.PlayerState;
/// <summary>
/// api.rec.net endpoints that return player-state objects whose Deserialize
/// methods do strict required-key reads via Util.GetKey&lt;T&gt;. The previous
/// catch-all returned {} or {"Success":true} for these, which crashed the
/// client with KeyNotFoundException at:
/// RecNet.Progression.Deserialize → "PlayerId", "Level", "XP"
/// RecNet.Reputation.Deserialize → "AccountId", "Noteriety" (sic), Cheer*, Sub*
/// RecNet.ModerationBlockDetail.Deserialize → "ReportCategory", "Duration", "GameSessionId", "Message"
///
/// Each endpoint here returns the minimum-viable shape (zeros / empty
/// strings) that satisfies every required key. Real values can be plugged
/// in later when persistence is added for these.
///
/// Specific routes here win over the api/PlayerReporting/{*path} and
/// api.rec.net catch-alls.
/// </summary>
[ApiController]
public class PlayerStateController(
DorkNetDbContext db,
NotificationService notifications,
LevelService level) : ControllerBase
{
// RecNet.Progressions.GetProgressionById / GetMyProgression — real
// values from PlayerEntity.Level / .XP. Default 1/0 when the
// account hasn't been seen yet (synthetic ids passed in queries).
[HttpGet("api/players/v1/progression/{accountId:long}")]
public async Task<ActionResult<Progression>> GetProgression(long accountId)
{
var p = await db.Players
.Where(p => p.Id == accountId)
.Select(p => new { p.Id, p.Level, p.XP })
.FirstOrDefaultAsync();
return Ok(new Progression
{
PlayerId = (int)(p?.Id ?? accountId),
Level = p?.Level ?? 1,
XP = p?.XP ?? 0,
});
}
[HttpPost("api/players/v1/progression/bulk")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<ActionResult<List<Progression>>> GetProgressionBulk(
[FromForm(Name = "Ids")] string? ids)
{
var idList = ParseIds(ids).Select(i => (long)i).ToList();
if (idList.Count == 0) return Ok(new List<Progression>());
var rows = await db.Players
.Where(p => idList.Contains(p.Id))
.Select(p => new Progression
{
PlayerId = (int)p.Id,
Level = p.Level,
XP = p.XP,
})
.ToListAsync();
// Synthesise zeros for any ids the watch asked about that
// don't exist in our DB — keeps array length stable.
var found = rows.Select(r => r.PlayerId).ToHashSet();
rows.AddRange(idList
.Where(id => !found.Contains((int)id))
.Select(id => new Progression { PlayerId = (int)id }));
return Ok(rows);
}
// RecNet.Reputations.GetReputationById — aggregates CheerEntity
// by category. Each Cheer* count is the sum of distinct cheers
// received from other players in that category.
[HttpGet("api/playerReputation/v1/{accountId:long}")]
public async Task<ActionResult<Reputation>> GetReputation(long accountId) =>
Ok(await BuildReputationAsync(accountId));
[HttpPost("api/playerReputation/v1/bulk")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<ActionResult<List<Reputation>>> GetReputationBulk(
[FromForm(Name = "Ids")] string? ids)
{
var idList = ParseIds(ids).Select(i => (long)i).ToList();
var result = new List<Reputation>(idList.Count);
foreach (var id in idList)
result.Add(await BuildReputationAsync(id));
return Ok(result);
}
/// <summary>POST api/playerReputation/v1/cheer/{playerId} — cheer
/// another player. Idempotent on (caller, target, type) so
/// repeat-clicking the same category doesn't inflate the count.
/// Pushes a presence-update notification so the target's watch
/// refreshes their cheer total.</summary>
[HttpPost("api/playerReputation/v1/cheer/{playerId:long}")]
[Authorize]
public async Task<ActionResult> CheerPlayer(long playerId, [FromQuery] int type = 0)
{
var me = this.RequireCurrentPlayerId();
if (me == playerId) return BadRequest("cannot_cheer_self");
var existing = await db.Cheers.FirstOrDefaultAsync(c =>
c.FromPlayerId == me && c.TargetPlayerId == playerId &&
c.TargetRoomId == 0 && c.Type == type);
if (existing is not null) return Ok(new { already_cheered = true });
db.Cheers.Add(new CheerEntity
{
FromPlayerId = me,
TargetPlayerId = playerId,
Type = type,
});
await db.SaveChangesAsync();
// Reward the cheered player with a small XP bump.
await level.AwardXpAsync(playerId, LevelService.CheerReceivedXp, $"cheer_from:{me}");
await notifications.NotifyAsync(playerId,
PushNotificationId.SubscriptionUpdateProfile,
new { Reason = "CheerReceived", From = me, Type = type });
return Ok();
}
/// <summary>POST api/rooms/v1/cheer/{roomId} — cheer a room.
/// Same idempotency rules as player cheers; bumps
/// <see cref="RoomEntity.CheerCount"/> as a denormalised counter
/// so the watch's "Hot" rooms feed doesn't have to JOIN-aggregate
/// on every request.</summary>
[HttpPost("api/rooms/v1/cheer/{roomId:long}")]
[Authorize]
public async Task<ActionResult> CheerRoom(long roomId, [FromQuery] int type = 0)
{
var me = this.RequireCurrentPlayerId();
var existing = await db.Cheers.FirstOrDefaultAsync(c =>
c.FromPlayerId == me && c.TargetRoomId == roomId &&
c.TargetPlayerId == 0 && c.Type == type);
if (existing is not null) return Ok(new { already_cheered = true });
db.Cheers.Add(new CheerEntity
{
FromPlayerId = me,
TargetRoomId = roomId,
Type = type,
});
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is not null)
{
room.CheerCount += 1;
// Cheers feed the Hot-tab ranking — weight 5x a first
// visit so the "Trending" feed reflects what players
// actually like, not just what they passed through.
room.HotScore += 10.0;
}
await db.SaveChangesAsync();
return Ok();
}
private async Task<Reputation> BuildReputationAsync(long accountId)
{
var rows = await db.Cheers
.Where(c => c.TargetPlayerId == accountId)
.GroupBy(c => c.Type)
.Select(g => new { Type = g.Key, Count = g.Count() })
.ToListAsync();
int Count(int type) => rows.FirstOrDefault(r => r.Type == type)?.Count ?? 0;
var subscriberCount = await db.Subscriptions
.CountAsync(s => s.TargetPlayerId == accountId);
var subscribedCount = await db.Subscriptions
.CountAsync(s => s.SubscriberPlayerId == accountId);
// SelectedCheer is the pinned badge category the player chose
// via PlayerCheerController.SetSelectedCheer. Stored as a
// PlayerSettingEntity with key "SelectedCheer" and Value = the
// category int as string. Null when unset — UI just shows no
// pinned badge.
var selected = await db.PlayerSettings
.Where(s => s.PlayerId == accountId && s.Key == "SelectedCheer")
.Select(s => s.Value)
.FirstOrDefaultAsync();
int? selectedCheer = int.TryParse(selected, out var sc) ? sc : null;
return new Reputation
{
AccountId = (int)accountId,
Notoriety = rows.Sum(r => r.Count),
CheerGeneral = Count(0),
CheerHelpful = Count(1),
CheerGreatHost = Count(2),
CheerSportsman = Count(3),
CheerCreative = Count(4),
CheerCredit = Count(5),
SelectedCheer = selectedCheer,
SubscriberCount = subscriberCount,
SubscribedCount = subscribedCount,
};
}
/// <summary>
/// "1,2,3" → [1, 2, 3]. The client formats Ids as a comma-separated
/// string when calling AddField on the form. Tolerant of empty input
/// and whitespace.
/// </summary>
private static IEnumerable<int> ParseIds(string? ids) =>
string.IsNullOrWhiteSpace(ids)
? Enumerable.Empty<int>()
: ids.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(s => int.TryParse(s, out var v) ? v : 0)
.Where(v => v != 0);
// RecNet.PlayerReporting.GetModerationBlockDetails
// IMPORTANT: returns a single object, not an array. Defaults indicate
// "user is not currently blocked / kicked".
// ASP.NET Core route matching is case-insensitive, so a single attribute
// covers both "PlayerReporting" and "playerreporting" callers.
[HttpGet("api/PlayerReporting/v1/moderationBlockDetails")]
public ActionResult<ModerationBlockDetail> GetModerationBlockDetails() =>
Ok(new ModerationBlockDetail());
}
@@ -0,0 +1,83 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.PlayerSubscriptions;
/// <summary>
/// api.rec.net/api/playersubscriptions/v1/* — asymmetric follow
/// graph. The <c>my</c> endpoint returns who the caller follows;
/// subscribe/unsubscribe mutate the row directly. Reputation
/// aggregates pull SubscriberCount / SubscribedCount counts from
/// here.
/// </summary>
[ApiController]
[Route("api/[controller]/v1")]
[Authorize]
public class PlayerSubscriptionsController(
DorkNetDbContext db,
NotificationService notifications) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
[HttpGet("my")]
public async Task<ActionResult> My()
{
var subs = await db.Subscriptions
.Where(s => s.SubscriberPlayerId == Me)
.Select(s => new { s.TargetPlayerId, s.CreatedAt })
.ToListAsync();
return Ok(subs);
}
[HttpGet("{playerId:long}/subscribers")]
public async Task<ActionResult> Subscribers(long playerId)
{
var subs = await db.Subscriptions
.Where(s => s.TargetPlayerId == playerId)
.Select(s => new { s.SubscriberPlayerId, s.CreatedAt })
.ToListAsync();
return Ok(subs);
}
[HttpPost("subscribe/{targetId:long}")]
public async Task<ActionResult> Subscribe(long targetId)
{
if (targetId == Me) return BadRequest("cannot_subscribe_to_self");
var existing = await db.Subscriptions.FirstOrDefaultAsync(s =>
s.SubscriberPlayerId == Me && s.TargetPlayerId == targetId);
if (existing is not null) return Ok(new { already_subscribed = true });
db.Subscriptions.Add(new SubscriptionEntity
{
SubscriberPlayerId = Me,
TargetPlayerId = targetId,
});
await db.SaveChangesAsync();
// Tell the target their subscriber count changed so the watch
// refreshes their profile page if open.
await notifications.NotifyAsync(targetId,
PushNotificationId.SubscriptionUpdateProfile,
new { Reason = "NewSubscriber", From = Me });
return Ok();
}
[HttpDelete("subscribe/{targetId:long}")]
[HttpPost("unsubscribe/{targetId:long}")]
public async Task<ActionResult> Unsubscribe(long targetId)
{
var row = await db.Subscriptions.FirstOrDefaultAsync(s =>
s.SubscriberPlayerId == Me && s.TargetPlayerId == targetId);
if (row is null) return NotFound();
db.Subscriptions.Remove(row);
await db.SaveChangesAsync();
return Ok();
}
}
@@ -0,0 +1,68 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using DorkNet.Models.Players;
using DorkNet.Server.Auth;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Players.V2;
[ApiController]
[Route("api/[controller]/v2")]
[Authorize]
public class PlayersController(PlayerService playerService, NotificationService notificationService) : ControllerBase
{
private long CurrentPlayerId => this.RequireCurrentPlayerId();
[HttpGet("me")]
public async Task<ActionResult<PlayerProfile>> GetSelf()
{
var player = await playerService.GetByIdAsync(CurrentPlayerId);
if (player is null) return NotFound();
return Ok(PlayerService.ToProfile(player));
}
[HttpGet("{id:long}")]
public async Task<ActionResult<PlayerProfile>> GetById(long id)
{
var player = await playerService.GetByIdAsync(id);
if (player is null) return NotFound();
return Ok(PlayerService.ToProfile(player));
}
[HttpGet("search")]
public async Task<ActionResult<List<PlayerProfile>>> Search([FromQuery] string query)
{
if (string.IsNullOrWhiteSpace(query)) return Ok(new List<PlayerProfile>());
var results = await playerService.SearchAsync(query);
return Ok(results.Select(PlayerService.ToProfile).ToList());
}
[HttpPost("displayName")]
public async Task<ActionResult<PlayerProfile>> UpdateDisplayName([FromForm] string? DisplayName)
{
if (string.IsNullOrWhiteSpace(DisplayName))
return BadRequest("DisplayName is required");
var updated = await playerService.UpdateDisplayNameAsync(CurrentPlayerId, DisplayName);
if (!updated) return NotFound();
var player = await playerService.GetByIdAsync(CurrentPlayerId);
await notificationService.NotifyAsync(CurrentPlayerId, Models.Notification.PushNotificationId.SubscriptionUpdateProfile, PlayerService.ToProfile(player!));
return Ok(PlayerService.ToProfile(player!));
}
[HttpPut("{id:long}")]
public async Task<ActionResult<PlayerProfile>> UpdateProfile(long id, [FromBody] UpdateProfileRequest req)
{
if (id != CurrentPlayerId) return Forbid();
if (req.DisplayName is not null)
await playerService.UpdateDisplayNameAsync(id, req.DisplayName);
if (req.Bio is not null)
await playerService.UpdateBioAsync(id, req.Bio);
var player = await playerService.GetByIdAsync(id);
return Ok(PlayerService.ToProfile(player!));
}
}
@@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Data;
namespace DorkNet.Server.Controllers.API.PlayersBanned;
/// <summary>
/// api.{rec.net,localhost}/api/PlayersBanned/v1/all — current global
/// ban list. The watch's mod panel queries this on open.
/// </summary>
[ApiController]
public class PlayersBannedController(DorkNetDbContext db) : ControllerBase
{
[HttpGet("api/PlayersBanned/v1/all")]
public async Task<IActionResult> All()
{
var now = DateTime.UtcNow;
var rows = await db.Players
.Where(p => p.BannedUntil != null && p.BannedUntil > now)
.Select(p => new
{
PlayerId = (int)p.Id,
p.Username,
BannedUntil = p.BannedUntil,
})
.ToListAsync();
return Ok(rows);
}
}
@@ -0,0 +1,554 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.ProgressionApi;
/// <summary>
/// api.rec.net/api/{challenge,checklist,objectives}/* — daily
/// challenges, onboarding checklist, and the full objectives surface
/// (myprogress, cleargroup, updateobjective, completegroup). All
/// concepts share the same persistence model:
/// <see cref="ObjectiveProgressEntity"/> rows keyed by a prefix:
/// <c>challenge:{id}</c>, <c>checklist:{id}</c>, <c>group:{id}</c>,
/// <c>obj:{group}:{index}</c>.
/// </summary>
[ApiController]
public class ProgressionController(DorkNetDbContext db, LevelService level) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
private long? MeOrNull => this.CurrentPlayerId();
private const int RecCenterTokens = 2;
// ── Challenges ───────────────────────────────────────────────────────
/// <summary>GET <c>api/challenge/v{1,2}/getCurrent</c> — returns
/// the active daily-challenge map (NOT a flat challenge object).
/// Verified against
/// <c>Cpp2IL_ISIL/.../RecNet/Challenges.txt:105-184</c>
/// (<c>GetCurrentChallenges</c> hits this URL and parses as
/// <c>ChallengeMap</c>). Wire shape per
/// <c>RecNet/ChallengeMap.txt:540-616</c>:
///
/// <c>ChallengeMapId</c> (int, required), <c>CompletedRequired</c>
/// (bool, required), <c>StartAt</c>/<c>EndAt</c>/<c>ServerTime</c>
/// (DateTime, required), <c>Challenges</c> (List&lt;RecNetChallenge&gt;,
/// required), <c>Gift</c> (ChallengeGift, required),
/// <c>ChallengeThemeString</c> (string, optional).
///
/// Each <c>RecNetChallenge</c> requires only <c>ChallengeId</c>;
/// <c>Name</c>/<c>Config</c>/<c>Description</c>/<c>Tooltip</c>/<c>Complete</c>
/// are optional. Each <c>ChallengeGift</c> requires <c>GiftDropId</c>,
/// <c>Xp</c>, <c>Level</c>, <c>GiftContext</c>, <c>GiftRarity</c>.
///
/// Previous flat-challenge response crashed
/// <c>ChallengeMap.Deserialize</c> at <c>Util.GetKey("ChallengeMapId")</c>.</summary>
[HttpGet("api/challenge/v1/getCurrent")]
[HttpGet("api/challenge/v2/getCurrent")]
public async Task<IActionResult> CurrentChallenge()
{
var pid = MeOrNull;
var now = DateTime.UtcNow;
var start = now.Date.AddDays(-(int)now.DayOfWeek);
var mapId = CurrentChallengeMapId();
var keys = new[] { $"challenge:{mapId}:0", $"challenge:{mapId}:1", $"challenge:{mapId}:2" };
var rows = pid is long me
? await db.ObjectiveProgress
.Where(o => o.PlayerId == me && keys.Contains(o.Key))
.ToDictionaryAsync(o => o.Key, o => o.IsCompleted)
: new Dictionary<string, bool>();
bool Done(int idx) => rows.TryGetValue(keys[idx], out var v) && v;
return Ok(new
{
ChallengeMapId = mapId,
CompletedRequired = false,
StartAt = start,
EndAt = start.AddDays(7),
ServerTime = now,
ChallengeThemeString = "Weekly",
Challenges = new[]
{
new { ChallengeId = mapId * 10 + 0, Name = "Visit any room",
Config = "{\"Goal\":1}", Description = "Step into any room this week.",
Tooltip = "", Complete = Done(0) },
new { ChallengeId = mapId * 10 + 1, Name = "Cheer a player",
Config = "{\"Goal\":1}", Description = "Send a cheer to anyone you meet this week.",
Tooltip = "", Complete = Done(1) },
new { ChallengeId = mapId * 10 + 2, Name = "Finish an activity",
Config = "{\"Goal\":1}", Description = "Complete any activity this week.",
Tooltip = "", Complete = Done(2) },
},
Gift = new
{
GiftDropId = mapId,
AvatarItemDesc = "",
ConsumableItemDesc = "",
EquipmentPrefabName = "",
EquipmentModificationGuid = "",
Xp = 100,
Level = 1,
GiftContext = 0,
GiftRarity = 0,
},
});
}
public sealed class UpdateChallengeRequest
{
public int Id { get; set; }
public int ChallengeMapId { get; set; }
public int ChallengeId { get; set; }
public int Progress { get; set; }
}
[HttpPost("api/challenge/v2/updateProgress")]
[Authorize]
public async Task<IActionResult> UpdateChallengeProgress([FromForm] UpdateChallengeRequest formReq)
{
var pid = Me;
var req = await ReadChallengeRequestAsync(formReq);
var mapId = req.ChallengeMapId != 0 ? req.ChallengeMapId : CurrentChallengeMapId();
var challengeId = req.ChallengeId != 0 ? req.ChallengeId : req.Id;
var index = challengeId >= mapId * 10 && challengeId < mapId * 10 + 10
? challengeId - mapId * 10
: Math.Abs(challengeId % 10);
index = Math.Clamp(index, 0, 2);
var key = $"challenge:{mapId}:{index}";
var row = await db.ObjectiveProgress.FirstOrDefaultAsync(o => o.PlayerId == pid && o.Key == key);
if (row is null)
{
row = new ObjectiveProgressEntity { PlayerId = pid, Key = key };
db.ObjectiveProgress.Add(row);
}
if (!row.IsCompleted)
{
row.IsCompleted = true;
row.ClearedAt = DateTime.UtcNow;
}
await db.SaveChangesAsync();
var weeklyRewarded = false;
var allKeys = new[] { $"challenge:{mapId}:0", $"challenge:{mapId}:1", $"challenge:{mapId}:2" };
var completeCount = await db.ObjectiveProgress
.CountAsync(o => o.PlayerId == pid && allKeys.Contains(o.Key) && o.IsCompleted);
if (completeCount >= allKeys.Length)
{
weeklyRewarded = await GrantRewardOnceAsync(
pid,
$"weekly-challenge:{mapId}",
xp: 250,
tokens: 250,
reason: $"weekly_challenge:{mapId}");
}
return Ok(new
{
ChallengeMapId = mapId,
ChallengeId = challengeId,
Progress = Math.Max(req.Progress, 10),
IsCompleted = true,
ClearedAt = row.ClearedAt,
Rewarded = weeklyRewarded,
});
}
// ── Checklist ────────────────────────────────────────────────────────
/// <summary>GET <c>api/checklist/v{1,2}/current</c> — returns
/// a flat <c>List&lt;ChecklistObjective&gt;</c>, NOT a wrapper
/// object. Wire shape per
/// <c>Cpp2IL_CS/.../RecNet/Checklist.cs</c>:
/// <c>Order(int), Objective(ObjectiveType int), Count(int),
/// CreditAmount(int)</c>. All four keys are required
/// (<c>Util.GetKey</c>, not <c>OrDefault</c>).
///
/// Completion state is tracked separately by the client via
/// <see cref="ObjectiveProgress"/> rows it cross-references — we
/// don't put a per-item "done" flag on the wire; the client
/// matches by Order. Returning an empty list ([]) is also valid
/// per the deserialiser (List wrapper accepts empty).</summary>
[HttpGet("api/checklist/v1/current")]
[HttpGet("api/checklist/v2/current")]
public IActionResult CurrentChecklist()
{
// Default NUX checklist for a brand-new account. Each row
// maps an in-game step to an ObjectiveType enum value
// (decompiled from ProgressionManager.cs).
return Ok(new[]
{
new { Order = 0, Objective = 38 /* SaveOutfitSlot */, Count = 1, CreditAmount = 25 },
new { Order = 1, Objective = 32 /* VisitACustomRoom */, Count = 1, CreditAmount = 25 },
new { Order = 2, Objective = 2 /* AddAFriend */, Count = 1, CreditAmount = 25 },
new { Order = 3, Objective = 30 /* GoToRecCenter */, Count = 1, CreditAmount = 25 },
new { Order = 4, Objective = 6 /* CheerAPlayer */, Count = 1, CreditAmount = 25 },
});
}
public sealed class CompleteChecklistRequest
{
public int Id { get; set; }
public int ItemIndex { get; set; }
}
[HttpPost("api/checklist/v1/complete")]
[HttpPost("api/checklist/v2/complete")]
[Authorize]
public async Task<IActionResult> CompleteChecklist([FromBody] CompleteChecklistRequest req)
{
var pid = Me;
var id = req.ItemIndex != 0 ? req.ItemIndex : req.Id;
var key = $"checklist:{id}";
var row = await db.ObjectiveProgress.FirstOrDefaultAsync(o => o.PlayerId == pid && o.Key == key);
if (row is null)
{
row = new ObjectiveProgressEntity { PlayerId = pid, Key = key };
db.ObjectiveProgress.Add(row);
}
row.IsCompleted = true;
row.ClearedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
var rewarded = await GrantRewardOnceAsync(
pid,
$"checklist:{id}",
xp: 25,
tokens: 25,
reason: $"checklist:{id}");
var balance = await level.GetBalanceAsync(pid, RecCenterTokens);
return Ok(BalanceUpdateResponse(balance, RecCenterTokens, rewarded ? 25 : 0, 303));
}
// ── Objectives bulk-complete (real persistence) ──────────────────────
/// <summary>POST <c>api/objectives/v1/completegroup</c> — replaces
/// the previous shape-stub in MissingEndpointsController. Now
/// upserts a real <see cref="ObjectiveProgressEntity"/> row keyed
/// by <c>group:{id}</c>.</summary>
public sealed class UpdateObjectiveRequest
{
public int Group { get; set; }
public int Index { get; set; }
public float Progress { get; set; }
public bool IsCompleted { get; set; }
}
/// <summary>POST <c>api/objectives/v1/updateobjective</c> — per-
/// objective progress update. Was previously caught by the
/// namespace wildcard returning <c>[]</c>; now persists to
/// <see cref="ObjectiveProgressEntity"/> keyed
/// <c>obj:{group}:{index}</c> so progress survives sessions.
/// Wire shape from <c>RecNet.Objectives.UpdateObjectiveProgress</c>.</summary>
[HttpPost("api/objectives/v1/updateobjective")]
[Authorize]
public async Task<IActionResult> UpdateObjective([FromBody] UpdateObjectiveRequest req)
{
var pid = Me;
var key = $"obj:{req.Group}:{req.Index}";
var row = await db.ObjectiveProgress.FirstOrDefaultAsync(o => o.PlayerId == pid && o.Key == key);
if (row is null)
{
row = new ObjectiveProgressEntity { PlayerId = pid, Key = key };
db.ObjectiveProgress.Add(row);
}
if (req.IsCompleted || req.Progress >= 1f)
{
row.IsCompleted = true;
row.ClearedAt ??= DateTime.UtcNow;
}
await db.SaveChangesAsync();
var rewardKey = $"obj:{req.Group}:{req.Index}";
var isRewarded = await IsRewardedAsync(pid, rewardKey);
return Ok(new
{
req.Group,
req.Index,
Progress = row.IsCompleted ? 1f : req.Progress,
VisualProgress = row.IsCompleted ? 1f : req.Progress,
IsCompleted = row.IsCompleted,
IsRewarded = isRewarded,
IsDirty = false,
});
}
[HttpPost("api/objectives/v1/completegroup")]
[Authorize]
public async Task<IActionResult> CompleteGroup([FromBody] CompleteGroupBody body)
{
var pid = Me;
var key = $"group:{body.Group}";
var row = await db.ObjectiveProgress.FirstOrDefaultAsync(o => o.PlayerId == pid && o.Key == key);
if (row is null)
{
row = new ObjectiveProgressEntity { PlayerId = pid, Key = key };
db.ObjectiveProgress.Add(row);
}
row.IsCompleted = true;
row.ClearedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
var rewarded = await GrantObjectiveGroupRewardAsync(pid, body.Group);
return Ok(new
{
Group = body.Group,
IsCompleted = true,
ClearedAt = row.ClearedAt!.Value.ToString("o"),
RequiresCompleteOnServer = false,
IsRewarded = true,
Rewarded = rewarded,
});
}
public sealed class CompleteGroupBody { public int Group { get; set; } }
/// <summary>GET <c>api/objectives/v1/myprogress</c> +
/// <c>api/players/v2/objectives</c> — the watch's daily/weekly
/// objectives checklist. Returns
/// <c>{Objectives: [...], ObjectiveGroups: [...]}</c> per
/// <c>Objectives.MyProgress.Deserialize</c> (RVA 0x14510A0).
/// Empty lists are acceptable; both keys must be present.</summary>
[HttpGet("api/objectives/v1/myprogress")]
[HttpGet("api/players/v2/objectives")]
public async Task<IActionResult> MyObjectiveProgress()
{
var pid = this.CurrentPlayerId();
if (pid is not long me)
return Ok(new { Objectives = Array.Empty<object>(), ObjectiveGroups = Array.Empty<object>() });
var rows = await db.ObjectiveProgress
.Where(o => o.PlayerId == me)
.ToListAsync();
var rewardKeys = rows
.Where(o => o.Key.StartsWith("reward:"))
.Select(o => o.Key["reward:".Length..])
.ToHashSet(StringComparer.Ordinal);
var objectives = rows
.Where(o => o.Key.StartsWith("obj:"))
.Select(o =>
{
var (grp, idx) = ParseObjectiveKey(o.Key);
return new
{
Index = idx,
Group = grp,
Progress = o.IsCompleted ? 1f : 0f,
VisualProgress = o.IsCompleted ? 1f : 0f,
IsCompleted = o.IsCompleted,
IsRewarded = rewardKeys.Contains(o.Key),
IsDirty = false,
};
});
var groups = rows
.Where(o => o.Key.StartsWith("group:"))
.Select(o => new
{
Group = int.TryParse(o.Key["group:".Length..], out var g) ? g : 0,
IsCompleted = o.IsCompleted,
ClearedAt = o.ClearedAt ?? new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc),
RequiresCompleteOnServer = false,
IsRewarded = rewardKeys.Contains(o.Key),
});
return Ok(new { Objectives = objectives, ObjectiveGroups = groups });
}
/// <summary>POST <c>api/objectives/v1/cleargroup</c> — flips a
/// group's <c>IsCompleted</c> flag, used by the checklist's
/// "claim group reward" button. Response is a single
/// <c>ObjectiveGroupProgress</c> with Group/IsCompleted/ClearedAt
/// required keys; <c>RequiresCompleteOnServer</c> is optional.</summary>
[HttpPost("api/objectives/v1/cleargroup")]
public async Task<IActionResult> ClearGroup([FromForm(Name = "group")] int? group)
{
var pid = this.CurrentPlayerId();
var key = $"group:{group ?? 0}";
var now = DateTime.UtcNow;
if (pid is long me)
{
var row = await db.ObjectiveProgress
.FirstOrDefaultAsync(o => o.PlayerId == me && o.Key == key);
if (row is null)
{
row = new ObjectiveProgressEntity { PlayerId = me, Key = key };
db.ObjectiveProgress.Add(row);
}
row.IsCompleted = true;
row.ClearedAt = now;
await db.SaveChangesAsync();
await GrantObjectiveGroupRewardAsync(me, group ?? 0);
}
return Ok(new
{
Group = group ?? 0,
IsCompleted = true,
ClearedAt = now.ToString("o"),
RequiresCompleteOnServer = false,
IsRewarded = true,
});
}
/// <summary>POST <c>api/players/v2/objectives</c> — batched
/// ProgressionManager completion records. These are separate from
/// ObjectiveProgress rows: they grant XP/currency for daily
/// objective events and must be idempotent per UTC day.</summary>
[HttpPost("api/players/v2/objectives")]
[Authorize]
public async Task<IActionResult> CompletePlayerObjectives([FromBody] List<PlayerObjectiveCompletionRecord>? records)
{
var pid = Me;
var grantedXp = 0;
var grantedTokens = 0;
foreach (var record in records ?? new())
{
var (xp, tokens, reason) = RewardForObjectiveType(record.ObjectiveType, record.AdditionalXp);
if (xp <= 0 && tokens <= 0) continue;
if (await GrantRewardOnceAsync(pid, $"{reason}:{DateTime.UtcNow:yyyyMMdd}", xp, tokens, reason))
{
grantedXp += xp;
grantedTokens += tokens;
}
}
var balance = await level.GetBalanceAsync(pid, RecCenterTokens);
return Ok(new
{
GrantedXp = grantedXp,
GrantedCurrency = grantedTokens,
CurrencyType = RecCenterTokens,
Balance = balance,
BalanceType = 0,
});
}
public sealed class PlayerObjectiveCompletionRecord
{
public int ObjectiveType { get; set; }
public bool InParty { get; set; }
public int AdditionalXp { get; set; }
}
private static (int Group, int Index) ParseObjectiveKey(string key)
{
const string prefix = "obj:";
if (!key.StartsWith(prefix)) return (-1, -1);
var rest = key[prefix.Length..];
var colon = rest.IndexOf(':');
if (colon <= 0) return (-1, -1);
var grp = int.TryParse(rest[..colon], out var g) ? g : -1;
var idx = int.TryParse(rest[(colon + 1)..], out var i) ? i : -1;
return (grp, idx);
}
private static int CurrentChallengeMapId() =>
(int)((DateTime.UtcNow - new DateTime(2020, 1, 1)).Days / 7 % 52 + 1);
private async Task<UpdateChallengeRequest> ReadChallengeRequestAsync(UpdateChallengeRequest formReq)
{
if (Request.HasFormContentType || formReq.Id != 0 || formReq.ChallengeId != 0 || formReq.ChallengeMapId != 0)
return formReq;
try
{
var body = await System.Text.Json.JsonSerializer.DeserializeAsync<UpdateChallengeRequest>(
Request.Body,
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return body ?? formReq;
}
catch
{
return formReq;
}
}
private async Task<bool> GrantObjectiveGroupRewardAsync(long playerId, int group)
{
var (xp, tokens, name) = group switch
{
0 => (150, 150, "daily-objectives"),
1 => (100, 100, "new-player-punch-card"),
2 => (75, 75, "orientation-checklist"),
_ => (50, 50, $"objective-group-{group}"),
};
return await GrantRewardOnceAsync(playerId, $"group:{group}", xp, tokens, name);
}
private static (int Xp, int Tokens, string Reason) RewardForObjectiveType(int objectiveType, int additionalXp)
{
var xp = Math.Max(additionalXp, 0);
return objectiveType switch
{
10 or 11 or 12 => (Math.Max(xp, 25), 25, $"daily-objective:{objectiveType}"),
13 => (Math.Max(xp, 150), 150, "all-daily-objectives"),
14 => (Math.Max(xp, 50), 50, "complete-any-daily"),
15 => (Math.Max(xp, 75), 75, "complete-any-weekly"),
25 => (Math.Max(xp, 25), 25, "nux-punchcard-objective"),
26 => (Math.Max(xp, 100), 100, "nux-all-punchcard-objectives"),
_ => (xp, 0, $"objective:{objectiveType}"),
};
}
private async Task<bool> IsRewardedAsync(long playerId, string rewardKey) =>
await db.ObjectiveProgress.AnyAsync(o =>
o.PlayerId == playerId && o.Key == $"reward:{rewardKey}" && o.IsCompleted);
private async Task<bool> GrantRewardOnceAsync(long playerId, string rewardKey, int xp, int tokens, string reason)
{
var sentinelKey = $"reward:{rewardKey}";
var exists = await db.ObjectiveProgress.AnyAsync(o =>
o.PlayerId == playerId && o.Key == sentinelKey && o.IsCompleted);
if (exists) return false;
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
{
PlayerId = playerId,
Key = sentinelKey,
IsCompleted = true,
ClearedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
if (xp > 0) await level.AwardXpAsync(playerId, xp, reason);
if (tokens > 0) await level.GrantCurrencyAsync(playerId, RecCenterTokens, tokens, reason);
return true;
}
private static object BalanceUpdateResponse(long balance, int currencyType, int delta, int balanceAddType) => new
{
Balance = balance,
CurrencyType = currencyType,
BalanceType = 0,
BalanceUpdates = new[]
{
new
{
UpdateResponse = 0,
Data = delta <= 0
? Array.Empty<object>()
: new object[]
{
new
{
BalanceAddType = balanceAddType,
BaseAward = delta,
BonusAward = 0,
RateLimit = 0,
CurrentCount = 1,
Total = delta,
BalanceType = 0,
BalanceInGiftBox = false,
},
},
},
},
};
}
@@ -0,0 +1,51 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Data;
namespace DorkNet.Server.Controllers.API.QuickPlay;
/// <summary>
/// api.rec.net/api/quickPlay/* — the watch's "Quick Play" tile picks
/// a popular open AG room when the player wants to jump in
/// somewhere social without browsing.
///
/// • <c>GET v{1,2}/getandclear</c> — returns the next match the
/// user was queued for, then clears it. We just synthesise a
/// "join hot AG room" target on each call (no real queue).
/// • <c>POST v1/set</c> — stores a desired-match tag. We log + ack;
/// no persistence needed for a single-region private server.
/// </summary>
[ApiController]
public class QuickPlayController(DorkNetDbContext db) : ControllerBase
{
/// <summary>GET <c>v{1,2}/getandclear</c> — the watch polls this
/// on every connect / navigation to check whether the user has a
/// pending quickplay queue. Returning a real {RoomId, RoomName}
/// makes the client treat it as "you queued, go there now" and
/// immediately teleports — that's WHY users were randomly
/// spawning in Paddleball etc.
///
/// Correct behaviour: return empty {} unless we actually have a
/// queued match for the caller. The /set endpoint is what
/// queues; getandclear is the consume.
///
/// We don't persist queue state on a single-region private
/// server (no real matchmaking pool), so always return empty.</summary>
[HttpGet("api/quickPlay/v1/getandclear")]
[HttpGet("api/quickPlay/v2/getandclear")]
public IActionResult GetAndClear() => Ok(new { });
public sealed class SetQuickPlayRequest
{
public string? Tag { get; set; }
}
[HttpPost("api/quickPlay/v1/set")]
[Authorize]
public IActionResult Set([FromBody] SetQuickPlayRequest? req)
{
// Preference is per-session; we don't persist (no benefit in
// a small private deployment). Just ack.
return Ok(new { Tag = req?.Tag ?? string.Empty, Set = true });
}
}
@@ -0,0 +1,367 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
// Storage-side enum aliased so the wire mapping stays explicit at
// every callsite. Wire enum values (RelationshipType /
// ReciprocalStatus) live in this file as constants — they're tied
// to the disassembly and not used elsewhere.
using EntityStatus = DorkNet.Server.Data.Entities.RelationshipStatus;
namespace DorkNet.Server.Controllers.API.Relationships.V2;
/// <summary>
/// api/relationships/v2 — friendship + reciprocal-state endpoints.
///
/// All routes / wire shapes verified against the patched 2020 client:
/// Cpp2IL_ISIL/IsilDump/Assembly-CSharp/RecNet/Relationships.txt
/// Cpp2IL_ISIL/IsilDump/Assembly-CSharp/RecNet/Relationship.txt
///
/// Wire response = a Relationship object (or List of them for /get).
/// Relationship.Deserialize strict-reads via Util.GetKey&lt;T&gt;:
/// "PlayerID" (int) — REQUIRED — the OTHER account's id
/// "RelationshipType" (int) — REQUIRED — 0..3
/// "Muted" (int) — REQUIRED — ReciprocalStatus 0..3
/// "Ignored" (int) — REQUIRED — ReciprocalStatus 0..3
/// "Favorited" (int) — optional (Util.GetKeyOrDefault, defaults 0)
///
/// RelationshipType (from caller's perspective):
/// 0 None, 1 FriendRequestSent, 2 FriendRequestReceived, 3 Friend
/// ReciprocalStatus (symmetric flag — who set it):
/// 0 None, 1 Local (just me), 2 Remote (just them), 3 Mutual
///
/// URLs called from the watch (Relationships.txt, all under
/// "api/relationships/"):
/// POST v2/addfriend?id={N} AddFriend
/// POST v2/removefriend?id={N} RemoveFriend / cancel-pending
/// POST v2/sendfriendrequest?id={N} SendFriendRequest
/// POST v2/acceptfriendrequest?id={N} AcceptFriendRequest
/// POST v1/favorite?id={N} FavoritePlayer
/// POST v1/unfavorite?id={N} UnfavoritePlayer
/// POST v1/mute / unmute / ignore / unignore (PostPreferenceChange)
/// GET v2/get GetMyRelationships
/// All POST URLs return one Relationship; GET returns List&lt;Relationship&gt;.
/// </summary>
[ApiController]
[Authorize]
public class RelationshipsController(DorkNetDbContext db, NotificationService notifications) : ControllerBase
{
// RelationshipType — int values match Relationship.RelationshipType
// in the patched dump.cs.
private const int RT_None = 0;
private const int RT_FriendRequestSent = 1;
private const int RT_FriendRequestReceived = 2;
private const int RT_Friend = 3;
// ReciprocalStatus values — also match the dump.cs enum.
private const int RS_None = 0;
private const int RS_Local = 1;
private const int RS_Remote = 2;
private const int RS_Mutual = 3;
private long CurrentPlayerId => this.RequireCurrentPlayerId();
// ── GET /api/relationships/v2/get ────────────────────────────────
// Watch URL: "api/relationships/v2/get". Returns the caller's full
// relationship list as List<Relationship>.
[HttpGet("api/relationships/v2/get")]
public async Task<ActionResult> GetMyRelationships()
{
var me = CurrentPlayerId;
var rels = await db.Relationships
.Where(r => r.RequesterId == me || r.TargetId == me)
.ToListAsync();
var wire = rels.Select(r => BuildRelationship(r, me)).ToList();
return Ok(wire);
}
/// <summary>GET <c>api/relationships/v2/personaldetails/{playerId}</c>
/// — single relationship row from the caller's perspective. Used
/// by the watch's profile-card view to determine whether to show
/// the "Add Friend" or "Remove Friend" affordance. Returns the
/// same Relationship wire shape as <c>v2/get</c>'s list elements.</summary>
[HttpGet("api/relationships/v2/personaldetails/{playerId:long}")]
public async Task<ActionResult> PersonalDetails(long playerId)
{
var me = CurrentPlayerId;
if (playerId <= 0 || playerId == me) return Ok(EmptyRelationship(playerId));
var row = await FindAsync(me, playerId);
return Ok(row is null ? EmptyRelationship(playerId) : BuildRelationship(row, me));
}
// ── /api/relationships/v2/sendfriendrequest?id={N} ───────────────
// Watch URL from Relationships.txt: "{0}v2/sendfriendrequest?id={1}".
// The ISIL labels the call site Core.Post but the live trace shows
// the watch sending GET — accepting both is harmless and the
// safest cross-build choice.
[HttpGet("api/relationships/v2/sendfriendrequest")]
[HttpPost("api/relationships/v2/sendfriendrequest")]
public async Task<ActionResult> SendFriendRequest([FromQuery] long id)
{
var me = CurrentPlayerId;
if (id <= 0 || id == me)
return Ok(EmptyRelationship(id));
var existing = await FindAsync(me, id);
if (existing is not null)
return Ok(BuildRelationship(existing, me));
var row = new RelationshipEntity
{
RequesterId = me,
TargetId = id,
Status = EntityStatus.PendingSent,
};
db.Relationships.Add(row);
await db.SaveChangesAsync();
await notifications.FriendRequestReceived(id, me);
return Ok(BuildRelationship(row, me));
}
// ── /api/relationships/v2/acceptfriendrequest?id={N} ────────────
[HttpGet("api/relationships/v2/acceptfriendrequest")]
[HttpPost("api/relationships/v2/acceptfriendrequest")]
public async Task<ActionResult> AcceptFriendRequest([FromQuery] long id)
{
var me = CurrentPlayerId;
if (id <= 0)
return Ok(EmptyRelationship(id));
// Only an incoming pending row can be accepted. The other side
// must have requested us — RequesterId=them, TargetId=me.
var rel = await db.Relationships.FirstOrDefaultAsync(r =>
r.RequesterId == id && r.TargetId == me &&
r.Status == EntityStatus.PendingSent);
if (rel is null)
return Ok(EmptyRelationship(id));
rel.Status = EntityStatus.Friend;
rel.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
await notifications.FriendRequestAccepted(id, me);
return Ok(BuildRelationship(rel, me));
}
// ── /api/relationships/v2/removefriend?id={N} ────────────────────
// Symmetrical: undoes a friendship OR cancels a pending request OR
// declines an incoming one. Block rows are left alone — the watch
// has a separate explicit unblock path. Live trace shows the watch
// sending GET (BestHTTP queueing oddity vs the ISIL Core.Post call);
// accept both verbs.
[HttpGet("api/relationships/v2/removefriend")]
[HttpPost("api/relationships/v2/removefriend")]
public async Task<ActionResult> RemoveFriend([FromQuery] long id)
{
var me = CurrentPlayerId;
if (id <= 0)
return Ok(EmptyRelationship(id));
var rel = await FindAsync(me, id);
if (rel is null)
return Ok(EmptyRelationship(id));
if (rel.Status == EntityStatus.Blocked)
return Ok(BuildRelationship(rel, me));
db.Relationships.Remove(rel);
await db.SaveChangesAsync();
await notifications.FriendRemoved(id, me);
return Ok(EmptyRelationship(id));
}
// ── /api/relationships/v2/addfriend?id={N} ───────────────────────
// Per the disassembly, AddFriend is its own endpoint distinct from
// SendFriendRequest. Live trace shows the watch sending GET; ISIL
// says Core.Post. Accept both verbs.
[HttpGet("api/relationships/v2/addfriend")]
[HttpPost("api/relationships/v2/addfriend")]
public async Task<ActionResult> AddFriend([FromQuery] long id)
{
var me = CurrentPlayerId;
if (id <= 0 || id == me)
return Ok(EmptyRelationship(id));
var existing = await FindAsync(me, id);
if (existing is null)
{
existing = new RelationshipEntity
{
RequesterId = me,
TargetId = id,
Status = EntityStatus.Friend,
};
db.Relationships.Add(existing);
}
else if (existing.Status != EntityStatus.Blocked)
{
existing.Status = EntityStatus.Friend;
existing.UpdatedAt = DateTime.UtcNow;
}
await db.SaveChangesAsync();
return Ok(BuildRelationship(existing, me));
}
// ── v1 favorite / unfavorite ─────────────────────────────────────
// Watch URLs: "api/relationships/v1/favorite?id={N}" and
// "api/relationships/v1/unfavorite?id={N}". These return a
// boolean-ish RecNetResult per the FavoritePlayer/UnfavoritePlayer
// disassembly (Core.Post … ExpectHttpStatusSuccess), not a
// Relationship — they don't surface any state change in the
// 2020 storage model so we just acknowledge.
[HttpPost("api/relationships/v1/favorite")]
public Task<IActionResult> Favorite([FromQuery] long id) => SetPreference(id, favorited: true);
[HttpPost("api/relationships/v1/unfavorite")]
public Task<IActionResult> Unfavorite([FromQuery] long id) => SetPreference(id, favorited: false);
[HttpPost("api/relationships/v1/mute")]
public Task<IActionResult> Mute([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId)
=> SetPreference(id != 0 ? id : formId ?? 0, muted: true);
[HttpPost("api/relationships/v1/unmute")]
public Task<IActionResult> Unmute([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId)
=> SetPreference(id != 0 ? id : formId ?? 0, muted: false);
[HttpPost("api/relationships/v1/ignore")]
public Task<IActionResult> Ignore([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId)
=> SetPreference(id != 0 ? id : formId ?? 0, ignored: true);
[HttpPost("api/relationships/v1/unignore")]
public Task<IActionResult> Unignore([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId)
=> SetPreference(id != 0 ? id : formId ?? 0, ignored: false);
/// <summary>Update favorited/muted/ignored on the caller's row.
/// Creates a placeholder row when none exists (so an "ignore"
/// before any friend request is still recorded).</summary>
private async Task<IActionResult> SetPreference(long otherId,
bool? favorited = null, bool? muted = null, bool? ignored = null)
{
var me = this.RequireCurrentPlayerId();
if (otherId <= 0 || otherId == me)
return Ok(new Models.Auth.RecNetResult { Success = false, Error = "invalid_id" });
var row = await db.Relationships.FirstOrDefaultAsync(r =>
r.RequesterId == me && r.TargetId == otherId);
if (row is null)
{
row = new RelationshipEntity
{
RequesterId = me,
TargetId = otherId,
Status = EntityStatus.Friend, // placeholder; favorite/mute/ignore don't require friend status
};
db.Relationships.Add(row);
}
if (favorited is bool f) row.Favorited = f;
if (muted is bool m) row.Muted = m;
if (ignored is bool i) row.Ignored = i;
row.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(new Models.Auth.RecNetResult { Success = true, Error = string.Empty });
}
public sealed class BulkIgnoreRequest
{
public int Platform { get; set; }
public List<string>? PlatformUserIds { get; set; }
}
/// <summary>POST <c>api/relationships/v1/bulkignoreplatformusers</c>
/// — bulk-import Steam/Oculus blocks. Persists to
/// <see cref="PlatformIgnoreEntity"/> so when those users sign
/// up later we can auto-set their relationship to Ignored.</summary>
[HttpPost("api/relationships/v1/bulkignoreplatformusers")]
[Authorize]
public async Task<IActionResult> BulkIgnorePlatformUsers([FromBody] BulkIgnoreRequest req)
{
var me = this.RequireCurrentPlayerId();
if (req?.PlatformUserIds is null || req.PlatformUserIds.Count == 0)
return Ok(new { Imported = 0 });
var added = 0;
foreach (var puid in req.PlatformUserIds.Distinct())
{
if (string.IsNullOrWhiteSpace(puid)) continue;
var exists = await db.PlatformIgnores.AnyAsync(i =>
i.PlayerId == me && i.Platform == req.Platform && i.PlatformUserId == puid);
if (exists) continue;
db.PlatformIgnores.Add(new PlatformIgnoreEntity
{
PlayerId = me,
Platform = req.Platform,
PlatformUserId = puid,
});
added++;
}
await db.SaveChangesAsync();
return Ok(new { Imported = added });
}
// ── helpers ──────────────────────────────────────────────────────
private Task<RelationshipEntity?> FindAsync(long me, long other) =>
db.Relationships.FirstOrDefaultAsync(r =>
(r.RequesterId == me && r.TargetId == other) ||
(r.RequesterId == other && r.TargetId == me));
/// <summary>Map a storage row to the watch's wire RelationshipType
/// from <paramref name="me"/>'s perspective. PendingSent flips
/// direction depending on which side originated.</summary>
private static int MapType(RelationshipEntity r, long me) => r.Status switch
{
EntityStatus.Friend => RT_Friend,
EntityStatus.Blocked => RT_None, // Blocked is not a friendship; the watch reads block state via a separate flag we don't track yet.
EntityStatus.PendingSent => r.RequesterId == me ? RT_FriendRequestSent : RT_FriendRequestReceived,
EntityStatus.PendingReceived => r.RequesterId == me ? RT_FriendRequestReceived : RT_FriendRequestSent,
_ => RT_None,
};
/// <summary>
/// Build the wire object the patched <c>Relationship.Deserialize</c>
/// reads. Keys are case-sensitive (Util.GetKey is a Dictionary
/// TryGetValue) — we ship exactly what's in the disassembly.
/// Implemented as a Dictionary because System.Text.Json's
/// case-insensitive collision check rejects anonymous types with
/// PascalCase property names like "PlayerID" + "Muted" if any
/// other property could collide on lowercase — using a Dictionary
/// avoids that whole class of trouble.
/// </summary>
private static Dictionary<string, object> BuildRelationship(RelationshipEntity r, long me)
{
var other = r.RequesterId == me ? r.TargetId : r.RequesterId;
// ReciprocalStatus enum: 0=None, 1=ByMe, 2=ByThem, 3=Both.
// Our schema only tracks "by me" (the requester's perspective)
// so we can answer ByMe / None; ByThem / Both would require
// looking up the OTHER direction's row too. Cheap extra query
// omitted — the watch's UI only consumes the ByMe bit.
var mineDir = r.RequesterId == me;
var muted = mineDir && r.Muted ? 1 : 0;
var ignored = mineDir && r.Ignored ? 1 : 0;
var favored = mineDir && r.Favorited ? 1 : 0;
return new()
{
["PlayerID"] = (int)other,
["RelationshipType"] = MapType(r, me),
["Muted"] = muted,
["Ignored"] = ignored,
["Favorited"] = favored,
};
}
/// <summary>"None" relationship for a target the caller has nothing
/// on file with — used as a no-op success response so the watch's
/// Relationship.Deserialize stays satisfied.</summary>
private static Dictionary<string, object> EmptyRelationship(long other) => new()
{
["PlayerID"] = (int)other,
["RelationshipType"] = RT_None,
["Muted"] = RS_None,
["Ignored"] = RS_None,
["Favorited"] = RS_None,
};
}
@@ -0,0 +1,90 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Data;
namespace DorkNet.Server.Controllers.API.Role;
/// <summary>
/// api.rec.net/api/role/* — role / privilege probes the watch consults
/// when deciding whether to surface dev-only UI (the in-watch debug
/// console button, hidden admin-tool tab, etc).
///
/// URL verified against
/// Cpp2IL_ISIL/IsilDump/Assembly-CSharp/RecNet/Accounts.txt:5168
/// (<c>"role/developer/{0}"</c>) called from
/// <c>RecNet.Accounts.GetAccountIsDeveloper</c>. Response shape is a
/// bare JSON boolean — the watch wraps the call with
/// <c>PromiseExtensions.ExpectPrimitiveResponse&lt;bool&gt;</c>, so an
/// array or object response throws InvalidCastException and silently
/// disables the dev UI.
///
/// To make a player a developer (and unlock the watch's
/// debug-console button), set <c>Players.IsDeveloper = 1</c> in the
/// SQLite DB or via the admin UI's player editor.
/// </summary>
[ApiController]
public class RoleController(DorkNetDbContext db) : ControllerBase
{
// Service-routed call from Accounts.GetAccountIsDeveloper. The
// 2020 dump's SendRequest carries `Service = 0` (Auth) — verified
// at Cpp2IL_ISIL/.../RecNet/Accounts.txt:5183-5186 against the
// RecNet.Service enum in dump.cs:586523-586538 (Auth=0). Service.Auth
// maps to <c>auth.{apex}</c> in ConfigService's ServiceUrls, and
// the path the watch appends has NO `/api/` prefix — it's just
// <c>role/developer/{id}</c>. The previous <c>/api/role/...</c>-only
// route never matched, so every nameplate probe came back 404 and
// the watch logged "Failed to GetIsDeveloper for accountId X: HTTP
// Error 404" once per remote player per scene load.
[HttpGet("/role/developer/{accountId:long}")]
[HttpGet("/api/role/developer/{accountId:long}")]
[AllowAnonymous]
public async Task<ActionResult<bool>> IsDeveloper(long accountId)
{
// OR IsCommunityTeam into the answer so the watch unlocks the
// in-settings "Developer Display Mode" slider for community-team
// members too — the slider's positions render the overhead
// badge as "Community Team" / "Developer" (see
// Cpp2IL_ISIL/.../PlayerUI.txt:9085-9099). The 2020 watch never
// probes a separate community-team role, so collapsing both
// flags into this one endpoint is the only way to reach the
// badge UI without patching the binary.
var isDev = await db.Players
.Where(p => p.Id == accountId)
.Select(p => (bool?)(p.IsDeveloper || p.IsCommunityTeam || p.IsAdmin))
.FirstOrDefaultAsync();
return Ok(isDev ?? false);
}
/// <summary>Mirror endpoint for the admin role — same shape, in case
/// later builds added an <c>/api/role/admin/{id}</c> probe. The 2020
/// dump only references the developer one but admins also get all
/// dev privileges, so the result is unioned.</summary>
[HttpGet("/role/admin/{accountId:long}")]
[HttpGet("/api/role/admin/{accountId:long}")]
[AllowAnonymous]
public async Task<ActionResult<bool>> IsAdmin(long accountId)
{
var isAdmin = await db.Players
.Where(p => p.Id == accountId)
.Select(p => (bool?)p.IsAdmin)
.FirstOrDefaultAsync();
return Ok(isAdmin ?? false);
}
/// <summary>Future-proofing for a hypothetical
/// <c>role/communityteam/{id}</c> probe — not referenced by the 2020
/// build, but mirrors the developer shape so a patched watch can
/// distinguish the two roles without a server change.</summary>
[HttpGet("/role/communityteam/{accountId:long}")]
[HttpGet("/api/role/communityteam/{accountId:long}")]
[AllowAnonymous]
public async Task<ActionResult<bool>> IsCommunityTeam(long accountId)
{
var isCt = await db.Players
.Where(p => p.Id == accountId)
.Select(p => (bool?)p.IsCommunityTeam)
.FirstOrDefaultAsync();
return Ok(isCt ?? false);
}
}
@@ -0,0 +1,1062 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Controllers.Match;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Rooms.V2;
/// <summary>
/// api.rec.net/api/rooms/* — DB-backed implementation of the watch's
/// Rooms tab.
///
/// Replaces the previous stubs in MiscStubsController for:
/// v1/hot, v1/filters, v1/tags, v1/search, v2/baserooms, v2/myrooms,
/// v2/mybookmarks, v2/mymoderated, v2/mysubscribed, v2/myrecent,
/// v2/name/{name}, v2/{id}, v3/{id}, v3/details/{id}, v4/details/{id},
/// v1/personaldetails/{id}, v2/personaldetails/{id}.
///
/// All endpoints serialize Rooms via RoomService.ToWireRoom which matches
/// Room.Deserialize at RVA 0x114E430 (PascalCase keys, all 16 required
/// fields plus optional VR-low / mobile / mic-mute flags).
/// </summary>
[ApiController]
public class RoomsController(
RoomService rooms,
DorkNetDbContext db,
PlayerPresenceService presence,
OnlinePresenceService onlinePresence,
DomainConfig domain,
NotificationService notifications,
ILogger<RoomsController> logger) : ControllerBase
{
private long? CurrentPlayerId => ControllerBaseExtensions.CurrentPlayerId(this);
// ── Browse / Hot / Search ────────────────────────────────────────────
/// <summary>
/// `Rooms.GetHotRooms(roomScoreType, tags)` — the watch's "Trending" tab.
/// `roomScoreType` is ignored (we always sort by HotScore). `tags` is
/// passed through as a substring filter against the comma-separated
/// TagsCsv column (e.g. `#community`, `#recroomoriginal`).
/// </summary>
[HttpGet("api/rooms/v1/hot")]
public async Task<IActionResult> Hot(
[FromQuery] string? roomScoreType,
[FromQuery] string? tags)
=> Ok((await rooms.HotAsync(tags)).Select(RoomService.ToWireRoom).ToList());
/// <summary>
/// `Rooms.SearchForRooms(query)` — free-text room search.
/// </summary>
[HttpGet("api/rooms/v1/search")]
[HttpGet("api/rooms/v2/search")]
public async Task<IActionResult> Search(
[FromQuery(Name = "query")] string? query,
[FromQuery(Name = "value")] string? value)
=> Ok((await rooms.SearchAsync(query ?? value ?? string.Empty))
.Select(RoomService.ToWireRoom).ToList());
/// <summary>GET <c>api/rooms/v2/live</c> — currently-active rooms
/// (rooms with at least one player presence). Same shape as Hot
/// — order by HotScore as a proxy for "live activity" since we
/// don't track instance population separately yet.</summary>
[HttpGet("api/rooms/v2/live")]
public async Task<IActionResult> Live([FromQuery] string? roomScoreType)
=> Ok((await rooms.HotAsync(null)).Select(RoomService.ToWireRoom).ToList());
/// <summary>
/// `Rooms.GetBaseRooms` — list of "base" rooms used by Rec Room Originals
/// to seed the room creation UI. Returns the same set as #recroomoriginal.
/// </summary>
[HttpGet("api/rooms/v2/baserooms")]
[HttpGet("api/rooms/v3/baserooms")]
public async Task<IActionResult> BaseRooms()
=> Ok((await rooms.HotAsync("#recroomoriginal")).Select(RoomService.ToWireRoom).ToList());
/// <summary>
/// `Rooms.GetFilters` — pinned/popular tag chips shown above the room
/// list in the watch.
/// </summary>
// Tag wire format: NO leading '#'. The watch's RoomFilterChip prepends
// '#' at render time, so sending "#community" produces "##community" in
// the UI. Names stored in the DB also drop the prefix.
[HttpGet("api/rooms/v1/filters")]
public IActionResult Filters() => Ok(new
{
PinnedFilters = new[] { "community", "recroomoriginal", "featured", "quest" },
PopularFilters = new[] { "paintball", "dodgeball", "soccer", "lasertag", "discgolf" },
});
[HttpGet("api/rooms/v1/tags")]
public IActionResult Tags() => Ok(new[] {
"community", "recroomoriginal", "featured", "quest",
"paintball", "dodgeball", "soccer", "lasertag", "discgolf",
});
[HttpGet("api/rooms/v1/pinnedtags")]
public IActionResult PinnedTags() => Ok(new[] { "community", "recroomoriginal", "featured" });
[HttpGet("api/rooms/v1/populartags")]
public IActionResult PopularTags() => Ok(new[] { "paintball", "dodgeball", "soccer" });
// ── Single room lookups ──────────────────────────────────────────────
/// <summary>
/// `Rooms.GetByName(roomName)` — used when the client constructs the
/// share-link target or when /goto/room/{name} runs. Falls back to a
/// synthesised "Room_<name>" for unknown names so the deserializer
/// doesn't crash on 404.
/// </summary>
[HttpGet("api/rooms/v2/name/{roomName}")]
[HttpGet("api/rooms/v3/name/{roomName}")]
public async Task<IActionResult> ByName(string roomName)
{
var r = await rooms.GetByNameAsync(roomName);
return Ok(RoomService.ToWireRoom(r ?? Synthetic(roomName)));
}
/// <summary>
/// `Rooms.GetById(roomId)` — same shape as ByName.
/// </summary>
[HttpGet("api/rooms/v2/{roomId:long}")]
[HttpGet("api/rooms/v3/{roomId:long}")]
public async Task<IActionResult> ById(long roomId)
{
var r = await rooms.GetByIdAsync(roomId);
return Ok(RoomService.ToWireRoom(r ?? Synthetic($"Room_{roomId}", roomId)));
}
// ── RoomDetails (the boot-critical one) ─────────────────────────────
//
// /v4/details/{roomId} is hit *during* the matchmaking goto flow.
// RoomDetails.Deserialize at RVA 0x114C580 expects PascalCase keys:
// Room (object), Scenes (list), CoOwners/InvitedCoOwners,
// Moderators/InvitedModerators, Hosts/InvitedHosts (all int lists),
// CheerCount, FavoriteCount, VisitCount, Tags.
//
// Scenes is populated for every room. The persistence coroutine
// (`RoomPersistenceManager+<DownloadRoomDataBlobCoroutine>`) runs
// unconditionally on scene load and dereferences
// `Rooms.LocalRoomScene.DataBlobName` on its first MoveNext — so
// returning Scenes=[] makes LocalRoomScene null and NPEs the boot.
//
// The historical "Scenes=[] short-circuits the persistence manager"
// comment that used to live here was the reverse of what actually
// happens in this build. Confirmed empirically with the [req] log:
// /v4/details/1 fires before scene load for the dorm too, so
// Rooms.LocalRoom is populated and the coroutine just needs a
// populated Scenes[0] to get a non-null LocalRoomScene.
/// <summary>GET api/rooms/v1/datahistory/{roomId} — returns the
/// version history for a room as a list of saves. Drives the
/// watch's "Restore to old version" UI in the room-settings tab.
///
/// The watch sometimes calls this with <c>roomId=0</c>, which we
/// interpret as "the caller's currently-presence-tracked room"
/// (typically their dorm). Anything else is a literal room id.
///
/// Wire shape: <c>List&lt;RoomDataHistoryDTO&gt;</c>. Deserializer
/// reads EXACTLY three PascalCase keys (verified at
/// <c>Cpp2IL_ISIL/.../RecNet/Rooms_NestedType_RoomDataHistoryDTO.txt</c>):
/// <c>RoomDataHistoryId</c> (long), <c>DataBlobName</c> (string),
/// <c>CreatedAt</c> (DateTime). Extra/wrong keys aren't ignored —
/// missing required keys throw <c>KeyNotFoundException</c> in
/// LitJson and the watch shows an empty history list. Don't add
/// helper fields here; pipe them through a separate admin endpoint
/// if needed.</summary>
[HttpGet("api/rooms/v1/datahistory/{roomId:long}")]
[HttpGet("api/rooms/v2/datahistory/{roomId:long}")]
public async Task<IActionResult> DataHistory(long roomId)
{
// 0 is a sentinel — fall back to the caller's current room
// from PlayerPresenceService.
if (roomId == 0 && CurrentPlayerId is long pid &&
presence.GetRoom(pid)?.RoomId is long currentId)
{
roomId = currentId;
}
if (roomId == 0) return Ok(Array.Empty<object>());
var blobs = await db.RoomDataBlobs
.Where(b => b.RoomId == roomId)
.OrderByDescending(b => b.UploadedAt)
.Take(50)
.Select(b => new
{
RoomDataHistoryId = b.Id,
DataBlobName = b.BlobName,
CreatedAt = b.UploadedAt,
})
.ToListAsync();
return Ok(blobs);
}
/// <summary>POST api/rooms/v4/saveData — the watch's
/// <c>RecNet.Rooms.UploadLocalRoomSceneData</c> save flow.
/// Body shape mirrors <c>RecNet.Rooms+SaveRoomSceneRequest</c>
/// (Cpp2IL_CS RecNet/Rooms.cs:858): RoomSceneId, RoomDataFilename,
/// InventionUsages[], CreatorActionContext, RequestPlayerId.
///
/// Persists the new <c>CurrentDataBlobName</c> on the room
/// (and, for dorms, the per-player DormStateEntity) so the next
/// /v4/details + cdn fetch loads what just got uploaded. Returns
/// a populated <c>RoomScene</c> JSON — that's what the watch's
/// continuation callback (b__1 in DisplayClass124_0) expects;
/// returning an empty body trips a "HTTP Error 404" path in
/// BestHTTP's Future framework even though the HTTP status was
/// fine.</summary>
[HttpPost("api/rooms/v4/saveData")]
[Authorize]
public async Task<IActionResult> SaveData([FromBody] SaveRoomSceneRequest body)
{
var pid = this.RequireCurrentPlayerId();
// Resolve the room. The watch sends RoomSceneId (the scene's
// OrderIndex per BuildRoomDetails above — 0 for single-scene
// rooms). To find the parent room we cross-reference the
// player's current presence (which RecordResponseAsync stamps
// on every /goto). This is the only signal we have without a
// RoomId on the request body.
var current = presence.GetRoom(pid);
if (current is null)
{
// No active room — without it we can't pick a target row.
// Return a degenerate RoomScene so the deserializer doesn't
// throw, but flag CanMatchmakeInto=false so the watch
// doesn't think the save succeeded.
return Ok(new
{
RoomSceneId = body.RoomSceneId,
RoomId = 0L,
RoomSceneLocationId = "76d98498-60a1-430c-ab76-b54a29b7a163",
Name = "Home",
IsSandbox = false,
DataBlobName = body.RoomDataFilename ?? string.Empty,
MaxPlayers = 8,
CanMatchmakeInto = false,
DataModifiedAt = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"),
});
}
var roomId = current.RoomId;
var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found" });
var newBlob = body.RoomDataFilename?.Trim() ?? string.Empty;
// Multi-scene rooms have a RoomScenes row per scene. The 2020
// client usually sends the scene's OrderIndex, but some flows
// send a stale/non-index id while the local room is still a
// single-scene dorm/custom room. In that common single-scene
// case, stamp the only row anyway; otherwise the pushed
// RoomDetails can keep Scenes[0].DataBlobName at v(N) while
// Rooms.CurrentDataBlobName is v(N+1), and the post-save reload
// reads stale bytes.
var sceneRowsForRoom = await db.RoomScenes
.Where(s => s.RoomId == roomId)
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var sceneRow = sceneRowsForRoom.FirstOrDefault(s => s.OrderIndex == (int)body.RoomSceneId)
?? (sceneRowsForRoom.Count == 1 ? sceneRowsForRoom[0] : null);
// Dorms: per-player state, keyed by the calling player so two
// people in the same dorm room ID save into separate slots.
if (room.IsDormRoom)
{
if (room.CreatorPlayerId != pid) return Forbid();
var dormState = await db.DormStates.FirstOrDefaultAsync(d => d.PlayerId == pid);
if (dormState is null)
{
dormState = new DormStateEntity { PlayerId = pid };
db.DormStates.Add(dormState);
}
dormState.CurrentDataBlobName = newBlob;
dormState.UpdatedAt = DateTime.UtcNow;
}
else
{
// Owned rooms: only the creator can save. Admin override
// intentionally not included — admins should use /restore
// when they need to roll a room back.
if (room.CreatorPlayerId != pid) return Forbid();
}
if (sceneRow is not null)
{
sceneRow.DataBlobName = newBlob;
sceneRow.DataModifiedAt = DateTime.UtcNow;
}
await db.Rooms
.Where(r => r.Id == roomId)
.ExecuteUpdateAsync(s => s
.SetProperty(r => r.CurrentDataBlobName, newBlob)
.SetProperty(r => r.UpdatedAt, DateTime.UtcNow));
await db.SaveChangesAsync();
// The watch's MasterUploadRoomDataBlobCoroutine waits for BOTH
// the /upload + /saveData responses AND a SubscriptionUpdateRoom
// SignalR push before flipping the save state machine out of
// SAVING. Without this push the spinner stays "Saving..." forever
// even though both HTTP calls returned 200 — verified via the
// RoomDetailsUpdatedCallback closure in
// RoomPersistenceManager+<>c__DisplayClass174_0.
//
// For dorms we re-shape with the caller as creator + their own
// DormStateEntity blob name so the watch's set_LocalRoomDetails
// matches LocalRoomDetails.RoomId and triggers
// SafeRaiseRoomDetailsUpdated(roomId).
var pushRoom = room;
if (room.IsDormRoom)
pushRoom = CloneWithCreator(room, pid, newBlob);
else
pushRoom = CloneWithCreator(room, room.CreatorPlayerId, newBlob);
foreach (var scene in sceneRowsForRoom)
{
if (sceneRow is not null && scene.Id == sceneRow.Id)
{
scene.DataBlobName = newBlob;
scene.DataModifiedAt = DateTime.UtcNow;
}
}
var savedSceneId = sceneRow?.OrderIndex ?? body.RoomSceneId;
var roomDetailsPayload = BuildRoomDetails(pushRoom, sceneRowsForRoom, savedSceneId, newBlob);
logger.LogInformation(
"[rooms-save] saveData player={PlayerId} room={RoomId} requestedScene={RequestedSceneId} savedScene={SavedSceneId} blob={BlobName} sceneRow={SceneRowFound} scenes={SceneCount}",
pid, roomId, body.RoomSceneId, savedSceneId, newBlob, sceneRow is not null, sceneRowsForRoom.Count);
var playersInInstance = onlinePresence.OnlinePlayerIds()
.Where(playerId =>
{
var playerRoom = presence.GetRoom(playerId);
return playerRoom is not null
&& playerRoom.RoomId == roomId
&& playerRoom.RoomInstanceId == current.RoomInstanceId;
})
.Append(pid)
.Distinct()
.ToArray();
logger.LogInformation(
"[rooms-save] fanout room update player={PlayerId} room={RoomId} instance={InstanceId} blob={BlobName} recipients={RecipientCount}",
pid, roomId, current.RoomInstanceId, newBlob, playersInInstance.Length);
var updatedRoomInstances = new Dictionary<long, RoomInstanceDto>();
foreach (var playerId in playersInInstance)
{
var currentPresence = presence.GetRoom(playerId);
if (currentPresence is null && playerId == pid)
{
currentPresence = current;
}
if (currentPresence is null) continue;
currentPresence.DataBlob = newBlob;
presence.SetRoom(playerId, currentPresence);
updatedRoomInstances[playerId] = currentPresence;
}
// SubscriptionUpdateRoom delivers the new RoomDetails (with
// Scenes[0].DataBlobName = newBlob). Rooms.OnSubscriptionUpdateRoom
// → StoreRoomDetailsInCache → set_LocalRoomDetails iterates
// Scenes for one matching RoomSceneId == LocalSubRoomId and writes
// LocalRoomScene to that scene, which is what DownloadRoomData
// BlobCoroutine reads when PostSaveReloading runs.
foreach (var playerId in playersInInstance)
{
await notifications.NotifyAsync(playerId, PushNotificationId.SubscriptionUpdateRoom, roomDetailsPayload);
}
// RoomInstanceUpdate keeps the matchmaking-side dataBlob and any
// friends-list "in room X" indicators in sync. No nudge — live
// doesn't send one, and the fake sub-room id confuses the watch's
// OnLocalGameSessionUpdated scene re-resolve.
foreach (var (playerId, currentPresence) in updatedRoomInstances)
{
await notifications.NotifyAsync(
playerId,
PushNotificationId.SubscriptionUpdateGameSession,
currentPresence);
}
// PresenceUpdate keeps the next heartbeat aligned with the blob
// applied through the room-details push above.
foreach (var (playerId, currentPresence) in updatedRoomInstances)
{
var presencePayload = new
{
playerId = (int)playerId,
statusVisibility = 1,
deviceClass = 0,
vrMovementMode = 0,
roomInstance = new
{
roomInstanceId = currentPresence.RoomInstanceId,
roomId = currentPresence.RoomId,
subRoomId = currentPresence.SubRoomId,
location = currentPresence.Location,
photonRegionId = currentPresence.PhotonRegionId,
photonRoomId = currentPresence.PhotonRoomId,
maxCapacity = currentPresence.MaxCapacity,
isFull = currentPresence.IsFull,
isPrivate = currentPresence.IsPrivate,
isInProgress = currentPresence.IsInProgress,
dataBlob = currentPresence.DataBlob,
eventId = currentPresence.EventId,
name = currentPresence.Name,
},
};
await notifications.NotifyAsync(playerId, PushNotificationId.SubscriptionUpdatePresence, presencePayload);
}
return Ok(new
{
RoomSceneId = savedSceneId,
RoomId = roomId,
RoomSceneLocationId = sceneRow?.RoomSceneLocationId ?? room.LocationReplicationId,
Name = sceneRow?.Name ?? "Home",
IsSandbox = sceneRow?.IsSandbox ?? false,
DataBlobName = newBlob,
MaxPlayers = sceneRow?.MaxPlayers ?? 8,
CanMatchmakeInto = sceneRow?.CanMatchmakeInto ?? true,
DataModifiedAt = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"),
});
}
public class SaveRoomSceneRequest
{
public long RoomSceneId { get; set; }
public string? RoomDataFilename { get; set; }
public List<long>? InventionUsages { get; set; }
public CreatorActionContextDto? CreatorActionContext { get; set; }
public long RequestPlayerId { get; set; }
}
public class CreatorActionContextDto
{
public bool IsTeachableMomentRunning { get; set; }
}
/// <summary>POST api/rooms/v1/restore — switch a room back to a
/// previous save. Body: roomId, blobName (or version id). Updates
/// the player's DormStateEntity (for dorms) or RoomEntity
/// CurrentDataBlobName (for owned rooms) so the next visit loads
/// the historical bytes.</summary>
/// <summary>POST <c>api/rooms/v1/datahistory/restore</c> — restore a
/// room to a previous save.
///
/// Request body: <c>RestoreDataHistoryRequest</c> serialized by
/// Unity's <c>JsonUtility.ToJson</c>, which uses C# field names.
/// Verified at <c>Cpp2IL_ISIL/.../Rooms.txt:13513-14</c> — the only
/// field on the request type is the long history id at offset +16,
/// and Unity's serializer names it <c>roomDataHistoryId</c>. We
/// look up <see cref="RoomDataBlobEntity"/> by that id, find its
/// parent room, and rewrite <c>CurrentDataBlobName</c> (and the
/// dorm-state row for personal dorms) so the next room reload
/// downloads the historical bytes.
///
/// Response: <c>CreateModifyRoomSceneResponse</c> — keys are
/// PascalCase per <c>Cpp2IL_ISIL/.../Rooms_NestedType_CreateModifyRoomSceneResponse.txt</c>:
/// <c>Result</c> (int, CreateModifyRoomStatus: 0=Success), and
/// <c>RoomScene</c> (the restored scene). The watch's
/// <c>ProcessCreateModifyRoomSceneResponse</c> chain expects both;
/// returning a bare <c>{restored: "..."}</c> like the old impl makes
/// the watch's deserializer throw and the restore silently fails.
/// The legacy form-urlencoded path stays under
/// <c>/api/rooms/v1/restore</c> for the admin SPA's restore UI.
/// </summary>
public sealed class RestoreDataHistoryRequest
{
// Unity JsonUtility serializes C# private backing fields by
// their declared name. The watch's request type has a single
// field which Unity emits as "roomDataHistoryId" (camelCase) —
// [JsonPropertyName] here matches that wire key explicitly so
// System.Text.Json picks it up regardless of any future global
// naming policy.
[System.Text.Json.Serialization.JsonPropertyName("roomDataHistoryId")]
public long RoomDataHistoryId { get; set; }
}
[HttpPost("api/rooms/v1/datahistory/restore")]
[Authorize]
public async Task<IActionResult> RestoreDataHistory([FromBody] RestoreDataHistoryRequest body)
{
var pid = this.RequireCurrentPlayerId();
var blob = await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.Id == body.RoomDataHistoryId);
if (blob is null) return NotFound(new { error = "version_not_found" });
var room = await rooms.GetByIdAsync(blob.RoomId);
if (room is null) return NotFound(new { error = "room_not_found" });
// Ownership: only the dorm owner / room creator can restore.
// Admin override is intentionally NOT included here — restore
// is destructive (overrides the player's chosen state) so we
// require the actual creator.
if (room.CreatorPlayerId != pid)
return Forbid();
if (room.IsDormRoom)
{
var dormState = await db.DormStates.FirstOrDefaultAsync(d => d.PlayerId == pid);
if (dormState is null)
{
dormState = new DormStateEntity { PlayerId = pid };
db.DormStates.Add(dormState);
}
dormState.CurrentDataBlobName = blob.BlobName;
dormState.UpdatedAt = DateTime.UtcNow;
}
await db.Rooms
.Where(r => r.Id == room.Id)
.ExecuteUpdateAsync(s => s
.SetProperty(r => r.CurrentDataBlobName, blob.BlobName)
.SetProperty(r => r.UpdatedAt, DateTime.UtcNow));
await db.SaveChangesAsync();
// Push the same SubscriptionUpdateRoom the save flow sends so
// the watch's StoreRoomDetailsInCache → SafeRaiseRoomDetailsUpdated
// chain fires and any open room-detail panels refresh.
var pushRoom = room.IsDormRoom ? CloneWithCreator(room, pid, blob.BlobName) : CloneWithCreator(room, room.CreatorPlayerId, blob.BlobName);
var allScenes = await db.RoomScenes
.Where(s => s.RoomId == room.Id)
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var roomDetailsPayload = BuildRoomDetails(pushRoom, allScenes, 0L, blob.BlobName);
await notifications.NotifyAsync(pid, PushNotificationId.SubscriptionUpdateRoom, roomDetailsPayload);
// CreateModifyRoomSceneResponse shape. RoomScene fields match
// RecNet.RoomScene.Deserialize (PascalCase, RoomSceneId,
// RoomId, RoomSceneLocationId, Name, IsSandbox, DataBlobName,
// MaxPlayers, CanMatchmakeInto, DataModifiedAt).
return Ok(new
{
Result = 0, // CreateModifyRoomStatus.Success
RoomScene = new
{
RoomSceneId = 0L,
RoomId = room.Id,
RoomSceneLocationId = room.LocationReplicationId,
Name = room.Name,
IsSandbox = false,
DataBlobName = blob.BlobName,
MaxPlayers = 8,
CanMatchmakeInto = true,
DataModifiedAt = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"),
},
});
}
/// <summary>Legacy form-urlencoded restore — kept for the admin SPA's
/// "Restore version" picker which posts {roomId, blobName} fields.
/// New callers (the watch) use the JSON variant above.</summary>
[HttpPost("api/rooms/v1/restore")]
[Authorize]
public async Task<IActionResult> RestoreVersionLegacy(
[FromForm] long roomId,
[FromForm] string? blobName,
[FromForm] long version = 0)
{
var pid = this.RequireCurrentPlayerId();
var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found" });
var blob = !string.IsNullOrEmpty(blobName)
? await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.BlobName == blobName && b.RoomId == roomId)
: await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.Id == version && b.RoomId == roomId);
if (blob is null) return NotFound(new { error = "version_not_found" });
if (room.CreatorPlayerId != pid)
return Forbid();
if (room.IsDormRoom)
{
var dormState = await db.DormStates.FirstOrDefaultAsync(d => d.PlayerId == pid);
if (dormState is null)
{
dormState = new DormStateEntity { PlayerId = pid };
db.DormStates.Add(dormState);
}
dormState.CurrentDataBlobName = blob.BlobName;
dormState.UpdatedAt = DateTime.UtcNow;
}
await db.Rooms
.Where(r => r.Id == roomId)
.ExecuteUpdateAsync(s => s
.SetProperty(r => r.CurrentDataBlobName, blob.BlobName)
.SetProperty(r => r.UpdatedAt, DateTime.UtcNow));
await db.SaveChangesAsync();
return Ok(new { restored = blob.BlobName });
}
/// <summary>GET <c>api/rooms/v{1,2}/personaldetails/{roomId}</c> —
/// per-caller bookmark/cheer flags for one room. Wire shape verified
/// against <c>RecNet.PersonalRoomDetails.Deserialize</c>
/// (<c>Cpp2IL_ISIL/.../PersonalRoomDetails.txt:85-91</c>):
/// <c>IsCheering</c> - bool, REQUIRED (Util.GetKey, throws if missing)
/// <c>IsBookmarked</c> - bool, REQUIRED
/// Both keys are PascalCase. Returning the full room-details payload
/// here (which is what we used to do) makes the deserialiser throw
/// KeyNotFoundException for IsCheering, and that blocks the entire
/// /goto/* flow because RecNet.Rooms.GetPersonalRoomDetails is
/// awaited before SessionManager.JoinRoom can run.</summary>
[HttpGet("api/rooms/v2/personaldetails/{roomId:long}")]
[HttpGet("api/rooms/v1/personaldetails/{roomId:long}")]
public async Task<IActionResult> PersonalDetails(long roomId)
{
var pid = CurrentPlayerId ?? 0;
bool cheering = false, bookmarked = false;
if (pid > 0)
{
cheering = await db.Cheers.AnyAsync(c => c.TargetRoomId == roomId && c.FromPlayerId == pid);
bookmarked = await db.RoomBookmarks.AnyAsync(b => b.RoomId == roomId && b.PlayerId == pid);
}
return Ok(new { IsCheering = cheering, IsBookmarked = bookmarked });
}
[HttpGet("api/rooms/v4/details/{roomId:long}")]
[HttpGet("api/rooms/v3/details/{roomId:long}")]
public async Task<IActionResult> Details(long roomId)
{
var r = await rooms.GetByIdAsync(roomId);
var room = r ?? Synthetic($"Room_{roomId}", roomId);
// Dorm-specific shaping: each player has their own first-class
// dorm row. Only the dorm's actual creator should see Creator
// role / Maker Pen. Guests loading someone else's dorm must see
// the owner as CreatorPlayerId, while still receiving the
// owner's DormStateEntity blob so they load the same room state.
if (room.IsDormRoom)
{
var dormBlobName = await db.DormStates
.Where(d => d.PlayerId == room.CreatorPlayerId)
.Select(d => d.CurrentDataBlobName)
.FirstOrDefaultAsync()
?? string.Empty;
room = CloneWithCreator(room, room.CreatorPlayerId, dormBlobName);
}
var scenes = await db.RoomScenes
.Where(s => s.RoomId == room.Id)
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var roomRoles = await db.RoomRoles
.Where(r => r.RoomId == room.Id)
.ToListAsync();
return Ok(BuildRoomDetails(room, scenes, roles: roomRoles));
}
/// <summary>
/// Shallow clone of a RoomEntity with a different CreatorPlayerId
/// and (optionally) a different CurrentDataBlobName. Used to give
/// the per-caller dorm the appearance of being authored by the
/// caller and pointing at the caller's own most recent save.
/// Doesn't touch the DB — purely a response-shaping utility.
/// </summary>
public static RoomEntity CloneWithCreator(
RoomEntity src, long creatorId, string? overrideBlobName = null) => new()
{
Id = src.Id,
Name = src.Name,
Description = src.Description,
CreatorPlayerId = creatorId,
ImageName = src.ImageName,
State = src.State,
Accessibility = src.Accessibility,
SupportsLevelVoting = src.SupportsLevelVoting,
IsAGRoom = src.IsAGRoom,
IsDormRoom = src.IsDormRoom,
CloningAllowed = src.CloningAllowed,
SupportsVRLow = src.SupportsVRLow,
SupportsMobile = src.SupportsMobile,
SupportsScreens = src.SupportsScreens,
SupportsWalkVR = src.SupportsWalkVR,
SupportsTeleportVR = src.SupportsTeleportVR,
AllowsJuniors = src.AllowsJuniors,
RoomWarningMask = src.RoomWarningMask,
CustomRoomWarning = src.CustomRoomWarning,
DisableMicAutoMute = src.DisableMicAutoMute,
LocationReplicationId = src.LocationReplicationId,
TagsCsv = src.TagsCsv,
CheerCount = src.CheerCount,
FavoriteCount = src.FavoriteCount,
VisitCount = src.VisitCount,
HotScore = src.HotScore,
CreatedAt = src.CreatedAt,
UpdatedAt = src.UpdatedAt,
CurrentDataBlobName = overrideBlobName ?? src.CurrentDataBlobName,
};
/// <summary>
/// Wire shape for the RoomDetails object — matches RoomDetails.Deserialize
/// at RVA 0x114C580. JSON key names DO NOT necessarily match the C#
/// property/field names from dump.cs — the deserializer is hand-written
/// and uses different (mostly PascalCase) literal strings. Verified
/// empirically: this exact shape gets past Deserialize successfully.
/// </summary>
public static object BuildRoomDetails(
RoomEntity room,
IReadOnlyList<RoomSceneEntity>? sceneRows = null,
long? overrideSceneId = null,
string? overrideDataBlobName = null,
IReadOnlyList<RoomRoleEntity>? roles = null)
{
// Tags is a list of OBJECTS, not strings. RoomDetails.Deserialize
// does Util.DeserializeList<RoomTag>(tags) — each entry is parsed as
// a Dictionary with `Type` (int RoomTagType: 0=Auto, 1=PlayerAdded,
// 2=AG) and `Tag` (string). Sending bare strings crashes:
// InvalidCastException: Unable to cast 'String' to 'Dictionary`2'
var tags = string.IsNullOrEmpty(room.TagsCsv)
? Array.Empty<object>()
: room.TagsCsv
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(t => (object)new { Type = 0, Tag = t })
.ToArray();
// Scenes population: every room must produce a Scenes[0] entry
// with RoomSceneId=0 (matching the matchmaking SubRoomId=0) so
// that Rooms.set_LocalRoomDetails actually transitions
// Rooms.LocalRoomScene to the new room's scene reference.
//
// Why every room: the setter (verified in Cpp2IL ISIL of
// RecNet.Rooms.set_LocalRoomDetails) iterates Scenes looking
// for a RoomSceneId match; if none matches (Scenes=[] or
// mismatched), it falls through WITHOUT writing LocalRoomScene
// — so the field stays stale at whatever the previous room set.
// After the dorm fix landed, that stale value started leaking
// into the rec center: the dorm sets LocalRoomScene → user
// goes to rec center → if we send Scenes=[], LocalRoomScene
// STAYS pointing at the dorm scene → DownloadRoomDataBlob
// Coroutine reads scene.DataBlobName from the dorm and
// downloads room_1_v1.dat (smoking gun in the server log)
// during the rec center load. The rec center master init
// blocks → FROSTBITE.
//
// The DataBlobName field is what determines whether the
// persistence coroutine actually downloads anything.
// OnRoomDataBlobNameChanged (RoomPersistenceManager.txt ISIL
// {027-040}) short-circuits when DataBlobName is empty: it
// sets GetRoomDataBlobPromise to a CompletedPromise<byte[]>
// with empty bytes and returns immediately — no HTTP fetch,
// no DeserializeRoomDataBlobCoroutine, the master init
// proceeds with default-zero permissions.
//
// • Dorm / customisable rooms: DataBlobName is either the
// latest uploaded blob name (RoomEntity.CurrentDataBlobName,
// written by StorageController on /upload) or, before any
// save, "room_<id>_v1.dat" — which the catch-all serves
// from RoomDataBlobService (the all-perms default blob).
// • AG-Original rooms: DataBlobName="" → completed-promise
// short-circuit → no download → no leaked stale-blob
// behaviour → master flow proceeds → spawn fires. Maker
// Pen stays locked, which matches public-server semantics
// (you can't edit the rec center).
//
// Heuristic: "customisable" iff it's the dorm OR a user-created
// room (CreatorPlayerId != 1, the seeded system account that
// owns AG-Originals like the rec center). User clones inherit
// the all-perms default blob until their first save replaces
// it via StorageController; the rec center / paintball / etc.
// serve "" so the persistence flow short-circuits.
var customisable = room.IsDormRoom || room.CreatorPlayerId != 1;
var dataBlobName = !string.IsNullOrEmpty(room.CurrentDataBlobName)
? room.CurrentDataBlobName
: customisable ? $"room_{room.Id}_v1.dat" : "";
// Multi-scene rooms (imported via tools/import-room.ps1) carry one
// RoomSceneEntity row per sub-room. Emit the full Scenes[] array
// when present so cross-scene portals (Lobby→Ch1, etc.) can
// resolve. Single-scene rooms (every legacy room, every dorm,
// every AG-Original) fall back to the synthesised Scenes[0] from
// RoomEntity fields.
object[] scenes;
if (sceneRows is { Count: > 0 })
{
scenes = sceneRows
.Select(s => (object)new
{
RoomSceneId = (long)s.OrderIndex,
RoomId = room.Id,
RoomSceneLocationId = s.RoomSceneLocationId,
Name = s.Name,
IsSandbox = s.IsSandbox,
DataBlobName = !string.IsNullOrWhiteSpace(overrideDataBlobName) &&
(overrideSceneId == s.OrderIndex || sceneRows.Count == 1)
? overrideDataBlobName
: s.DataBlobName,
MaxPlayers = s.MaxPlayers,
CanMatchmakeInto = s.CanMatchmakeInto,
DataModifiedAt = (s.DataModifiedAt == default ? DateTime.UtcNow : s.DataModifiedAt)
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
})
.ToArray();
}
else
{
scenes = new object[]
{
new
{
RoomSceneId = 0L, // MUST match RoomInstance.SubRoomId
RoomId = room.Id,
RoomSceneLocationId = room.LocationReplicationId,
Name = "Home",
IsSandbox = false,
DataBlobName = dataBlobName,
MaxPlayers = 8,
CanMatchmakeInto = true,
// Use the room's actual UpdatedAt so the watch's
// "is my local cache stale?" check sees an accurate
// freshness timestamp. Hardcoding the build date
// (2020-03-06) made the watch flag every dorm visit
// as "room is not up to date" because the cached
// local copy was always newer than the server's
// declared timestamp.
DataModifiedAt = (room.UpdatedAt == default ? DateTime.UtcNow : room.UpdatedAt)
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
},
};
}
// Owners tile (UIRoomDetails bottom-left) reads CoOwners — the
// 2020 RoomDetails.Deserialize reads keys WITHOUT the "Ids"
// suffix (verified at RoomDetails.txt:1096/1141/1146/1191/1196:
// "CoOwners" / "InvitedCoOwners" / "Moderators" /
// "InvitedModerators" / "Hosts" / "InvitedHosts"). The
// GetListKey calls are strict — wrong names throw
// KeyNotFoundException → "Failed to get room details:
// Malformed Response" → dorm load stalls in a redirect loop.
//
// CreatorPlayerId is always implicitly a CoOwner so RR Originals
// surface Coach (id=1) as the owner instead of rendering blank.
// Additional grants come from RoomRoleEntity rows when supplied;
// callers without role data (clone/myrooms/etc.) ship just the
// implicit creator.
var roleList = roles ?? Array.Empty<RoomRoleEntity>();
int[] PlayersIn(int role, bool accepted) => roleList
.Where(r => r.Role == role && r.Accepted == accepted)
.Select(r => (int)r.PlayerId)
.ToArray();
var coOwnerSet = new HashSet<int> { (int)room.CreatorPlayerId };
foreach (var pid in PlayersIn(0, true)) coOwnerSet.Add(pid);
return new
{
Room = RoomService.ToWireRoom(room),
Scenes = scenes,
CoOwners = coOwnerSet.ToArray(),
InvitedCoOwners = PlayersIn(0, false),
Moderators = PlayersIn(1, true),
InvitedModerators = PlayersIn(1, false),
Hosts = PlayersIn(2, true),
InvitedHosts = PlayersIn(2, false),
CheerCount = room.CheerCount,
FavoriteCount = room.FavoriteCount,
VisitCount = room.VisitCount,
// VisitorCount is the official Rec.Net "unique visitors"
// companion field (see Stats payload at apim.rec.net).
// 2020 RoomDetails.Deserialize doesn't read it but leaving
// it on the wire is harmless and lets the admin UI / any
// future client surface the number.
VisitorCount = room.VisitorCount,
Tags = tags,
};
}
// ── Clone / create ───────────────────────────────────────────────────
/// <summary>
/// `Rooms.CloneRoom(roomIdToClone, name)` — POST api/rooms/v1/clone
/// with body `{"Name":"<new>","RoomId":<sourceId>}`.
///
/// Response shape (CreateModifyRoomResponse.Deserialize, RVA 0xAF1950):
/// Result (int CreateModifyRoomStatus: 0=Success, 1=Unknown,
/// 2=PermissionDenied, 3=RoomNotActive,
/// 4=RoomDoesNotExist, 5=RoomHasNoDataBlob, …)
/// RoomDetails (RoomDetails object, only populated on Result=0)
/// </summary>
[HttpPost("api/rooms/v1/clone")]
[Authorize]
public async Task<IActionResult> Clone([FromBody] CloneRoomRequest req)
{
var pid = CurrentPlayerId;
if (pid is null) return Unauthorized();
// CloneAsync sets the CreateModifyRoomStatus code itself
// (Success/RoomDoesNotExist/DuplicateName/InappropriateName) so the
// watch can show the right toast instead of a generic "Unknown".
var result = await rooms.CloneAsync(req.RoomId, req.Name ?? string.Empty, pid.Value);
return Ok(new
{
Result = result.Status,
RoomDetails = result.Room is null ? null : BuildRoomDetails(result.Room),
});
}
public class CloneRoomRequest
{
public string? Name { get; set; }
public long RoomId { get; set; }
}
// ── My-Rooms tabs ────────────────────────────────────────────────────
[HttpGet("api/rooms/v2/myrooms")]
[Authorize]
public async Task<IActionResult> MyRooms()
{
var pid = CurrentPlayerId;
if (pid is null) return Ok(Array.Empty<object>());
return Ok((await rooms.CreatedByAsync(pid.Value)).Select(RoomService.ToWireRoom).ToList());
}
[HttpGet("api/rooms/v2/mybookmarks")]
[HttpGet("api/rooms/v2/mybookmarkedrooms")]
[Authorize]
public async Task<IActionResult> MyBookmarks()
{
var pid = CurrentPlayerId;
if (pid is null) return Ok(Array.Empty<object>());
return Ok((await rooms.BookmarkedByAsync(pid.Value)).Select(RoomService.ToWireRoom).ToList());
}
[HttpGet("api/rooms/v2/mymoderated")]
[HttpGet("api/rooms/v1/modrooms")]
[HttpGet("api/rooms/v2/mysubscribed")]
[HttpGet("api/rooms/v2/myrecent")]
public IActionResult MyOtherTabs() => Ok(Array.Empty<object>());
// ── Bookmark mutations ───────────────────────────────────────────────
[HttpPost("api/rooms/v1/bookmark/{roomId:long}")]
[HttpPost("api/rooms/v2/bookmark/{roomId:long}")]
[Authorize]
public async Task<IActionResult> Bookmark(long roomId)
{
var pid = CurrentPlayerId;
if (pid is null) return Unauthorized();
await rooms.BookmarkAsync(pid.Value, roomId);
return Ok(new { success = true, error = "" });
}
[HttpDelete("api/rooms/v1/bookmark/{roomId:long}")]
[HttpDelete("api/rooms/v2/bookmark/{roomId:long}")]
[Authorize]
public async Task<IActionResult> Unbookmark(long roomId)
{
var pid = CurrentPlayerId;
if (pid is null) return Unauthorized();
await rooms.UnbookmarkAsync(pid.Value, roomId);
return Ok(new { success = true, error = "" });
}
// ── Helpers ──────────────────────────────────────────────────────────
/// <summary>GET <c>api/rooms/v1/agRoomIds</c> — list of every
/// seeded AG / RR-Original room id. Used by the watch to bias the
/// room browser toward official rooms first.</summary>
[HttpGet("api/rooms/v1/agRoomIds")]
public async Task<IActionResult> AgRoomIds() =>
Ok(await rooms.AgRoomIdsAsync());
/// <summary>GET <c>api/rooms/v1/featuredRoomGroup</c> +
/// <c>v3/featured</c> — the "Featured" carousel on the watch's
/// rooms tab. Returns the top-12 AG rooms by HotScore.</summary>
[HttpGet("api/rooms/v1/featuredRoomGroup")]
[HttpGet("api/rooms/v3/featured")]
public async Task<IActionResult> Featured() => Ok(new
{
Name = "Featured",
FeaturedRooms = await rooms.FeaturedAgRoomIdsAsync(12),
});
/// <summary>POST <c>api/rooms/v1/bookmark</c> — toggle bookmark
/// state for a room. Body: <c>RoomId</c> + optional <c>Bookmark</c>
/// flag (default = add).</summary>
[HttpPost("api/rooms/v1/bookmark")]
[Authorize]
public async Task<IActionResult> BookmarkV1(
[FromForm(Name = "RoomId")] long? roomId,
[FromForm(Name = "Bookmark")] bool? bookmark)
{
if (roomId is not long rid) return Ok(new { success = true });
var pid = this.RequireCurrentPlayerId();
if (bookmark == false) await rooms.UnbookmarkAsync(pid, rid);
else await rooms.BookmarkAsync(pid, rid);
return Ok(new { success = true, error = "" });
}
/// <summary>POST <c>api/rooms/v1/cheer</c> — cheer a room (body
/// form). Idempotent per (caller, room) — duplicate taps don't
/// inflate CheerCount.</summary>
[HttpPost("api/rooms/v1/cheer")]
[Authorize]
public async Task<IActionResult> CheerRoom(
[FromForm(Name = "RoomId")] long? roomId,
[FromForm(Name = "Type")] int? type)
{
if (roomId is not long rid) return Ok(new { success = true });
var me = this.RequireCurrentPlayerId();
var (newCount, already) = await rooms.CheerRoomAsync(me, rid, type ?? 0);
return Ok(new { RoomId = rid, CheerCount = newCount, AlreadyCheered = already });
}
/// <summary>POST <c>api/rooms/v1/uncheer</c> — remove a previously
/// applied room cheer.</summary>
[HttpPost("api/rooms/v1/uncheer")]
[HttpPost("api/rooms/v1/uncheer/{roomId:long}")]
[Authorize]
public async Task<IActionResult> UncheerRoom(
long? roomId,
[FromForm(Name = "RoomId")] long? roomIdForm,
[FromForm(Name = "Type")] int? type)
{
var rid = roomId ?? roomIdForm ?? 0;
if (rid == 0) return Ok(new { success = true });
var me = this.RequireCurrentPlayerId();
var already = await rooms.UncheerRoomAsync(me, rid, type ?? 0);
return Ok(new { RoomId = rid, AlreadyUncheered = already });
}
/// <summary>GET <c>api/rooms/v1/cheers/{roomId}</c> — did the
/// caller cheer this room, and what's the current count?</summary>
[HttpGet("api/rooms/v1/cheers/{roomId:long}")]
[Authorize]
public async Task<IActionResult> CheckCheered(long roomId)
{
var me = this.CurrentPlayerId();
var (count, iCheered) = await rooms.GetCheerStateAsync(me ?? 0, roomId);
return Ok(new { RoomId = roomId, CheerCount = count, ICheered = iCheered });
}
/// <summary>
/// Synthesise a placeholder Room when the client asks for one we don't
/// have in the DB. Keeps the deserializer happy — every required key
/// is populated. Safe defaults: public, AG, default DormRoom location.
///
/// Special case: roomId=1 is the canonical DormRoom id used by
/// GoToController when the client visits its own dorm. Make sure the
/// /v4/details/1 follow-up shows "DormRoom" rather than "Room_1".
/// </summary>
private static RoomEntity Synthetic(string name, long? id = null)
{
var isDorm = (id == 1) || name.Equals("DormRoom", StringComparison.OrdinalIgnoreCase);
var displayName = isDorm ? "DormRoom" : name;
return new RoomEntity
{
Id = id ?? Math.Abs((long)name.GetHashCode()),
Name = displayName,
Description = isDorm
? "Your private dorm — yours alone, decorated however you like."
: $"{displayName} room",
CreatorPlayerId = 1,
State = 0,
Accessibility = isDorm ? 0 /* Private */ : 1 /* Public */,
IsAGRoom = !isDorm,
IsDormRoom = isDorm,
LocationReplicationId = "76d98498-60a1-430c-ab76-b54a29b7a163",
};
}
}
@@ -0,0 +1,319 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.Rooms.V2;
/// <summary>
/// api.rec.net/api/rooms/v{1,2}/{modify,modifyPermissions,banfromroom,
/// importroombans,roombans,report,modify/scene,modify/sceneParent,
/// modify/tags,modifyscene} — owner-side mutation surface for rooms.
///
/// Split from <see cref="RoomsController"/> to keep that file
/// (browse / details / clone) at a manageable size. All endpoints
/// are owner-gated (the caller must equal
/// <see cref="RoomEntity.CreatorPlayerId"/>) or admin-bypassed via
/// <see cref="AdminOnlyAttribute"/> on the admin-only sub-routes.
/// </summary>
[ApiController]
[Authorize]
public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
private async Task<RoomEntity?> RequireOwnedRoomAsync(long roomId)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return null;
return room.CreatorPlayerId == Me ? room : null;
}
// ── /modify (general fields) ─────────────────────────────────────────
public sealed class ModifyRoomRequest
{
public long RoomId { get; set; }
public string? Name { get; set; }
public string? Description { get; set; }
public string? ImageName { get; set; }
public string? Tags { get; set; }
public int? Accessibility { get; set; }
public bool? SupportsScreens { get; set; }
public bool? SupportsMobile { get; set; }
public bool? SupportsVRLow { get; set; }
public bool? SupportsWalkVR { get; set; }
public bool? SupportsTeleportVR { get; set; }
public bool? DisableMicAutoMute { get; set; }
}
[HttpPost("api/rooms/v2/modify")]
public async Task<IActionResult> Modify([FromBody] ModifyRoomRequest req)
{
var room = await RequireOwnedRoomAsync(req.RoomId);
if (room is null) return NotFound();
if (!string.IsNullOrWhiteSpace(req.Name)) room.Name = req.Name.Trim();
if (req.Description is not null) room.Description = req.Description;
if (req.ImageName is not null) room.ImageName = req.ImageName;
if (req.Tags is not null) room.TagsCsv = req.Tags;
if (req.Accessibility.HasValue) room.Accessibility = Math.Clamp(req.Accessibility.Value, 0, 2);
if (req.SupportsScreens.HasValue) room.SupportsScreens = req.SupportsScreens.Value;
if (req.SupportsMobile.HasValue) room.SupportsMobile = req.SupportsMobile.Value;
if (req.SupportsVRLow.HasValue) room.SupportsVRLow = req.SupportsVRLow.Value;
if (req.SupportsWalkVR.HasValue) room.SupportsWalkVR = req.SupportsWalkVR.Value;
if (req.SupportsTeleportVR.HasValue) room.SupportsTeleportVR = req.SupportsTeleportVR.Value;
if (req.DisableMicAutoMute.HasValue) room.DisableMicAutoMute = req.DisableMicAutoMute.Value;
room.UpdatedAt = DateTime.UtcNow;
try
{
await db.SaveChangesAsync();
}
catch (DbUpdateException)
{
// Likely a unique-name collision.
return Conflict(new { Result = 2, Message = "name taken" });
}
return Ok(new { Result = 0, Room = Services.RoomService.ToWireRoom(room) });
}
public sealed class ModifyPermissionsRequest
{
public long RoomId { get; set; }
public int? Accessibility { get; set; }
public bool? SupportsLevelVoting { get; set; }
public bool? AllowsJuniors { get; set; }
public bool? CloningAllowed { get; set; }
public int? RoomWarningMask { get; set; }
public string? CustomRoomWarning { get; set; }
}
[HttpPost("api/rooms/v2/modifyPermissions")]
public async Task<IActionResult> ModifyPermissions([FromBody] ModifyPermissionsRequest req)
{
var room = await RequireOwnedRoomAsync(req.RoomId);
if (room is null) return NotFound();
if (req.Accessibility.HasValue) room.Accessibility = Math.Clamp(req.Accessibility.Value, 0, 2);
if (req.SupportsLevelVoting.HasValue) room.SupportsLevelVoting = req.SupportsLevelVoting.Value;
if (req.AllowsJuniors.HasValue) room.AllowsJuniors = req.AllowsJuniors.Value;
if (req.CloningAllowed.HasValue) room.CloningAllowed = req.CloningAllowed.Value;
if (req.RoomWarningMask.HasValue) room.RoomWarningMask = req.RoomWarningMask.Value;
if (req.CustomRoomWarning is not null) room.CustomRoomWarning = req.CustomRoomWarning;
room.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(Services.RoomService.ToWireRoom(room));
}
// ── Scene mutations (LocationReplicationId) ──────────────────────────
public sealed class ModifySceneRequest
{
public long RoomId { get; set; }
public string? LocationReplicationId { get; set; }
}
[HttpPost("api/rooms/v1/modify/scene")]
[HttpPost("api/rooms/v1/modifyscene")]
public async Task<IActionResult> ModifyScene([FromBody] ModifySceneRequest req)
{
var room = await RequireOwnedRoomAsync(req.RoomId);
if (room is null) return NotFound();
if (!string.IsNullOrWhiteSpace(req.LocationReplicationId))
room.LocationReplicationId = req.LocationReplicationId;
room.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(Services.RoomService.ToWireRoom(room));
}
public sealed class ModifySceneParentRequest
{
public long RoomId { get; set; }
public long? ParentRoomId { get; set; }
}
/// <summary>Sub-room / nested-scene parent override. We don't
/// model sub-rooms as a real graph, so we accept-and-ignore but
/// still owner-gate to prevent grief.</summary>
[HttpPost("api/rooms/v1/modify/sceneParent")]
public async Task<IActionResult> ModifySceneParent([FromBody] ModifySceneParentRequest req)
{
var room = await RequireOwnedRoomAsync(req.RoomId);
if (room is null) return NotFound();
return Ok(new { Result = 0 });
}
public sealed class ModifyTagsRequest
{
public long RoomId { get; set; }
public string? Tags { get; set; }
}
[HttpPost("api/rooms/v1/modify/tags")]
public async Task<IActionResult> ModifyTags([FromBody] ModifyTagsRequest req)
{
var room = await RequireOwnedRoomAsync(req.RoomId);
if (room is null) return NotFound();
room.TagsCsv = req.Tags ?? string.Empty;
room.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(Services.RoomService.ToWireRoom(room));
}
// ── Bans ─────────────────────────────────────────────────────────────
public sealed class BanFromRoomRequest
{
public long RoomId { get; set; }
public long PlayerId { get; set; }
public int BanType { get; set; } // 0=Soft (kick), 1=Permanent, 2=Temp
public DateTime? Until { get; set; }
public string? Reason { get; set; }
}
[HttpPost("api/rooms/v2/banfromroom")]
public async Task<IActionResult> BanFromRoom([FromBody] BanFromRoomRequest req)
{
var room = await RequireOwnedRoomAsync(req.RoomId);
if (room is null) return NotFound();
db.RoomBans.Add(new RoomBanEntity
{
RoomId = req.RoomId,
BannedPlayerId = req.PlayerId,
BannedByPlayerId = Me,
BanType = req.BanType,
Until = req.Until,
Reason = req.Reason ?? string.Empty,
});
await db.SaveChangesAsync();
return Ok(new { Result = 0 });
}
public sealed class ImportRoomBansRequest
{
public long RoomId { get; set; }
public List<BanFromRoomRequest>? Bans { get; set; }
}
[HttpPost("api/rooms/v1/importroombans")]
public async Task<IActionResult> ImportRoomBans([FromBody] ImportRoomBansRequest req)
{
var room = await RequireOwnedRoomAsync(req.RoomId);
if (room is null) return NotFound();
if (req.Bans is null || req.Bans.Count == 0) return Ok(new { Imported = 0 });
foreach (var b in req.Bans)
{
db.RoomBans.Add(new RoomBanEntity
{
RoomId = req.RoomId,
BannedPlayerId = b.PlayerId,
BannedByPlayerId = Me,
BanType = b.BanType,
Until = b.Until,
Reason = b.Reason ?? string.Empty,
});
}
await db.SaveChangesAsync();
return Ok(new { Imported = req.Bans.Count });
}
[HttpGet("api/rooms/v1/roombans/{roomId:long}")]
public async Task<IActionResult> RoomBansList(long roomId)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
// Only owner sees the ban list (admins also via existing
// admin endpoints).
if (room.CreatorPlayerId != Me) return Forbid();
var rows = await db.RoomBans
.Where(b => b.RoomId == roomId && (b.Until == null || b.Until > DateTime.UtcNow))
.OrderByDescending(b => b.CreatedAt)
.ToListAsync();
return Ok(rows.Select(b => new
{
b.Id,
b.RoomId,
b.BannedPlayerId,
b.BannedByPlayerId,
b.BanType,
b.Until,
b.Reason,
b.CreatedAt,
}));
}
// ── Report ───────────────────────────────────────────────────────────
public sealed class RoomReportRequest
{
public long RoomId { get; set; }
public int Category { get; set; }
public string? Message { get; set; }
}
[HttpPost("api/rooms/v2/report")]
public async Task<IActionResult> Report([FromBody] RoomReportRequest req)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == req.RoomId);
if (room is null) return NotFound();
var msg = req.Message ?? string.Empty;
db.Reports.Add(new ReportEntity
{
ReporterPlayerId = Me,
TargetPlayerId = room.CreatorPlayerId,
TargetRoomId = req.RoomId,
RoomId = req.RoomId,
Category = req.Category,
Message = msg[..Math.Min(1000, msg.Length)],
});
await db.SaveChangesAsync();
return Ok(new { Reported = true });
}
public sealed class RoomRolePermissionRequest
{
public long RoomId { get; set; }
public long PlayerId { get; set; }
}
/// <summary>POST <c>api/rooms/v1/roomRolePermissions</c> — watch
/// posts its self-computed role + permission grants for
/// server-side validation. Role enum:
/// 0=Visitor, 5=Member, 10=Mod, 20=Host, 30=Owner. Room creator
/// is always Owner; everyone else defaults to Visitor.</summary>
[HttpPost("api/rooms/v1/roomRolePermissions")]
[AllowAnonymous]
public async Task<IActionResult> RoomRolePermissions(
[FromBody] RoomRolePermissionRequest? body,
[FromForm(Name = "RoomId")] long? roomIdForm,
[FromForm(Name = "PlayerId")] long? playerIdForm)
{
var roomId = body?.RoomId ?? roomIdForm ?? 0;
var playerId = body?.PlayerId ?? playerIdForm ?? (this.CurrentPlayerId() ?? 0);
if (roomId <= 0 || playerId <= 0)
return Ok(new { success = true, error = "" });
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
int role = (room?.CreatorPlayerId == playerId) ? 30 : 0;
long permissions = role switch
{
30 => -1L,
10 => 0x0FFFFFFFL,
5 => 0x000000FFL,
_ => 0x0000000FL,
};
return Ok(new
{
success = true,
error = "",
RoomId = roomId,
PlayerId = (int)playerId,
Role = role,
Permissions = permissions,
});
}
}
@@ -0,0 +1,176 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.Royale;
/// <summary>
/// api.rec.net/api/royale/* — Rec Royale battle-royale stats.
///
/// Per <c>RecNet.RecNetRecRoyaleStats</c> (decompiled at
/// <c>Cpp2IL_CS/.../RecNet/RecNetRecRoyaleStats.cs</c>) the client
/// only calls TWO endpoints:
///
/// • <c>GET api/royale/v1/current</c> → <c>RecRoyalePlayerProgress</c>:
/// <c>TotalXP, Level, RankIdx, RankName, CurrentLevelXPThreshold,
/// NextLevelXPThreshold, NextLevelAcornReward</c>.
/// • <c>POST api/royale/v2/matchcomplete</c> with body
/// <c>MatchCompleteStats</c>: <c>Rank, NumEliminations, SecondsAlive,
/// WalkGame, CustomGame, ChestsOpened, ShieldPotionsConsumed,
/// HealthPotionsConsumed, SecondsInAir</c>; returns <c>StatUpdates</c>:
/// <c>XPAwardStrings, TotalXPAwarded, NewProgress</c>.
///
/// We persist match rows to <see cref="RoyaleMatchEntity"/> +
/// <see cref="RoyaleMatchPlayerEntity"/> for server-side audit, and
/// roll the per-player <see cref="RoyalePlayerProgressEntity"/>.
/// </summary>
[ApiController]
public class RoyaleController(DorkNetDbContext db) : ControllerBase
{
// Rank ladder names (from the watch's RankIdx → RankName mapping).
// Approximated; the client's ladder isn't exposed in the dump but
// these are the standard 2020-era Royale tiers.
private static readonly string[] RankNames =
{
"Recruit", "Cadet", "Sergeant", "Lieutenant", "Captain",
"Major", "Colonel", "Commander", "Champion", "Legend",
};
private static long XpForLevel(int level) => 100L * level * level;
private static (int level, int rankIdx, string rankName, long currentThreshold, long nextThreshold, int reward)
Compute(long totalXp)
{
int lvl = 1;
while (XpForLevel(lvl + 1) <= totalXp) lvl++;
var rankIdx = Math.Min(RankNames.Length - 1, lvl / 5);
var current = XpForLevel(lvl);
var next = XpForLevel(lvl + 1);
var reward = 50 + 10 * lvl;
return (lvl, rankIdx, RankNames[rankIdx], current, next, reward);
}
[HttpGet("api/royale/v1/current")]
public async Task<IActionResult> Current()
{
var pid = this.CurrentPlayerId();
if (pid is not long me) return Ok(BlankProgress());
var row = await db.RoyalePlayerProgress.FirstOrDefaultAsync(p => p.PlayerId == me);
if (row is null) return Ok(BlankProgress());
return Ok(ToWireProgress(row));
}
public sealed class MatchCompleteStats
{
public int Rank { get; set; }
public int NumEliminations { get; set; }
public int SecondsAlive { get; set; }
public bool WalkGame { get; set; }
public bool CustomGame { get; set; }
public int ChestsOpened { get; set; }
public int ShieldPotionsConsumed { get; set; }
public int HealthPotionsConsumed { get; set; }
public int SecondsInAir { get; set; }
}
[HttpPost("api/royale/v2/matchcomplete")]
[Authorize]
public async Task<IActionResult> MatchComplete([FromBody] MatchCompleteStats body)
{
var pid = this.RequireCurrentPlayerId();
if (body is null) return BadRequest("missing body");
var match = new RoyaleMatchEntity
{
CompletedAt = DateTime.UtcNow,
Rank = body.Rank,
NumEliminations = body.NumEliminations,
SecondsAlive = body.SecondsAlive,
WalkGame = body.WalkGame,
CustomGame = body.CustomGame,
ChestsOpened = body.ChestsOpened,
ShieldPotionsConsumed = body.ShieldPotionsConsumed,
HealthPotionsConsumed = body.HealthPotionsConsumed,
SecondsInAir = body.SecondsInAir,
};
db.RoyaleMatches.Add(match);
await db.SaveChangesAsync();
db.RoyaleMatchPlayers.Add(new RoyaleMatchPlayerEntity
{
MatchId = match.Id,
PlayerId = pid,
Rank = body.Rank,
NumEliminations = body.NumEliminations,
SecondsAlive = body.SecondsAlive,
});
// Compute XP awards. Match the watch's pattern:
// • Survival: 1 XP per 10 seconds alive
// • Eliminations: 25 XP each
// • Placement: bonus inversely proportional to rank
// • Custom games don't award XP
var awards = new List<(string label, int xp)>();
if (!body.CustomGame)
{
var survivalXp = body.SecondsAlive / 10;
var killXp = body.NumEliminations * 25;
var rankXp = Math.Max(0, 200 - body.Rank * 5);
if (survivalXp > 0) awards.Add(($"Time Alive: {body.SecondsAlive}s", survivalXp));
if (killXp > 0) awards.Add(($"Eliminations: {body.NumEliminations}", killXp));
if (rankXp > 0) awards.Add(($"Rank #{body.Rank}", rankXp));
if (body.Rank == 1) awards.Add(("Victory Royale!", 500));
}
var totalAward = awards.Sum(a => a.xp);
// Update progress.
var row = await db.RoyalePlayerProgress.FirstOrDefaultAsync(p => p.PlayerId == pid);
if (row is null)
{
row = new RoyalePlayerProgressEntity { PlayerId = pid };
db.RoyalePlayerProgress.Add(row);
}
row.TotalXP += totalAward;
var (lvl, rankIdx, rankName, current, next, reward) = Compute(row.TotalXP);
row.Level = lvl;
row.RankIdx = rankIdx;
row.RankName = rankName;
row.CurrentLevelXPThreshold = current;
row.NextLevelXPThreshold = next;
row.NextLevelAcornReward = reward;
row.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(new
{
XPAwardStrings = awards.Select(a => $"+{a.xp} XP — {a.label}").ToList(),
TotalXPAwarded = (long)totalAward,
NewProgress = new[] { ToWireProgress(row) },
});
}
private static object ToWireProgress(RoyalePlayerProgressEntity p) => new
{
p.TotalXP,
p.Level,
p.RankIdx,
p.RankName,
p.CurrentLevelXPThreshold,
p.NextLevelXPThreshold,
p.NextLevelAcornReward,
};
private static object BlankProgress() => new
{
TotalXP = 0L,
Level = 1,
RankIdx = 0,
RankName = "Recruit",
CurrentLevelXPThreshold = 0L,
NextLevelXPThreshold = 100L,
NextLevelAcornReward = 50,
};
}
@@ -0,0 +1,118 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Sanitize;
/// <summary>
/// api.{rec.net,localhost}/api/sanitize/* — server-side profanity filter
/// the watch's text-input UI calls before allowing a room name /
/// invention name / chat message to be submitted. Delegates to
/// <see cref="ProfanityFilter"/> for the actual scrubbing. Response
/// shape: <c>{Text (purified), IsClean (bool)}</c>.
/// </summary>
[ApiController]
public class SanitizeController(ILogger<SanitizeController> logger) : ControllerBase
{
[HttpPost("api/sanitize/v1")]
[HttpPost("api/sanitize/v1/text")]
public async Task<IActionResult> Sanitize()
{
// RecRoom.StringSanitization.PurifyString sends JSON body
// {"Value":"<text>","ReplacementChar":42} — verified from a
// live chat-send error trace ("Received malformed RecNet
// response: ...Text:{\"Value\":...}..."). Earlier we used to
// pass the whole body through as "text", which made Text in
// the response contain the literal JSON string, and the watch's
// LitJson then choked on the malformed wrap.
string text = string.Empty;
try
{
if (Request.HasFormContentType)
text = Request.Form["Text"].ToString();
}
catch { }
if (string.IsNullOrEmpty(text))
{
using var reader = new StreamReader(Request.Body);
var raw = await reader.ReadToEndAsync();
if (!string.IsNullOrEmpty(raw))
{
var first = raw.AsSpan().TrimStart();
if (first.Length > 0 && first[0] == '{')
{
try
{
using var doc = System.Text.Json.JsonDocument.Parse(raw);
var root = doc.RootElement;
if (root.ValueKind == System.Text.Json.JsonValueKind.Object)
{
if (root.TryGetProperty("Value", out var vEl) && vEl.ValueKind == System.Text.Json.JsonValueKind.String)
text = vEl.GetString() ?? string.Empty;
else if (root.TryGetProperty("Text", out var tEl) && tEl.ValueKind == System.Text.Json.JsonValueKind.String)
text = tEl.GetString() ?? string.Empty;
else if (root.TryGetProperty("Input", out var iEl) && iEl.ValueKind == System.Text.Json.JsonValueKind.String)
text = iEl.GetString() ?? string.Empty;
}
}
catch { text = raw; }
}
else
{
text = raw;
}
}
}
// StringSanitization.PurifyString_d__9 chains Core.Post to
// ApiCallback<String> via ExpectPrimitiveResponse, so the watch's
// LitJson importer wants a BARE JSON string (e.g. "dwadwa"), not
// an object body. Returning {Text, IsClean} as we did before
// makes LitJson hand the Dictionary to Convert.ChangeType(_, typeof(string))
// which throws "Object must implement IConvertible". Strip the
// envelope — but use Content() with explicit application/json so
// ASP.NET Core's StringOutputFormatter doesn't serialize as
// text/plain (raw string sans quotes), which the watch then sees
// as an empty cleanVersion after LitJson decode.
var purified = ProfanityFilter.Purify(text);
var json = System.Text.Json.JsonSerializer.Serialize(purified);
logger.LogInformation("[sanitize] input=\"{Input}\" purified=\"{Purified}\" json={Json}", text, purified, json);
return Content(json, "application/json");
}
public sealed class SanitizeBody
{
public string? Text { get; set; }
public string? Input { get; set; }
public string? Value { get; set; }
}
/// <summary>POST <c>api/sanitize/v1/purifyString</c> — returns
/// <c>{Text}</c> with profanity replaced. Wire shape matches
/// <c>StringSanitization.PurifyString</c>.</summary>
[HttpPost("api/sanitize/v1/purifyString")]
public IActionResult PurifyString(
[FromBody] SanitizeBody? body,
[FromForm(Name = "Text")] string? textForm,
[FromForm(Name = "Input")] string? inputForm,
[FromForm(Name = "Value")] string? valueForm,
[FromQuery(Name = "text")] string? textQuery)
{
var input = body?.Text ?? body?.Input ?? body?.Value
?? textForm ?? inputForm ?? valueForm ?? textQuery ?? string.Empty;
return Ok(new { Text = ProfanityFilter.Purify(input) });
}
/// <summary>POST <c>api/sanitize/v1/requestIsStringPure</c> —
/// returns a bare JSON bool. Watch's <c>ExpectPrimitiveResponse</c>
/// rejects object bodies here.</summary>
[HttpPost("api/sanitize/v1/requestIsStringPure")]
public IActionResult IsStringPure(
[FromBody] SanitizeBody? body,
[FromForm(Name = "Text")] string? textForm,
[FromQuery(Name = "text")] string? textQuery)
{
var input = body?.Text ?? body?.Input ?? body?.Value
?? textForm ?? textQuery ?? string.Empty;
return Ok(ProfanityFilter.IsClean(input));
}
}
@@ -0,0 +1,101 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using System.Text.Json.Serialization;
namespace DorkNet.Server.Controllers.API.Settings.V2;
/// <summary>
/// api.rec.net/api/settings/v2 — player settings download / set / remove.
/// URL patterns (verified by disassembling RecNet.Settings$$DowloadLocalPlayerSettings
/// at RVA 0xAF55B0 and RecNet.Settings$$StoreLocalPlayerSetting at RVA 0xAF5650):
///
/// GET api/settings/v2/ — returns List&lt;{Key, Value}&gt;
/// POST api/settings/v2/set — body { Key, Value } (set or update one)
/// POST api/settings/v2/remove — body { Key } (delete one)
///
/// The CLIENT side parses the GET response with Util.DeserializeList&lt;Setting&gt;
/// and reads each entry's "Key" and "Value" fields (verified by string literal
/// references in the deserializer body — both are PascalCase).
///
/// Returning a Dictionary&lt;string,string&gt; here serializes as "{}"
/// when empty, which the client casts to a List and dies with
/// InvalidCastException: Unable to cast object of type 'Dictionary`2' to
/// type 'List`1'. Always return a real List.
/// </summary>
[ApiController]
[Route("api/[controller]/v2")]
[Authorize]
public class SettingsController(DorkNetDbContext db) : ControllerBase
{
private long CurrentPlayerId => this.RequireCurrentPlayerId();
[HttpGet]
[HttpGet("get")]
public async Task<IActionResult> GetSettings()
{
var settings = await db.PlayerSettings
.Where(s => s.PlayerId == CurrentPlayerId)
.Select(s => new SettingDto { Key = s.Key, Value = s.Value })
.ToListAsync();
return Ok(settings);
}
[HttpPost("set")]
public async Task<ActionResult> SetSetting([FromBody] SetSettingRequest req)
{
var existing = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == CurrentPlayerId && s.Key == req.Key);
if (existing is null)
{
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = CurrentPlayerId,
Key = req.Key,
Value = req.Value,
});
}
else
{
existing.Value = req.Value;
}
await db.SaveChangesAsync();
return Ok(new SettingDto { Key = req.Key, Value = req.Value });
}
[HttpPost("remove")]
public async Task<ActionResult> RemoveSetting([FromBody] SetSettingRequest req)
{
var existing = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == CurrentPlayerId && s.Key == req.Key);
if (existing is not null)
{
db.PlayerSettings.Remove(existing);
await db.SaveChangesAsync();
}
return Ok();
}
}
public class SettingDto
{
[JsonPropertyName("Key")]
public string Key { get; set; } = string.Empty;
[JsonPropertyName("Value")]
public string Value { get; set; } = string.Empty;
}
public class SetSettingRequest
{
[JsonPropertyName("Key")]
public string Key { get; set; } = string.Empty;
[JsonPropertyName("Value")]
public string Value { get; set; } = string.Empty;
}
@@ -0,0 +1,143 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Auth;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Store;
/// <summary>
/// api.rec.net/api/purchase/v1/* — real purchase flow that verifies
/// the player has enough currency, deducts it, and grants the item
/// to their inventory. Replaces the old "ack-everything" stubs in
/// AllEndpointsController that let the watch think it bought
/// something but never actually persisted the transaction.
///
/// The 2020 client follows a three-step flow when the player taps
/// "buy" on a Shop item:
/// 1. POST initiatepurchase — server validates the item exists +
/// player has funds, returns a transaction id (we just echo
/// back the item id; no real reservation needed for a private
/// server's threat model).
/// 2. POST processpurchase — server actually deducts currency,
/// grants the item to the avatar's InventoryJson list, returns
/// the new balance.
/// 3. POST completepurchase — final ack; client uses this to
/// flip its local UI state from "purchasing…" to "owned".
/// We keep all three so the watch's flow doesn't deadlock waiting
/// for one or another, but the actual mutation happens in
/// processpurchase. initiate and complete just validate + ack.
/// </summary>
[ApiController]
[Authorize]
public class PurchaseController(StoreService store, ILogger<PurchaseController> logger) : ControllerBase
{
public sealed record PurchaseRequest(long? ItemId, string? Slug, int? Quantity);
/// <summary>POST initiatepurchase — validate the item exists and
/// the caller has enough currency, but don't mutate yet. Returns
/// the resolved item id so the watch can echo it back on
/// processpurchase.</summary>
[HttpPost("api/purchase/v1/initiatepurchase")]
[HttpPost("api/purchase/v2/initiatepurchase")]
[HttpPost("api/purchase/v3/initiatepurchase")]
public async Task<IActionResult> Initiate([FromForm] PurchaseRequest body)
{
var item = await ResolveItemAsync(body);
if (item is null) return Ok(new { success = false, error = "item_not_found" });
return Ok(new
{
success = true,
error = "",
ItemId = item.Id,
Slug = item.Slug,
// Hand back a token the watch may include in
// processpurchase. We accept anything on the way in, so
// this is informational.
TransactionId = $"txn-{item.Id}-{DateTime.UtcNow.Ticks}",
});
}
/// <summary>POST processpurchase — atomic charge + grant. The
/// real work. Returns success+balance on completion or the
/// specific error code on failure (insufficient_funds,
/// item_not_available, etc.) so the watch can show the right UI
/// message.</summary>
[HttpPost("api/purchase/v1/processpurchase")]
[HttpPost("api/purchase/v2/processpurchase")]
[HttpPost("api/purchase/v3/processpurchase")]
public async Task<IActionResult> Process([FromForm] PurchaseRequest body)
{
var pid = this.RequireCurrentPlayerId();
var item = await ResolveItemAsync(body);
if (item is null) return Ok(new { success = false, error = "item_not_found" });
var result = await store.PurchaseAsync(pid, item.Id);
logger.LogInformation(
"[purchase] player={Pid} item={Item} success={Success} error={Error} balance={Balance}",
pid, item.Slug, result.Success, result.Error ?? "", result.Balance ?? 0);
return Ok(new
{
success = result.Success,
error = result.Error ?? "",
Balance = result.Balance ?? 0,
CurrencyType = item.CurrencyType,
ItemId = item.Id,
Slug = result.Slug ?? item.Slug,
});
}
/// <summary>POST completepurchase — final ack. Client flips its
/// UI to "owned" state and refreshes the avatar selector.</summary>
[HttpPost("api/purchase/v1/completepurchase")]
[HttpPost("api/purchase/v2/completepurchase")]
[HttpPost("api/purchase/v3/completepurchase")]
public IActionResult Complete() =>
Ok(new { success = true, error = "" });
/// <summary>POST cancelpurchase — client retreated mid-flow
/// (closed the panel, network blip, etc.). Nothing to roll back
/// since we don't reserve in initiate.</summary>
[HttpPost("api/purchase/v1/cancelpurchase")]
[HttpPost("api/purchase/v2/cancelpurchase")]
[HttpPost("api/purchase/v3/cancelpurchase")]
public IActionResult Cancel() =>
Ok(new { success = true, error = "" });
/// <summary>POST cleanuppending — client startup probe to release
/// any stale "in-flight" purchases from a crashed previous
/// session. We don't track in-flight state, so always-ack is
/// correct.</summary>
[HttpPost("api/purchase/v1/cleanuppending")]
[HttpPost("api/purchase/v2/cleanuppending")]
[HttpPost("api/purchase/v3/cleanuppending")]
public IActionResult CleanupPending() =>
Ok(new { success = true, error = "" });
/// <summary>POST <c>subscription/v1/cancel</c> — Rec Room+
/// subscription cancel button. Verified URL at
/// Commerce.txt:2188. The private server has no real billing
/// integration (no Stripe, no platform IAP receipts) so the
/// subscription is purely cosmetic — we ack the cancel so the
/// watch flips its UI to "canceled" and stops the recurring
/// renewal nag. If a real subscription entity ever lands, this
/// is where to expire its CurrentPeriodEnd.</summary>
[HttpPost("/subscription/v1")]
[HttpPost("/subscription/v1/cancel")]
[HttpPost("/api/subscription/v1")]
[HttpPost("/api/subscription/v1/cancel")]
public IActionResult CancelSubscription()
{
logger.LogInformation("[subscription] cancel requested by player {Pid}",
this.CurrentPlayerId() ?? 0);
return Ok(new { success = true, error = "" });
}
private async Task<Data.Entities.StoreItemEntity?> ResolveItemAsync(PurchaseRequest body)
{
if (body.ItemId is long id && id > 0)
return await store.GetByIdAsync(id);
if (!string.IsNullOrWhiteSpace(body.Slug))
return await store.GetBySlugAsync(body.Slug);
return null;
}
}
@@ -0,0 +1,358 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Store;
/// <summary>
/// api.rec.net/api/storefronts/v2/{buyItem,buyTier,buyElite} — the
/// secondary purchase URL family used by the watch's gift-drop
/// tiles and season-pass tier buttons. Wire request matches
/// <c>RecNet.RequestPurchaseItemDTO</c>:
/// <c>StorefrontType, PurchasableItemId, CurrencyType,
/// CouponConsumablePlayerMappingId, Gift</c>.
///
/// Response is <c>PurchaseBalanceUpdateResponseDTO</c>:
/// <c>BalanceUpdates: List&lt;BalanceUpdateDTO&gt;</c>. The client
/// applies these balance deltas optimistically + reads the optional
/// gift package from inventory after.
///
/// The primary purchase URL (<c>api/purchase/v1/*</c>) is handled
/// elsewhere by PurchaseController; this just covers the v2 surface.
/// </summary>
[ApiController]
[Authorize]
public class StorefrontsBuyController(
DorkNetDbContext db,
LevelService level,
NotificationService notifications) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
public sealed class RequestPurchaseItemDto
{
public int StorefrontType { get; set; }
public int PurchasableItemId { get; set; }
public int CurrencyType { get; set; } = 2;
public long? CouponConsumablePlayerMappingId { get; set; }
public GiftDto? Gift { get; set; }
}
public sealed class GiftDto
{
public long ToPlayerId { get; set; }
public string? Message { get; set; }
public bool Anonymous { get; set; }
public int GiftContext { get; set; }
}
[HttpPost("api/storefronts/v2/buyItem")]
public async Task<IActionResult> BuyItem([FromBody] RequestPurchaseItemDto req)
{
if (req is null) return BadRequest("missing body");
var pid = Me;
var item = await db.StoreItems.FirstOrDefaultAsync(s => s.Id == req.PurchasableItemId && s.IsActive);
if (item is null) return NotFound(BalanceUpdateResponse(0, 2 /* CurrencyType */, null, null));
var balance = await level.GetBalanceAsync(pid, req.CurrencyType);
if (balance < item.Price)
{
// Insufficient funds — return current balance, no item granted.
return Ok(BalanceUpdateResponse(balance, req.CurrencyType, null, null));
}
// Gift block (when present) means the BUYER spends the tokens but
// the ITEM goes to Gift.ToPlayerId. Self-gifting is disallowed —
// the watch's gift UI prevents it, but guard server-side too.
var giftTarget = req.Gift is { ToPlayerId: > 0 } g && g.ToPlayerId != pid
? g
: null;
var recipient = giftTarget?.ToPlayerId ?? pid;
var isGift = giftTarget is not null;
var newBalance = await level.GrantCurrencyAsync(pid, req.CurrencyType, -item.Price,
isGift ? $"giftItem:{item.Slug}->{recipient}" : $"buyItem:{item.Slug}");
// Grant the item to the RECIPIENT (= buyer for self-purchase, gift
// target for gift purchase). Avatar payloads append to
// AvatarEntity.InventoryJson; consumables and everything else
// insert a PlayerInventory row.
if (StoreService.TryGetAvatarItemPayload(item.Slug, out _, out var avatarItemDesc))
{
await GrantWardrobeAsync(recipient, StoreService.InventoryAvatarItemDesc(avatarItemDesc));
}
else
{
await GrantInventoryAsync(recipient, item.Slug);
}
// Insert a real GiftPackageEntity for the gift-box flow. The watch
// shows a gift-box animation after a purchase, then POSTs the
// package Id to /api/avatar/v2/gifts/consume/ to claim it. If the
// Id isn't a real row, that consume returns 404 and the watch
// logs 'Server failed to consume gift … HTTP Error 404' — the
// user's symptom on every successful buy. By writing the row
// synchronously and returning its DB-assigned Id in the
// BalanceUpdate response, the subsequent consume succeeds.
StoreService.TryGetAvatarItemPayload(item.Slug, out var avatarItemType, out avatarItemDesc);
var inventoryAvatarDesc = StoreService.InventoryAvatarItemDesc(avatarItemDesc);
StoreService.TryGetConsumableItemDesc(item.Slug, out var consumableItemDesc);
if (StoreService.TryGetHairDyePayload(item.Slug, out var hairDyeConsumableDesc, out var hairDyeColorGuid))
{
avatarItemType = 1;
inventoryAvatarDesc = hairDyeColorGuid;
consumableItemDesc = hairDyeConsumableDesc;
}
var gift = new GiftPackageEntity
{
RecipientPlayerId = recipient,
FromPlayerId = isGift && !giftTarget!.Anonymous ? (int?)pid : null,
AvatarItemType = string.IsNullOrEmpty(inventoryAvatarDesc) ? null : avatarItemType,
AvatarItemDescOrHairDyeDesc = inventoryAvatarDesc,
ConsumableItemDesc = consumableItemDesc,
CurrencyType = req.CurrencyType,
Currency = 0,
Xp = 0,
Level = 1,
GiftContext = isGift ? giftTarget!.GiftContext : 0,
GiftRarity = 0,
Message = isGift ? (giftTarget!.Message ?? string.Empty) : string.Empty,
Platform = -1,
PackageVariant = "Standard",
PackageMaterial = string.Empty,
Consumed = false,
IsValid = true,
SupportsCurrentPlatform = true,
};
db.GiftPackages.Add(gift);
await db.SaveChangesAsync();
// Push the buyer's balance update so their wallet UI refreshes.
await notifications.NotifyAsync(pid,
PushNotificationId.StorefrontBalancePurchase,
new { req.CurrencyType, Balance = newBalance, ItemId = item.Id, item.Slug });
if (isGift)
{
// Tell the recipient a gift is waiting. The watch dispatches
// GiftPackageReceived (id=30) to refresh /api/avatar/v2/gifts
// and pop the "you got a gift" toast; the gift box itself
// appears next time the recipient opens their inventory.
await notifications.NotifyAsync(recipient,
PushNotificationId.GiftPackageReceived,
new
{
Id = gift.Id,
Platform = -1,
Xp = 0,
Level = 1,
GiftRarity = 0,
GiftContext = gift.GiftContext,
FromPlayerId = gift.FromPlayerId,
AvatarItemType = gift.AvatarItemType,
AvatarItemDesc = avatarItemDesc,
ConsumableItemDesc = gift.ConsumableItemDesc ?? string.Empty,
Message = gift.Message ?? string.Empty,
});
}
// For gift purchases the buyer doesn't get a box — their flow
// ends with the balance update. Returning an empty Data array
// (giftPackageId = null) makes the watch's BuyItem callback
// skip the gift-receive animation on the sender's screen.
return Ok(BalanceUpdateResponse(newBalance, req.CurrencyType,
isGift ? null : item.Slug,
isGift ? null : gift.Id));
}
[HttpPost("api/storefronts/v2/buyTier")]
public async Task<IActionResult> BuyTier([FromBody] RequestPurchaseItemDto req)
{
// Season-pass tier: no per-item table, so we just deduct a
// small fixed cost (1 token) per tier and ack. A full
// season-pass system would have a SeasonProgressEntity.
var pid = Me;
const int TierCost = (int)StoreService.StoreItemPrice;
var balance = await level.GetBalanceAsync(pid, req.CurrencyType);
if (balance < TierCost) return Ok(BalanceUpdateResponse(balance, req.CurrencyType));
var newBalance = await level.GrantCurrencyAsync(pid, req.CurrencyType, -TierCost,
$"buyTier:{req.PurchasableItemId}");
return Ok(BalanceUpdateResponse(newBalance, req.CurrencyType));
}
[HttpPost("api/storefronts/v2/buyElite")]
public async Task<IActionResult> BuyElite([FromBody] RequestPurchaseItemDto req)
{
// Elite-tier purchase (paid premium track). Without real
// money flow on a private server we just ack.
var pid = Me;
var balance = await level.GetBalanceAsync(pid, req.CurrencyType);
return Ok(BalanceUpdateResponse(balance, req.CurrencyType));
}
// ── Helpers ──────────────────────────────────────────────────────────
private async Task GrantWardrobeAsync(long pid, string guid)
{
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid);
if (avatar is null)
{
avatar = new AvatarEntity { PlayerId = pid };
db.Avatars.Add(avatar);
}
try
{
var list = System.Text.Json.JsonSerializer
.Deserialize<List<string>>(avatar.InventoryJson) ?? new();
if (!list.Contains(guid))
{
list.Add(guid);
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(list);
avatar.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
}
catch
{
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(new[] { guid });
avatar.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
}
private async Task GrantInventoryAsync(long pid, string slug)
{
var row = await db.PlayerInventory
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.ItemSlug == slug);
if (row is null)
{
db.PlayerInventory.Add(new PlayerInventoryEntity
{
PlayerId = pid,
ItemSlug = slug,
Quantity = 1,
});
}
else
{
row.Quantity += 1;
}
await db.SaveChangesAsync();
}
private static object BalanceUpdateResponse(long balance, int currencyType,
string? grantedSlug = null, long? giftPackageId = null)
{
// PurchaseBalanceUpdateResponseDTO<GiftPackage> wire shape, verified
// against Cpp2IL_ISIL/.../RecNet/Avatars_NestedType_GiftPackage.txt:
//
// PurchaseBalanceUpdateResponseDTO EXTENDS BalanceResponseDTO,
// so the watch first runs BalanceResponseDTO.Deserialize which
// does Util.GetKey<long>("Balance") / GetKey<int>("CurrencyType")
// / GetKey<int>("BalanceType") — REQUIRED, KeyNotFoundException
// on miss. Mirror the trio on the OUTER object.
//
// Then it reads BalanceUpdates: List<BalanceUpdateDTO<GiftPackage>>.
// Each inner BalanceUpdateDTO has UpdateResponse + Data, where
// Data is List<GiftPackage>. GiftPackage.Deserialize REQUIRES:
// "Id" (long) | Util.GetKey
// "Platform" (int) | Util.GetKey
// "Xp" (int) | Util.GetKey
// "Level" (int) | Util.GetKey
// "GiftRarity" (int) | Util.GetKey
// "GiftContext" (int) | Util.GetKey
// Plus optional FromPlayerId / AvatarItemType / AvatarItemDesc /
// ConsumableItemDesc / EquipmentPrefabName / EquipmentModificationGuid /
// CurrencyType / Currency / Message.
//
// The previous shape shipped {CurrencyType,Balance,BalanceType,Platform}
// in the Data array — those don't match GiftPackage's required keys,
// so Util.GetKey<long>("Id") threw KeyNotFoundException, the
// ApiCallback received a non-null error string, and StorePurchaseItem
// Screen logged the error as "Null". The user's
// output_log.txt:920 shows that callback path firing on every
// purchase attempt. New shape ships a real (synthetic) GiftPackage
// with all required keys + the granted item's GUID under
// AvatarItemDesc so the watch's gift-receive flow can preview the
// unlocked item.
//
// CRITICAL: the watch follows up every purchase with a POST to
// /api/avatar/v2/gifts/consume/ containing this Id. If the Id
// isn't a real GiftPackageEntity row in the DB, consume returns
// 404 and the watch's <RunConsumeGift> logs 'Server failed to
// consume gift … HTTP Error 404', which the player-destroy
// cascade picks up on (the user's symptom on every buy:
// game crashes after opening the gift box). Caller MUST pass
// <paramref name="giftPackageId"/> = the saved
// GiftPackageEntity.Id. The previous code used
// DateTime.UtcNow.Ticks as a fake id.
var giftId = giftPackageId ?? DateTime.UtcNow.Ticks;
// Pull the GUID off the slug for wardrobe-* purchases so the
// watch's GiftPackage receives the AvatarItem GUID and can
// render the unlock animation. dorknet-* slugs leave the desc
// empty (those are non-wardrobe SKUs).
var avatarItemType = 0;
var avatarItemDesc = string.Empty;
var consumableItemDesc = string.Empty;
if (grantedSlug is not null)
{
StoreService.TryGetAvatarItemPayload(grantedSlug, out avatarItemType, out avatarItemDesc);
StoreService.TryGetConsumableItemDesc(grantedSlug, out consumableItemDesc);
if (StoreService.TryGetHairDyePayload(grantedSlug, out var hairDyeConsumableDesc, out var hairDyeColorGuid))
{
avatarItemType = 1;
avatarItemDesc = hairDyeColorGuid;
consumableItemDesc = hairDyeConsumableDesc;
}
}
var giftPackage = new
{
// REQUIRED via Util.GetKey — KeyNotFoundException on miss.
Id = giftId,
Platform = 0,
Xp = 0,
Level = 1,
GiftRarity = 0,
GiftContext = 0,
// Optional — surfaced to the watch's gift-preview UI.
FromPlayerId = (int?)null,
AvatarItemType = string.IsNullOrEmpty(avatarItemDesc) ? (int?)null : avatarItemType,
AvatarItemDesc = avatarItemDesc,
AvatarItemDescOrHairDyeDesc = StoreService.InventoryAvatarItemDesc(avatarItemDesc),
ConsumableItemDesc = consumableItemDesc,
EquipmentPrefabName = string.Empty,
EquipmentModificationGuid = string.Empty,
CurrencyType = currencyType,
Currency = 0,
Message = string.Empty,
Consumed = false,
ErrorMessage = string.Empty,
};
return new
{
// BalanceResponseDTO required trio.
Balance = balance,
CurrencyType = currencyType,
BalanceType = 0,
// PurchaseBalanceUpdateResponseDTO.BalanceUpdates =
// List<BalanceUpdateDTO<GiftPackage>>.
BalanceUpdates = new[]
{
new
{
UpdateResponse = 0, // UpdateResponseTypes.Success
Data = grantedSlug is null
? Array.Empty<object>()
: new object[] { giftPackage },
},
},
};
}
}
@@ -0,0 +1,498 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using System.Text.Json;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Store;
/// <summary>
/// api.rec.net/api/storefronts/v* — real implementation backed by the
/// <see cref="StoreService"/> catalog (StoreItems table). Replaces the
/// previous AllEndpointsController stubs that returned empty lists,
/// which made the watch's Shop tab look broken.
///
/// Wire shape: every endpoint returns either a list of items (for
/// catalog endpoints) or a StorefrontDTO with a StoreItems array.
/// </summary>
[ApiController]
public class StorefrontsController(
StoreService store,
DorkNetDbContext db,
IConfiguration config,
LevelService level,
DomainConfig domain) : ControllerBase
{
/// <summary>GET api/storefronts/v3/all — full active catalog. The
/// watch's Shop tab uses this to populate every category.</summary>
[HttpGet("api/storefronts/v1/all")]
[HttpGet("api/storefronts/v2/all")]
[HttpGet("api/storefronts/v3/all")]
[AllowAnonymous]
public async Task<IActionResult> All()
{
var items = await store.GetAllActiveAsync();
return Ok(items.Select(i => StoreService.ToItemDto(i, domain.Apex)).ToArray());
}
/// <summary>GET api/storefronts/v3/skus — alias for the full
/// catalog. Some 2020 client paths call this instead of /all
/// during the watch's "browse all SKUs" flow.</summary>
[HttpGet("api/storefronts/v3/skus")]
[AllowAnonymous]
public async Task<IActionResult> Skus()
{
var items = await store.GetAllActiveAsync();
return Ok(items.Select(i => StoreService.ToItemDto(i, domain.Apex)).ToArray());
}
/// <summary>GET api/storefronts/v3/season — items currently in the
/// season-pass storefront. Empty for now since we don't run
/// seasons; returning an empty list rather than the catch-all so
/// the deserialiser sees the right shape.</summary>
[HttpGet("api/storefronts/v3/season")]
[AllowAnonymous]
public async Task<IActionResult> Season()
{
var items = await store.GetActiveByStorefrontAsync("season:1");
return Ok(items.Select(i => StoreService.ToItemDto(i, domain.Apex)).ToArray());
}
/// <summary>GET api/storefronts/v3/giftdrop — list of available
/// gift-drop storefront ids. The watch then queries each one via
/// /giftdropstore/{id} below to render the rotating shelf.</summary>
[HttpGet("api/storefronts/v3/giftdrop")]
[AllowAnonymous]
public IActionResult GiftDropList() =>
Ok(StoreService.GetStorefrontDefinitions()
.Where(s => s.StorefrontType is > 0)
.Select(s => s.StorefrontType!.Value)
.Distinct()
.OrderBy(i => i)
.ToArray());
/// <summary>GET api/storefronts/v3/giftdropstore/{id} — one
/// storefront shelf. The <c>{id}</c> is a value from the
/// <c>RecNet.StorefrontTypes</c> enum (verified against
/// Cpp2IL_CS/.../RecNet/StorefrontTypes.cs):
/// None=0, LaserTag=1, RecCenter=2, <b>Watch=3</b>,
/// Quest_*=100..103, RecRoyale=200, Cafe=300, Paintball=400..406,
/// Bowling=500, StuntRunner=600, DormMirror=700.
/// The Watch storefront (id=3) is the in-game Shop's Featured /
/// Clothing / Other grid. That grid assumes every gift drop has a
/// renderable AvatarItemDesc, so only wardrobe-backed rows are
/// surfaced there. Other storefronts use the canonical
/// <c>giftdrop:{id}</c> key plus the shared <c>rro</c> and
/// <c>all</c> admin keys.
/// </summary>
[HttpGet("api/storefronts/v3/giftdropstore/{id:int}")]
[AllowAnonymous]
public async Task<IActionResult> GiftDropStore(int id)
{
var (key, items) = id switch
{
3 => ($"watch", await store.GetRenderableGiftDropItemsAsync()),
_ => (StoreService.StorefrontKeyForType(id), await store.GetRoomStorefrontItemsAsync(id)),
};
return Ok(StoreService.ToStorefrontDto(key, items, domain.Apex, id));
}
/// <summary>GET api/storefronts/v1/balance/{type} — currency
/// balance for the caller. Wire shape is a list of
/// {CurrencyType, Balance, BalanceType, Platform} entries. The
/// watch displays the Balance value next to item prices.</summary>
[HttpGet("api/storefronts/v1/balance/{currencyType:int}")]
[HttpGet("api/storefronts/v2/balance/{currencyType:int}")]
[HttpGet("api/storefronts/v3/balance/{currencyType:int}")]
[HttpGet("api/storefronts/v4/balance/{currencyType:int}")]
[HttpGet("api/storefronts/v4/balance")]
[Authorize]
public async Task<IActionResult> Balance(int currencyType = 2,
[FromServices] LevelService level = null!)
{
var pid = this.RequireCurrentPlayerId();
var balance = await level.GetBalanceAsync(pid, currencyType);
return Ok(new[]
{
new
{
CurrencyType = currencyType,
BalanceType = 0,
Balance = balance,
Platform = 0,
},
});
}
/// <summary>GET <c>api/storefronts/v{1-4}/balanceAddType/{type}/{tier}</c>
/// — purchase-tier metadata for a currency. The watch uses this
/// to populate the "add tokens" tier list. Empty for a private
/// server with no IAP.</summary>
[HttpGet("api/storefronts/v1/balanceAddType/{currencyType:int}/{tierId:int}")]
[HttpGet("api/storefronts/v2/balanceAddType/{currencyType:int}/{tierId:int}")]
[HttpGet("api/storefronts/v3/balanceAddType/{currencyType:int}/{tierId:int}")]
[HttpGet("api/storefronts/v4/balanceAddType/{currencyType:int}/{tierId:int}")]
public IActionResult BalanceAddType(int currencyType, int tierId)
=> Ok(BalanceAddConfig(currencyType, tierId));
/// <summary>GET <c>api/storefronts/v2/balance</c> — every currency
/// balance for the caller in a single response. The watch's
/// wallet UI uses this to render all four currency chips at once.
/// CurrencyType per <c>CurrencyType.cs</c>: 0=Token, 1=Cheer,
/// 2=Coin, 3=Premium.</summary>
[HttpGet("api/storefronts/v2/balance")]
[Authorize]
public async Task<IActionResult> AllBalances()
{
var pid = this.RequireCurrentPlayerId();
var coin = await level.GetBalanceAsync(pid, 2);
var token = await level.GetBalanceAsync(pid, 0);
return Ok(new[]
{
new { CurrencyType = 2, BalanceType = 0, Balance = coin, Platform = 0 },
new { CurrencyType = 0, BalanceType = 0, Balance = token, Platform = 0 },
new { CurrencyType = 1, BalanceType = 0, Balance = 0L, Platform = 0 },
new { CurrencyType = 3, BalanceType = 0, Balance = 0L, Platform = 0 },
});
}
/// <summary>GET <c>api/storefronts/v1/current</c> — single active
/// storefront the watch's "today's deals" panel renders. Returns
/// the <c>main</c> storefront's items as a single PurchasableItem
/// list (bounded to keep older watches snappy).</summary>
[HttpGet("api/storefronts/v1/current")]
public async Task<IActionResult> Current()
{
if (!config.GetValue("Store:EnableWatchGiftDrops", true))
return Ok(StoreService.ToStorefrontDto("Main", Array.Empty<StoreItemEntity>(), domain.Apex));
IQueryable<StoreItemEntity> query = db.StoreItems
.Where(s => s.IsActive && s.Storefront == "main")
.OrderByDescending(s => s.UpdatedAt);
var max = config.GetValue("Store:MaxCurrentStorefrontItems", 96);
if (max > 0) query = query.Take(max);
var rows = await query.ToListAsync();
return Ok(StoreService.ToStorefrontDto("Main", rows, domain.Apex));
}
/// <summary>GET <c>api/storefronts/v1/season/{seasonId}</c> —
/// season-pass tier list, sourced from StoreItems tagged
/// <c>Storefront = "season:{id}"</c>.</summary>
[HttpGet("api/storefronts/v1/season/{seasonId:int}")]
public async Task<IActionResult> Season(int seasonId)
{
var key = $"season:{seasonId}";
var tiers = await db.StoreItems
.Where(s => s.IsActive && s.Storefront == key)
.OrderBy(s => s.Price)
.Select(s => new
{
TierId = s.Id,
s.DisplayName,
s.Description,
s.Price,
CurrencyType = s.CurrencyType,
s.ImageName,
})
.ToListAsync();
return Ok(new
{
SeasonId = seasonId,
SeasonType = seasonId,
Active = tiers.Count > 0,
StartsAt = DateTime.UtcNow.AddDays(-30),
EndsAt = DateTime.UtcNow.AddDays(30),
Tiers = tiers,
});
}
/// <summary>GET <c>api/storefronts/v1/objectives</c> — season
/// objective progress for the caller, keyed
/// <c>season:{n}</c>.</summary>
[HttpGet("api/storefronts/v1/objectives")]
public async Task<IActionResult> Objectives()
{
var pid = this.CurrentPlayerId();
if (pid is not long me) return Ok(Array.Empty<object>());
var rows = await db.ObjectiveProgress
.Where(o => o.PlayerId == me && o.Key.StartsWith("season:"))
.Select(o => new { Key = o.Key, IsCompleted = o.IsCompleted, ClearedAt = o.ClearedAt })
.ToListAsync();
return Ok(rows);
}
/// <summary>POST <c>api/storefronts/v1/objectives</c> —
/// <c>Storefronts.CompleteObjectives</c> uploads completion
/// records and expects a <c>BalanceUpdateResponseDTO</c> back.
/// We ship the wrapper with the required Balance/CurrencyType/
/// BalanceType trio so <c>BalanceResponseDTO.Deserialize</c>
/// doesn't throw, plus an empty <c>BalanceUpdates</c>.</summary>
[HttpPost("api/storefronts/v1/objectives")]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
[Authorize]
public async Task<IActionResult> CompleteObjectives()
{
var pid = this.RequireCurrentPlayerId();
var records = await ReadStorefrontObjectiveRecordsAsync();
var total = 0;
foreach (var record in records ?? new())
{
if (record.CompletionPercentage <= 0f) continue;
var rewardKey = $"storefront-objective:{record.ObjectiveType}:{DateTime.UtcNow:yyyyMMdd}";
if (await MarkRewardOnceAsync(pid, rewardKey))
{
var award = record.ObjectiveType switch
{
10 or 11 or 12 => 25,
13 => 150,
14 => 50,
15 => 75,
4000 or 4001 => 50,
_ => Math.Max(5, (int)Math.Round(25 * Math.Clamp(record.CompletionPercentage, 0f, 1f))),
};
total += award;
}
}
if (total > 0)
{
await level.AwardXpAsync(pid, total, "storefront-objectives");
await level.GrantCurrencyAsync(pid, 2, total, "storefront-objectives");
}
var balance = await level.GetBalanceAsync(pid, 2);
return Ok(BalanceUpdateResponse(balance, 2, new[]
{
RewardModification(100, total, total, currentCount: total > 0 ? 1 : 0),
}));
}
public sealed class StorefrontObjectiveCompletionRecord
{
public int ObjectiveType { get; set; }
public float CompletionPercentage { get; set; }
public long? RoomId { get; set; }
}
public sealed class GrantBalanceRewardDto
{
public int CurrencyType { get; set; } = 2;
public List<GrantBalanceRequest> BalanceAdds { get; set; } = new();
}
public sealed class GrantBalanceRequest
{
public float Multiplier { get; set; } = 1f;
public int BalanceAddType { get; set; } = 1;
}
[HttpPost("api/storefronts/v2/balance")]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
[Authorize]
public async Task<IActionResult> ModifyBalance()
{
var body = await ReadGrantBalanceRewardAsync();
var pid = this.RequireCurrentPlayerId();
var currencyType = body.CurrencyType == 0 ? 2 : body.CurrencyType;
var modifications = new List<object>();
var total = 0;
foreach (var add in body.BalanceAdds ?? new())
{
var config = BalanceAddConfigValues(currencyType, add.BalanceAddType);
var multiplier = Math.Clamp(add.Multiplier <= 0 ? 1f : add.Multiplier, 0f, config.MaxPartialMultiplier);
var baseAward = config.IgnorePartialMultiplier
? config.BaseAward
: (int)Math.Round(config.BaseAward * multiplier);
baseAward = Math.Max(0, baseAward);
if (baseAward == 0) continue;
total += baseAward;
modifications.Add(RewardModification(add.BalanceAddType, baseAward, baseAward, currentCount: 1));
}
if (total > 0)
{
await level.GrantCurrencyAsync(pid, currencyType, total, "storefront-balance-reward");
await level.AwardXpAsync(pid, Math.Max(10, total / 2), "storefront-balance-reward");
}
var balance = await level.GetBalanceAsync(pid, currencyType);
return Ok(BalanceUpdateResponse(balance, currencyType, modifications));
}
public sealed class BalanceAddTypeRequest { public long Amount { get; set; } }
/// <summary>POST <c>api/storefronts/v1/balanceAddType/{type}/{playerId}</c>
/// — admin-only currency credit. Grants via
/// <see cref="LevelService.GrantCurrencyAsync"/>.</summary>
[HttpPost("api/storefronts/v1/balanceAddType/{type:int}/{playerId:long}")]
[Authorize]
public async Task<IActionResult> BalanceAddTypeAdmin(int type, long playerId, [FromBody] BalanceAddTypeRequest? body)
{
var me = this.RequireCurrentPlayerId();
var isAdmin = await db.Players.Where(p => p.Id == me).Select(p => p.IsAdmin).FirstOrDefaultAsync();
if (!isAdmin) return Forbid();
var amount = body?.Amount ?? 0L;
if (amount == 0) return Ok(new { Added = 0L });
var newBalance = await level.GrantCurrencyAsync(playerId, type, amount, $"balanceAddType:{me}");
return Ok(new
{
CurrencyType = type,
Balance = newBalance,
BalanceType = 0,
Platform = 0,
});
}
private static object BalanceAddConfig(int currencyType, int balanceAddType)
{
var cfg = BalanceAddConfigValues(currencyType, balanceAddType);
return new
{
CurrencyType = currencyType,
BalanceAddType = balanceAddType,
cfg.BaseAward,
BonusAwardMin = 0,
BonusAwardMax = 0,
RateLimitType = 0,
IgnorePartialMultiplier = cfg.IgnorePartialMultiplier,
MaxPartialMultiplier = (int)Math.Ceiling(cfg.MaxPartialMultiplier),
RateLimit = 0,
BalanceInGiftBox = false,
};
}
private static (int BaseAward, float MaxPartialMultiplier, bool IgnorePartialMultiplier)
BalanceAddConfigValues(int currencyType, int balanceAddType) => balanceAddType switch
{
1 => (100, 10f, false), // DirectBalanceWithMultiplier: Stunt Runner / banked loot.
10 => (25, 1f, true), // NUXChallenge.
11 => (100, 1f, true), // AllNUXChallenges.
100 => (25, 1f, true), // DailyChallenge.
101 => (150, 1f, true), // AllDailyChallenges.
200 => (50, 1f, true), // FinishActivity.
1000 => (100, 1f, true), // WonGame.
1001 => (25, 1f, true), // LostGame.
_ => (25, 1f, true),
};
private async Task<GrantBalanceRewardDto> ReadGrantBalanceRewardAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
var dto = new GrantBalanceRewardDto
{
CurrencyType = int.TryParse(form["CurrencyType"], out var ct) ? ct : 2,
};
if (int.TryParse(form["BalanceAddType"], out var bat) || int.TryParse(form["balanceAddType"], out bat))
{
dto.BalanceAdds.Add(new GrantBalanceRequest
{
BalanceAddType = bat,
Multiplier = float.TryParse(form["Multiplier"], out var mult) ? mult : 1f,
});
}
return dto;
}
try
{
var dto = await JsonSerializer.DeserializeAsync<GrantBalanceRewardDto>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return dto ?? new GrantBalanceRewardDto();
}
catch
{
return new GrantBalanceRewardDto();
}
}
private async Task<List<StorefrontObjectiveCompletionRecord>> ReadStorefrontObjectiveRecordsAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
if (!int.TryParse(form["objectiveType"], out var objectiveType)
&& !int.TryParse(form["ObjectiveType"], out objectiveType))
{
return new();
}
return new()
{
new StorefrontObjectiveCompletionRecord
{
ObjectiveType = objectiveType,
CompletionPercentage = float.TryParse(form["completionPercentage"], out var pct)
|| float.TryParse(form["CompletionPercentage"], out pct)
? pct
: 1f,
RoomId = long.TryParse(form["roomId"], out var roomId)
|| long.TryParse(form["RoomId"], out roomId)
? roomId
: null,
},
};
}
try
{
var rows = await JsonSerializer.DeserializeAsync<List<StorefrontObjectiveCompletionRecord>>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return rows ?? new();
}
catch
{
return new();
}
}
private async Task<bool> MarkRewardOnceAsync(long playerId, string rewardKey)
{
var key = $"reward:{rewardKey}";
if (await db.ObjectiveProgress.AnyAsync(o => o.PlayerId == playerId && o.Key == key && o.IsCompleted))
return false;
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
{
PlayerId = playerId,
Key = key,
IsCompleted = true,
ClearedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
return true;
}
private static object RewardModification(int balanceAddType, int baseAward, int total, int currentCount) => new
{
BalanceAddType = balanceAddType,
BaseAward = baseAward,
BonusAward = 0,
RateLimit = 0,
CurrentCount = currentCount,
Total = total,
BalanceType = 0,
BalanceInGiftBox = false,
};
private static object BalanceUpdateResponse(long balance, int currencyType, IEnumerable<object> modifications) => new
{
Balance = balance,
CurrencyType = currencyType,
BalanceType = 0,
BalanceUpdates = new[]
{
new
{
UpdateResponse = 0,
Data = modifications.Where(m => m is not null).ToArray(),
},
},
};
}
@@ -0,0 +1,195 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.TestCaseManagement;
/// <summary>
/// api.rec.net/api/testcasemanagement/* — internal QA tooling. URL
/// surface verified against
/// <c>Cpp2IL_ISIL/.../RecNet/TestCaseManagement.txt</c>:
///
/// GET api/testcasemanagement/v1/testpasssummary → List&lt;TestPass&gt;
/// GET api/testcasemanagement/v1/testpass/{id} → TestPass
/// GET api/testcasemanagement/v1/testcase/{id} → TestCase
/// POST api/testcasemanagement/v1/testcase/{id}/claim
/// POST api/testcasemanagement/v1/testcase/{id}/unclaim
/// POST api/testcasemanagement/v1/testcase/{id}/status
///
/// Wire DTOs match <c>RecNet.TestCase</c> + <c>RecNet.TestPass</c>
/// exactly:
///
/// • TestCase: <c>Id(string), Key, Title, Description, RoomName,
/// Status(TestCaseStatus enum 0=NotYetTested/1=Claimed/2=Failed/3=Passed),
/// MinNumAssignedPlayers, AssignedPlayerIds(List&lt;int&gt;),
/// AssignedPlayerNames, Tags, JiraUrl, JiraBugUrl</c>.
/// • TestPass: <c>Id(uint), Name, Description, StartDate, EndDate?,
/// WasManuallyClosed, TestCases(List&lt;TestCase&gt;), Tags,
/// NumTestCases, NumPassedTestCases, NumFailedTestCases</c>.
///
/// Routes are reachable by anyone (the watch only renders this tab
/// when the player has the developer role; client-side gate). The
/// claim/status mutations no-op if the caller isn't authenticated.
/// </summary>
[ApiController]
public class TestCaseManagementController(DorkNetDbContext db) : ControllerBase
{
[HttpGet("api/testcasemanagement/v1/testpasssummary")]
public async Task<IActionResult> TestPassSummaries()
{
var passes = await db.TestPasses.ToListAsync();
var caseCountsByPass = await db.TestCases
.Where(c => c.TestPassId != null)
.GroupBy(c => c.TestPassId!.Value)
.Select(g => new PassCounts(g.Key, g.Count(),
g.Count(c => c.Status == 3), g.Count(c => c.Status == 2)))
.ToDictionaryAsync(x => x.PassId);
return Ok(passes.Select(p => ToWirePass(p, new List<TestCaseEntity>(), caseCountsByPass)));
}
[HttpGet("api/testcasemanagement/v1/testpass/{id:long}")]
public async Task<IActionResult> TestPass(long id)
{
if (id < 0 || id > uint.MaxValue) return NotFound();
var pid = (uint)id;
var pass = await db.TestPasses.FirstOrDefaultAsync(p => p.Id == pid);
if (pass is null) return NotFound();
var cases = await db.TestCases.Where(c => c.TestPassId == pid).ToListAsync();
var counts = new Dictionary<uint, PassCounts>
{
[pid] = new(pid, cases.Count, cases.Count(c => c.Status == 3), cases.Count(c => c.Status == 2)),
};
return Ok(ToWirePass(pass, cases, counts));
}
private record PassCounts(uint PassId, int Total, int Passed, int Failed);
[HttpGet("api/testcasemanagement/v1/testcase/{id}")]
public async Task<IActionResult> TestCase(string id)
{
var tc = await db.TestCases.FirstOrDefaultAsync(c => c.Id == id);
if (tc is null) return NotFound();
return Ok(ToWireCase(tc));
}
[HttpPost("api/testcasemanagement/v1/testcase/{id}/claim")]
[Authorize]
public async Task<IActionResult> Claim(string id)
{
var pid = this.RequireCurrentPlayerId();
var tc = await db.TestCases.FirstOrDefaultAsync(c => c.Id == id);
if (tc is null) return NotFound();
var player = await db.Players.Where(p => p.Id == pid).Select(p => p.Username).FirstOrDefaultAsync();
var ids = ParseIntList(tc.AssignedPlayerIdsJson);
var names = ParseStringList(tc.AssignedPlayerNamesJson);
if (!ids.Contains((int)pid))
{
ids.Add((int)pid);
names.Add(player ?? $"Player {pid}");
tc.AssignedPlayerIdsJson = JsonSerializer.Serialize(ids);
tc.AssignedPlayerNamesJson = JsonSerializer.Serialize(names);
}
tc.Status = 1; // Claimed
tc.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWireCase(tc));
}
[HttpPost("api/testcasemanagement/v1/testcase/{id}/unclaim")]
[Authorize]
public async Task<IActionResult> Unclaim(string id)
{
var pid = this.RequireCurrentPlayerId();
var tc = await db.TestCases.FirstOrDefaultAsync(c => c.Id == id);
if (tc is null) return NotFound();
var ids = ParseIntList(tc.AssignedPlayerIdsJson);
var names = ParseStringList(tc.AssignedPlayerNamesJson);
var idx = ids.IndexOf((int)pid);
if (idx >= 0)
{
ids.RemoveAt(idx);
if (idx < names.Count) names.RemoveAt(idx);
tc.AssignedPlayerIdsJson = JsonSerializer.Serialize(ids);
tc.AssignedPlayerNamesJson = JsonSerializer.Serialize(names);
}
if (ids.Count == 0 && tc.Status == 1) tc.Status = 0; // back to NotYetTested
tc.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWireCase(tc));
}
public sealed class StatusUpdateRequest
{
public int NewStatus { get; set; }
}
[HttpPost("api/testcasemanagement/v1/testcase/{id}/status")]
[Authorize]
public async Task<IActionResult> UpdateStatus(string id, [FromBody] StatusUpdateRequest req)
{
var tc = await db.TestCases.FirstOrDefaultAsync(c => c.Id == id);
if (tc is null) return NotFound();
if (req.NewStatus is < 0 or > 3) return BadRequest("invalid status");
tc.Status = req.NewStatus;
tc.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWireCase(tc));
}
// ── Helpers ──────────────────────────────────────────────────────────
private static List<int> ParseIntList(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new();
try { return JsonSerializer.Deserialize<List<int>>(json) ?? new(); }
catch { return new(); }
}
private static List<string> ParseStringList(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new();
try { return JsonSerializer.Deserialize<List<string>>(json) ?? new(); }
catch { return new(); }
}
private static object ToWireCase(TestCaseEntity c) => new
{
c.Id,
c.Key,
c.Title,
c.Description,
c.RoomName,
c.Status,
c.MinNumAssignedPlayers,
AssignedPlayerIds = ParseIntList(c.AssignedPlayerIdsJson),
AssignedPlayerNames = ParseStringList(c.AssignedPlayerNamesJson),
Tags = ParseStringList(c.TagsJson),
c.JiraUrl,
c.JiraBugUrl,
};
private static object ToWirePass(TestPassEntity p, List<TestCaseEntity> cases,
IDictionary<uint, PassCounts> counts)
{
var c = counts.TryGetValue(p.Id, out var v) ? v : new PassCounts(p.Id, 0, 0, 0);
return new
{
p.Id,
p.Name,
p.Description,
p.StartDate,
p.EndDate,
p.WasManuallyClosed,
TestCases = cases.Select(ToWireCase),
Tags = ParseStringList(p.TagsJson),
NumTestCases = c.Total,
NumPassedTestCases = c.Passed,
NumFailedTestCases = c.Failed,
};
}
}
@@ -0,0 +1,22 @@
using Microsoft.AspNetCore.Mvc;
namespace DorkNet.Server.Controllers.API.Version;
[ApiController]
public class VersionController : ControllerBase
{
// VersionStatus enum: ValidForPlay=0, ValidForMenu=1, UpdateRequired=2
private static readonly object ValidForPlay = new { VersionStatus = 0 };
[HttpGet("api/version/v1")]
[HttpGet("api/version")]
public ActionResult<object> GetV1() => Ok(ValidForPlay);
// Actual path the 2020 build uses
[HttpGet("api/versioncheck/v4")]
[HttpGet("api/versioncheck/v3")]
[HttpGet("api/versioncheck/v2")]
[HttpGet("api/versioncheck/v1")]
[HttpGet("api/versioncheck")]
public ActionResult<object> GetCheck() => Ok(ValidForPlay);
}
@@ -0,0 +1,573 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Auth;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Accounts;
/// <summary>
/// accounts.rec.net — account management.
/// Shapes match dump.cs:
/// RecNet.Account { AccountId, RawUsername, Username, DisplayName,
/// ProfileImage, TreatAsJunior, HasBirthday, Platforms }
/// RecNet.SelfAccount Account + { Email, Phone, Birthday, JuniorState,
/// ParentAccountId }
/// RecNet.RecNetResult { Success, Error }
/// Accounts.CreateAccountResponse RecNetResult + { Account }
/// </summary>
[ApiController]
public class AccountsController(
PlayerService playerService,
AuthService authService,
OrphanAccountTracker orphans,
DorkNetDbContext db,
NotificationService notifications,
ServerSettingsService settings,
ILogger<AccountsController> logger) : ControllerBase
{
[HttpGet("/account/v1/savedlogins")]
[HttpGet("/account/v2/savedlogins")]
[HttpGet("/account/v1/cachedlogins")]
public async Task<IActionResult> GetSavedLogins(
[FromQuery] int platform = 0,
[FromQuery] string? platformId = null)
{
var players = await playerService.GetCachedLoginsAsync(platform, platformId);
return Ok(players.Select(p => new CachedLogin
{
Platform = platform,
PlatformId = platformId ?? string.Empty,
AccountId = (int)p.Id,
LastLoginTime = p.LastSeenAt,
RequirePassword = false,
}).ToList());
}
// Accounts.GetLocalAccount → SelfAccount shape.
// The 2020 client calls the unversioned /account/me path (observed in
// server logs); v1/v2 forms are kept for older builds and api.rec.net
// bridging. Returns SelfAccount, NOT base Account (this is the *self*
// endpoint — RecNet.Login.<>c.<LoginHelper>b__35_4 expects a SelfAccount).
[HttpGet("/account/me")]
[HttpGet("/account/v1/me")]
[HttpGet("/account/v2/me")]
[HttpGet("/account/v1/account")]
public async Task<IActionResult> GetMe()
{
var player = await GetCurrentPlayerAsync();
if (player is null) return Unauthorized();
return Ok(BuildSelfAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName));
}
/// <summary>
/// Resolves the player tied to the bearer token on the request. Returns
/// null when no/invalid bearer is supplied — the caller decides whether
/// that is a 401 (every "self" endpoint should be) or a noisier
/// fallback. Anonymous-account creation was previously the fallback
/// path; that's gone now because every caller has gone through
/// `/account/create` (deviceId-bound) before reaching us.
/// </summary>
private async Task<Data.Entities.PlayerEntity?> GetCurrentPlayerAsync()
{
var auth = Request.Headers.Authorization.ToString();
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return null;
var token = auth["Bearer ".Length..].Trim();
var id = authService.ValidateToken(token);
if (id is not long playerId) return null;
return await playerService.GetByIdAsync(playerId);
}
// Accounts.GetAccountById → Account shape. The 2020 watch posts to
// the BARE <c>/account/{id}</c> path (no <c>v1</c> prefix) when
// resolving a room's creator from RoomDetails — verified in the
// output_log "Failed to GetAccountById: HTTP Error 404" trace where
// PlayerDetailsWatchUIFlow.SetAccount NREs because the GetAccountById
// promise rejected with 404. Accept both shapes.
[HttpGet("/account/{accountId:long}")]
[HttpGet("/account/v1/{accountId:long}")]
[HttpGet("/account/v1/account/{accountId:long}")]
public async Task<IActionResult> GetAccountById(long accountId)
{
var player = await playerService.GetByIdAsync(accountId);
if (player is null) return NotFound();
return Ok(BuildAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName));
}
// Accounts.GetAccountByUsername
[HttpGet("/account/v1/username/{username}")]
public async Task<IActionResult> GetAccountByUsername(string username)
{
var player = await playerService.GetByUsernameAsync(username);
if (player is null) return NotFound();
return Ok(BuildAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName));
}
// Accounts.CreateAccount — actual URL observed in server logs:
// POST accounts.rec.net/account/create
// body: platform=0&platformId=<steamId>&deviceId=<hash>
// Game expects CreateAccountResponse {Success, Error, Account}. Lambda
// <CreateAccount>b__29_0 NPEs if response.Account is null after deserializing.
// Clean PascalCase here — multi-case keys appear to confuse LitJson's typed
// path so its TypeMap.Add throws "Key: none" or leaves Account unpopulated.
[HttpPost("/account/create")]
[HttpPost("/account/v1/create")]
[HttpPost("/account/v1")]
[HttpPost("/account/v2/create")]
public async Task<IActionResult> CreateAccount(
[FromForm] int platform,
[FromForm] string? platformId,
[FromForm] string? deviceId)
{
// Admin kill-switch. Watch's <CreateAccount>b__29_0 lambda
// (Accounts_NestedType___c.txt:311) branches on Success: false
// → ErroredPromise<Account> with the Error string surfaced to
// the player; true → reads response.Account. Account stays a
// non-null stub even on refusal so LitJson's typed Deserialize
// path doesn't fault before the Success check runs.
if (await settings.AreSignupsDisabledAsync())
{
logger.LogInformation(
"[accounts] account/create refused — signups disabled (device={Device} platform={Platform} platformId={PlatformId})",
deviceId, platform, platformId);
return Ok(new CreateAccountResponse
{
Success = false,
Error = "Account creation is currently disabled by the server admin.",
});
}
// The 2020 watch's "Create new account" UI and its boot-time
// "ensure my account exists" probe BOTH hit this endpoint with
// identical request bodies. To support multi-account-per-device
// we always create a brand-new row; the boot-flow case (where
// the user actually wants their cached account) is handled by
// the OrphanAccountTracker — when the next cached_login picks
// a DIFFERENT account_id, that handler deletes this just-
// created row.
var player = await playerService.CreateNewAccountAsync(
deviceId: deviceId,
platform: platform,
platformId: platformId);
orphans.TrackCreation(deviceId, platformId, player.Id);
logger.LogInformation(
"[accounts] account/create created {Id} ({Username}) for device {Device} platform={Platform} platformId={PlatformId}",
player.Id, player.Username, deviceId, platform, platformId);
return Ok(new CreateAccountResponse
{
Success = true,
Error = string.Empty,
Account = BuildAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName),
});
}
// Accounts.SearchForAccounts — used by the watch's typeahead AND the
// localhost public site's account search. Anonymous-safe (no JWT
// required) so the front-facing site can call it directly.
[HttpGet("/account/v1/search")]
[Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Search([FromQuery] string? query, [FromQuery] int take = 20)
{
if (string.IsNullOrWhiteSpace(query)) return Ok(Array.Empty<object>());
var clamped = Math.Clamp(take, 1, 50);
var matches = await playerService.SearchAsync(query.Trim(), clamped);
return Ok(matches
.Select(p => BuildAccount(p.Id, p.Username, p.DisplayName, p.ProfileImageName))
.ToList());
}
// Accounts.GetAccountsBulk / GetAccountsFromPlatformIds / FromEmails / FromPhones
// Form body: Ids=1,2,3 (BestHTTP HTTPFormBase.AddField). Accept both
// GET and POST and any content type so route matching succeeds even
// when the client sends multipart vs urlencoded.
[HttpPost("/account/v1/bulk")]
[HttpGet("/account/v1/bulk")]
[HttpPost("/account/bulk")]
[HttpGet("/account/bulk")]
[HttpPost("/account/v1/platformids")]
[HttpPost("/account/v1/emails")]
[HttpPost("/account/v1/phones")]
[HttpPost("/account/v1/byplatformid")]
public async Task<IActionResult> Bulk()
{
// The watch calls this route family with payloads keyed by
// either Ids (account ids), Emails (email addresses), or
// Phones (E.164 strings) depending on which Accounts.GetXxx
// helper fired it. Read all three plus the lowercase / singular
// variants the cached-account hydration uses.
string? rawIds = null;
string? rawEmails = null;
string? rawPhones = null;
try
{
if (Request.HasFormContentType)
{
rawIds = NonEmptyOrNull(Request.Form["Ids"].ToString())
?? NonEmptyOrNull(Request.Form["ids"].ToString())
?? NonEmptyOrNull(Request.Form["Id"].ToString())
?? NonEmptyOrNull(Request.Form["id"].ToString());
rawEmails = NonEmptyOrNull(Request.Form["Emails"].ToString())
?? NonEmptyOrNull(Request.Form["emails"].ToString())
?? NonEmptyOrNull(Request.Form["Email"].ToString())
?? NonEmptyOrNull(Request.Form["email"].ToString());
rawPhones = NonEmptyOrNull(Request.Form["Phones"].ToString())
?? NonEmptyOrNull(Request.Form["phones"].ToString())
?? NonEmptyOrNull(Request.Form["Phone"].ToString())
?? NonEmptyOrNull(Request.Form["phone"].ToString());
}
}
catch { /* form parse may fail on non-form bodies */ }
rawIds ??= NonEmptyOrNull(Request.Query["Ids"].ToString())
?? NonEmptyOrNull(Request.Query["ids"].ToString())
?? NonEmptyOrNull(Request.Query["Id"].ToString())
?? NonEmptyOrNull(Request.Query["id"].ToString());
static string? NonEmptyOrNull(string? s) => string.IsNullOrWhiteSpace(s) ? null : s;
// Build the set of player ids to hydrate from whatever the
// caller sent. Each source contributes additively — the watch
// never mixes (Ids + Emails) in one call, but the union is
// harmless when it happens.
var ids = new HashSet<long>();
if (rawIds is not null)
{
foreach (var n in rawIds.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
if (long.TryParse(n, out var v) && v > 0) ids.Add(v);
}
if (rawEmails is not null)
{
var emails = rawEmails.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(e => e.ToLowerInvariant())
.Where(e => e.Length > 0)
.ToList();
if (emails.Count > 0)
{
var matched = await db.Players
.Where(p => p.Email != null && emails.Contains(p.Email.ToLower()))
.Select(p => p.Id)
.ToListAsync();
foreach (var id in matched) ids.Add(id);
}
}
if (rawPhones is not null)
{
var phones = rawPhones.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(p => p.Length > 0)
.ToList();
if (phones.Count > 0)
{
var matched = await db.Players
.Where(p => p.Phone != null && phones.Contains(p.Phone))
.Select(p => p.Id)
.ToListAsync();
foreach (var id in matched) ids.Add(id);
}
}
if (ids.Count == 0) return Ok(Array.Empty<object>());
// Strip ids the caller has blocked, and ids that have blocked the
// caller. This prevents `bulk` from being used as a cheap oracle
// ("who blocked me?"). When the caller is anonymous (no JWT) we
// skip the filter entirely — `bulk` is also used during boot for
// public profile hydration before the player has authenticated.
var me = this.CurrentPlayerId();
if (me is long callerId)
{
var blocked = await db.Relationships
.Where(r => r.Status == RelationshipStatus.Blocked &&
((r.RequesterId == callerId && ids.Contains(r.TargetId)) ||
(r.TargetId == callerId && ids.Contains(r.RequesterId))))
.Select(r => r.RequesterId == callerId ? r.TargetId : r.RequesterId)
.ToListAsync();
foreach (var b in blocked) ids.Remove(b);
}
var accounts = new List<object>();
foreach (var id in ids)
{
var p = await playerService.GetByIdAsync(id);
if (p is not null)
accounts.Add(BuildAccount(p.Id, p.Username, p.DisplayName, p.ProfileImageName));
}
return Ok(accounts);
}
// Accounts.GetNameGenerationOptions → NameGenDTO {Nouns, Adjectives}
[HttpGet("/account/v1/namegen")]
[HttpGet("/account/v1/namegeneration")]
public IActionResult NameGen() => Ok(new
{
Nouns = new[] { "Player", "Hero", "Star", "Fox", "Wolf" },
Adjectives = new[] { "Brave", "Swift", "Clever", "Bold", "Bright" },
});
// Phone-confirm is the one mutation we don't persist — there's no
// SMS provider in the loop. Acknowledge silently.
[HttpPost("/account/v1/confirmphone")]
public IActionResult ConfirmPhone() =>
Ok(new RecNetResult { Success = true, Error = string.Empty });
/// <summary>POST /account/v1/birthday — sets the player's
/// birthday and updates IsJunior accordingly. Rec Room's junior
/// cutoff is &lt;13 years old; we recompute it on every birthday
/// write so a freshly turned 13 player loses the junior flag the
/// next time they update.</summary>
[HttpPost("/account/v1/birthday")]
[HttpPost("/account/me/birthday")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> SetBirthday([FromForm(Name = "birthday")] string? birthday)
{
if (string.IsNullOrWhiteSpace(birthday) ||
!DateTime.TryParse(birthday, out var bday))
return Ok(new RecNetResult { Success = false, Error = "invalid_date" });
var player = await GetCurrentPlayerAsync();
if (player is null) return Unauthorized();
player.Birthday = bday.Date;
player.IsJunior = AgeYears(bday) < 13;
await db.SaveChangesAsync();
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
/// <summary>POST /account/v1/email — set or clear contact email.
/// Persisted verbatim; format isn't validated server-side, the
/// in-game email keyboard does it.</summary>
[HttpPost("/account/v1/email")]
[HttpPost("/account/me/email")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> SetEmail([FromForm(Name = "email")] string? email)
{
var player = await GetCurrentPlayerAsync();
if (player is null) return Unauthorized();
player.Email = string.IsNullOrWhiteSpace(email) ? null : email.Trim();
await db.SaveChangesAsync();
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
/// <summary>POST /account/v1/phone — set or clear contact phone.</summary>
[HttpPost("/account/v1/phone")]
[HttpPost("/account/me/phone")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> SetPhone([FromForm(Name = "phone")] string? phone)
{
var player = await GetCurrentPlayerAsync();
if (player is null) return Unauthorized();
player.Phone = string.IsNullOrWhiteSpace(phone) ? null : phone.Trim();
await db.SaveChangesAsync();
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
/// <summary>POST /account/v1/profileimage — body: imageName=...
/// or fileName=... The client first POSTs the bytes to
/// <c>storage.rec.net/upload</c> with FileType=Image, gets back a
/// Filename, then sends that filename here. We persist it on
/// PlayerEntity.ProfileImageName; reads from /account/me +
/// /account/v1/{id} surface it.</summary>
[HttpPost("/account/v1/profileimage")]
[HttpPost("/account/me/profileimage")]
[HttpPost("/profileimage")]
[HttpPut("/account/v1/profileimage")]
[HttpPut("/account/me/profileimage")]
[HttpPut("/profileimage")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> SetProfileImage(
[FromForm(Name = "imageName")] string? imageName,
[FromForm(Name = "fileName")] string? fileName,
[FromForm(Name = "image")] string? imageAlt)
{
// Fall back to query string for the rare client paths that
// send these fields as URL params instead of form fields.
var name = imageName ?? fileName ?? imageAlt
?? Request.Query["imageName"].ToString()
?? Request.Query["fileName"].ToString();
var player = await GetCurrentPlayerAsync();
if (player is null) return Unauthorized();
player.ProfileImageName = string.IsNullOrWhiteSpace(name) ? null : name.Trim();
await db.SaveChangesAsync();
logger.LogInformation(
"[accounts] profile image set: player={PlayerId} → {Name}",
player.Id, player.ProfileImageName ?? "<cleared>");
await NotifyAccountImageChangedAsync(player);
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
private Task NotifyAccountImageChangedAsync(PlayerEntity player)
{
var account = BuildAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName);
var selfAccount = BuildSelfAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName);
return Task.WhenAll(
notifications.NotifyTypedAsync(player.Id, "AccountUpdate", account),
notifications.NotifyTypedAsync(player.Id, "SelfAccountUpdate", selfAccount));
}
private static int AgeYears(DateTime birthday)
{
var today = DateTime.UtcNow.Date;
var age = today.Year - birthday.Year;
if (birthday.Date > today.AddYears(-age)) age--;
return age;
}
// Everyone is a dev for now — exposes the in-game debug console + dev
// tools. Per user request: easier debugging while iterating on the
// private server. Flip back to false later if you want shipping behavior.
[HttpGet("/account/v1/{accountId:long}/isdeveloper")]
[HttpGet("/account/me/isdeveloper")]
public IActionResult IsDeveloper(long accountId = 0) => Ok(new { IsDeveloper = true });
/// <summary>
/// PUT /account/me/username — body: username=NewName
/// Real handler that persists the change to the DB. Was previously
/// going to GlobalCatchAllController and returning the shotgun, which
/// meant username changes didn't actually save.
/// </summary>
[HttpPut("/account/me/username")]
[HttpPost("/account/me/username")]
[HttpPost("/account/v1/username")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> ChangeUsername([FromForm(Name = "username")] string? username)
{
if (string.IsNullOrWhiteSpace(username))
return Ok(new { success = false, error = "Username required" });
var player = await GetCurrentPlayerAsync();
if (player is null) return Unauthorized();
await playerService.UpdateUsernameAsync(player.Id, username.Trim());
var updated = await playerService.GetByIdAsync(player.Id);
return Ok(new
{
success = true,
error = "",
account = new
{
accountId = (int)player.Id,
username = updated?.Username ?? username,
displayName = updated?.DisplayName ?? username,
},
});
}
/// <summary>
/// PUT /account/me/displayname — same flow as username but only updates
/// the DisplayName field (Username stays as-is).
/// </summary>
[HttpPut("/account/me/displayname")]
[HttpPost("/account/me/displayname")]
[HttpPost("/account/v1/displayname")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> ChangeDisplayName([FromForm(Name = "displayName")] string? displayName)
{
if (string.IsNullOrWhiteSpace(displayName))
return Ok(new { success = false, error = "DisplayName required" });
var player = await GetCurrentPlayerAsync();
if (player is null) return Unauthorized();
await playerService.UpdateDisplayNameAsync(player.Id, displayName.Trim());
return Ok(new { success = true, error = "" });
}
/// <summary>
/// PUT /account/me/bio — body: bio=...
/// </summary>
[HttpPut("/account/me/bio")]
[HttpPost("/account/me/bio")]
[HttpPost("/account/v1/bio")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> ChangeBio([FromForm(Name = "bio")] string? bio)
{
var player = await GetCurrentPlayerAsync();
if (player is null) return Unauthorized();
await playerService.UpdateBioAsync(player.Id, bio ?? string.Empty);
return Ok(new { success = true, error = "" });
}
// ParentalControl.Deserialize at RVA 0x14543A0 — required keys:
// accountId (int, GetKey)
// disallowInAppPurchases (bool, GetKey)
// Returning {} crashes the client with
// "Failed to GetLocalAccountParentalControl: Malformed Response".
[HttpGet("/parentalcontrol/me")]
[HttpGet("/parentalcontrol/v1/me")]
public async Task<IActionResult> ParentalControlMe()
{
var player = await GetCurrentPlayerAsync();
if (player is null) return Unauthorized();
return Ok(new
{
accountId = (int)player.Id,
disallowInAppPurchases = false,
});
}
[HttpGet("/parentalcontrol/{accountId:long}")]
[HttpGet("/parentalcontrol/v1/{accountId:long}")]
public IActionResult ParentalControlById(long accountId) => Ok(new
{
accountId = (int)accountId,
disallowInAppPurchases = false,
});
[HttpGet("/account/v1/me/disallowiap")]
public IActionResult DisallowIap() => Ok(new { Disallow = false });
/// <summary>GET /account/{id}/bio — public bio lookup. Watch URL
/// from <c>Cpp2IL_ISIL/.../RecNet/Accounts.txt</c> line 5686:
/// <c>"account/{0}/bio"</c> (no v1 prefix). Wire shape from
/// BioDTO.txt: required keys <c>"accountId"</c> (int, Util.GetKey)
/// and <c>"bio"</c> (string, optional via the GetKeyOrDefault-
/// flavoured 0x180EE6770 helper). Returning the previous
/// <c>{ Bio = ... }</c> PascalCase shape made the watch's
/// <c>BioDTO.Deserialize</c> KeyNotFoundException on
/// <c>accountId</c>, which surfaced as "Failed to GetBio for
/// accountId N: Malformed Response" in the client log.</summary>
[HttpGet("/account/{accountId:long}/bio")]
[HttpGet("/account/v1/{accountId:long}/bio")]
public async Task<IActionResult> GetBio(long accountId)
{
var p = await playerService.GetByIdAsync(accountId);
return Ok(new { accountId = (int)accountId, bio = p?.Bio ?? string.Empty });
}
[HttpGet("/account/v1/{accountId:long}/platformid")]
public IActionResult GetPlatformId(long accountId) => Ok(new { PlatformId = 0L });
// Previously had a /{*path} catch-all that returned [] / {}. Removed —
// unknown accounts URLs now 404 so we notice them in logs and wire
// them properly rather than silently masking missing endpoints.
private static RecNetAccount BuildAccount(long id, string username, string? displayName, string? profileImage = null)
=> new()
{
AccountId = (int)id,
RawUsername = username,
Username = username,
DisplayName = displayName ?? username,
ProfileImage = profileImage ?? string.Empty,
TreatAsJunior = false,
HasBirthday = true,
Platforms = 1,
};
private static RecNetSelfAccount BuildSelfAccount(long id, string username, string? displayName, string? profileImage = null)
=> new()
{
AccountId = (int)id,
RawUsername = username,
Username = username,
DisplayName = displayName ?? username,
ProfileImage = profileImage ?? string.Empty,
TreatAsJunior = false,
HasBirthday = true,
Platforms = 1,
Email = string.Empty,
Phone = string.Empty,
Birthday = new DateTime(2000, 1, 1),
JuniorState = 0,
ParentAccountId = null,
};
}
@@ -0,0 +1,2920 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Hubs;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Admin;
/// <summary>
/// api.rec.net/api/admin/v1/* — privileged moderation endpoints. Every
/// action is gated by <see cref="AdminOnlyAttribute"/>, which checks
/// <see cref="PlayerEntity.IsAdmin"/>; every state mutation is logged
/// to <see cref="AdminActionEntity"/> for audit replay.
///
/// The first account created on a fresh DB gets <c>IsAdmin = true</c>
/// (see <see cref="PlayerService.GetOrCreateByDeviceAsync"/>) so a
/// freshly-bootstrapped private server always has at least one root
/// admin without manual SQL.
/// </summary>
[ApiController]
[Route("api/admin/v1")]
[Authorize]
[AdminOnly]
public class AdminController(
DorkNetDbContext db,
NotificationService notifications,
LevelService level,
CommunityBoardService communityBoard,
RoomBlobNormalizerService roomBlobNormalizer,
HtrAssetMirrorService htrMirror,
OnlinePresenceService onlinePresence,
PlayerPresenceService playerPresence,
PlayerLogService playerLog,
ServerSettingsService serverSettings,
IObjectStorage storage,
DomainConfig domain,
ILogger<AdminController> adminLogger) : ControllerBase
{
private long CurrentAdminId => this.RequireCurrentPlayerId();
// ── Player request logs ──────────────────────────────────────────────
/// <summary>Most recent HTTP requests made by a single player, newest
/// first. Backed by <see cref="PlayerLogService"/>'s Redis ring buffer
/// (in-process fallback for dev) — capped at
/// <see cref="PlayerLogService.MaxEntriesPerPlayer"/> entries per
/// player with a 7-day key TTL. Used by the admin UI's "Player logs"
/// tab to spot what API call a player was making when they hit a bug.</summary>
[HttpGet("players/{id:long}/logs")]
public ActionResult PlayerLogs(long id, [FromQuery] int take = 200)
{
var entries = playerLog.GetRecent(id, take);
return Ok(entries);
}
// ── Players ──────────────────────────────────────────────────────────
/// <summary>List players with online status, paginated. Used by the
/// admin watch tab. Default page size 50, max 200 to keep responses
/// reasonable.</summary>
[HttpGet("players")]
public async Task<ActionResult> ListPlayers(
[FromQuery] string? query, [FromQuery] int take = 50, [FromQuery] int skip = 0)
{
take = Math.Clamp(take, 1, 200);
skip = Math.Max(0, skip);
IQueryable<PlayerEntity> q = db.Players.OrderByDescending(p => p.LastSeenAt);
if (!string.IsNullOrWhiteSpace(query))
q = q.Where(p => p.Username.Contains(query) || p.DisplayName.Contains(query));
var page = await q.Skip(skip).Take(take).ToListAsync();
var onlineSet = onlinePresence.OnlinePlayerIds().ToHashSet();
return Ok(page.Select(p => new
{
p.Id,
p.Username,
p.DisplayName,
p.Email,
p.IsAdmin,
p.IsDeveloper,
p.IsCommunityTeam,
p.IsVerified,
p.IsJunior,
p.BannedUntil,
p.LastIp,
p.LastSeenAt,
p.CreatedAt,
p.Level,
p.XP,
p.ProfileImageName,
Online = onlineSet.Contains(p.Id),
}));
}
/// <summary>Single-player detail — bundles the player record plus
/// their currency balances so the admin UI can render an edit page
/// in one round trip.</summary>
[HttpGet("players/{id:long}")]
public async Task<ActionResult> GetPlayer(long id)
{
var p = await db.Players.FirstOrDefaultAsync(x => x.Id == id);
if (p is null) return NotFound();
var balances = await db.CurrencyBalances
.Where(c => c.PlayerId == id)
.Select(c => new { c.CurrencyType, c.Balance })
.ToListAsync();
var avatar = await db.Avatars
.Where(a => a.PlayerId == id)
.Select(a => new { a.OutfitSelections, a.HairColor, a.SkinColor, a.FaceFeatures })
.FirstOrDefaultAsync();
return Ok(new
{
p.Id,
p.Username,
p.DisplayName,
p.Bio,
p.Email,
p.IsAdmin,
p.IsDeveloper,
p.IsCommunityTeam,
p.IsVerified,
p.IsJunior,
p.BannedUntil,
p.LastIp,
p.LastSeenAt,
p.CreatedAt,
p.Level,
p.XP,
p.ProfileImageName,
Online = onlinePresence.OnlinePlayerIds().Contains(p.Id),
Balances = balances,
Avatar = avatar,
});
}
public sealed record BanRequest(int DurationDays, string? Reason);
/// <summary>Ban a player for the given number of days. The
/// <see cref="BanCheckMiddleware"/> 401s any subsequent request
/// from the player until <c>BannedUntil</c> elapses; we also push
/// a <c>ModerationKick</c> notification so the watch sees the ban
/// immediately.</summary>
[HttpPost("players/{id:long}/ban")]
public async Task<ActionResult> BanPlayer(long id, [FromBody] BanRequest body)
{
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == id);
if (player is null) return NotFound();
if (player.IsAdmin) return BadRequest("Cannot ban an admin. Demote first.");
var days = Math.Clamp(body.DurationDays, 1, 3650);
player.BannedUntil = DateTime.UtcNow.AddDays(days);
await LogAsync("ban_player", "player", id, body.Reason ?? "");
await db.SaveChangesAsync();
await notifications.NotifyAsync(id, PushNotificationId.ModerationKick,
new { Reason = body.Reason ?? "", Until = player.BannedUntil });
return Ok(new { player.Id, player.BannedUntil });
}
[HttpPost("players/{id:long}/unban")]
public async Task<ActionResult> UnbanPlayer(long id, [FromBody] BanRequest? body)
{
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == id);
if (player is null) return NotFound();
player.BannedUntil = null;
await LogAsync("unban_player", "player", id, body?.Reason ?? "");
await db.SaveChangesAsync();
return Ok(new { player.Id, player.BannedUntil });
}
[HttpPost("players/{id:long}/promote")]
public async Task<ActionResult> Promote(long id)
{
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == id);
if (player is null) return NotFound();
player.IsAdmin = true;
await LogAsync("promote_admin", "player", id, "");
await db.SaveChangesAsync();
return Ok(new { player.Id, player.IsAdmin });
}
[HttpPost("players/{id:long}/demote")]
public async Task<ActionResult> Demote(long id)
{
// Refuse to demote yourself — guards against the last admin
// accidentally dropping privileges and being unable to recover.
if (id == CurrentAdminId)
return BadRequest("Cannot demote yourself. Have another admin do it.");
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == id);
if (player is null) return NotFound();
player.IsAdmin = false;
await LogAsync("demote_admin", "player", id, "");
await db.SaveChangesAsync();
return Ok(new { player.Id, player.IsAdmin });
}
// ── Rooms ────────────────────────────────────────────────────────────
public sealed record DeleteRoomRequest(string? Reason);
/// <summary>Soft-delete a room by setting <c>State = 1</c>
/// (Archived). The room stops appearing in browse/search results;
/// its data is still retrievable for an undelete if needed.</summary>
/// <summary>GET <c>api/admin/v1/diagnostics/room-images</c> —
/// emergency diagnostic for "room tile shows smiley" symptoms.
/// Returns one row per seeded room with: the DB ImageName, whether
/// the corresponding file exists in <c>data/images/</c>, and the
/// fully-qualified URL the client would fetch. Lets us tell at a
/// glance whether the bug is (a) DB never got real CDN names from
/// the backfill, (b) PNG missing on disk, or (c) wrong URL host
/// being served by the config endpoint.</summary>
[HttpGet("diagnostics/room-images")]
public async Task<IActionResult> DiagnosticsRoomImages()
{
var imgDir = Path.Combine(AppContext.BaseDirectory, "data", "images");
var rooms = await db.Rooms
.Where(r => r.Id >= 100 && r.Id < 1000)
.OrderBy(r => r.Id)
.Select(r => new { r.Id, r.Name, r.ImageName })
.ToListAsync();
var rows = rooms.Select(r =>
{
var path = Path.Combine(imgDir, r.ImageName ?? string.Empty);
return new
{
r.Id,
r.Name,
r.ImageName,
FileExists = !string.IsNullOrEmpty(r.ImageName) && System.IO.File.Exists(path),
FullPath = path,
FetchUrl = $"https://{domain.Sub("img")}/{r.ImageName}",
};
}).ToList();
return Ok(new
{
ImageDirOnDisk = imgDir,
ImageDirExists = Directory.Exists(imgDir),
ImageFileCount = Directory.Exists(imgDir) ? Directory.GetFiles(imgDir).Length : 0,
RoomImagesJsonExists = System.IO.File.Exists(
Path.Combine(AppContext.BaseDirectory, "data", "room_images.json")),
Rooms = rows,
});
}
/// <summary>GET <c>api/admin/v1/players/{id}/clothing-debug</c> —
/// inspect a single player's wardrobe state. Shows the raw
/// AvatarEntity columns the watch reads (OutfitSelections,
/// InventoryJson, HairColor, SkinColor), the parsed-and-filtered
/// list of items the player will see in their wardrobe drawer
/// (intersection of InventoryJson with the bundled safelist),
/// and any GUIDs in InventoryJson that AREN'T in the safelist
/// (those would crash the watch's parser if shipped — silently
/// dropped by AvatarItemsController.Get(), but worth knowing
/// they're sitting in the inventory).
///
/// Mostly here as a one-call sanity check after the avatar
/// pipeline overhaul: hit it against the player ID, eyeball the
/// drawer-count, confirm the ratio matches what's in the DB.</summary>
[HttpGet("players/{id:long}/clothing-debug")]
public async Task<ActionResult> ClothingDebug(long id)
{
var avatar = await db.Avatars
.Where(a => a.PlayerId == id)
.Select(a => new
{
a.OutfitSelections,
a.InventoryJson,
a.HairColor,
a.SkinColor,
a.FaceFeatures,
a.UpdatedAt,
})
.FirstOrDefaultAsync();
if (avatar is null) return NotFound(new { Message = $"No AvatarEntity for player {id}" });
var safe = DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.SafeGuids;
var catalog = DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.Catalog;
// Parse the player's inventory + intersect against the bundled
// safelist. Items that pass through end up in
// /api/avatar/v4/items as renderable wardrobe entries; items
// that fail are silently dropped at request time.
List<string> inventoryGuids;
try
{
inventoryGuids = System.Text.Json.JsonSerializer
.Deserialize<List<string>>(avatar.InventoryJson ?? "[]") ?? new();
}
catch { inventoryGuids = new(); }
var owned = inventoryGuids.Where(g => safe.Contains(g)).ToList();
var unsafeGuids = inventoryGuids.Where(g => !safe.Contains(g)).ToList();
// Parse OutfitSelections (the equipped loadout) the same way
// the watch does — comma-separated, each segment a GUID. Show
// which slot each one resolves to + whether it's in the safe
// catalog.
var equipped = (avatar.OutfitSelections ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(g => g.Trim())
.Where(g => g.Length > 0)
.Select(g => new
{
Guid = g,
InSafelist = safe.Contains(g),
FriendlyName = catalog.TryGetValue(g, out var c) ? c.FriendlyName : null,
})
.ToList();
return Ok(new
{
PlayerId = id,
avatar.UpdatedAt,
avatar.HairColor,
avatar.SkinColor,
avatar.FaceFeatures,
OutfitSelections = avatar.OutfitSelections,
EquippedItems = equipped,
InventoryRaw = avatar.InventoryJson,
InventoryCount = inventoryGuids.Count,
OwnedAndRenderable = owned.Count,
OwnedAndRenderableGuids = owned,
UnsafeOwnedGuids = unsafeGuids,
CatalogTotal = catalog.Count,
SafelistTotal = safe.Count,
});
}
/// <summary>GET <c>api/admin/v1/rooms</c> — flat list of every
/// room in the DB (id + name + creator + blob count) for the admin
/// UI's room-pickers. Custom (AG) rooms first, then RR Originals;
/// each ordered by name. The blob count helps the .htr mirror
/// dropdown skip rooms that have no blobs to scan.</summary>
[HttpGet("rooms")]
public async Task<ActionResult> ListAllRooms()
{
var rooms = await db.Rooms
.OrderByDescending(r => r.IsAGRoom)
.ThenBy(r => r.Name)
.Select(r => new
{
r.Id,
r.Name,
r.IsAGRoom,
r.IsDormRoom,
r.CreatorPlayerId,
BlobCount = db.RoomDataBlobs.Count(b => b.RoomId == r.Id),
})
.ToListAsync();
return Ok(rooms);
}
/// <summary>GET <c>api/admin/v1/rooms/{id}/visitors</c> — most
/// recent unique visitors of a room. Joins
/// <see cref="RoomVisitEntity"/> against <see cref="PlayerEntity"/>
/// so the admin UI can show "who's been here lately" with
/// usernames + per-player visit counts.</summary>
[HttpGet("rooms/{id:long}/visitors")]
public async Task<ActionResult> RoomVisitors(long id, [FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 200);
var rows = await (from v in db.RoomVisits
join p in db.Players on v.PlayerId equals p.Id
where v.RoomId == id
orderby v.LastVisitAt descending
select new
{
PlayerId = v.PlayerId,
p.Username,
p.DisplayName,
v.VisitCount,
v.FirstVisitAt,
v.LastVisitAt,
})
.Take(take)
.ToListAsync();
return Ok(rows);
}
[HttpDelete("rooms/{id:long}")]
public async Task<ActionResult> DeleteRoom(long id, [FromBody] DeleteRoomRequest? body)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
if (room is null) return NotFound();
room.State = 1; // Archived
await LogAsync("delete_room", "room", id, body?.Reason ?? "");
await db.SaveChangesAsync();
return Ok(new { room.Id, room.State });
}
// The bulk "purge every custom room" endpoint was removed: a single
// wrong click could wipe every imported map in one shot. Per-room
// purge (below) is the only hard-delete path, gated by two
// independent room-name typed confirmations to make accidental
// wipes effectively impossible.
public sealed record HardDeleteRoomRequest(
string? Reason,
// The admin UI prompts for the room name TWICE in separate
// inputs — both have to match the room's actual Name (exactly,
// case-sensitive) before the request will fire. Same pattern
// GitHub uses for repository deletion.
string? ConfirmName1,
string? ConfirmName2);
/// <summary>POST <c>api/admin/v1/rooms/{id}/purge</c> — hard-delete a
/// single custom room. Refuses to act on TRUE RR Originals (owned
/// by the system seed account) or dorms; for those, the soft-delete
/// via DELETE /rooms/{id} is the only supported path so a fat
/// finger doesn't wipe canonical content.
///
/// A user-cloned room often inherits <c>IsAGRoom=true</c> from its
/// source — that doesn't make it canonical, it just means the
/// matchmaker uses the AG-room join path. Gate purely on
/// <c>CreatorPlayerId == SystemAccountId</c>, which is the only
/// reliable "is this actually a seeded RR Original?" signal.
///
/// Requires BOTH <c>ConfirmName1</c> and <c>ConfirmName2</c> in the
/// body to exactly match the room's current Name. Both must match
/// independently — the SPA renders two separate inputs and the
/// admin types the name twice. Returns 400 with
/// <c>confirm_name_mismatch</c> if either is missing/wrong so the
/// SPA can surface the error inline without leaking which one
/// failed.</summary>
[HttpPost("rooms/{id:long}/purge")]
public async Task<ActionResult> HardDeleteRoom(long id, [FromBody] HardDeleteRoomRequest? body)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
if (room is null) return NotFound();
if (room.IsDormRoom)
return BadRequest(new { error = "refuses_to_hard_delete_dorm", note = "Dorms are per-player state; never purge." });
if (room.IsAGRoom && room.CreatorPlayerId == PlayerService.SystemAccountId)
return BadRequest(new { error = "refuses_to_hard_delete_seeded_room", note = "This row is owned by the system seed account — soft-archive via DELETE /rooms/{id} instead." });
var confirm1 = body?.ConfirmName1 ?? string.Empty;
var confirm2 = body?.ConfirmName2 ?? string.Empty;
if (!string.Equals(confirm1, room.Name, StringComparison.Ordinal)
|| !string.Equals(confirm2, room.Name, StringComparison.Ordinal))
{
return BadRequest(new
{
error = "confirm_name_mismatch",
note = "Both ConfirmName1 and ConfirmName2 must exactly equal the room's current Name (case-sensitive).",
});
}
var scenes = await db.RoomScenes.Where(s => s.RoomId == id).ToListAsync();
var roomBlobs = await db.RoomDataBlobs.Where(b => b.RoomId == id).ToListAsync();
RoomDataBlobEntity? imageBlob = null;
if (!string.IsNullOrEmpty(room.ImageName))
{
imageBlob = await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.RoomId == 0 && b.BlobName == room.ImageName);
}
db.RoomScenes.RemoveRange(scenes);
db.RoomDataBlobs.RemoveRange(roomBlobs);
if (imageBlob is not null) db.RoomDataBlobs.Remove(imageBlob);
db.Rooms.Remove(room);
await LogAsync("hard_delete_room", "room", id,
$"name={room.Name} reason={body?.Reason ?? ""} blobs={roomBlobs.Count} scenes={scenes.Count}");
await db.SaveChangesAsync();
return Ok(new
{
deleted = id,
name = room.Name,
blobs = roomBlobs.Count,
scenes = scenes.Count,
thumbnail = imageBlob is not null,
});
}
// ── Leaderboards ─────────────────────────────────────────────────────
/// <summary>Verified stat-channel metadata. The 2020 watch's
/// <c>ConditionalSetStat_ReturningNewValue</c> handles the
/// high-vs-low-is-better decision locally, so the server doesn't
/// enforce direction — this is purely for the admin SPA to render
/// channels sensibly.
///
/// Only entries cross-referenced against decompilation live here.
/// Channel 7 is verified by
/// <c>StuntRunnerManager.get_TotalTimeStatChannelForLeaderboard</c>
/// (StuntRunnerManager.txt:220-228 returns the literal 7); the
/// stored value is milliseconds, hence <c>ValueFormat = "time-ms"</c>.
/// Other channels intentionally stay un-named — they used to ship
/// with best-effort guesses ("Paintball wins", "Dodgeball wins",
/// etc.) that were almost certainly wrong, so the admin UI now
/// renders unknown channels as "Channel N" rather than misleading
/// labels. Add new entries here only when you've verified the
/// channel mapping in the decompiled client.</summary>
private static readonly Dictionary<int, StatChannelMeta> KnownStatChannels = new()
{
[7] = new("Stunt Runner total time", true, "time-ms"),
};
private sealed record StatChannelMeta(string Name, bool LowerIsBetter, string ValueFormat);
/// <summary>GET <c>api/admin/v1/leaderboards/channels</c> — summary
/// of every stat channel that has at least one row. Used by the
/// Leaderboards SPA tab to populate the channel picker.</summary>
[HttpGet("leaderboards/channels")]
public async Task<ActionResult> ListChannels()
{
var rows = await db.LeaderboardStats
.GroupBy(s => s.StatChannel)
.Select(g => new
{
Channel = g.Key,
EntryCount = g.Count(),
MinValue = g.Min(x => x.Value),
MaxValue = g.Max(x => x.Value),
})
.OrderBy(r => r.Channel)
.ToListAsync();
// DB-defined meta beats hardcoded KnownStatChannels so admins
// can rename / reassign channels live without a code change.
var dbMeta = await db.LeaderboardChannelMeta.ToDictionaryAsync(c => c.Channel);
var labelled = rows.Select(r =>
{
dbMeta.TryGetValue(r.Channel, out var live);
KnownStatChannels.TryGetValue(r.Channel, out var fallback);
return new
{
r.Channel,
Name = live?.Name is { Length: > 0 } n ? n : (fallback?.Name ?? $"Channel {r.Channel}"),
RoomId = live?.RoomId ?? 0,
LowerIsBetter = live?.LowerIsBetter ?? fallback?.LowerIsBetter ?? false,
ValueFormat = live?.ValueFormat ?? fallback?.ValueFormat ?? "count",
Verified = live is not null || fallback is not null,
r.EntryCount,
r.MinValue,
r.MaxValue,
};
}).ToList();
return Ok(new
{
Channels = labelled,
// Surface the known set as well so the SPA can offer
// "create a row in channel X" even when the channel is
// currently empty.
Known = KnownStatChannels.Select(kv => new
{
Channel = kv.Key,
kv.Value.Name,
kv.Value.LowerIsBetter,
kv.Value.ValueFormat,
}),
});
}
/// <summary>GET <c>api/admin/v1/leaderboards/{channel}</c> — every
/// stat row in a channel with the player's username, sorted by
/// the channel's natural direction (lower-is-better channels
/// sorted ascending, everything else descending).</summary>
[HttpGet("leaderboards/{channel:int}")]
public async Task<ActionResult> ChannelDetail(int channel, [FromQuery] int take = 100)
{
take = Math.Clamp(take, 1, 1000);
var liveMeta = await db.LeaderboardChannelMeta.FirstOrDefaultAsync(c => c.Channel == channel);
KnownStatChannels.TryGetValue(channel, out var fallback);
var name = liveMeta?.Name is { Length: > 0 } ln ? ln : (fallback?.Name ?? $"Channel {channel}");
var lowerIsBetter = liveMeta?.LowerIsBetter ?? fallback?.LowerIsBetter ?? false;
var valueFormat = liveMeta?.ValueFormat ?? fallback?.ValueFormat ?? "count";
var query = db.LeaderboardStats
.Where(s => s.StatChannel == channel);
query = lowerIsBetter
? query.OrderBy(s => s.Value)
: query.OrderByDescending(s => s.Value);
var rows = await (from s in query.Take(take)
join p in db.Players on s.PlayerId equals p.Id into pj
from p in pj.DefaultIfEmpty()
select new
{
s.Id,
s.PlayerId,
Username = p == null ? null : p.Username,
DisplayName = p == null ? null : p.DisplayName,
s.Value,
s.UpdatedAt,
}).ToListAsync();
return Ok(new
{
Channel = channel,
Name = name,
RoomId = liveMeta?.RoomId ?? 0,
LowerIsBetter = lowerIsBetter,
ValueFormat = valueFormat,
Verified = liveMeta is not null || fallback is not null,
Entries = rows.Select((r, i) => new
{
Rank = i + 1,
r.Id,
r.PlayerId,
r.Username,
r.DisplayName,
r.Value,
r.UpdatedAt,
}),
});
}
public sealed record SetLeaderboardScoreRequest(long PlayerId, int Value);
/// <summary>POST <c>api/admin/v1/leaderboards/{channel}/score</c>
/// — upsert a single player's score in a channel.</summary>
[HttpPost("leaderboards/{channel:int}/score")]
public async Task<ActionResult> SetScore(int channel, [FromBody] SetLeaderboardScoreRequest body)
{
var row = await db.LeaderboardStats.FirstOrDefaultAsync(s =>
s.PlayerId == body.PlayerId && s.StatChannel == channel);
if (row is null)
{
row = new LeaderboardStatEntity
{
PlayerId = body.PlayerId,
StatChannel = channel,
Value = body.Value,
};
db.LeaderboardStats.Add(row);
}
else
{
row.Value = body.Value;
row.UpdatedAt = DateTime.UtcNow;
}
await LogAsync("set_leaderboard_score", "player", body.PlayerId, $"channel={channel} value={body.Value}");
await db.SaveChangesAsync();
return Ok(new { row.PlayerId, row.StatChannel, row.Value });
}
[HttpDelete("leaderboards/{channel:int}/score/{playerId:long}")]
public async Task<ActionResult> DeleteScore(int channel, long playerId)
{
var row = await db.LeaderboardStats.FirstOrDefaultAsync(s =>
s.PlayerId == playerId && s.StatChannel == channel);
if (row is null) return NotFound();
db.LeaderboardStats.Remove(row);
await LogAsync("delete_leaderboard_score", "player", playerId, $"channel={channel}");
await db.SaveChangesAsync();
return Ok(new { deleted = playerId, channel });
}
/// <summary>DELETE <c>api/admin/v1/leaderboards/{channel}</c> —
/// wipe every entry in a stat channel. Used for season resets or
/// to clear test data.</summary>
[HttpDelete("leaderboards/{channel:int}")]
public async Task<ActionResult> WipeChannel(int channel)
{
var rows = await db.LeaderboardStats.Where(s => s.StatChannel == channel).ToListAsync();
if (rows.Count == 0) return Ok(new { wiped = 0, channel });
db.LeaderboardStats.RemoveRange(rows);
await LogAsync("wipe_leaderboard_channel", "system", channel, $"entries={rows.Count}");
await db.SaveChangesAsync();
return Ok(new { wiped = rows.Count, channel });
}
// ── Rec Room Originals (RROs) ────────────────────────────────────────
/// <summary>GET <c>api/admin/v1/rooms/originals</c> — the canonical
/// Rec Room Originals (rooms whose <c>CreatorPlayerId ==
/// PlayerService.SystemAccountId</c>). Backs the RRO admin tab
/// which lets admins flip per-room properties (cloning, max
/// players, description) without scrolling through the global
/// rooms list.</summary>
[HttpGet("rooms/originals")]
public async Task<ActionResult> ListOriginals()
{
var rooms = await db.Rooms
.Where(r => r.CreatorPlayerId == PlayerService.SystemAccountId)
.OrderBy(r => r.Name)
.Select(r => new
{
r.Id,
r.Name,
r.Description,
r.ImageName,
r.Accessibility,
r.CloningAllowed,
r.IsAGRoom,
r.IsDormRoom,
r.State,
r.TagsCsv,
r.HotScore,
r.VisitCount,
r.VisitorCount,
r.CheerCount,
r.CurrentDataBlobName,
SceneCount = db.RoomScenes.Count(s => s.RoomId == r.Id),
BlobCount = db.RoomDataBlobs.Count(b => b.RoomId == r.Id),
})
.ToListAsync();
return Ok(rooms);
}
public sealed record UpdateRoomPropsRequest(
string? Description,
bool? CloningAllowed,
int? Accessibility,
string? TagsCsv,
double? HotScore,
int? State,
string? ImageName);
/// <summary>POST <c>api/admin/v1/rooms/{id}/props</c> — partial
/// update of room properties. Only the non-null fields in the
/// body get written; the rest are left untouched. Backs the
/// RRO edit form and is also usable for regular custom rooms.</summary>
[HttpPost("rooms/{id:long}/props")]
public async Task<ActionResult> UpdateRoomProps(long id, [FromBody] UpdateRoomPropsRequest body)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
if (room is null) return NotFound();
var changes = new List<string>();
if (body.Description is string desc)
{
room.Description = desc;
changes.Add($"description={desc.Length} chars");
}
if (body.CloningAllowed is bool c) { room.CloningAllowed = c; changes.Add($"cloning={c}"); }
if (body.Accessibility is int a) { room.Accessibility = a; changes.Add($"accessibility={a}"); }
if (body.TagsCsv is string tags) { room.TagsCsv = tags; changes.Add($"tags={tags}"); }
if (body.HotScore is double h) { room.HotScore = h; changes.Add($"hot={h}"); }
if (body.State is int st) { room.State = st; changes.Add($"state={st}"); }
if (body.ImageName is string img) { room.ImageName = img; changes.Add($"image={img}"); }
if (changes.Count == 0) return Ok(new { room.Id, unchanged = true });
await LogAsync("update_room_props", "room", id, string.Join(", ", changes));
await db.SaveChangesAsync();
return Ok(new { room.Id, room.Name, applied = changes });
}
// ── Room detail (unified per-room view backing the admin SPA) ─────────
/// <summary>GET <c>api/admin/v1/rooms/{id}</c> — every column on the
/// room plus owner/co-owner/mod/host display info, blob count, scene
/// count, current data blob name, and the latest CheerCount. The
/// admin SPA's per-room detail page consumes this in one shot rather
/// than fanning out to the legacy <c>/rooms</c>, <c>/rooms/originals</c>,
/// <c>/playerReputation</c> endpoints separately.</summary>
[HttpGet("rooms/{id:long}")]
public async Task<ActionResult> GetRoomDetail(long id)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
if (room is null) return NotFound();
var sceneCount = await db.RoomScenes.CountAsync(s => s.RoomId == id);
var blobCount = await db.RoomDataBlobs.CountAsync(b => b.RoomId == id);
var roles = await db.RoomRoles
.Where(r => r.RoomId == id)
.OrderBy(r => r.Role).ThenBy(r => r.GrantedAt)
.ToListAsync();
// Hydrate display names for creator + every role grant in one
// hit so the UI doesn't have to follow up with /players/{id}.
var playerIds = roles.Select(r => r.PlayerId)
.Append(room.CreatorPlayerId)
.Distinct()
.ToList();
var players = await db.Players
.Where(p => playerIds.Contains(p.Id))
.Select(p => new { p.Id, p.Username, p.DisplayName, p.ProfileImageName })
.ToListAsync();
object PlayerInfo(long pid) =>
players.FirstOrDefault(p => p.Id == pid) is { } p
? new { id = p.Id, username = p.Username, displayName = p.DisplayName, profileImageName = p.ProfileImageName }
: new { id = pid, username = $"#{pid}", displayName = "", profileImageName = (string?)null };
return Ok(new
{
room.Id,
room.Name,
room.Description,
room.ImageName,
room.State,
room.Accessibility,
room.IsAGRoom,
room.IsDormRoom,
room.CloningAllowed,
room.SupportsLevelVoting,
room.SupportsVRLow,
room.SupportsMobile,
room.SupportsScreens,
room.SupportsWalkVR,
room.SupportsTeleportVR,
room.AllowsJuniors,
room.DisableMicAutoMute,
room.RoomWarningMask,
room.CustomRoomWarning,
room.TagsCsv,
room.CheerCount,
room.FavoriteCount,
room.VisitCount,
room.VisitorCount,
room.HotScore,
room.LocationReplicationId,
room.CurrentDataBlobName,
room.CreatedAt,
room.UpdatedAt,
sceneCount,
blobCount,
owner = PlayerInfo(room.CreatorPlayerId),
roles = roles.Select(r => new
{
r.Id,
r.PlayerId,
r.Role,
r.Accepted,
r.GrantedByPlayerId,
r.GrantedAt,
player = PlayerInfo(r.PlayerId),
}).ToList(),
});
}
public sealed record AddRoleRequest(long PlayerId, int Role, bool Accepted = true);
/// <summary>POST <c>api/admin/v1/rooms/{id}/roles</c> — grant a
/// (PlayerId, Role) pair on the room. Role: 0=CoOwner, 1=Moderator,
/// 2=Host. Idempotent — re-granting the same role flips Accepted
/// only.</summary>
[HttpPost("rooms/{id:long}/roles")]
public async Task<ActionResult> AddRoomRole(long id, [FromBody] AddRoleRequest body)
{
if (body is null) return BadRequest("missing body");
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
if (room is null) return NotFound();
if (body.PlayerId <= 0) return BadRequest("missing player");
if (body.Role is < 0 or > 2) return BadRequest("invalid role");
var existing = await db.RoomRoles.FirstOrDefaultAsync(r =>
r.RoomId == id && r.PlayerId == body.PlayerId && r.Role == body.Role);
if (existing is null)
{
db.RoomRoles.Add(new RoomRoleEntity
{
RoomId = id,
PlayerId = body.PlayerId,
Role = body.Role,
Accepted = body.Accepted,
GrantedByPlayerId = CurrentAdminId,
});
}
else
{
existing.Accepted = body.Accepted;
}
await LogAsync("room_role_add", "room", id, $"player={body.PlayerId} role={body.Role}");
await db.SaveChangesAsync();
return Ok(new { ok = true });
}
/// <summary>DELETE <c>api/admin/v1/rooms/{id}/roles/{playerId}/{role}</c>
/// — revoke a specific role grant. No-op if the row doesn't exist.</summary>
[HttpDelete("rooms/{id:long}/roles/{playerId:long}/{role:int}")]
public async Task<ActionResult> RemoveRoomRole(long id, long playerId, int role)
{
var row = await db.RoomRoles.FirstOrDefaultAsync(r =>
r.RoomId == id && r.PlayerId == playerId && r.Role == role);
if (row is null) return Ok(new { ok = true, deleted = 0 });
db.RoomRoles.Remove(row);
await LogAsync("room_role_remove", "room", id, $"player={playerId} role={role}");
await db.SaveChangesAsync();
return Ok(new { ok = true, deleted = 1 });
}
public sealed record TransferOwnerRequest(long NewCreatorPlayerId);
/// <summary>POST <c>api/admin/v1/rooms/{id}/owner</c> — reassign the
/// room's CreatorPlayerId. The previous owner is automatically added
/// as a CoOwner so the original creator keeps a foothold on their
/// own room. Reject self-transfers.</summary>
[HttpPost("rooms/{id:long}/owner")]
public async Task<ActionResult> TransferOwner(long id, [FromBody] TransferOwnerRequest body)
{
if (body is null || body.NewCreatorPlayerId <= 0) return BadRequest("missing new owner");
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
if (room is null) return NotFound();
if (room.CreatorPlayerId == body.NewCreatorPlayerId) return Ok(new { ok = true, unchanged = true });
var previousOwner = room.CreatorPlayerId;
room.CreatorPlayerId = body.NewCreatorPlayerId;
room.UpdatedAt = DateTime.UtcNow;
// Auto-grant the former owner CoOwner (id=0) so they don't lose
// access to a room they built. Idempotent — re-running this in
// a fast undo-redo flow won't accumulate dupes.
if (previousOwner > 0 && !await db.RoomRoles.AnyAsync(r =>
r.RoomId == id && r.PlayerId == previousOwner && r.Role == 0))
{
db.RoomRoles.Add(new RoomRoleEntity
{
RoomId = id,
PlayerId = previousOwner,
Role = 0,
Accepted = true,
GrantedByPlayerId = CurrentAdminId,
});
}
await LogAsync("room_transfer_owner", "room", id, $"from={previousOwner} to={body.NewCreatorPlayerId}");
await db.SaveChangesAsync();
return Ok(new { ok = true });
}
/// <summary>GET <c>api/admin/v1/rooms/{id}/instances</c> — active
/// Photon instances for one specific room. Subset of the global
/// <c>/instances</c> endpoint pre-filtered to one room; the admin
/// SPA's room detail page embeds these so admins don't have to
/// scroll a global instance list looking for matches.
///
/// The <c>masterPlayerId</c> field is best-effort: Photon's master
/// is whoever has the lowest <c>ActorNumber</c> in the room (i.e.
/// the first to join). The server doesn't see ActorNumbers, but
/// the player with the lowest DorkNet player id among current
/// participants is a reasonable proxy on dorms (creator joined
/// first) and pretty good on user rooms. For non-dorm rooms with
/// the creator absent, the value just identifies a stable "lead"
/// participant — the room won't fall apart if it's not the actual
/// Photon master, and admins primarily use the badge to know
/// which row to click "Pull in" against to land in the right
/// shard.</summary>
[HttpGet("rooms/{id:long}/instances")]
public async Task<ActionResult> GetRoomInstances(long id)
{
var room = await db.Rooms.Where(r => r.Id == id)
.Select(r => new { r.Id, r.CreatorPlayerId, r.IsDormRoom })
.FirstOrDefaultAsync();
var onlinePids = onlinePresence.OnlinePlayerIds().ToList();
var byInstance = new Dictionary<long, (long roomInstanceId, long roomId, long subRoomId, string roomName, string photonRoomId, string photonRegionId, string location, int maxCapacity, bool isPrivate, List<long> pids)>();
foreach (var pid in onlinePids)
{
var r = playerPresence.GetRoom(pid);
if (r is null || r.RoomId != id) continue;
if (!byInstance.TryGetValue(r.RoomInstanceId, out var slot))
{
slot = (r.RoomInstanceId, r.RoomId, r.SubRoomId, r.Name ?? "", r.PhotonRoomId ?? "", r.PhotonRegionId ?? "", r.Location ?? "", r.MaxCapacity, r.IsPrivate, new List<long>());
}
slot.pids.Add(pid);
byInstance[r.RoomInstanceId] = slot;
}
if (byInstance.Count == 0) return Ok(Array.Empty<object>());
var allPids = byInstance.Values.SelectMany(v => v.pids).Distinct().ToList();
var players = await db.Players
.Where(p => allPids.Contains(p.Id))
.Select(p => new { p.Id, p.Username, p.DisplayName })
.ToDictionaryAsync(p => p.Id);
return Ok(byInstance.Values.Select(i =>
{
// Master heuristic: dorm owner if present, else lowest pid.
long master;
if (room is { IsDormRoom: true } && i.pids.Contains(room.CreatorPlayerId))
master = room.CreatorPlayerId;
else
master = i.pids.OrderBy(p => p).FirstOrDefault();
return new
{
roomInstanceId = i.roomInstanceId,
roomId = i.roomId,
subRoomId = i.subRoomId,
roomName = i.roomName,
photonRoomId = i.photonRoomId,
photonRegionId = i.photonRegionId,
location = i.location,
maxCapacity = i.maxCapacity,
isPrivate = i.isPrivate,
masterPlayerId = master,
participants = i.pids.Select(pid => players.TryGetValue(pid, out var p)
? new { id = p.Id, username = p.Username, displayName = p.DisplayName, isMaster = pid == master }
: new { id = pid, username = $"#{pid}", displayName = "", isMaster = pid == master }).ToList(),
};
}));
}
public sealed record PullIntoInstanceRequest(long PlayerId);
/// <summary>POST <c>api/admin/v1/rooms/{id}/instances/{instanceId}/pull</c>
/// — pull a specific player into one already-active instance of this
/// room. Wraps the existing player force-join flow but pre-fills the
/// instance and validates that the target instance is actually live
/// in this room. Useful for "drop me into the shard the streamer is
/// already in" without having to dig the instance id out by hand.</summary>
[HttpPost("rooms/{id:long}/instances/{instanceId:long}/pull")]
public async Task<ActionResult> PullIntoInstance(long id, long instanceId, [FromBody] PullIntoInstanceRequest body)
{
if (body is null || body.PlayerId <= 0) return BadRequest("missing player");
// Find any participant currently in the target instance — we
// need their RoomInstanceDto to know which Photon shard to
// route the pulled player into. If nobody's online in the
// instance anymore the instance has effectively died; reject.
DorkNet.Server.Controllers.Match.RoomInstanceDto? sample = null;
foreach (var pid in onlinePresence.OnlinePlayerIds())
{
var r = playerPresence.GetRoom(pid);
if (r is not null && r.RoomId == id && r.RoomInstanceId == instanceId)
{
sample = r;
break;
}
}
if (sample is null) return NotFound(new { error = "instance_not_active" });
// Re-stamp presence on the target so their next heartbeat /
// goto sees us pointing at this instance. Push a SignalR
// SubscriptionUpdateGameSession so the watch's matchmaking
// updates LocalRoomInstance immediately.
playerPresence.SetRoom(body.PlayerId, sample);
await notifications.NotifyAsync(body.PlayerId,
PushNotificationId.SubscriptionUpdateGameSession,
sample);
await LogAsync("instance_pull", "instance", instanceId, $"player={body.PlayerId} room={id}");
await db.SaveChangesAsync();
return Ok(new { ok = true, instanceId, roomInstanceId = sample.RoomInstanceId });
}
/// <summary>POST <c>api/admin/v1/rooms/{id}/instances/{instanceId}/close</c>
/// — kick every player currently in the instance via ModerationKick
/// pushes and clear their presence so the server-side view matches.
/// The Photon room itself isn't directly destroyed (we don't have
/// Photon admin credentials); it dies on its own once the last
/// participant disconnects. In practice every kick fires within
/// the same second, so the room is empty seconds later.</summary>
[HttpPost("rooms/{id:long}/instances/{instanceId:long}/close")]
public async Task<ActionResult> CloseInstance(long id, long instanceId, [FromBody] KickRequest? body)
{
var reason = body?.Reason ?? "Instance closed by admin";
var kicked = new List<long>();
foreach (var pid in onlinePresence.OnlinePlayerIds().ToList())
{
var r = playerPresence.GetRoom(pid);
if (r is null || r.RoomId != id || r.RoomInstanceId != instanceId) continue;
await notifications.NotifyAsync(pid, PushNotificationId.ModerationKick, new { Reason = reason });
playerPresence.Clear(pid);
kicked.Add(pid);
}
if (kicked.Count > 0)
{
await LogAsync("instance_close", "instance", instanceId, $"room={id} kicked={kicked.Count}");
await db.SaveChangesAsync();
}
return Ok(new { ok = true, kicked });
}
/// <summary>GET <c>api/admin/v1/rooms/{id}/leaderboards</c> — every
/// stat channel mapped to this room via
/// <see cref="LeaderboardChannelMetaEntity"/>, plus the row count
/// per channel and the top scorer. Channels that report scores but
/// haven't been mapped yet show up in
/// <see cref="LeaderboardOrphans"/> instead.</summary>
[HttpGet("rooms/{id:long}/leaderboards")]
public async Task<ActionResult> GetRoomLeaderboards(long id)
{
var metas = await db.LeaderboardChannelMeta
.Where(c => c.RoomId == id)
.OrderBy(c => c.Channel)
.ToListAsync();
if (metas.Count == 0) return Ok(Array.Empty<object>());
var channels = metas.Select(m => m.Channel).ToList();
var counts = await db.LeaderboardStats
.Where(s => channels.Contains(s.StatChannel))
.GroupBy(s => s.StatChannel)
.Select(g => new { Channel = g.Key, Count = g.Count(), Best = g.Max(x => x.Value), Worst = g.Min(x => x.Value) })
.ToListAsync();
return Ok(metas.Select(m =>
{
var stat = counts.FirstOrDefault(c => c.Channel == m.Channel);
return new
{
m.Channel,
m.Name,
m.LowerIsBetter,
m.ValueFormat,
EntryCount = stat?.Count ?? 0,
BestValue = stat is null ? (long?)null : (m.LowerIsBetter ? stat.Worst : stat.Best),
};
}));
}
public sealed record UpsertChannelMetaRequest(int Channel, long RoomId, string Name, bool LowerIsBetter, string? ValueFormat);
/// <summary>POST <c>api/admin/v1/leaderboards/meta</c> — register or
/// rename a stat channel + assign it to a room. Idempotent; updates
/// the existing row when the channel already has metadata. Does NOT
/// touch the value rows themselves — admins can register a channel
/// before any scores are reported.</summary>
[HttpPost("leaderboards/meta")]
public async Task<ActionResult> UpsertChannelMeta([FromBody] UpsertChannelMetaRequest body)
{
if (body is null) return BadRequest("missing body");
var row = await db.LeaderboardChannelMeta.FirstOrDefaultAsync(c => c.Channel == body.Channel);
if (row is null)
{
row = new LeaderboardChannelMetaEntity { Channel = body.Channel };
db.LeaderboardChannelMeta.Add(row);
}
row.RoomId = body.RoomId;
row.Name = body.Name ?? string.Empty;
row.LowerIsBetter = body.LowerIsBetter;
row.ValueFormat = string.IsNullOrWhiteSpace(body.ValueFormat) ? "count" : body.ValueFormat;
row.UpdatedAt = DateTime.UtcNow;
await LogAsync("leaderboard_meta_upsert", "channel", body.Channel, $"room={body.RoomId} name={row.Name}");
await db.SaveChangesAsync();
return Ok(new { row.Channel, row.RoomId, row.Name, row.LowerIsBetter, row.ValueFormat });
}
/// <summary>DELETE <c>api/admin/v1/leaderboards/meta/{channel}</c> —
/// remove the metadata row. The channel goes back to showing as
/// "Channel N" / unscoped. Existing score rows are kept.</summary>
[HttpDelete("leaderboards/meta/{channel:int}")]
public async Task<ActionResult> DeleteChannelMeta(int channel)
{
var row = await db.LeaderboardChannelMeta.FirstOrDefaultAsync(c => c.Channel == channel);
if (row is null) return Ok(new { ok = true, deleted = 0 });
db.LeaderboardChannelMeta.Remove(row);
await LogAsync("leaderboard_meta_remove", "channel", channel, "");
await db.SaveChangesAsync();
return Ok(new { ok = true, deleted = 1 });
}
/// <summary>GET <c>api/admin/v1/leaderboards/orphans</c> — every
/// stat channel id that has at least one score row but no meta row.
/// Used by the admin Leaderboards UI to surface "you have data for
/// channel 11 — want to give it a name and assign it to a room?".
/// Critical for figuring out which channels Stunt Runner courses
/// actually use: load a course, drive it, then orphans surfaces
/// the channel id the game just reported.</summary>
[HttpGet("leaderboards/orphans")]
public async Task<ActionResult> LeaderboardOrphans()
{
var mapped = await db.LeaderboardChannelMeta.Select(c => c.Channel).ToListAsync();
var rows = await db.LeaderboardStats
.Where(s => !mapped.Contains(s.StatChannel))
.GroupBy(s => s.StatChannel)
.Select(g => new { Channel = g.Key, EntryCount = g.Count(), LastSeen = g.Max(s => s.UpdatedAt) })
.OrderByDescending(r => r.LastSeen)
.ToListAsync();
return Ok(rows);
}
// ── Server settings (runtime toggles) ────────────────────────────────
/// <summary>GET <c>api/admin/v1/settings</c> — current server-wide
/// toggle state. Single row, single round trip.</summary>
[HttpGet("settings")]
public async Task<ActionResult> GetServerSettings()
{
var row = await serverSettings.GetAsync();
return Ok(new
{
row.SignupsDisabled,
row.UpdatedAt,
});
}
public sealed record SignupsToggleRequest(bool Disabled);
/// <summary>POST <c>api/admin/v1/settings/signups</c> — flip the
/// server-wide account-creation kill switch. While disabled, both
/// <c>POST /account/create</c> and <c>POST /api/account/v1/create</c>
/// reply with <c>Success=false</c> + a user-facing error string and
/// the watch surfaces that to whoever tried to sign up. Existing
/// logins are untouched.</summary>
[HttpPost("settings/signups")]
public async Task<ActionResult> SetSignupsDisabled([FromBody] SignupsToggleRequest body)
{
var row = await serverSettings.SetSignupsDisabledAsync(body.Disabled);
await LogAsync(body.Disabled ? "signups_disabled" : "signups_enabled", "system", 0, "");
await db.SaveChangesAsync();
return Ok(new
{
row.SignupsDisabled,
row.UpdatedAt,
});
}
// ── Audit log ────────────────────────────────────────────────────────
[HttpGet("audit")]
public async Task<ActionResult> GetAuditLog([FromQuery] int take = 100)
{
take = Math.Clamp(take, 1, 500);
var rows = await db.AdminActions
.OrderByDescending(a => a.Timestamp)
.Take(take)
.ToListAsync();
return Ok(rows);
}
// ── Reports moderation ───────────────────────────────────────────────
/// <summary>List unresolved player reports, oldest-first.
/// Admins triage from this queue.</summary>
[HttpGet("reports")]
public async Task<ActionResult> ListReports([FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 200);
var rows = await db.Reports
.Where(r => r.ResolvedAt == null)
.OrderBy(r => r.CreatedAt)
.Take(take)
.ToListAsync();
return Ok(rows);
}
public sealed record ResolveReportRequest(string? Note);
/// <summary>Mark a report as resolved with an optional note.
/// The actual moderation action (ban, warn, dismiss) is the
/// admin's call; this endpoint only closes the report row.</summary>
[HttpPost("reports/{id:long}/resolve")]
public async Task<ActionResult> ResolveReport(long id, [FromBody] ResolveReportRequest? body)
{
var rep = await db.Reports.FirstOrDefaultAsync(r => r.Id == id);
if (rep is null) return NotFound();
if (rep.ResolvedAt is not null) return Ok(new { already_resolved = true });
rep.ResolvedAt = DateTime.UtcNow;
rep.ResolverAdminId = CurrentAdminId;
rep.ResolutionNote = (body?.Note ?? string.Empty).Trim();
await LogAsync("resolve_report", "report", id, rep.ResolutionNote);
await db.SaveChangesAsync();
return Ok(new { rep.Id, rep.ResolvedAt });
}
// ── Kick / IP ban ────────────────────────────────────────────────────
public sealed record KickRequest(string? Reason);
/// <summary>Force-disconnect a player from the SignalR hub and
/// signal them to reload via a ModerationKick push. Doesn't ban
/// — the player can immediately reconnect — but boots them out
/// of whatever room/session they're in. For temporary
/// moderation actions where a full ban is too heavy.</summary>
[HttpPost("players/{id:long}/kick")]
public async Task<ActionResult> KickPlayer(long id, [FromBody] KickRequest? body)
{
await notifications.NotifyAsync(id, PushNotificationId.ModerationKick,
new { Reason = body?.Reason ?? "" });
await LogAsync("kick_player", "player", id, body?.Reason ?? "");
await db.SaveChangesAsync();
return Ok(new { kicked = id });
}
public sealed record IpBanRequest(string Cidr, string? Reason, int? DurationDays);
/// <summary>Add an IP-level ban. Optional duration in days
/// (null = permanent until removed). Format: dotted IPv4 or
/// CIDR like <c>1.2.3.0/24</c>.</summary>
[HttpPost("ipbans")]
public async Task<ActionResult> AddIpBan([FromBody] IpBanRequest body)
{
if (string.IsNullOrWhiteSpace(body.Cidr))
return BadRequest("missing cidr");
var until = body.DurationDays is int days
? DateTime.UtcNow.AddDays(Math.Clamp(days, 1, 3650))
: (DateTime?)null;
var entry = new IpBanEntity
{
Cidr = body.Cidr.Trim(),
Reason = (body.Reason ?? string.Empty).Trim(),
BannedByAdminId = CurrentAdminId,
Until = until,
};
db.IpBans.Add(entry);
await LogAsync("ipban_add", "ip", 0, $"{body.Cidr} until {until:o}");
await db.SaveChangesAsync();
return Ok(entry);
}
[HttpDelete("ipbans/{id:long}")]
public async Task<ActionResult> RemoveIpBan(long id)
{
var ban = await db.IpBans.FirstOrDefaultAsync(b => b.Id == id);
if (ban is null) return NotFound();
db.IpBans.Remove(ban);
await LogAsync("ipban_remove", "ip", id, ban.Cidr);
await db.SaveChangesAsync();
return Ok();
}
[HttpGet("ipbans")]
public async Task<ActionResult> ListIpBans()
{
var rows = await db.IpBans
.OrderByDescending(b => b.BannedAt)
.ToListAsync();
return Ok(rows);
}
// ── Stats ────────────────────────────────────────────────────────────
/// <summary>Real-time server snapshot for the admin dashboard.
/// Every aggregate is one cheap query — designed to be hit by
/// the admin watch tab on a 5-second timer without melting the
/// SQLite write path.</summary>
[HttpGet("stats")]
public async Task<ActionResult> Stats()
{
var totalPlayers = await db.Players.CountAsync();
var totalRooms = await db.Rooms.CountAsync();
var totalInventions = await db.Inventions.CountAsync();
var openReports = await db.Reports.CountAsync(r => r.ResolvedAt == null);
var bannedNow = await db.Players.CountAsync(p =>
p.BannedUntil != null && p.BannedUntil > DateTime.UtcNow);
var activeIpBans = await db.IpBans.CountAsync(b =>
b.Until == null || b.Until > DateTime.UtcNow);
var online = onlinePresence.OnlinePlayerIds().ToArray();
var topRooms = await db.Rooms
.OrderByDescending(r => r.VisitCount)
.Take(10)
.Select(r => new { r.Id, r.Name, r.VisitCount, r.VisitorCount, r.CheerCount })
.ToListAsync();
var recentJoins = await db.Players
.OrderByDescending(p => p.CreatedAt)
.Take(10)
.Select(p => new { p.Id, p.Username, p.CreatedAt })
.ToListAsync();
return Ok(new
{
Players = new
{
Total = totalPlayers,
OnlineNow = online.Length,
BannedNow = bannedNow,
},
Rooms = new { Total = totalRooms, TopByVisits = topRooms },
Inventions = totalInventions,
Moderation = new
{
OpenReports = openReports,
ActiveIpBans = activeIpBans,
},
RecentJoins = recentJoins,
ServerTime = DateTime.UtcNow,
});
}
// ── Avatar / outfit / inventory grants ──────────────────────────────
public sealed record OutfitRequest(string? OutfitSelections, string? FaceFeatures, string? HairColor, string? SkinColor);
/// <summary>Get a player's current avatar so the admin UI can
/// pre-fill its edit form. Returns the same DTO shape as
/// <c>GET api/avatar/v2/equipped/{id}</c>.</summary>
[HttpGet("players/{id:long}/avatar")]
public async Task<ActionResult> GetAvatar(long id)
{
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == id);
if (avatar is null) return NotFound();
return Ok(new
{
avatar.PlayerId,
avatar.OutfitSelections,
avatar.FaceFeatures,
avatar.HairColor,
avatar.SkinColor,
avatar.UpdatedAt,
});
}
/// <summary>POST <c>api/admin/v1/players/{id}/avatar/reset</c> —
/// wipe a player's equipped outfit + inventory back to the canonical
/// 2020 starter values. Recovery path for the
/// "watch crashes after ~10 minutes" symptom where a player has an
/// item GUID (e.g. <c>Eye_MirrorShades_PinkClear</c>) whose
/// outfits_assets bundle isn't shipped with this build, so
/// <see cref="OutfitManager"/>.SpawnOutfitImposter throws a
/// Dependency Exception from Addressables and the engine eventually
/// tears down the local player. Resetting clears those entries
/// from <see cref="AvatarEntity.OutfitSelections"/> and
/// <see cref="AvatarEntity.InventoryJson"/> so the next avatar
/// load only references items the local build has on disk.</summary>
[HttpPost("players/{id:long}/avatar/reset")]
public async Task<ActionResult> ResetAvatar(long id)
{
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == id);
if (avatar is null)
{
avatar = new AvatarEntity { PlayerId = id };
db.Avatars.Add(avatar);
}
avatar.OutfitSelections = PlayerService.StarterOutfitSelections;
avatar.InventoryJson = PlayerService.StarterInventoryJson;
avatar.HairColor = PlayerService.StarterHairColor;
avatar.SkinColor = PlayerService.StarterSkinColor;
avatar.UpdatedAt = DateTime.UtcNow;
await LogAsync("reset_avatar", "player", id, "");
await db.SaveChangesAsync();
// Push a profile update so the affected watch re-fetches its
// avatar without waiting for the next scene load.
await notifications.NotifyAsync(id,
PushNotificationId.SubscriptionUpdateProfile,
new { Reason = "AdminAvatarReset" });
return Ok(new
{
avatar.PlayerId,
avatar.OutfitSelections,
avatar.HairColor,
avatar.SkinColor,
});
}
/// <summary>Replace another player's outfit / hair / skin colour.
/// Pushes <c>SubscriptionUpdateProfile</c> so the affected watch
/// re-fetches its avatar and the new clothes propagate to other
/// players in the same room.</summary>
[HttpPost("players/{id:long}/avatar")]
public async Task<ActionResult> SetAvatar(long id, [FromBody] OutfitRequest body)
{
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == id);
if (avatar is null)
{
avatar = new AvatarEntity { PlayerId = id };
db.Avatars.Add(avatar);
}
if (body.OutfitSelections is not null) avatar.OutfitSelections = body.OutfitSelections;
if (body.FaceFeatures is not null) avatar.FaceFeatures = body.FaceFeatures;
if (body.HairColor is not null) avatar.HairColor = body.HairColor;
if (body.SkinColor is not null) avatar.SkinColor = body.SkinColor;
avatar.UpdatedAt = DateTime.UtcNow;
await LogAsync("set_avatar", "player", id, body.OutfitSelections ?? "");
await db.SaveChangesAsync();
await notifications.NotifyAsync(id,
PushNotificationId.SubscriptionUpdateProfile,
new { Reason = "AdminAvatarUpdate" });
return Ok(new { avatar.PlayerId, avatar.OutfitSelections, avatar.HairColor, avatar.SkinColor });
}
public sealed record GrantItemRequest(string ItemId, int Quantity);
/// <summary>Append an item GUID to the player's inventory JSON list.
/// The client treats <c>InventoryJson</c> as the truth source for
/// what they own; once granted, the next avatar fetch shows the
/// item available in the customisation menu.</summary>
[HttpPost("players/{id:long}/inventory/grant")]
public async Task<ActionResult> GrantItem(long id, [FromBody] GrantItemRequest body)
{
if (string.IsNullOrWhiteSpace(body.ItemId)) return BadRequest("missing itemId");
var qty = Math.Clamp(body.Quantity, 1, 1000);
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == id);
if (avatar is null)
{
avatar = new AvatarEntity { PlayerId = id };
db.Avatars.Add(avatar);
}
// Inventory wire format: JSON array of { itemId, quantity }. Merge
// by itemId so repeat grants don't duplicate rows.
var list = ParseInventory(avatar.InventoryJson);
var existing = list.FirstOrDefault(e => e.ItemId == body.ItemId);
if (existing is null)
list.Add(new InventoryEntry { ItemId = body.ItemId, Quantity = qty });
else
existing.Quantity += qty;
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(list);
avatar.UpdatedAt = DateTime.UtcNow;
await LogAsync("grant_item", "player", id, $"{body.ItemId} x{qty}");
await db.SaveChangesAsync();
await notifications.NotifyAsync(id,
PushNotificationId.SubscriptionUpdateProfile,
new { Reason = "InventoryGrant", body.ItemId, Quantity = qty });
return Ok(new { granted = body.ItemId, quantity = qty });
}
public sealed record CurrencyRequest(int CurrencyType, long Amount, string? Reason);
/// <summary>Adjust a player's currency wallet. Positive amounts
/// grant; negative deduct (clamped to zero by
/// <see cref="LevelService.GrantCurrencyAsync"/>). The client's
/// next storefronts/balance fetch returns the new value.</summary>
[HttpPost("players/{id:long}/currency")]
public async Task<ActionResult> GrantCurrency(long id, [FromBody] CurrencyRequest body)
{
var newBalance = await level.GrantCurrencyAsync(
id, body.CurrencyType, body.Amount, body.Reason ?? "admin_grant");
await LogAsync("grant_currency", "player", id,
$"type={body.CurrencyType} amount={body.Amount}");
await db.SaveChangesAsync();
return Ok(new { id, body.CurrencyType, balance = newBalance });
}
public sealed record XpRequest(int Amount, string? Reason);
/// <summary>Grant XP to a player. Triggers level-up rewards via
/// <see cref="LevelService.AwardXpAsync"/> if the new XP crosses a
/// level threshold.</summary>
[HttpPost("players/{id:long}/xp")]
public async Task<ActionResult> GrantXp(long id, [FromBody] XpRequest body)
{
var (lvl, xp) = await level.AwardXpAsync(id, body.Amount, body.Reason ?? "admin_grant");
await LogAsync("grant_xp", "player", id, $"amount={body.Amount}");
await db.SaveChangesAsync();
return Ok(new { id, level = lvl, xp });
}
public sealed record ProfileFlagsRequest(
bool? IsVerified,
bool? IsDeveloper,
bool? IsJunior,
bool? IsCommunityTeam);
/// <summary>Toggle the verified / developer / community-team /
/// junior flags on a player profile. Verified shows up as the
/// blue-checkmark badge on the watch's profile card; IsDeveloper
/// and IsCommunityTeam both unlock the watch's overhead-badge
/// slider (see <see cref="DorkNet.Server.Controllers.API.Role.RoleController.IsDeveloper"/>)
/// — once unlocked, the player picks "Community Team" or
/// "Developer" themselves from the in-watch settings menu and the
/// label renders above their head for every other player in the
/// room. We expose both as separate admin flags so the audit log
/// preserves the distinction even though the 2020 watch can't
/// enforce one badge over the other.</summary>
[HttpPost("players/{id:long}/flags")]
public async Task<ActionResult> SetFlags(long id, [FromBody] ProfileFlagsRequest body)
{
var p = await db.Players.FirstOrDefaultAsync(x => x.Id == id);
if (p is null) return NotFound();
if (body.IsVerified is bool v) p.IsVerified = v;
if (body.IsDeveloper is bool d) p.IsDeveloper = d;
if (body.IsJunior is bool j) p.IsJunior = j;
if (body.IsCommunityTeam is bool ct) p.IsCommunityTeam = ct;
await LogAsync("set_flags", "player", id,
$"verified={p.IsVerified} dev={p.IsDeveloper} communityteam={p.IsCommunityTeam} junior={p.IsJunior}");
await db.SaveChangesAsync();
return Ok(new { p.Id, p.IsVerified, p.IsDeveloper, p.IsCommunityTeam, p.IsJunior });
}
public sealed record DisplayNameRequest(string DisplayName);
/// <summary>Force-set a player's display name (admin override of
/// the normal username-uniqueness flow).</summary>
[HttpPost("players/{id:long}/displayName")]
public async Task<ActionResult> SetDisplayName(long id, [FromBody] DisplayNameRequest body)
{
var p = await db.Players.FirstOrDefaultAsync(x => x.Id == id);
if (p is null) return NotFound();
if (string.IsNullOrWhiteSpace(body.DisplayName)) return BadRequest("empty");
p.DisplayName = body.DisplayName.Trim();
await LogAsync("set_display_name", "player", id, p.DisplayName);
await db.SaveChangesAsync();
return Ok(new { p.Id, p.DisplayName });
}
private sealed class InventoryEntry
{
[System.Text.Json.Serialization.JsonPropertyName("itemId")]
public string ItemId { get; set; } = string.Empty;
[System.Text.Json.Serialization.JsonPropertyName("quantity")]
public int Quantity { get; set; }
}
private static List<InventoryEntry> ParseInventory(string? json)
{
if (string.IsNullOrWhiteSpace(json) || json == "[]") return new();
try
{
return System.Text.Json.JsonSerializer.Deserialize<List<InventoryEntry>>(json) ?? new();
}
catch
{
return new();
}
}
// ── Avatar catalog (gifting picker source) ───────────────────────────
/// <summary>GET <c>api/admin/v1/avatar-items</c> — flat list of every
/// renderable avatar item, keyed by the GUID the inventory grant
/// endpoint expects. Backs the Gift page's item picker so admins can
/// browse by friendly name instead of pasting GUIDs by hand. Read-only.
/// Catalog source is <c>data/avatar_item_lookup.json</c>, parsed once
/// at startup by <see cref="DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController"/>.</summary>
[HttpGet("avatar-items")]
public ActionResult ListAvatarItems()
{
var catalog = DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.Catalog;
var safe = DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.SafeGuids;
var rows = catalog
.Select(kv => new
{
Guid = kv.Key,
Slot = kv.Value.AvatarItemType,
FriendlyName = kv.Value.FriendlyName,
Tooltip = kv.Value.Tooltip,
Rarity = kv.Value.Rarity,
Safe = safe.Contains(kv.Key),
})
.Where(r => r.Safe) // hide GUIDs that would crash the watch's parser
.OrderBy(r => r.Slot)
.ThenBy(r => r.FriendlyName)
.ToList();
return Ok(rows);
}
// ── Send gift package (in-game gift box popup) ────────────────────────
public sealed record SendGiftRequest(
string? AvatarItemGuid,
int? AvatarItemType, // 0 = Outfit, 1 = HairDye (per RecNet.Avatars+AvatarItemType)
int? CurrencyType, // 1 = Tokens, 2 = Coins (0 = none)
int? Currency,
int? Xp,
int? Level,
string? Message,
int? Rarity); // 0 Common, 10 Uncommon, 20 Rare, 30 Epic, 50 Legendary
/// <summary>POST <c>api/admin/v1/players/{id}/gift</c> — drop a
/// <see cref="GiftPackageEntity"/> into the recipient's gift inbox.
/// The watch's <c>RecNet.Avatars.DowloadGiftPackages</c> loop picks
/// it up on the next poll and shows the standard gift-box popup;
/// the player taps "open" to fire <c>LocalConsumeGiftPackage</c>,
/// which hits <see cref="DorkNet.Server.Controllers.API.Avatar.V2.AvatarGiftsController.ConsumeViaBody"/>
/// and finally writes the avatar item into <c>InventoryJson</c>.
/// We DO NOT touch InventoryJson here — that bypasses the gift
/// flow and the player never sees the popup.
///
/// Also pushes a <see cref="PushNotificationId.GiftPackageReceived"/>
/// over SignalR so an online player sees the toast immediately
/// without waiting for the next /gifts poll.</summary>
[HttpPost("players/{id:long}/gift")]
public async Task<ActionResult> SendGift(long id, [FromBody] SendGiftRequest body)
{
var recipient = await db.Players.FirstOrDefaultAsync(p => p.Id == id);
if (recipient is null) return NotFound();
var hasItem = !string.IsNullOrWhiteSpace(body.AvatarItemGuid);
var hasCurrency = body.CurrencyType is int ct && ct > 0 && body.Currency is int c && c > 0;
var hasXp = body.Xp is int xp && xp > 0;
var hasLevel = body.Level is int lvl && lvl > 0;
if (!hasItem && !hasCurrency && !hasXp && !hasLevel)
return BadRequest(new { error = "empty_gift", message = "Provide at least one of: avatar item, currency, XP, level." });
// Wire shape MUST match the working /api/avatar/v2/gifts/generate
// flow exactly. The watch's ParseGiftPackageReceived
// (Avatars.txt:616) gates on IsValid + SupportsCurrentPlatform,
// then runs the gift through GiftManager. Two deviations from
// /generate were breaking us:
// 1. FromPlayerId set to the admin's id — the watch's P2P-gift
// rendering path looks up sender info; if that lookup
// doesn't return what the popup renderer expects, the toast
// still fires from the SignalR push but the box never
// materializes. /generate uses null; we match.
// 2. IsGifted = true tagged it as a player-to-player gift,
// which routes through a different code path entirely
// (separate inbox / different popup). /generate leaves it
// at the default false; we match.
// CRITICAL invariants for AvatarItemDescOrHairDyeDesc, both
// verified against the in-game crash trace:
//
// 1. AvatarItemType MUST be null when no avatar item is in the
// gift. Setting it to 0 (Outfit) with an empty desc routes
// GiftManager.DequeueGift past the HasAvatarItemOrHairDye
// gate into OutfitManager.MarkGiftPackageItemAvailableOnCurrentPlatform("")
// → AvatarItem.FromRecNetString("") → "empty RecNet string"
// AvatarParseException.
//
// 2. When there IS an item, AvatarItemDescOrHairDyeDesc must
// have at least 4 comma-separated parts. The client's
// FromRecNetString(string) splits on `,` then constructs
// ArraySegment(parts, 1, 3) — parts.Length < 4 triggers
// ArgumentException at ArraySegmentEnumerator.get_Current
// (output_log.txt:1567). Bare GUIDs crash; we suffix `,,,`
// for three empty default customisations. See
// AvatarItemsController.cs:31-40 for the canonical comment.
var gift = new GiftPackageEntity
{
RecipientPlayerId = id,
FromPlayerId = null,
AvatarItemType = hasItem ? (body.AvatarItemType ?? 0) : (int?)null,
AvatarItemDescOrHairDyeDesc = hasItem
? NormalizeAvatarItemDesc(body.AvatarItemGuid!.Trim())
: string.Empty,
CurrencyType = hasCurrency ? body.CurrencyType!.Value : 0,
Currency = hasCurrency ? body.Currency!.Value : 0,
Xp = hasXp ? body.Xp!.Value : 0,
Level = hasLevel ? body.Level!.Value : 0,
GiftContext = 0,
GiftRarity = body.Rarity ?? 0,
Message = string.IsNullOrWhiteSpace(body.Message)
? "A gift from the admins."
: body.Message.Trim(),
Platform = -1,
PackageVariant = "Standard",
PackageMaterial = string.Empty,
IsValid = true,
SupportsCurrentPlatform = true,
};
db.GiftPackages.Add(gift);
await LogAsync("send_gift", "player", id,
$"item={(hasItem ? gift.AvatarItemDescOrHairDyeDesc : "-")} " +
$"currency={(hasCurrency ? $"{gift.CurrencyType}×{gift.Currency}" : "-")} " +
$"xp={gift.Xp} level={gift.Level}");
await db.SaveChangesAsync();
// GiftPackageReceivedImmediate (31), NOT GiftPackageReceived (30).
// GiftManager.OnGiftPackageReceivedEvent (GiftManager.txt:501) takes
// a `showImmediately` bool — true for id 31, false for id 30. When
// false the handler returns *without* calling ReceiveGifts, so no
// box pops. Admin gifts want the box now, not "next time you open
// the gift inbox", so push 31. The watch's RecNet.Avatars handler
// registers BOTH ids (Avatars.txt:177 + 192), and 31 is the only
// one that ends up invoking the GiftManager popup path.
await notifications.NotifyAsync(id,
PushNotificationId.GiftPackageReceivedImmediate,
new
{
gift.Id,
gift.FromPlayerId,
gift.AvatarItemType,
AvatarItemDesc = gift.AvatarItemDescOrHairDyeDesc,
gift.AvatarItemDescOrHairDyeDesc,
gift.EquipmentPrefabName,
gift.EquipmentModificationGuid,
gift.CurrencyType,
gift.Currency,
gift.Xp,
gift.Level,
gift.GiftContext,
gift.GiftRarity,
gift.Message,
gift.Platform,
gift.PackageMaterial,
gift.PackageVariant,
gift.Consumed,
gift.IsValid,
gift.ErrorMessage,
gift.SupportsCurrentPlatform,
gift.IsGifted,
});
return Ok(new { gift.Id, gift.RecipientPlayerId });
}
/// <summary>Pad a bare avatar item GUID with three empty default
/// customisations so the watch's <c>AvatarItem.FromRecNetString</c>
/// doesn't crash on <c>ArraySegment(parts, 1, 3)</c> when the
/// string only contains the GUID. Idempotent — descs that already
/// have at least 4 comma-separated parts are returned untouched.</summary>
private static string NormalizeAvatarItemDesc(string descOrGuid)
{
if (string.IsNullOrWhiteSpace(descOrGuid)) return string.Empty;
var commaCount = 0;
foreach (var ch in descOrGuid) if (ch == ',') commaCount++;
return commaCount >= 3 ? descOrGuid : descOrGuid + new string(',', 3 - commaCount);
}
// ── Pending gift inbox (clean-up) ────────────────────────────────────
/// <summary>GET <c>api/admin/v1/players/{id}/gifts</c> — list the
/// recipient's pending (unconsumed) gift packages. Used by the admin
/// Gift page to surface broken / abandoned gifts that the watch
/// can't render so we can delete them. Includes consumed ones too
/// in case we want to audit what's been opened.</summary>
[HttpGet("players/{id:long}/gifts")]
public async Task<ActionResult> ListPlayerGifts(long id, [FromQuery] bool includeConsumed = false)
{
IQueryable<GiftPackageEntity> q = db.GiftPackages.Where(g => g.RecipientPlayerId == id);
if (!includeConsumed) q = q.Where(g => !g.Consumed);
var rows = await q
.OrderByDescending(g => g.CreatedAt)
.Take(200)
.Select(g => new
{
g.Id,
g.RecipientPlayerId,
g.FromPlayerId,
g.AvatarItemType,
g.AvatarItemDescOrHairDyeDesc,
g.CurrencyType,
g.Currency,
g.Xp,
g.Level,
g.GiftContext,
g.GiftRarity,
g.Message,
g.Consumed,
g.ConsumedAt,
g.IsValid,
g.IsGifted,
g.CreatedAt,
})
.ToListAsync();
return Ok(rows);
}
/// <summary>DELETE <c>api/admin/v1/gifts/{id}</c> — hard-delete a
/// gift package row. Use when a gift is broken (wrong shape, won't
/// render its box in the watch) or was a mis-fire from a test send.
/// Does not affect the player's actual inventory if they'd already
/// consumed it — that write happened during consume and is final.</summary>
[HttpDelete("gifts/{id:long}")]
public async Task<ActionResult> DeleteGift(long id)
{
var gift = await db.GiftPackages.FirstOrDefaultAsync(g => g.Id == id);
if (gift is null) return NotFound();
var recipient = gift.RecipientPlayerId;
db.GiftPackages.Remove(gift);
await LogAsync("delete_gift", "gift", id, $"recipient={recipient} consumed={gift.Consumed}");
await db.SaveChangesAsync();
return Ok(new { deleted = id });
}
/// <summary>POST <c>api/admin/v1/players/{id}/gifts/clear</c> —
/// nuke every unconsumed gift for a player. Useful after a burst of
/// broken admin-test gifts to reset the inbox to a clean state.
/// Consumed gifts are left alone (they're history, not state).</summary>
[HttpPost("players/{id:long}/gifts/clear")]
public async Task<ActionResult> ClearPlayerGifts(long id)
{
var pending = await db.GiftPackages
.Where(g => g.RecipientPlayerId == id && !g.Consumed)
.ToListAsync();
db.GiftPackages.RemoveRange(pending);
await LogAsync("clear_gifts", "player", id, $"removed={pending.Count}");
await db.SaveChangesAsync();
return Ok(new { removed = pending.Count });
}
// ── Password reset (admin override) ──────────────────────────────────
public sealed record SetPasswordRequest(string NewPassword);
/// <summary>Admin-set a new password for any account. Hashes with
/// BCrypt and persists onto <see cref="PlayerEntity.PasswordHash"/>.
/// The audit row records the action but never the plaintext.
/// Refuses passwords shorter than 8 chars — same floor as the
/// self-service flow.</summary>
[HttpPost("players/{id:long}/password")]
public async Task<ActionResult> SetPassword(long id, [FromBody] SetPasswordRequest body)
{
if (string.IsNullOrWhiteSpace(body.NewPassword) || body.NewPassword.Length < 8)
return BadRequest(new { error = "password_too_short", min = 8 });
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == id);
if (player is null) return NotFound();
player.PasswordHash = BCrypt.Net.BCrypt.HashPassword(body.NewPassword);
await LogAsync("set_password", "player", id, "");
await db.SaveChangesAsync();
// Push a logout so any active sessions on the old password get
// dropped — the watch re-authenticates with the new password.
await notifications.NotifyAsync(id, PushNotificationId.Logout,
new { Reason = "AdminPasswordReset" });
return Ok(new { id });
}
// ── Live instance viewer ─────────────────────────────────────────────
/// <summary>GET <c>api/admin/v1/instances</c> — list every active room
/// instance an online player is currently sitting in. Joins the live
/// online-player set from <see cref="OnlinePresenceService"/> against
/// <see cref="PlayerPresenceService"/> (which holds the last-known
/// <see cref="RoomInstanceDto"/> per player, TTL'd to 45 seconds) and
/// groups by <c>RoomInstanceId</c>. Empty list when no one is online.</summary>
[HttpGet("instances")]
public async Task<ActionResult> ListInstances()
{
var onlineIds = onlinePresence.OnlinePlayerIds().ToHashSet();
if (onlineIds.Count == 0) return Ok(Array.Empty<object>());
// Bulk-pull names so we can render usernames per participant.
var nameMap = await db.Players
.Where(p => onlineIds.Contains(p.Id))
.Select(p => new { p.Id, p.Username, p.DisplayName })
.ToDictionaryAsync(p => p.Id, p => new { p.Username, p.DisplayName });
var roomNameMap = await db.Rooms
.Select(r => new { r.Id, r.Name })
.ToDictionaryAsync(r => r.Id, r => r.Name);
var grouped = new Dictionary<long, InstanceGroup>();
foreach (var pid in onlineIds)
{
var room = playerPresence.GetRoom(pid);
if (room is null) continue;
if (!grouped.TryGetValue(room.RoomInstanceId, out var g))
{
g = new InstanceGroup
{
RoomInstanceId = room.RoomInstanceId,
RoomId = room.RoomId,
SubRoomId = room.SubRoomId,
RoomName = roomNameMap.GetValueOrDefault(room.RoomId, $"#{room.RoomId}"),
PhotonRoomId = room.PhotonRoomId,
PhotonRegionId = room.PhotonRegionId,
Location = room.Location,
MaxCapacity = room.MaxCapacity,
IsPrivate = room.IsPrivate,
Participants = new(),
};
grouped[room.RoomInstanceId] = g;
}
nameMap.TryGetValue(pid, out var nm);
g.Participants.Add(new InstanceParticipant
{
Id = pid,
Username = nm?.Username ?? $"#{pid}",
DisplayName = nm?.DisplayName ?? $"#{pid}",
});
}
return Ok(grouped.Values
.OrderByDescending(g => g.Participants.Count)
.ThenBy(g => g.RoomName));
}
private sealed class InstanceGroup
{
public long RoomInstanceId { get; set; }
public long RoomId { get; set; }
public long SubRoomId { get; set; }
public string RoomName { get; set; } = "";
public string PhotonRoomId { get; set; } = "";
public string PhotonRegionId { get; set; } = "";
public string Location { get; set; } = "";
public int MaxCapacity { get; set; }
public bool IsPrivate { get; set; }
public List<InstanceParticipant> Participants { get; set; } = new();
}
private sealed class InstanceParticipant
{
public long Id { get; set; }
public string Username { get; set; } = "";
public string DisplayName { get; set; } = "";
}
// ── Force-join (experimental) ────────────────────────────────────────
/// <summary>
/// Force-join body. <c>RoomInstanceId</c> + <c>PhotonRoomId</c> are
/// optional — when omitted, the watch lands in a fresh matchmade
/// instance of the room. When provided (from the Instances browser
/// → "send into this match" flow), we target that exact Photon room
/// so the admin can drop a player into a specific public or private
/// instance other players are already in.
/// </summary>
public sealed record ForceJoinRequest(
long RoomId,
long? SubRoomId,
long? RoomInstanceId,
string? PhotonRoomId,
string? PhotonRegionId);
/// <summary>POST <c>api/admin/v1/players/{id}/forcejoin</c> — rewrite
/// the target player's <see cref="PlayerPresenceService"/> entry to
/// the destination room (and optionally a specific Photon instance),
/// then push <c>SubscriptionUpdateGameSession</c> + <c>SubscriptionUpdateRoom</c>
/// so the watch's cached RoomInstance + RoomDetails match the new
/// server-side state. The watch's
/// <c>OnPresenceHeartbeatResponse</c> detects the change on its
/// next heartbeat and refreshes its cache; pair with
/// <c>POST players/{id}/kick</c> to also drop the player out of
/// their current Photon match so they re-matchmake into the new
/// target on reconnect.
///
/// When <c>RoomInstanceId</c> + <c>PhotonRoomId</c> are supplied,
/// the player lands in that EXACT instance (public or private). Use
/// the Instances browser to enumerate available Photon rooms and
/// pick one. With both fields omitted, a fresh deterministic
/// instance id is used so the player matchmakes normally.</summary>
[HttpPost("players/{id:long}/forcejoin")]
public async Task<ActionResult> ForceJoin(long id, [FromBody] ForceJoinRequest body)
{
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == id);
if (player is null) return NotFound();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == body.RoomId);
if (room is null) return NotFound(new { error = "room_not_found" });
// Build the destination RoomInstance. When the admin picked a
// specific instance from the Instances browser, RoomInstanceId
// + PhotonRoomId arrive together — use them verbatim so the
// player lands in THAT match, not a fresh one. Otherwise mint
// deterministic defaults that match what /goto/room would
// produce for a first join (roomId*100k + subId base; bare
// ^roomname_id photon room name).
var subRoomId = body.SubRoomId ?? 0;
var photonRoomId = !string.IsNullOrEmpty(body.PhotonRoomId)
? body.PhotonRoomId
: $"^{room.Name.ToLowerInvariant()}_{room.Id}";
var roomInstanceId = body.RoomInstanceId
?? (room.Id * 100_000L + subRoomId);
var photonRegionId = !string.IsNullOrEmpty(body.PhotonRegionId)
? body.PhotonRegionId
: "us";
var roomInstance = new DorkNet.Server.Controllers.Match.RoomInstanceDto
{
RoomInstanceId = roomInstanceId,
RoomId = room.Id,
SubRoomId = subRoomId,
Location = room.LocationReplicationId,
PhotonRegionId = photonRegionId,
PhotonRoomId = photonRoomId,
Name = room.Name,
MaxCapacity = 8,
IsFull = false,
IsPrivate = false,
IsInProgress = true,
};
playerPresence.SetRoom(id, roomInstance);
// Two pushes back-to-back: GameSession update changes the
// watch's cached RoomInstance (OnSubscriptionUpdateRoomInstance
// → UpdateCachedRoomInstance), and SubscriptionUpdateRoom
// refreshes the watch's RoomDetails cache so the room-info
// panel doesn't show stale data after the warp.
await notifications.NotifyAsync(id,
PushNotificationId.SubscriptionUpdateGameSession, roomInstance);
await notifications.NotifyAsync(id,
PushNotificationId.SubscriptionUpdateRoom,
new
{
Room = new { Id = room.Id, Name = room.Name },
RoomId = room.Id,
SubRoomId = subRoomId,
Reason = "AdminForceJoin",
});
await LogAsync("force_join", "player", id,
$"room={body.RoomId} sub={subRoomId} instance={roomInstanceId} photon={photonRoomId}");
await db.SaveChangesAsync();
return Ok(new
{
id,
roomId = body.RoomId,
subRoomId,
roomInstanceId,
photonRoomId,
});
}
// ── System ───────────────────────────────────────────────────────────
public sealed record SetMotdRequest(string Message);
/// <summary>Push a server-maintenance broadcast to every connected
/// player. The MOTD config value is changed via appsettings.json
/// (server restart required); this is for ad-hoc announcements.</summary>
[HttpPost("broadcast")]
public async Task<ActionResult> Broadcast([FromBody] SetMotdRequest body)
{
if (string.IsNullOrWhiteSpace(body.Message)) return BadRequest("empty message");
await notifications.BroadcastAsync(PushNotificationId.ServerMaintenance,
new { Message = body.Message });
await LogAsync("broadcast", "system", 0, body.Message);
await db.SaveChangesAsync();
return Ok();
}
// ── Store catalog management ─────────────────────────────────────────
/// <summary>Canonical storefront keys the admin UI can assign to
/// store items. Exact <c>giftdrop:N</c> keys target a single
/// in-room shelf; <c>rro</c> and <c>all</c> are shared groups.</summary>
[HttpGet("storefronts")]
public ActionResult ListStorefronts() =>
Ok(StoreService.GetStorefrontDefinitions().Select(s => new
{
s.Key,
s.StorefrontType,
s.DisplayName,
s.Scope,
}));
/// <summary>List every item in the catalog (including inactive
/// rows) so the admin UI can render the full catalog and let an
/// admin flip flags / edit prices.</summary>
[HttpGet("storeitems")]
public async Task<ActionResult> ListStoreItems(
[FromQuery] string? storefront, [FromQuery] string? category,
[FromQuery] int take = 200, [FromQuery] int skip = 0)
{
take = Math.Clamp(take, 1, 500);
skip = Math.Max(0, skip);
IQueryable<StoreItemEntity> q = db.StoreItems;
if (!string.IsNullOrWhiteSpace(storefront)) q = q.Where(i => i.Storefront == storefront);
if (!string.IsNullOrWhiteSpace(category)) q = q.Where(i => i.Category == category);
var rows = await q.OrderBy(i => i.Storefront).ThenBy(i => i.Category).ThenBy(i => i.Price)
.Skip(skip).Take(take).ToListAsync();
return Ok(rows);
}
public sealed record StoreItemUpsertRequest(
string? Slug, string? DisplayName, string? Description,
string? Category, string? ImageName, int? CurrencyType,
long? Price, bool? IsActive, bool? IsLimitedTime,
DateTime? AvailableUntil, string? Storefront);
/// <summary>Create a new catalog item.</summary>
[HttpPost("storeitems")]
public async Task<ActionResult> CreateStoreItem([FromBody] StoreItemUpsertRequest body)
{
if (string.IsNullOrWhiteSpace(body.Slug)) return BadRequest("missing_slug");
if (string.IsNullOrWhiteSpace(body.DisplayName)) return BadRequest("missing_display_name");
var existing = await db.StoreItems.AnyAsync(i => i.Slug == body.Slug);
if (existing) return Conflict(new { error = "slug_taken" });
var item = new StoreItemEntity
{
Slug = body.Slug.Trim(),
DisplayName = body.DisplayName.Trim(),
Description = (body.Description ?? string.Empty).Trim(),
Category = (body.Category ?? "accessory").Trim().ToLowerInvariant(),
ImageName = body.ImageName ?? string.Empty,
CurrencyType = body.CurrencyType ?? 2,
Price = Math.Max(0, body.Price ?? 0),
IsActive = body.IsActive ?? true,
IsLimitedTime = body.IsLimitedTime ?? false,
AvailableUntil = body.AvailableUntil,
Storefront = (body.Storefront ?? "main").Trim(),
};
db.StoreItems.Add(item);
await LogAsync("create_store_item", "storeitem", 0, item.Slug);
await db.SaveChangesAsync();
return Ok(item);
}
/// <summary>Patch an existing catalog item — any null field is
/// left untouched so the admin UI can do partial edits.</summary>
[HttpPost("storeitems/{id:long}")]
[HttpPut("storeitems/{id:long}")]
public async Task<ActionResult> UpdateStoreItem(long id, [FromBody] StoreItemUpsertRequest body)
{
var item = await db.StoreItems.FirstOrDefaultAsync(i => i.Id == id);
if (item is null) return NotFound();
if (body.DisplayName is not null) item.DisplayName = body.DisplayName.Trim();
if (body.Description is not null) item.Description = body.Description.Trim();
if (body.Category is not null) item.Category = body.Category.Trim().ToLowerInvariant();
if (body.ImageName is not null) item.ImageName = body.ImageName;
if (body.CurrencyType is int ct) item.CurrencyType = ct;
if (body.Price is long p) item.Price = Math.Max(0, p);
if (body.IsActive is bool a) item.IsActive = a;
if (body.IsLimitedTime is bool lt) item.IsLimitedTime = lt;
if (body.AvailableUntil.HasValue) item.AvailableUntil = body.AvailableUntil;
if (body.Storefront is not null) item.Storefront = body.Storefront.Trim();
item.UpdatedAt = DateTime.UtcNow;
await LogAsync("update_store_item", "storeitem", id, item.Slug);
await db.SaveChangesAsync();
return Ok(item);
}
/// <summary>Delete a catalog item. Soft-flag is recommended via
/// IsActive=false; hard delete is here for cleanup of test
/// items.</summary>
[HttpDelete("storeitems/{id:long}")]
public async Task<ActionResult> DeleteStoreItem(long id)
{
var item = await db.StoreItems.FirstOrDefaultAsync(i => i.Id == id);
if (item is null) return NotFound();
db.StoreItems.Remove(item);
await LogAsync("delete_store_item", "storeitem", id, item.Slug);
await db.SaveChangesAsync();
return Ok(new { deleted = id });
}
// ── Helpers ──────────────────────────────────────────────────────────
private Task LogAsync(string action, string targetType, long targetId, string reason)
{
db.AdminActions.Add(new AdminActionEntity
{
AdminPlayerId = CurrentAdminId,
Action = action,
TargetType = targetType,
TargetId = targetId,
Reason = reason,
});
return Task.CompletedTask;
}
// ── Community Board ──────────────────────────────────────────────
// Backs the dorm community board panel. The watch hits
// /api/communityboard/v1/current; admins edit here.
/// <summary>GET the current board state. Same shape the watch
/// receives — handy for the admin UI to populate its form.</summary>
[HttpGet("communityboard")]
public async Task<ActionResult<CommunityBoardState>> GetCommunityBoard()
=> Ok(await communityBoard.GetAsync());
/// <summary>POST a full replacement state. Any null nested objects
/// (FeaturedPlayer / FeaturedRoomGroup / CurrentAnnouncement)
/// clear that section. Lists default to empty if omitted.</summary>
[HttpPost("communityboard")]
public async Task<ActionResult<CommunityBoardState>> SetCommunityBoard(
[FromBody] CommunityBoardState body)
{
body.InstagramImages ??= new();
body.Videos ??= new();
var saved = await communityBoard.UpdateAsync(body);
await LogAsync("set_community_board", "global", 0,
$"announcement=\"{saved.CurrentAnnouncement?.Message ?? ""}\" " +
$"featuredPlayer={saved.FeaturedPlayer?.Id} " +
$"videos={saved.Videos.Count} instagram={saved.InstagramImages.Count}");
return Ok(saved);
}
/// <summary>POST <c>api/admin/v1/communityboard/instagram/upload</c>
/// — accept a single image file, persist it as a global
/// <see cref="RoomDataBlobEntity"/> at <c>RoomId=0</c> under a
/// generated hash filename, return the filename + a ready-to-paste
/// img.* URL. The Community Board SPA section pipes a file picker
/// into this so admins don't have to know what an
/// <c>ImageName</c> is — they just drop a JPG/PNG and we wire it up.
///
/// Cap: 25 MB. Anything larger is almost certainly a mistake for an
/// Instagram-strip tile and would also push us close to the CF edge
/// limit on a non-chunked upload.</summary>
[HttpPost("communityboard/instagram/upload")]
[DisableRequestSizeLimit]
[RequestFormLimits(MultipartBodyLengthLimit = 25_000_000)]
public async Task<ActionResult> UploadInstagramImage([FromForm(Name = "file")] IFormFile? file)
{
if (file is null || file.Length == 0)
return BadRequest(new { error = "missing_file" });
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (ext is not (".jpg" or ".jpeg" or ".png" or ".webp"))
return BadRequest(new { error = "unsupported_extension", allowed = new[] { ".jpg", ".jpeg", ".png", ".webp" } });
using var ms = new MemoryStream();
await file.CopyToAsync(ms);
var bytes = ms.ToArray();
// Hashed filename so re-uploads of the same content don't
// proliferate and so the URL is content-addressed. Prefix
// `cb_insta_` to keep these distinguishable from room-image
// blobs at a glance when grepping the table.
using var sha = System.Security.Cryptography.SHA1.Create();
var hash = Convert.ToHexString(sha.ComputeHash(bytes)).ToLowerInvariant();
var imageName = $"cb_insta_{hash[..24]}{ext}";
var existing = await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.BlobName == imageName);
if (existing is null)
{
var (bucket, key) = BlobRouter.Route(imageName);
await storage.PutAsync(bucket, key, bytes,
ext == ".png" ? "image/png" : "image/jpeg");
db.RoomDataBlobs.Add(new RoomDataBlobEntity
{
RoomId = 0,
BlobName = imageName,
UploadedByPlayerId = CurrentAdminId,
UploadedAt = DateTime.UtcNow,
});
await LogAsync("cb_insta_upload", "community_board", 0, $"image={imageName} bytes={bytes.Length}");
await db.SaveChangesAsync();
}
// Use the configured apex (DomainConfig) so the returned URL
// works whether the admin is on localhost, rec.net, or a future
// deployment apex — driven solely by the DORKNET_DOMAIN env var.
return Ok(new
{
imageName,
imageUrl = $"https://{domain.Sub("img")}/{imageName}",
bytes = bytes.Length,
reused = existing is not null,
});
}
/// <summary>POST <c>api/admin/v1/communityboard/video/upload</c> —
/// accept a single video file and persist it as a global
/// <see cref="RoomDataBlobEntity"/> at <c>RoomId=0</c> under a hashed
/// <c>cb_video_*</c> name. The board's Videos section binds the
/// returned <c>blobName</c> straight into <c>VideoState.BlobName</c>
/// so the watch's <c>CommunityBoardVideoData</c> resolves it to
/// <c>cdn.{apex}/&lt;blobName&gt;</c>.
///
/// Cap: 100 MB — picks the same ceiling Cloudflare enforces at the
/// edge so a non-chunked POST never sees a 413. Anything bigger
/// should be hosted externally and pasted into <c>SourceUrl</c>.</summary>
// [RequestSizeLimit] alone is enough — pairing it with
// [DisableRequestSizeLimit] was undefined-behaviour territory
// (Microsoft docs explicitly say "don't combine them"). Setting
// 100 MB at the action keeps Kestrel from clipping the body at its
// 30 MB default while still capping uploads under Cloudflare's
// free-tier edge limit.
[HttpPost("communityboard/video/upload")]
[RequestFormLimits(MultipartBodyLengthLimit = 100_000_000, ValueLengthLimit = 100_000_000)]
[RequestSizeLimit(100_000_000)]
public async Task<ActionResult> UploadCommunityBoardVideo([FromForm(Name = "file")] IFormFile? file)
{
if (file is null || file.Length == 0)
return BadRequest(new { error = "missing_file" });
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (ext is not (".mp4" or ".webm" or ".mov" or ".m4v"))
return BadRequest(new {
error = "unsupported_extension",
allowed = new[] { ".mp4", ".webm", ".mov", ".m4v" },
});
var declaredSize = file.Length;
using var ms = new MemoryStream();
await file.CopyToAsync(ms);
var bytes = ms.ToArray();
// Catch the "Kestrel/Cloudflare clipped the body" case BEFORE
// we hash + write — IFormFile.Length reports what the multipart
// parser saw in the part header, so a mismatch with how many
// bytes the stream actually produced is a transport-level
// truncation and the resulting blob would be the corrupted file
// the user saw on download.
if (bytes.LongLength != declaredSize)
{
adminLogger.LogError(
"[cb-video-upload] size mismatch — multipart says {Declared:N0} bytes, stream produced {Read:N0} bytes (file={Name})",
declaredSize, bytes.LongLength, file.FileName);
return BadRequest(new {
error = "upload_truncated",
declaredBytes = declaredSize,
receivedBytes = bytes.LongLength,
});
}
// Content-addressed name keeps re-uploads from proliferating —
// identical bytes resolve to the same row + S3 object regardless
// of original filename. `cb_video_` prefix keeps it greppable.
using var sha = System.Security.Cryptography.SHA1.Create();
var hash = Convert.ToHexString(sha.ComputeHash(bytes)).ToLowerInvariant();
var blobName = $"cb_video_{hash[..24]}{ext}";
var (bucket, key) = BlobRouter.Route(blobName);
// We were previously skipping S3 PUT when the DB row already
// existed. That created a zombie state: if the first upload's
// PUT silently failed (Garage outage, bad creds) the row was
// committed but the bytes weren't, and every subsequent
// upload of the same content returned `reused: true` without
// ever fixing the missing object. Verify S3 has the object;
// re-PUT if it doesn't.
var existing = await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.BlobName == blobName);
var alreadyOnS3 = existing is not null && await storage.ExistsAsync(bucket, key);
if (!alreadyOnS3)
{
await storage.PutAsync(bucket, key, bytes, MimeFromExt(ext));
// Post-PUT HEAD: Garage occasionally accepts a PutObject
// and returns 200 without persisting (auth misconfig,
// bucket policy reject). HeadObject is the cheapest way to
// confirm the bytes are actually queryable before we
// pretend the upload succeeded.
if (!await storage.ExistsAsync(bucket, key))
{
adminLogger.LogError(
"[cb-video-upload] PUT returned OK but HEAD says {Bucket}/{Key} missing — Garage may be silently dropping",
bucket, key);
return StatusCode(500, new { error = "s3_put_lost", bucket, key });
}
adminLogger.LogInformation(
"[cb-video-upload] stored {Bucket}/{Key} ({Bytes:N0} bytes, ext={Ext})",
bucket, key, bytes.LongLength, ext);
}
if (existing is null)
{
db.RoomDataBlobs.Add(new RoomDataBlobEntity
{
RoomId = 0,
BlobName = blobName,
UploadedByPlayerId = CurrentAdminId,
UploadedAt = DateTime.UtcNow,
});
await LogAsync("cb_video_upload", "community_board", 0, $"video={blobName} bytes={bytes.Length}");
await db.SaveChangesAsync();
}
return Ok(new
{
blobName,
videoUrl = $"https://{domain.Sub("cdn")}/video/{blobName}",
bytes = bytes.Length,
reused = existing is not null && alreadyOnS3,
repaired = existing is not null && !alreadyOnS3,
});
}
/// <summary>POST <c>api/admin/v1/communityboard/videothumb/upload</c>
/// — accept a single image and persist it under a hashed
/// <c>cb_thumb_*</c> name for use as <see cref="VideoState"/>'s
/// <c>ThumbnailBlobName</c>. Mechanically identical to the Instagram
/// upload endpoint, just with a different prefix so thumbnail vs.
/// gallery images are distinguishable at a glance in the blob table.
///
/// Cap: 25 MB. The watch renders thumbnails at small sizes; bigger
/// is wasted bandwidth.</summary>
[HttpPost("communityboard/videothumb/upload")]
[DisableRequestSizeLimit]
[RequestFormLimits(MultipartBodyLengthLimit = 25_000_000)]
public async Task<ActionResult> UploadCommunityBoardVideoThumbnail(
[FromForm(Name = "file")] IFormFile? file)
{
if (file is null || file.Length == 0)
return BadRequest(new { error = "missing_file" });
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (ext is not (".jpg" or ".jpeg" or ".png" or ".webp"))
return BadRequest(new {
error = "unsupported_extension",
allowed = new[] { ".jpg", ".jpeg", ".png", ".webp" },
});
using var ms = new MemoryStream();
await file.CopyToAsync(ms);
var bytes = ms.ToArray();
using var sha = System.Security.Cryptography.SHA1.Create();
var hash = Convert.ToHexString(sha.ComputeHash(bytes)).ToLowerInvariant();
var blobName = $"cb_thumb_{hash[..24]}{ext}";
var existing = await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.BlobName == blobName);
if (existing is null)
{
var (bucket, key) = BlobRouter.Route(blobName);
await storage.PutAsync(bucket, key, bytes,
ext == ".png" ? "image/png" : ext == ".webp" ? "image/webp" : "image/jpeg");
db.RoomDataBlobs.Add(new RoomDataBlobEntity
{
RoomId = 0,
BlobName = blobName,
UploadedByPlayerId = CurrentAdminId,
UploadedAt = DateTime.UtcNow,
});
await LogAsync("cb_videothumb_upload", "community_board", 0, $"thumb={blobName} bytes={bytes.Length}");
await db.SaveChangesAsync();
}
return Ok(new
{
blobName,
imageUrl = $"https://{domain.Sub("img")}/{blobName}",
bytes = bytes.Length,
reused = existing is not null,
});
}
// MIME mapping for the video upload — the CDN serves whatever
// Content-Type we wrote to S3, so getting this right makes the
// watch's HTTPVideoStreamPlayer accept the response without
// sniffing. Default to mp4 since that's what most modern exporters
// produce; unknown extensions fall back to octet-stream which the
// watch usually still handles but is worth flagging.
private static string MimeFromExt(string ext) => ext switch
{
".mp4" => "video/mp4",
".m4v" => "video/mp4",
".webm" => "video/webm",
".mov" => "video/quicktime",
_ => "application/octet-stream",
};
// ── Loading screen tips ──────────────────────────────────────────────
public sealed class LoadingScreenTipDto
{
public long? Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
public string ImageName { get; set; } = string.Empty;
public int Context { get; set; }
public int PlatformMask { get; set; } = -1;
public string RoomNamesCsv { get; set; } = string.Empty;
public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
}
/// <summary>GET <c>api/admin/v1/loadingscreentips</c> — list every
/// tip (including inactive ones) for the admin SPA.</summary>
[HttpGet("loadingscreentips")]
public async Task<IActionResult> ListLoadingScreenTips()
{
var rows = await db.LoadingScreenTips
.OrderBy(t => t.SortOrder).ThenBy(t => t.Id)
.AsNoTracking()
.ToListAsync();
return Ok(rows.Select(ToLoadingScreenTipDto));
}
/// <summary>POST <c>api/admin/v1/loadingscreentips</c> — create
/// a new tip. Returns the persisted row including its assigned
/// Id so the SPA can update its local state.</summary>
[HttpPost("loadingscreentips")]
public async Task<IActionResult> CreateLoadingScreenTip([FromBody] LoadingScreenTipDto body)
{
var entity = new Data.Entities.LoadingScreenTipEntity
{
Title = (body.Title ?? string.Empty).Trim(),
Message = (body.Message ?? string.Empty).Trim(),
ImageName = (body.ImageName ?? string.Empty).Trim(),
Context = body.Context,
PlatformMask = body.PlatformMask,
RoomNamesCsv = NormaliseCsv(body.RoomNamesCsv),
SortOrder = body.SortOrder,
IsActive = body.IsActive,
};
db.LoadingScreenTips.Add(entity);
await db.SaveChangesAsync();
await LogAsync("create_loading_tip", "global", entity.Id, entity.Title);
return Ok(ToLoadingScreenTipDto(entity));
}
/// <summary>PUT <c>api/admin/v1/loadingscreentips/{id}</c> — update
/// an existing tip.</summary>
[HttpPut("loadingscreentips/{id:long}")]
public async Task<IActionResult> UpdateLoadingScreenTip(long id, [FromBody] LoadingScreenTipDto body)
{
var row = await db.LoadingScreenTips.FirstOrDefaultAsync(t => t.Id == id);
if (row is null) return NotFound();
row.Title = (body.Title ?? string.Empty).Trim();
row.Message = (body.Message ?? string.Empty).Trim();
row.ImageName = (body.ImageName ?? string.Empty).Trim();
row.Context = body.Context;
row.PlatformMask = body.PlatformMask;
row.RoomNamesCsv = NormaliseCsv(body.RoomNamesCsv);
row.SortOrder = body.SortOrder;
row.IsActive = body.IsActive;
row.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
await LogAsync("update_loading_tip", "global", id, row.Title);
return Ok(ToLoadingScreenTipDto(row));
}
/// <summary>DELETE <c>api/admin/v1/loadingscreentips/{id}</c> — hard
/// delete a tip. (Toggling <c>IsActive=false</c> via the update
/// endpoint is the soft-delete path.)</summary>
[HttpDelete("loadingscreentips/{id:long}")]
public async Task<IActionResult> DeleteLoadingScreenTip(long id)
{
var row = await db.LoadingScreenTips.FirstOrDefaultAsync(t => t.Id == id);
if (row is null) return NotFound();
db.LoadingScreenTips.Remove(row);
await db.SaveChangesAsync();
await LogAsync("delete_loading_tip", "global", id, row.Title);
return Ok(new { deleted = id });
}
/// <summary>POST <c>api/admin/v1/loadingscreentips/upload</c> —
/// accept an image file (jpg/png/webp), persist it via the same
/// content-addressed flow as the Community Board uploader, and
/// return the BlobName + img.* URL. The SPA pipes this into the
/// tip's <c>ImageName</c> field.</summary>
[HttpPost("loadingscreentips/upload")]
[DisableRequestSizeLimit]
[RequestFormLimits(MultipartBodyLengthLimit = 25_000_000)]
public async Task<IActionResult> UploadLoadingScreenTipImage([FromForm(Name = "file")] IFormFile? file)
{
if (file is null || file.Length == 0)
return BadRequest(new { error = "missing_file" });
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (ext is not (".jpg" or ".jpeg" or ".png" or ".webp"))
return BadRequest(new { error = "unsupported_extension", allowed = new[] { ".jpg", ".jpeg", ".png", ".webp" } });
using var ms = new MemoryStream();
await file.CopyToAsync(ms);
var bytes = ms.ToArray();
using var sha = System.Security.Cryptography.SHA1.Create();
var hash = Convert.ToHexString(sha.ComputeHash(bytes)).ToLowerInvariant();
var imageName = $"loadingtip_{hash[..24]}{ext}";
var existing = await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.BlobName == imageName);
if (existing is null)
{
var (bucket, key) = BlobRouter.Route(imageName);
await storage.PutAsync(bucket, key, bytes,
ext == ".png" ? "image/png" : "image/jpeg");
db.RoomDataBlobs.Add(new RoomDataBlobEntity
{
RoomId = 0,
BlobName = imageName,
UploadedByPlayerId = CurrentAdminId,
UploadedAt = DateTime.UtcNow,
});
await LogAsync("loading_tip_upload", "global", 0, $"image={imageName} bytes={bytes.Length}");
await db.SaveChangesAsync();
}
return Ok(new
{
imageName,
imageUrl = $"https://{domain.Sub("img")}/{imageName}",
bytes = bytes.Length,
reused = existing is not null,
});
}
private static LoadingScreenTipDto ToLoadingScreenTipDto(Data.Entities.LoadingScreenTipEntity t) => new()
{
Id = t.Id,
Title = t.Title,
Message = t.Message,
ImageName = t.ImageName,
Context = t.Context,
PlatformMask = t.PlatformMask,
RoomNamesCsv = t.RoomNamesCsv,
SortOrder = t.SortOrder,
IsActive = t.IsActive,
};
private static string NormaliseCsv(string? csv)
{
if (string.IsNullOrWhiteSpace(csv)) return string.Empty;
var parts = csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return string.Join(",", parts);
}
// ── Room import ──────────────────────────────────────────────────────
/// <summary>POST <c>api/admin/v1/rooms/import</c> — multipart upload
/// of an archived multi-scene room. Each .room file (one per scene)
/// is uploaded with its filename including the scene folder, e.g.
/// <c>Lobby/abc.room</c> or <c>Ch1_Headfirst/def.room</c>. The form
/// can either send the per-file folder via <c>webkitRelativePath</c>
/// (browser folder picker preserves it) or via the filename itself.
///
/// Server inserts:
/// 1. A <see cref="RoomEntity"/> row for the new room (id 1000+).
/// 2. One <see cref="RoomDataBlobEntity"/> per uploaded file,
/// named <c>{roomname}_{scenefolder}.room</c> (lowercased).
/// 3. One <see cref="RoomSceneEntity"/> per scene with the entry
/// scene at OrderIndex=0 and the rest in folder order.
///
/// Default <c>RoomSceneLocationId</c> for every scene is the
/// MakerRoom (Basement) GUID — the canonical 2020 custom-room
/// canvas. If the import was a 2020-era export of an apartment
/// scene, edit <see cref="RoomSceneEntity.RoomSceneLocationId"/>
/// per-row after import.
///
/// Returns 400 if a room with this name already exists or no
/// files were uploaded.</summary>
[HttpPost("rooms/import")]
[DisableRequestSizeLimit]
[RequestFormLimits(MultipartBodyLengthLimit = 500_000_000)]
public async Task<IActionResult> ImportRoom(
[FromForm(Name = "name")] string? name,
[FromForm(Name = "description")] string? description,
[FromForm(Name = "entryScene")] string? entryScene,
[FromForm(Name = "creatorPlayerId")] long? creatorPlayerId,
[FromForm(Name = "scenePaths")] List<string>? scenePaths,
[FromForm(Name = "files")] List<IFormFile>? files,
[FromForm(Name = "normalizeBlobs")] bool? normalizeBlobs)
{
// Per-import "run blob bytes through the 2020 protobuf normaliser"
// toggle, mirroring RoomZipImportController. Default OFF — see
// that file's notes for why the round-trip currently crashes the
// watch on load. Diagnostic Normalize() call still runs so its
// parse-OK / parse-FAIL log line remains in the import telemetry.
var normalize = normalizeBlobs ?? false;
if (string.IsNullOrWhiteSpace(name))
return BadRequest(new { error = "name_required" });
if (files is null || files.Count == 0)
return BadRequest(new { error = "no_files" });
var roomName = name.Trim();
if (await db.Rooms.AnyAsync(r => r.Name == roomName))
return BadRequest(new { error = "duplicate_name", message = $"A room named '{roomName}' already exists." });
// Pair each uploaded file with its scene folder name. Browsers
// submit folder uploads as files with FileName = "Lobby/blob.room"
// when webkitdirectory is used; fall back to a parallel
// scenePaths[] form field when the client supplies it explicitly
// (e.g. uploading individual files via JS FormData with the
// scene name set per entry).
//
// Each blob is also passed through RoomBlobNormalizerService
// (round-trip via the full 2020 PersistedRoomData schema). That
// strips redundant default-value encodings the modern client
// emits — without this, the watch's stricter 2020 parser logs
// "Error attempting to parse room data stream" and boots the
// player to dorm. Normalize() falls back to the original bytes
// if parse fails, so a corrupted upload doesn't drop content.
var perScene = new List<(string SceneName, string OriginalName, byte[] Bytes, RoomBlobNormalizerService.Result Norm)>();
for (int i = 0; i < files.Count; i++)
{
var f = files[i];
// FormFile.FileName is "scenefolder/blob.room" when uploaded
// via webkitdirectory. ScenePaths (if provided) overrides.
var sceneName = scenePaths is { } sp && i < sp.Count && !string.IsNullOrWhiteSpace(sp[i])
? sp[i].Trim()
: ExtractSceneFolder(f.FileName);
if (string.IsNullOrWhiteSpace(sceneName))
{
return BadRequest(new {
error = "missing_scene_folder",
message = $"File '{f.FileName}' has no scene folder. Either upload via folder picker, or supply scenePaths[] alongside files[]."
});
}
using var ms = new MemoryStream();
await f.CopyToAsync(ms);
var raw = ms.ToArray();
var norm = roomBlobNormalizer.Normalize(raw);
var persistedBytes = normalize ? norm.Bytes : raw;
adminLogger.LogInformation(
"[import] scene={Scene} file={File} raw={Raw:N0} → normalized={Out:N0} ({Status}) persistedFrom={Source}",
sceneName, f.FileName, raw.Length, norm.Bytes.Length,
norm.Normalized ? "ok" : $"fail: {norm.Error}",
normalize ? "normaliser" : "raw");
perScene.Add((sceneName, f.FileName, persistedBytes, norm));
}
// Pick entry scene: explicit override → "Lobby" if present →
// alphabetically-first folder.
var sceneNames = perScene.Select(s => s.SceneName).ToList();
var entry = !string.IsNullOrWhiteSpace(entryScene)
? perScene.FirstOrDefault(s => string.Equals(s.SceneName, entryScene, StringComparison.OrdinalIgnoreCase))
: default;
if (entry == default)
{
entry = perScene.FirstOrDefault(s => string.Equals(s.SceneName, "Lobby", StringComparison.OrdinalIgnoreCase));
}
if (entry == default) entry = perScene.OrderBy(s => s.SceneName).First();
// Allocate a new id in the user-room range (>=1000).
var newRoomId = (await db.Rooms
.Where(r => r.Id >= 1000)
.Select(r => (long?)r.Id)
.MaxAsync() ?? 999) + 1;
var creator = creatorPlayerId ?? CurrentAdminId;
const string makerRoomLocation = "a75f7547-79eb-47c6-8986-6767abcb4f92";
// Build BlobName per scene.
var slug = roomName.ToLowerInvariant().Replace(' ', '_');
string MakeBlobName(string sceneName) => $"{slug}_{sceneName.ToLowerInvariant()}.room";
// Reorder so entry scene is OrderIndex=0.
var ordered = new List<(string SceneName, string OriginalName, byte[] Bytes, RoomBlobNormalizerService.Result Norm)> { entry };
ordered.AddRange(perScene.Where(s => s.SceneName != entry.SceneName));
using var tx = await db.Database.BeginTransactionAsync();
try
{
// Insert blobs — bytes go to S3, DB only stores metadata.
foreach (var s in ordered)
{
var blobName = MakeBlobName(s.SceneName);
var (bucket, key) = BlobRouter.Route(blobName);
await storage.PutAsync(bucket, key, s.Bytes, "application/octet-stream");
db.RoomDataBlobs.Add(new RoomDataBlobEntity
{
RoomId = newRoomId,
BlobName = blobName,
UploadedByPlayerId = creator,
UploadedAt = DateTime.UtcNow,
});
}
// Insert Room row pointing at entry scene's blob.
var entryBlob = MakeBlobName(entry.SceneName);
db.Rooms.Add(new RoomEntity
{
Id = newRoomId,
Name = roomName,
Description = string.IsNullOrWhiteSpace(description)
? $"Imported via admin upload ({ordered.Count} scenes)"
: description.Trim(),
CreatorPlayerId = creator,
ImageName = string.Empty,
State = 0,
Accessibility = 1,
IsAGRoom = false,
IsDormRoom = false,
CloningAllowed = true,
LocationReplicationId = makerRoomLocation,
TagsCsv = "community",
CheerCount = 0,
FavoriteCount = 0,
VisitCount = 0,
VisitorCount = 0,
HotScore = 5.0,
CurrentDataBlobName = entryBlob,
});
// Insert RoomScenes rows.
int idx = 0;
foreach (var s in ordered)
{
db.RoomScenes.Add(new RoomSceneEntity
{
RoomId = newRoomId,
OrderIndex = idx++,
Name = s.SceneName,
RoomSceneLocationId = makerRoomLocation,
DataBlobName = MakeBlobName(s.SceneName),
MaxPlayers = 8,
IsSandbox = false,
CanMatchmakeInto = true,
DataModifiedAt = DateTime.UtcNow,
});
}
await db.SaveChangesAsync();
await tx.CommitAsync();
}
catch
{
await tx.RollbackAsync();
throw;
}
await LogAsync("import_room", "room", newRoomId,
$"name={roomName} scenes={ordered.Count} entry={entry.SceneName}");
// Fire-and-forget: scan the just-imported scene blobs for
// Holotar/AudioSampler .htr references and download each from
// cdn.rec.net into RoomDataBlobs (BlobName="{hash}.htr",
// RoomId=0). The watch's holotar projectors and audio
// samplers can then pull their content from cdn.localhost
// instead of failing on the public CDN. Doing this in the
// background keeps the upload response snappy (the .htr
// download set for a YarrHarrHeist-sized room is ~25 MB).
htrMirror.EnqueueAsync(
ordered.Select(s => s.Bytes).ToList(),
contextLabel: $"room={roomName}");
return Ok(new
{
roomId = newRoomId,
roomName,
sceneCount = ordered.Count,
entryScene = entry.SceneName,
normalizedSceneCount = ordered.Count(s => s.Norm.Normalized),
normalizationFailures = ordered.Where(s => !s.Norm.Normalized)
.Select(s => new { scene = s.SceneName, error = s.Norm.Error }),
htrMirrorStarted = true,
scenes = ordered.Select((s, i) => new
{
orderIndex = i,
name = s.SceneName,
blobName = MakeBlobName(s.SceneName),
rawBytes = s.Norm.InputBytes,
bytes = s.Bytes.Length,
normalized = s.Norm.Normalized,
}),
});
}
/// <summary>POST <c>api/admin/v1/rooms/{id}/mirror-htr</c> —
/// re-run the HoloTar / AudioSampler .htr mirror for an existing
/// room's blobs. Useful to (a) backfill assets for rooms imported
/// before the auto-mirror existed, (b) debug whether
/// HtrAssetMirrorService can actually extract refs from the C#
/// generated 2020 PersistedRoomData schema. Synchronous (awaits the
/// download stream) so the response carries the result counts —
/// the import-time path uses fire-and-forget instead.</summary>
[HttpPost("rooms/{roomId:long}/mirror-htr")]
public async Task<IActionResult> MirrorHtrForRoom(long roomId)
{
// Stream bytes back from S3 — DB carries only metadata now.
var blobNames = await db.RoomDataBlobs
.Where(b => b.RoomId == roomId)
.Select(b => b.BlobName)
.ToListAsync();
if (blobNames.Count == 0) return NotFound(new { error = "no_blobs", roomId });
var blobs = new List<byte[]>(blobNames.Count);
foreach (var blobName in blobNames)
{
var (bucket, key) = BlobRouter.Route(blobName);
var bytes = await storage.GetAsync(bucket, key);
if (bytes is { Length: > 0 }) blobs.Add(bytes);
}
if (blobs.Count == 0) return NotFound(new { error = "blobs_missing_in_s3", roomId, expected = blobNames.Count });
var result = await htrMirror.MirrorAsync(blobs, contextLabel: $"manual room={roomId}");
await LogAsync("mirror_htr", "room", roomId,
$"refs={result.TotalRefs} skipped={result.AlreadyMirrored} inserted={result.Inserted} parseFails={result.RoomBlobParseFailures}");
return Ok(new
{
roomId,
scannedBlobs = blobs.Count,
uniqueRefs = result.TotalRefs,
alreadyMirrored = result.AlreadyMirrored,
downloaded = result.Inserted,
roomParseFailures = result.RoomBlobParseFailures,
assetDownloadFailures = result.AssetDownloadFailures,
});
}
/// <summary>Pull the immediate-parent folder out of a multi-segment
/// filename like "Lobby/abc.room" or "YarrHarrHeist/Lobby/abc.room".
/// Returns the second-to-last segment (the file's parent folder).
/// Returns null if the filename has no folder component.</summary>
private static string? ExtractSceneFolder(string fileName)
{
if (string.IsNullOrWhiteSpace(fileName)) return null;
// Browsers may use either separator depending on platform.
var parts = fileName.Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 2) return null;
// The .room file's parent folder is the scene name.
return parts[^2];
}
}
@@ -0,0 +1,54 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Data;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Admin;
/// <summary>
/// admin.rec.net/api/admin/v1/login — same-origin login endpoint for
/// the browser-based admin UI. Wraps a username+password verify
/// against PlayerEntity.PasswordHash and returns the JWT pair the UI
/// can stash in localStorage. Refuses non-admin accounts up front so a
/// regular player password can't be used to scope into the admin tools
/// even if the JWT is valid against api/admin/v1/* (the AdminOnly
/// filter would catch it anyway, but the early reject saves a
/// confused-looking 403).
///
/// Lives on admin.rec.net so the static admin page (also served from
/// admin.rec.net) can fetch with a same-origin request and skip CORS
/// entirely.
/// </summary>
[ApiController]
[Route("api/admin/v1")]
public class AdminLoginController(
DorkNetDbContext db,
AuthService authService) : ControllerBase
{
public sealed record AdminLoginRequest(string Username, string Password);
[HttpPost("login")]
public async Task<ActionResult> Login([FromBody] AdminLoginRequest body)
{
if (string.IsNullOrWhiteSpace(body.Username) || string.IsNullOrWhiteSpace(body.Password))
return BadRequest(new { error = "missing_credentials" });
var player = await db.Players.FirstOrDefaultAsync(p => p.Username == body.Username);
if (player is null || player.PasswordHash is null)
return Unauthorized(new { error = "invalid_credentials" });
if (!BCrypt.Net.BCrypt.Verify(body.Password, player.PasswordHash))
return Unauthorized(new { error = "invalid_credentials" });
if (!player.IsAdmin)
return StatusCode(403, new { error = "not_admin" });
var (access, refresh) = authService.GenerateTokenPair(player.Id);
return Ok(new
{
access_token = access,
refresh_token = refresh,
account_id = player.Id,
username = player.Username,
display_name = player.DisplayName,
});
}
}
@@ -0,0 +1,1209 @@
using System.IO.Compression;
using System.Text.Json;
using System.Text.Json.Serialization;
using Google.Protobuf;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Admin;
/// <summary>
/// admin.rec.net/api/admin/v1/rooms/zip-bulk-import — preferred room
/// import path going forward. The legacy multi-file endpoint
/// (<c>POST api/admin/v1/rooms/import</c>) stays alongside this for
/// tooling that already targets it.
///
/// The archive format mirrors RecNet's own room export shape so a
/// user can download a room from rec.net, drop the zip into the admin
/// page, and we re-create it with full fidelity — including the
/// <c>.htr</c> AudioSampler / Holotar assets already extracted into
/// each subroom folder so we never have to hit Rec Room's CDN:
///
/// root.zip/
/// Rooms/Room_&lt;id&gt;_&lt;name&gt;_&lt;timestamp&gt;/
/// RoomDetails.json (full RecNet v4 details schema)
/// RoomImage.jpg | &lt;hash&gt;.jpg
/// Promo/Image/PromoImage_*.jpg
/// SubRooms/&lt;SceneName&gt;/
/// Subroom.json (per-subroom manifest)
/// &lt;hash&gt;.room (referenced by Subroom.CurrentSave.DataBlob)
/// AudioSampler/&lt;hash&gt;.htr
/// Holotar/&lt;hash&gt;.htr
/// Image/PVImage_&lt;hash&gt;.jpg
/// Inventions/Invention_&lt;id&gt;_&lt;name&gt;_&lt;timestamp&gt;/
/// Invention.json
/// InventionDetails.json
/// InventionVersion.json
/// InventionImage.jpg
/// &lt;hash&gt;.inv
///
/// Entry-scene resolution: the order in <c>RoomDetails.SubRooms[]</c>
/// is the entry order (RecNet API convention). The first entry is the
/// entry scene. The admin can override with an explicit
/// <c>RoomDetails.EntryScene</c> field.
/// </summary>
[ApiController]
[Route("api/admin/v1/rooms")]
[Authorize]
[AdminOnly]
public class RoomZipImportController(
DorkNetDbContext db,
RoomBlobNormalizerService roomBlobNormalizer,
IObjectStorage storage,
ILogger<RoomZipImportController> logger) : ControllerBase
{
private long CurrentAdminId => this.RequireCurrentPlayerId();
// ── Room-blob normaliser toggle ─────────────────────────────────────
// The normaliser re-encodes .room bytes through the 2020 schema to
// strip non-canonical encodings. It's been tripping the watch into
// "Error attempting to parse room data stream" — the generated 2020
// protobuf bindings appear to be missing fields or to emit them in
// a wire-order the watch's parser disagrees with.
//
// Per-import flag (`normalizeBlobs` in the form / finalize body)
// defaults OFF. The SPA's importer surfaces it as a checkbox so an
// admin can opt back in once we're confident a particular zip's
// contents survive the re-encode. The diagnostic parse still runs
// either way (its parse-OK / parse-FAIL log line is useful), but
// when the flag is false the original bytes are what reach S3.
/// <summary>Write a blob to S3 at its canonical (bucket, key) AND
/// record the metadata row in <see cref="RoomDataBlobEntity"/>.
/// Bytes are NOT persisted on the DB row — S3 is the only place
/// they live. Previously the importer wrote bytes to the
/// <c>Bytes</c> column and skipped S3 entirely, so freshly
/// imported rooms were only reachable through the read-path's
/// DB-fallback branch. Centralised here so every importer call
/// site (scene saves, .htr/PV/polaroid assets, .meta sentinels,
/// invention saves, room thumbnails, promo images) ends up in
/// S3 the same way.</summary>
private async Task<RoomDataBlobEntity> WriteBlobAsync(
long roomId, string blobName, byte[] bytes, long uploaderPlayerId,
string? referencesCsv = null, string? contentTypeOverride = null)
{
var (bucket, key) = BlobRouter.Route(blobName);
var contentType = contentTypeOverride ?? MimeFromName(blobName);
try
{
await storage.PutAsync(bucket, key, bytes, contentType);
}
catch (Exception ex)
{
logger.LogError(ex,
"[zip-import] S3 PUT failed bucket={Bucket} key={Key} blob={Blob} bytes={Bytes} — row will still be recorded so the import isn't lost",
bucket, key, blobName, bytes.Length);
}
var entity = new RoomDataBlobEntity
{
RoomId = roomId,
BlobName = blobName,
UploadedByPlayerId = uploaderPlayerId,
UploadedAt = DateTime.UtcNow,
ReferencedFilenamesCsv = referencesCsv ?? string.Empty,
};
db.RoomDataBlobs.Add(entity);
return entity;
}
private static string MimeFromName(string name) => Path.GetExtension(name).ToLowerInvariant() switch
{
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".gif" => "image/gif",
".webp" => "image/webp",
".mp4" => "video/mp4",
".htr" => "application/octet-stream",
".room" => "application/octet-stream",
".meta" => "application/octet-stream",
".inv" => "application/octet-stream",
".dat" => "application/octet-stream",
_ => "application/octet-stream",
};
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
// ── RoomDetails.json DTOs ──────────────────────────────────────────
public sealed class RoomDetailsDto
{
public long? RoomId { get; set; }
public string? Name { get; set; }
public string? FriendlyName { get; set; }
public string? Description { get; set; }
public string? ImageName { get; set; }
public int? WarningMask { get; set; }
public string? CustomWarning { get; set; }
public long? CreatorAccountId { get; set; }
public int? State { get; set; }
public int? Accessibility { get; set; }
public bool? SupportsLevelVoting { get; set; }
public bool? IsRRO { get; set; }
public bool? SupportsScreens { get; set; }
public bool? SupportsWalkVR { get; set; }
public bool? SupportsTeleportVR { get; set; }
public bool? SupportsVRLow { get; set; }
public bool? SupportsMobile { get; set; }
public bool? SupportsJuniors { get; set; }
public DateTime? CreatedAt { get; set; }
public DateTime? PublishedAt { get; set; }
public bool? IsDorm { get; set; }
public int? MaxPlayers { get; set; }
public bool? CloningAllowed { get; set; }
public bool? DisableMicAutoMute { get; set; }
public StatsDto? Stats { get; set; }
public List<TagDto>? Tags { get; set; }
public List<SubRoomDto>? SubRooms { get; set; }
public string? EntryScene { get; set; } // admin override; not in real exports
}
public sealed class StatsDto
{
public int? CheerCount { get; set; }
public int? FavoriteCount { get; set; }
public int? VisitorCount { get; set; }
public int? VisitCount { get; set; }
}
public sealed class TagDto
{
public string? Tag { get; set; }
public int? Type { get; set; }
public bool? IsPrimaryGenre { get; set; }
}
public sealed class SubRoomDto
{
public long? SubRoomId { get; set; }
public string? UnitySceneId { get; set; }
public string? Name { get; set; }
public bool? IsSandbox { get; set; }
public int? MaxPlayers { get; set; }
public int? Accessibility { get; set; }
public CurrentSaveDto? CurrentSave { get; set; }
}
public sealed class CurrentSaveDto
{
public long? SubRoomDataSaveId { get; set; }
public string? DataBlob { get; set; }
}
// ── Invention DTOs ────────────────────────────────────────────────
public sealed class InventionJsonDto
{
public long? InventionId { get; set; }
public string? ReplicationId { get; set; }
public long? CreatorPlayerId { get; set; }
public string? Name { get; set; }
public string? Description { get; set; }
public string? LongDescription { get; set; }
public string? ImageName { get; set; }
public int? UgcVersion { get; set; }
public int? CurrentVersionNumber { get; set; }
public int? LatestVersionNumber { get; set; }
public int? Accessibility { get; set; }
public DateTime? CreatedAt { get; set; }
public DateTime? ModifiedAt { get; set; }
public DateTime? FirstPublishedAt { get; set; }
public long? CreationRoomId { get; set; }
public int? NumPlayersHaveUsedInRoom { get; set; }
public int? CheerCount { get; set; }
public int? CreatorPermission { get; set; }
public int? GeneralPermission { get; set; }
public bool? IsAgInvention { get; set; }
public bool? IsRecRoomApproved { get; set; }
}
public sealed class InventionVersionDto
{
public int? VersionNumber { get; set; }
public string? BlobName { get; set; }
}
public sealed class InventionDetailsDto
{
public List<TagDto>? Tags { get; set; }
}
// ── Endpoint ──────────────────────────────────────────────────────
[HttpPost("zip-bulk-import")]
[DisableRequestSizeLimit]
[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)] // 2 GB
public async Task<IActionResult> ZipImport(
[FromForm(Name = "archive")] IFormFile? archive,
[FromForm(Name = "creatorPlayerId")] long? creatorPlayerId,
[FromForm(Name = "normalizeBlobs")] bool? normalizeBlobs)
{
if (archive is null || archive.Length == 0)
return BadRequest(new { error = "missing_archive" });
await using var stream = archive.OpenReadStream();
return await RunImportAsync(
stream, archive.Length, creatorPlayerId,
normalizeBlobs: normalizeBlobs ?? false);
}
// ── Chunked upload (Cloudflare 100MB workaround) ─────────────────
//
// The whole multipart-zip path 413s through Cloudflare's edge for
// anything over 100 MB on free/pro plans. Real RecNet exports
// routinely run 150-300 MB. Splitting the upload into ≤90 MB
// chunks keeps each request safely under the cap; the server
// appends chunks to a temp file and only invokes the import logic
// once the finalize call comes in.
//
// Sessions are in-memory and short-lived (admins use the importer
// synchronously). If the process restarts before finalize, the
// upload is lost — acceptable for an admin tool used by 1-2 people.
private static readonly System.Collections.Concurrent.ConcurrentDictionary<Guid, ZipUploadSession> Sessions = new();
public sealed class ZipUploadSession
{
public Guid Id { get; init; }
public string TempPath { get; init; } = string.Empty;
public long TotalBytes { get; set; }
public long ReceivedBytes { get; set; }
public string FileName { get; init; } = string.Empty;
public long AdminPlayerId { get; init; }
public DateTime CreatedAt { get; init; } = DateTime.UtcNow;
}
public sealed record ChunkInitRequest(string FileName, long TotalBytes);
/// <summary>POST <c>zip-upload-init</c> — allocates a temp file and
/// returns an <c>uploadId</c> the SPA threads through subsequent
/// chunk POSTs. Capped at 2 GB total; opens a sparse temp file so
/// disk usage tracks actual bytes received.</summary>
[HttpPost("zip-upload-init")]
public ActionResult ChunkInit([FromBody] ChunkInitRequest body)
{
if (body.TotalBytes <= 0 || body.TotalBytes > 2_000_000_000)
return BadRequest(new { error = "invalid_size" });
// Prune stale sessions before allocating a new one. Anything
// older than 2 hours with no activity is abandoned.
foreach (var (sid, sess) in Sessions)
{
if (DateTime.UtcNow - sess.CreatedAt > TimeSpan.FromHours(2))
{
Sessions.TryRemove(sid, out _);
try { if (System.IO.File.Exists(sess.TempPath)) System.IO.File.Delete(sess.TempPath); } catch { /* best effort */ }
}
}
var id = Guid.NewGuid();
var safeName = Path.GetFileName(body.FileName);
var tempPath = Path.Combine(Path.GetTempPath(), $"dorknet-ziplupload-{id:N}.zip");
// Pre-allocate the file at full length so chunk writes at
// arbitrary offsets work without the OS having to grow it
// page-by-page during the random writes.
using (var fs = new FileStream(tempPath, FileMode.Create, FileAccess.Write))
{
fs.SetLength(body.TotalBytes);
}
Sessions[id] = new ZipUploadSession
{
Id = id,
TempPath = tempPath,
TotalBytes = body.TotalBytes,
FileName = safeName,
AdminPlayerId = CurrentAdminId,
};
return Ok(new { uploadId = id });
}
/// <summary>POST <c>zip-upload-chunk/{id}?offset=N</c> — writes the
/// posted body at <c>offset</c> in the session's temp file.
/// Per-call size capped at 100 MB (Kestrel-side, well below the
/// Cloudflare 100 MB edge cap so we never see a 413). Body is the
/// raw chunk bytes; the SPA's <c>uploadZipInChunks</c> helper sends
/// the right slice.</summary>
[HttpPost("zip-upload-chunk/{id:guid}")]
[DisableRequestSizeLimit]
[RequestSizeLimit(100_000_000)]
public async Task<IActionResult> ChunkAppend(Guid id, [FromQuery] long offset)
{
if (!Sessions.TryGetValue(id, out var session)) return NotFound(new { error = "unknown_session" });
if (session.AdminPlayerId != CurrentAdminId) return Forbid();
if (offset < 0 || offset >= session.TotalBytes)
return BadRequest(new { error = "bad_offset", offset, total = session.TotalBytes });
await using var fs = new FileStream(session.TempPath, FileMode.Open, FileAccess.Write, FileShare.Read);
fs.Position = offset;
var written = 0L;
await Request.Body.CopyToAsync(fs);
written = fs.Position - offset;
session.ReceivedBytes = Math.Max(session.ReceivedBytes, fs.Position);
return Ok(new
{
uploadId = id,
offset,
written,
received = session.ReceivedBytes,
total = session.TotalBytes,
});
}
/// <summary>Body for <c>zip-upload-finalize</c>. <see cref="CreatorPlayerId"/>
/// stamps every imported room/invention with that player as the
/// owner. <see cref="SelectedRoomFolders"/> and
/// <see cref="SelectedInventionFolders"/>, when non-null and
/// non-empty, restrict the import to only those top-level folders
/// inside the archive — the SPA's preview offers a checkbox per
/// room/invention so admins can skip a bad export's broken entry
/// without rebuilding the zip. Null/empty means "import
/// everything" (back-compat with older SPA builds).</summary>
public sealed record ChunkFinalizeRequest(
long? CreatorPlayerId,
List<string>? SelectedRoomFolders,
List<string>? SelectedInventionFolders,
bool? NormalizeBlobs);
/// <summary>POST <c>zip-upload-finalize/{id}</c> — runs the existing
/// zip import on the assembled temp file then deletes it. Same
/// response shape as <c>zip-bulk-import</c>.</summary>
[HttpPost("zip-upload-finalize/{id:guid}")]
public async Task<IActionResult> ChunkFinalize(Guid id, [FromBody] ChunkFinalizeRequest? body)
{
if (!Sessions.TryGetValue(id, out var session)) return NotFound(new { error = "unknown_session" });
if (session.AdminPlayerId != CurrentAdminId) return Forbid();
try
{
await using var stream = System.IO.File.OpenRead(session.TempPath);
return await RunImportAsync(
stream,
session.TotalBytes,
body?.CreatorPlayerId,
body?.SelectedRoomFolders,
body?.SelectedInventionFolders,
normalizeBlobs: body?.NormalizeBlobs ?? false);
}
finally
{
Sessions.TryRemove(id, out _);
try { if (System.IO.File.Exists(session.TempPath)) System.IO.File.Delete(session.TempPath); }
catch (Exception ex) { logger.LogWarning(ex, "[zip-import] failed to clean up {Path}", session.TempPath); }
}
}
/// <summary>DELETE <c>zip-upload-abort/{id}</c> — drop an in-flight
/// upload session (e.g. user navigated away). No-op if the id is
/// already gone.</summary>
[HttpDelete("zip-upload-abort/{id:guid}")]
public IActionResult ChunkAbort(Guid id)
{
if (Sessions.TryRemove(id, out var session))
{
try { if (System.IO.File.Exists(session.TempPath)) System.IO.File.Delete(session.TempPath); }
catch (Exception ex) { logger.LogWarning(ex, "[zip-import] failed to clean up {Path}", session.TempPath); }
}
return Ok();
}
// ── Shared import core (called by both single-shot and chunked) ──
private async Task<IActionResult> RunImportAsync(
Stream archiveStream,
long archiveBytes,
long? creatorPlayerId,
List<string>? selectedRoomFolders = null,
List<string>? selectedInventionFolders = null,
bool normalizeBlobs = false)
{
var creator = creatorPlayerId ?? CurrentAdminId;
if (!await db.Players.AnyAsync(p => p.Id == creator))
return BadRequest(new { error = "creator_not_found", creator });
using var zip = new ZipArchive(archiveStream, ZipArchiveMode.Read, leaveOpen: false);
// Index entries by normalized path. We do path-based lookups
// throughout (per-room folder, per-subroom folder, per-invention
// folder), so a flat dictionary keyed by full path is simplest.
var entryByPath = new Dictionary<string, ZipArchiveEntry>(StringComparer.OrdinalIgnoreCase);
foreach (var e in zip.Entries)
{
if (e.FullName.EndsWith("/", StringComparison.Ordinal)) continue;
entryByPath[Normalize(e.FullName)] = e;
}
// Find every room manifest (`Rooms/<folder>/RoomDetails.json`).
// Folder names are like "Room_3163127_YarrHarrHeist_20260509_180319"
// but we don't parse anything out of them — RoomDetails.json is
// the source of truth.
var roomFolders = entryByPath.Keys
.Where(k => k.StartsWith("Rooms/", StringComparison.OrdinalIgnoreCase)
&& k.EndsWith("/RoomDetails.json", StringComparison.OrdinalIgnoreCase))
.Select(k => k.Substring(0, k.Length - "/RoomDetails.json".Length))
.ToList();
// Inventions can live either at the zip root (Inventions/Invention_<id>_<name>_<ts>/)
// OR inside a room folder (Rooms/Room_<id>_<name>_<ts>/Inventions/Invention_…).
// Both layouts show up in the wild — handle either.
var inventionFolders = entryByPath.Keys
.Where(k => k.EndsWith("/Invention.json", StringComparison.OrdinalIgnoreCase)
&& (k.StartsWith("Inventions/", StringComparison.OrdinalIgnoreCase)
|| k.Contains("/Inventions/", StringComparison.OrdinalIgnoreCase)))
.Select(k => k[..^"/Invention.json".Length])
.ToList();
if (roomFolders.Count == 0 && inventionFolders.Count == 0)
return BadRequest(new { error = "empty_archive_no_manifests" });
// If the admin supplied an explicit selection (non-null,
// non-empty), filter to just those folders. Comparison is
// case-insensitive and tolerates leading/trailing slashes that
// the client might have included. Anything in the selection
// that we can't match against the archive is just ignored —
// the per-room report will simply not include it.
if (selectedRoomFolders is { Count: > 0 })
{
var allowed = selectedRoomFolders
.Select(s => Normalize(s).TrimEnd('/'))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
roomFolders = roomFolders.Where(f => allowed.Contains(f)).ToList();
}
if (selectedInventionFolders is { Count: > 0 })
{
var allowed = selectedInventionFolders
.Select(s => Normalize(s).TrimEnd('/'))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
inventionFolders = inventionFolders.Where(f => allowed.Contains(f)).ToList();
}
var roomReports = new List<object>();
long nextRoomId = (await db.Rooms
.Where(r => r.Id >= 1000)
.Select(r => (long?)r.Id)
.MaxAsync() ?? 999) + 1;
foreach (var folder in roomFolders)
{
try
{
var result = await ImportRoomAsync(folder, nextRoomId, creator, entryByPath, normalizeBlobs);
roomReports.Add(result);
if (result.GetType().GetProperty("ok")!.GetValue(result) as bool? == true)
nextRoomId++;
}
catch (Exception ex)
{
logger.LogError(ex, "[zip-import] room folder='{Folder}' failed", folder);
roomReports.Add(new { folder, ok = false, error = ex.Message });
}
}
var inventionReports = new List<object>();
foreach (var folder in inventionFolders)
{
try { inventionReports.Add(await ImportInventionAsync(folder, creator, entryByPath)); }
catch (Exception ex)
{
logger.LogError(ex, "[zip-import] invention folder='{Folder}' failed", folder);
inventionReports.Add(new { folder, ok = false, error = ex.Message });
}
}
return Ok(new
{
archiveBytes,
roomCount = roomFolders.Count,
inventionCount = inventionFolders.Count,
rooms = roomReports,
inventions = inventionReports,
});
}
// ── Per-room import ───────────────────────────────────────────────
private async Task<object> ImportRoomAsync(
string roomFolder,
long roomId,
long creator,
Dictionary<string, ZipArchiveEntry> entries,
bool normalizeBlobs)
{
// RoomDetails.json
var detailsKey = $"{roomFolder}/RoomDetails.json";
var details = await ReadJsonAsync<RoomDetailsDto>(entries[detailsKey]) ?? new RoomDetailsDto();
var name = !string.IsNullOrWhiteSpace(details.Name) ? details.Name!.Trim() : Path.GetFileName(roomFolder);
if (await db.Rooms.AnyAsync(r => r.Name == name))
return new { name, ok = false, error = "duplicate_name", folder = roomFolder };
// Subroom manifests + blobs. Walk each folder under
// <roomFolder>/SubRooms/<scene>/ and pair its Subroom.json with
// the .room blob it references via CurrentSave.DataBlob, plus
// any AudioSampler/Holotar .htr files alongside.
var subroomRoot = $"{roomFolder}/SubRooms/";
var scenes = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var path in entries.Keys.Where(k => k.StartsWith(subroomRoot, StringComparison.OrdinalIgnoreCase)))
{
var rel = path.Substring(subroomRoot.Length);
var slash = rel.IndexOf('/');
if (slash <= 0) continue;
var sceneName = rel.Substring(0, slash);
if (!scenes.ContainsKey(sceneName)) scenes[sceneName] = sceneName;
}
var resolvedScenes = new List<ResolvedSubroom>();
var missingScenes = new List<string>();
foreach (var sceneName in scenes.Keys)
{
var sceneFolder = $"{subroomRoot}{sceneName}";
var subroomJsonKey = $"{sceneFolder}/Subroom.json";
if (!entries.TryGetValue(subroomJsonKey, out var subroomJsonEntry))
{
missingScenes.Add($"{sceneName} (missing Subroom.json)");
continue;
}
var manifest = await ReadJsonAsync<SubRoomDto>(subroomJsonEntry) ?? new SubRoomDto();
var blobFile = manifest.CurrentSave?.DataBlob;
if (string.IsNullOrWhiteSpace(blobFile))
{
missingScenes.Add($"{sceneName} (no CurrentSave.DataBlob)");
continue;
}
var blobKey = $"{sceneFolder}/{blobFile}";
if (!entries.TryGetValue(blobKey, out var blobEntry))
{
missingScenes.Add($"{sceneName} → {blobFile}");
continue;
}
var blobBytes = await ReadAllAsync(blobEntry);
// The normaliser still runs so we keep the parse-OK / parse-FAIL
// log line — useful telemetry even when we're not persisting
// its output. Per-import `normalizeBlobs` flag (defaulting
// off because the re-encode currently crashes the watch on
// load) decides which bytes actually reach S3.
var norm = roomBlobNormalizer.Normalize(blobBytes);
var persistedBytes = normalizeBlobs ? norm.Bytes : blobBytes;
// Collect .htr asset blobs that live alongside this subroom.
var htrAssets = new List<(string Name, ZipArchiveEntry Entry)>();
foreach (var sub in new[] { "AudioSampler", "Holotar" })
{
var prefix = $"{sceneFolder}/{sub}/";
foreach (var (k, e) in entries.Where(kv => kv.Key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)))
{
if (k.EndsWith(".htr", StringComparison.OrdinalIgnoreCase))
htrAssets.Add((Path.GetFileName(k), e));
}
}
// Image/ folder: persistence-view previews (PVImage_<hash>.ext)
// and — since the exporter consolidated layouts — also the
// placed-in-world polaroids that used to live in Polaroids/.
//
// The watch fetches BOTH URLs for the same content:
// * `img.localhost/PVImage_<hash>.png` — persistence-view preview
// * `img.localhost/<hash>.png` — placed polaroid (room blob
// references this bare-hash form, decoded directly from
// the .room protobuf in our sample)
// So for any `PVImage_`-prefixed file we save the bytes under
// BOTH BlobNames. Same file in S3/DB twice is cheap and saves
// us from missing one of the lookup paths.
const string pvPrefix = "PVImage_";
var pvImages = new List<(string Name, ZipArchiveEntry Entry)>();
var polaroids = new List<(string Name, ZipArchiveEntry Entry)>();
var imageFolderPrefix = $"{sceneFolder}/Image/";
foreach (var (k, e) in entries.Where(kv => kv.Key.StartsWith(imageFolderPrefix, StringComparison.OrdinalIgnoreCase)))
{
var fileName = Path.GetFileName(k);
pvImages.Add((fileName, e));
if (fileName.StartsWith(pvPrefix, StringComparison.OrdinalIgnoreCase))
{
var bareHashName = fileName[pvPrefix.Length..];
polaroids.Add((bareHashName, e));
}
}
// Legacy fallback: older archives shipped polaroids as a
// separate Polaroids/ subfolder. Still support those so we
// don't regress on older test zips that haven't been re-
// exported. The persistence loop's de-dupe will collapse
// overlaps from the Image/-derived path.
var polaroidFolderPrefix = $"{sceneFolder}/Polaroids/";
foreach (var (k, e) in entries.Where(kv => kv.Key.StartsWith(polaroidFolderPrefix, StringComparison.OrdinalIgnoreCase)))
{
polaroids.Add((Path.GetFileName(k), e));
}
resolvedScenes.Add(new ResolvedSubroom(
Manifest: manifest,
SceneName: sceneName,
Bytes: persistedBytes,
RawBytes: blobBytes.Length,
// When the toggle is off we still report `norm.Normalized` —
// it reflects whether the diagnostic parse succeeded, not
// whether we actually applied the normalised output.
NormalizedOk: norm.Normalized,
HtrAssets: htrAssets,
PvImages: pvImages,
Polaroids: polaroids));
logger.LogInformation(
"[zip-import] room='{Room}' scene='{Scene}' blob={Blob} raw={Raw:N0} normOutput={Norm:N0} persistedFrom={Source} htr={Htr} pv={Pv} polaroids={Pol}",
name, sceneName, blobFile, blobBytes.Length, norm.Bytes.Length,
normalizeBlobs ? "normaliser" : "raw",
htrAssets.Count, pvImages.Count, polaroids.Count);
}
if (resolvedScenes.Count == 0)
return new { name, ok = false, error = "no_resolvable_subrooms", missingScenes, folder = roomFolder };
// Entry resolution: order subrooms by manifest order if
// RoomDetails.SubRooms[] is present; otherwise by sceneName.
// EntryScene field overrides everything.
var manifestOrder = (details.SubRooms ?? new())
.Where(s => !string.IsNullOrWhiteSpace(s.Name))
.Select(s => s.Name!)
.ToList();
if (manifestOrder.Count > 0)
{
resolvedScenes = resolvedScenes
.OrderBy(r => manifestOrder.IndexOf(r.SceneName) is int i && i >= 0 ? i : int.MaxValue)
.ThenBy(r => r.SceneName)
.ToList();
}
else
{
resolvedScenes = resolvedScenes.OrderBy(r => r.SceneName).ToList();
}
// Entry resolution cascade:
// 1. Explicit `RoomDetails.EntryScene` override (admin-set,
// not in real exports).
// 2. <hash>.meta protobuf at the room root — RecNet's
// published-snapshot manifest. Field 2 is the entry
// subroom INDEX into RoomDetails.SubRooms[] (0-based).
// Decoded directly without a generated schema because
// the message is tiny (6 bytes total in the known sample).
// 3. First entry in manifest order.
// 4. Alphabetical fallback.
ResolvedSubroom? metaEntry = null;
int? metaIndex = null;
string? metaFileName = null;
var metaCandidate = entries
.Where(kv => kv.Key.StartsWith($"{roomFolder}/", StringComparison.OrdinalIgnoreCase)
&& !kv.Key[$"{roomFolder}/".Length..].Contains('/')
&& kv.Key.EndsWith(".meta", StringComparison.OrdinalIgnoreCase))
.Select(kv => kv.Value)
.FirstOrDefault();
if (metaCandidate is not null)
{
metaFileName = Path.GetFileName(metaCandidate.FullName);
var metaBytes = await ReadAllAsync(metaCandidate);
metaIndex = TryReadEntrySubroomIndexFromMeta(metaBytes);
logger.LogInformation("[zip-import] room='{Room}' .meta={Meta} bytes={Bytes} entryIndex={Idx}",
name, metaFileName, metaBytes.Length, metaIndex);
if (metaIndex is int idx && idx >= 0 && idx < manifestOrder.Count)
{
var sceneAtIndex = manifestOrder[idx];
metaEntry = resolvedScenes.FirstOrDefault(r =>
string.Equals(r.SceneName, sceneAtIndex, StringComparison.OrdinalIgnoreCase));
}
}
ResolvedSubroom entry;
string entrySource;
if (!string.IsNullOrWhiteSpace(details.EntryScene)
&& resolvedScenes.FirstOrDefault(r => string.Equals(r.SceneName, details.EntryScene, StringComparison.OrdinalIgnoreCase)) is { } overrideEntry)
{
entry = overrideEntry; entrySource = "RoomDetails.EntryScene";
}
else if (metaEntry is not null)
{
entry = metaEntry; entrySource = $"meta_index={metaIndex}";
}
else
{
entry = resolvedScenes[0]; entrySource = manifestOrder.Count > 0 ? "manifest_first" : "alphabetical_first";
}
// Image: try ImageName lookup first (RecNet-style hash filename),
// then RoomImage.* fallback (convenience format).
ZipArchiveEntry? imageEntry = null;
if (!string.IsNullOrWhiteSpace(details.ImageName)
&& entries.TryGetValue($"{roomFolder}/{details.ImageName}", out var hashed))
{
imageEntry = hashed;
}
else
{
imageEntry = entries.FirstOrDefault(kv =>
kv.Key.StartsWith($"{roomFolder}/RoomImage.", StringComparison.OrdinalIgnoreCase)).Value;
}
// Reorder so entry is first. Manifest order preserved for the rest.
var ordered = new List<ResolvedSubroom> { entry };
ordered.AddRange(resolvedScenes.Where(r => !ReferenceEquals(r, entry)));
var slug = name.ToLowerInvariant().Replace(' ', '_');
// Prefix with room_<id>_ so BlobRouter parks every .room scene
// save alongside its room_<id>_v*.dat versioned snapshots in
// saves/room/<id>/. Same routing parser handles both shapes;
// no per-extension special case.
string MakeBlobName(string sceneName) => $"room_{roomId}_{slug}_{sceneName.ToLowerInvariant()}.room";
// Use the entry subroom's UnitySceneId as the room's location.
var entryLocation = entry.Manifest.UnitySceneId ?? "a75f7547-79eb-47c6-8986-6767abcb4f92";
// Persist .htr assets and PV images at the GLOBAL level
// (RoomId=0) under their original hash filenames. The CDN
// / img.* controllers look up by exact blob name, so this
// matches what HtrAssetMirrorService would have produced if it
// had to fetch them from rec.net.
//
// We track three counters per asset type:
// * referenced — total unique filenames the archive points at
// * imported — newly inserted into RoomDataBlobs this run
// * alreadyInDb — already present from a prior import / mirror
// The third is important to surface: bulk-purge-custom keeps
// shared assets intentionally (other rooms might use them),
// so a re-import after purge will legitimately report 0 newly
// imported with non-zero already-in-DB. Reporting only "0
// imported" reads as "no HTR uploaded" which is wrong.
var htrAssetCount = 0;
var pvImageCount = 0;
var polaroidCount = 0;
var allHtrAssets = ordered.SelectMany(s => s.HtrAssets).ToList();
var allPvImages = ordered.SelectMany(s => s.PvImages).ToList();
var allPolaroids = ordered.SelectMany(s => s.Polaroids).ToList();
// De-dupe within the archive first (same .htr can appear under
// multiple scenes), then de-dupe against the DB. Both layers
// matter for accurate reporting.
var uniqueHtrNames = allHtrAssets.Select(a => a.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var uniquePvNames = allPvImages.Select(a => a.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var uniquePolaroidNames = allPolaroids.Select(a => a.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var existingHtrNames = new HashSet<string>(
await db.RoomDataBlobs
.Where(b => uniqueHtrNames.Contains(b.BlobName))
.Select(b => b.BlobName).ToListAsync(),
StringComparer.OrdinalIgnoreCase);
var existingPvNames = new HashSet<string>(
await db.RoomDataBlobs
.Where(b => uniquePvNames.Contains(b.BlobName))
.Select(b => b.BlobName).ToListAsync(),
StringComparer.OrdinalIgnoreCase);
var existingPolaroidNames = new HashSet<string>(
await db.RoomDataBlobs
.Where(b => uniquePolaroidNames.Contains(b.BlobName))
.Select(b => b.BlobName).ToListAsync(),
StringComparer.OrdinalIgnoreCase);
var htrAlreadyInDbAtStart = existingHtrNames.Count;
var pvAlreadyInDbAtStart = existingPvNames.Count;
var polaroidAlreadyInDbAtStart = existingPolaroidNames.Count;
var imageBlobName = string.Empty;
await using (var tx = await db.Database.BeginTransactionAsync())
{
// Subroom blobs
foreach (var s in ordered)
{
await WriteBlobAsync(roomId, MakeBlobName(s.SceneName), s.Bytes, creator);
}
// HTR assets (shared content — keyed by hash, RoomId=0)
foreach (var (hname, hentry) in allHtrAssets)
{
if (existingHtrNames.Contains(hname)) continue;
existingHtrNames.Add(hname);
var bytes = await ReadAllAsync(hentry);
await WriteBlobAsync(0, hname, bytes, creator);
htrAssetCount++;
}
// PV images (persistence-view previews; shared)
foreach (var (pname, pentry) in allPvImages)
{
if (existingPvNames.Contains(pname)) continue;
existingPvNames.Add(pname);
var bytes = await ReadAllAsync(pentry);
await WriteBlobAsync(0, pname, bytes, creator);
pvImageCount++;
}
// Polaroids (placed in-world; watch fetches by bare hash).
foreach (var (polName, polEntry) in allPolaroids)
{
if (existingPolaroidNames.Contains(polName)) continue;
existingPolaroidNames.Add(polName);
var bytes = await ReadAllAsync(polEntry);
await WriteBlobAsync(0, polName, bytes, creator);
polaroidCount++;
}
// Room-root opaque marker files (e.g. <hash>.meta — 6-byte
// sentinel the exporter drops, purpose unclear but watching
// for fetches just in case). Rename to room_<id>_<hash>.meta
// so BlobRouter parks them in saves/room/<id>/ alongside the
// versioned save snapshots — keeps every byte that belongs
// to a specific room in one S3 folder.
var rootMetaPrefix = $"{roomFolder}/";
foreach (var (k, mentry) in entries.Where(kv =>
kv.Key.StartsWith(rootMetaPrefix, StringComparison.OrdinalIgnoreCase)
&& !kv.Key[rootMetaPrefix.Length..].Contains('/')
&& kv.Key.EndsWith(".meta", StringComparison.OrdinalIgnoreCase)))
{
var originalName = Path.GetFileName(k);
var fname = $"room_{roomId}_{originalName}";
if (await db.RoomDataBlobs.AnyAsync(b => b.BlobName == fname)) continue;
var bytes = await ReadAllAsync(mentry);
await WriteBlobAsync(roomId, fname, bytes, creator);
}
// Room image
if (imageEntry is not null)
{
var bytes = await ReadAllAsync(imageEntry);
var ext = Path.GetExtension(imageEntry.Name).ToLowerInvariant();
if (string.IsNullOrEmpty(ext)) ext = ".jpg";
imageBlobName = $"image_room_{roomId}{ext}";
await WriteBlobAsync(0, imageBlobName, bytes, creator);
}
// Promo images (kept under their original hashed names so
// the room's promo strip resolves them via img.* CDN).
var promoPrefix = $"{roomFolder}/Promo/Image/";
foreach (var (k, pentry) in entries.Where(kv => kv.Key.StartsWith(promoPrefix, StringComparison.OrdinalIgnoreCase)))
{
var fname = Path.GetFileName(k);
if (await db.RoomDataBlobs.AnyAsync(b => b.BlobName == fname)) continue;
var bytes = await ReadAllAsync(pentry);
await WriteBlobAsync(0, fname, bytes, creator);
}
db.Rooms.Add(new RoomEntity
{
Id = roomId,
Name = name,
Description = details.Description?.Trim() ?? $"Imported from zip ({ordered.Count} scenes)",
CreatorPlayerId = creator,
ImageName = imageBlobName,
State = details.State ?? 0,
Accessibility = details.Accessibility ?? 1,
IsAGRoom = false,
IsDormRoom = details.IsDorm ?? false,
CloningAllowed = details.CloningAllowed ?? true,
SupportsLevelVoting = details.SupportsLevelVoting ?? false,
SupportsVRLow = details.SupportsVRLow ?? true,
SupportsMobile = details.SupportsMobile ?? false,
SupportsScreens = details.SupportsScreens ?? true,
SupportsWalkVR = details.SupportsWalkVR ?? true,
SupportsTeleportVR = details.SupportsTeleportVR ?? true,
AllowsJuniors = details.SupportsJuniors ?? true,
RoomWarningMask = details.WarningMask ?? 0,
CustomRoomWarning = details.CustomWarning ?? string.Empty,
DisableMicAutoMute = details.DisableMicAutoMute ?? false,
LocationReplicationId = entryLocation,
TagsCsv = JoinTags(details.Tags),
CheerCount = details.Stats?.CheerCount ?? 0,
FavoriteCount = details.Stats?.FavoriteCount ?? 0,
VisitCount = details.Stats?.VisitCount ?? 0,
VisitorCount = details.Stats?.VisitorCount ?? 0,
HotScore = 5.0,
CurrentDataBlobName = MakeBlobName(entry.SceneName),
CreatedAt = details.CreatedAt ?? DateTime.UtcNow,
});
int idx = 0;
foreach (var s in ordered)
{
db.RoomScenes.Add(new RoomSceneEntity
{
RoomId = roomId,
OrderIndex = idx++,
Name = s.SceneName,
RoomSceneLocationId = s.Manifest.UnitySceneId ?? entryLocation,
DataBlobName = MakeBlobName(s.SceneName),
MaxPlayers = s.Manifest.MaxPlayers ?? 8,
IsSandbox = s.Manifest.IsSandbox ?? false,
CanMatchmakeInto = true,
DataModifiedAt = DateTime.UtcNow,
});
}
db.AdminActions.Add(new AdminActionEntity
{
AdminPlayerId = CurrentAdminId,
Action = "zip_import_room",
TargetType = "room",
TargetId = roomId,
Reason = $"name={name} scenes={ordered.Count} entry={entry.SceneName} src={entrySource} " +
$"htr={htrAssetCount}new+{htrAlreadyInDbAtStart}existed " +
$"pv={pvImageCount}new+{pvAlreadyInDbAtStart}existed " +
$"polaroids={polaroidCount}new+{polaroidAlreadyInDbAtStart}existed",
});
await db.SaveChangesAsync();
await tx.CommitAsync();
}
return new
{
name,
ok = true,
roomId,
folder = roomFolder,
sceneCount = ordered.Count,
entryScene = entry.SceneName,
entryDetectionSource = entrySource,
missingScenes = missingScenes.Count > 0 ? missingScenes : null,
tags = details.Tags?.Select(t => t.Tag).Where(t => !string.IsNullOrWhiteSpace(t)).ToList(),
imageBlob = string.IsNullOrEmpty(imageBlobName) ? null : imageBlobName,
htrAssets = new
{
referenced = uniqueHtrNames.Count,
newlyImported = htrAssetCount,
alreadyInDb = htrAlreadyInDbAtStart,
},
pvImages = new
{
referenced = uniquePvNames.Count,
newlyImported = pvImageCount,
alreadyInDb = pvAlreadyInDbAtStart,
},
polaroids = new
{
referenced = uniquePolaroidNames.Count,
newlyImported = polaroidCount,
alreadyInDb = polaroidAlreadyInDbAtStart,
},
// Legacy fields kept for back-compat with anything that
// already reads them; new code should prefer htrAssets /
// pvImages / polaroids above.
htrAssetsImported = htrAssetCount,
pvImagesImported = pvImageCount,
stats = new
{
cheers = details.Stats?.CheerCount ?? 0,
favorites = details.Stats?.FavoriteCount ?? 0,
visitors = details.Stats?.VisitorCount ?? 0,
visits = details.Stats?.VisitCount ?? 0,
},
scenes = ordered.Select((s, i) => new
{
orderIndex = i,
name = s.SceneName,
blobName = MakeBlobName(s.SceneName),
bytes = s.Bytes.Length,
rawBytes = s.RawBytes,
normalized = s.NormalizedOk,
unitySceneId = s.Manifest.UnitySceneId,
isSandbox = s.Manifest.IsSandbox,
maxPlayers = s.Manifest.MaxPlayers,
htrAssets = s.HtrAssets.Count,
pvImages = s.PvImages.Count,
}),
};
}
// ── Per-invention import ─────────────────────────────────────────
private async Task<object> ImportInventionAsync(
string folder,
long creator,
Dictionary<string, ZipArchiveEntry> entries)
{
// Invention.json
var meta = await ReadJsonAsync<InventionJsonDto>(entries[$"{folder}/Invention.json"])
?? new InventionJsonDto();
InventionVersionDto version = entries.TryGetValue($"{folder}/InventionVersion.json", out var ve)
? await ReadJsonAsync<InventionVersionDto>(ve) ?? new InventionVersionDto()
: new InventionVersionDto();
InventionDetailsDto extras = entries.TryGetValue($"{folder}/InventionDetails.json", out var de)
? await ReadJsonAsync<InventionDetailsDto>(de) ?? new InventionDetailsDto()
: new InventionDetailsDto();
var name = !string.IsNullOrWhiteSpace(meta.Name) ? meta.Name!.Trim() : Path.GetFileName(folder);
// Find the .inv blob — by manifest reference if present, else
// first .inv file in the invention folder.
ZipArchiveEntry? blobEntry = null;
if (!string.IsNullOrWhiteSpace(version.BlobName)
&& entries.TryGetValue($"{folder}/{version.BlobName}", out var byManifest))
{
blobEntry = byManifest;
}
else
{
blobEntry = entries.FirstOrDefault(kv =>
kv.Key.StartsWith($"{folder}/", StringComparison.OrdinalIgnoreCase)
&& kv.Key.EndsWith(".inv", StringComparison.OrdinalIgnoreCase)).Value;
}
if (blobEntry is null)
return new { name, ok = false, error = "no_inv_blob", folder };
// Image (optional)
ZipArchiveEntry? imageEntry = null;
if (!string.IsNullOrWhiteSpace(meta.ImageName)
&& entries.TryGetValue($"{folder}/{meta.ImageName}", out var byHash))
{
imageEntry = byHash;
}
else
{
imageEntry = entries.FirstOrDefault(kv =>
kv.Key.StartsWith($"{folder}/InventionImage.", StringComparison.OrdinalIgnoreCase)).Value;
}
var blobBytes = await ReadAllAsync(blobEntry);
var entity = new InventionEntity
{
CreatorPlayerId = creator,
ReplicationId = !string.IsNullOrWhiteSpace(meta.ReplicationId)
? meta.ReplicationId!.Trim()
: Guid.NewGuid().ToString("N"),
Name = name,
Description = meta.Description?.Trim() ?? string.Empty,
ImageName = string.Empty, // set below after we know the blob name
IsAgInvention = meta.IsAgInvention ?? true,
IsPublished = false,
Permission = meta.Accessibility ?? 0,
CreatorPermission = meta.CreatorPermission ?? 100,
GeneralPermission = meta.GeneralPermission ?? 0,
CurrentVersionNumber = meta.CurrentVersionNumber ?? version.VersionNumber ?? 1,
NumPlayersHaveUsedInRoom = meta.NumPlayersHaveUsedInRoom ?? 0,
CheerCount = meta.CheerCount ?? 0,
CreationRoomId = meta.CreationRoomId,
TagsCsv = JoinTags(extras.Tags),
CreatedAt = meta.CreatedAt ?? DateTime.UtcNow,
UpdatedAt = meta.ModifiedAt ?? DateTime.UtcNow,
FirstPublishedAt = meta.FirstPublishedAt,
};
db.Inventions.Add(entity);
await db.SaveChangesAsync();
var blobName = $"invention_{entity.Id}_v{entity.CurrentVersionNumber}.dat";
entity.CurrentBlobName = blobName;
await WriteBlobAsync(0, blobName, blobBytes, creator);
string? imageBlobName = null;
if (imageEntry is not null)
{
var bytes = await ReadAllAsync(imageEntry);
var ext = Path.GetExtension(imageEntry.Name).ToLowerInvariant();
if (string.IsNullOrEmpty(ext)) ext = ".jpg";
imageBlobName = $"image_invention_{entity.Id}{ext}";
entity.ImageName = imageBlobName;
await WriteBlobAsync(0, imageBlobName, bytes, creator);
}
db.AdminActions.Add(new AdminActionEntity
{
AdminPlayerId = CurrentAdminId,
Action = "zip_import_invention",
TargetType = "invention",
TargetId = entity.Id,
Reason = $"name={name} blob={blobName} bytes={blobBytes.Length}",
});
await db.SaveChangesAsync();
logger.LogInformation(
"[zip-import] invention id={Id} name={Name} bytes={Bytes:N0} image={Image}",
entity.Id, name, blobBytes.Length, imageBlobName ?? "(none)");
return new
{
name,
ok = true,
inventionId = entity.Id,
folder,
blobName,
imageBlob = imageBlobName,
bytes = blobBytes.Length,
tags = extras.Tags?.Select(t => t.Tag).Where(t => !string.IsNullOrWhiteSpace(t)).ToList(),
};
}
// ── Helpers ──────────────────────────────────────────────────────
private static string Normalize(string path) => path.Replace('\\', '/').TrimStart('/');
private static async Task<byte[]> ReadAllAsync(ZipArchiveEntry entry)
{
await using var s = entry.Open();
using var ms = new MemoryStream();
await s.CopyToAsync(ms);
return ms.ToArray();
}
private static async Task<T?> ReadJsonAsync<T>(ZipArchiveEntry entry)
{
await using var s = entry.Open();
return await JsonSerializer.DeserializeAsync<T>(s, JsonOpts);
}
private static string JoinTags(List<TagDto>? tags)
{
if (tags is null || tags.Count == 0) return "community";
var values = tags
.Select(t => t.Tag?.Trim())
.Where(t => !string.IsNullOrEmpty(t))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
return values.Count > 0 ? string.Join(',', values!) : "community";
}
private sealed record ResolvedSubroom(
SubRoomDto Manifest,
string SceneName,
byte[] Bytes,
int RawBytes,
bool NormalizedOk,
List<(string Name, ZipArchiveEntry Entry)> HtrAssets,
List<(string Name, ZipArchiveEntry Entry)> PvImages,
List<(string Name, ZipArchiveEntry Entry)> Polaroids);
/// <summary>Decode RecNet's room-snapshot manifest <c>.meta</c> and
/// return the entry-subroom index it encodes, or null if the file
/// isn't shaped like one. The full <c>.proto</c> schema isn't part
/// of the generated C# bindings, but the known-sample bytes are
/// trivially small and parseable by hand:
///
/// 10 01 1A 02 08 01
/// ┬──── ┬──────────
/// │ │
/// │ └─ field 3 (length-delimited), inner = { field 1 (varint) = 1 }
/// └─ field 2 (varint) = 1
///
/// Empirically field 2 is the entry subroom INDEX into
/// <c>RoomDetails.SubRooms[]</c> (0-based), and field 3's nested
/// field 1 echoes the same value (probably "published version =
/// 1"). We only need field 2 for the entry-selection decision.</summary>
private static int? TryReadEntrySubroomIndexFromMeta(byte[] bytes)
{
if (bytes is null || bytes.Length == 0) return null;
try
{
using var input = new CodedInputStream(bytes);
while (!input.IsAtEnd)
{
var tag = input.ReadTag();
var field = WireFormat.GetTagFieldNumber(tag);
var wire = WireFormat.GetTagWireType(tag);
if (field == 2 && wire == WireFormat.WireType.Varint)
{
return (int)input.ReadInt64();
}
input.SkipLastField();
}
}
catch
{
return null;
}
return null;
}
}
@@ -0,0 +1,230 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Admin;
/// <summary>
/// admin.*/api/admin/v1/storage/backfill — one-shot migrator that
/// moves the legacy <c>RoomDataBlobs.Bytes</c> column out of the
/// database and into S3 at the canonical key from
/// <see cref="BlobRouter.Route"/>. After every row's bytes have been
/// uploaded and verified, a follow-up EF migration drops the
/// <c>Bytes</c> column entirely.
///
/// Why this exists: every controller that writes new content already
/// targets S3 directly (the importer, photo upload, profile image,
/// dorm save, room save, …). The <c>Bytes</c> column only holds
/// pre-cutover data — rooms imported before the S3-only refactor
/// landed. This endpoint drains that legacy column into S3 once and
/// then we never look at it again.
///
/// Runs as a background task so the HTTP request returns immediately
/// even for a multi-GB drain. Progress streams to the server log and
/// to a small in-memory <see cref="BackfillStatus"/> snapshot the UI
/// can poll. Idempotent — each row is checked against
/// <see cref="IObjectStorage.ExistsAsync"/> first, and rows whose
/// bytes are already in S3 get their DB <c>Bytes</c> cleared without
/// re-upload.
/// </summary>
[ApiController]
[Route("api/admin/v1/storage")]
[Authorize]
[AdminOnly]
public class StorageBackfillController(
ILogger<StorageBackfillController> log,
IServiceScopeFactory scopeFactory) : ControllerBase
{
private static readonly object _gate = new();
private static BackfillStatus _status = BackfillStatus.Idle;
/// <summary>POST <c>/storage/backfill?dryRun=true</c> — start a
/// drain pass. Returns 200 immediately; the actual work happens
/// in a background task. Re-POSTing while running returns 409.
/// Set <c>?dryRun=true</c> on the first pass to log every
/// (row → bucket/key) decision without writing to S3 or clearing
/// the DB <c>Bytes</c> column.</summary>
[HttpPost("backfill")]
public IActionResult Start([FromQuery] bool dryRun = false)
{
lock (_gate)
{
if (_status.Running)
return Conflict(new { error = "already_running", since = _status.StartedAt });
_status = BackfillStatus.Starting(dryRun);
}
_ = Task.Run(() => RunAsync(dryRun));
return Ok(new { started = true, dryRun });
}
/// <summary>GET <c>/storage/backfill/status</c> — current
/// progress. Updated row-by-row during a run.</summary>
[HttpGet("backfill/status")]
public IActionResult Status() => Ok(_status);
private async Task RunAsync(bool dryRun)
{
var startedAt = DateTime.UtcNow;
try
{
// Phase 1: pull the list of row ids that still have bytes.
// Materialise just the ids (not the bytes) up-front so we
// don't hold a reader open across per-row S3 PUTs — that
// would conflict with the per-row ExecuteUpdateAsync on
// Npgsql, which doesn't allow a second command while a
// reader is mid-iteration. Bytes get fetched one row at a
// time below so RAM only holds one blob at a time.
// Filter on Bytes != null only — Npgsql can't translate
// .Length on a byte[] property (it sees it as Enumerable.Any
// and bails). Empty-byte-array rows don't exist in our
// schema; rows either hold real bytes or NULL after backfill.
// We still re-check Length on the materialised row before
// uploading, defensive against any future client-evaluated
// edge case.
List<long> rowIds;
long total;
await using (var scope = scopeFactory.CreateAsyncScope())
{
var dbScoped = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
rowIds = await dbScoped.RoomDataBlobs
.AsNoTracking()
.Where(b => b.Bytes != null)
.OrderBy(b => b.Id)
.Select(b => b.Id)
.ToListAsync();
total = rowIds.Count;
}
log.LogInformation(
"[backfill] starting — rowsWithBytes={Total} dryRun={DryRun}",
total, dryRun);
lock (_gate) _status = _status with { Total = total };
// Phase 2: per-row upload + clear. Each row gets its own
// short-lived DbContext so the reader/writer conflict is
// structurally impossible. Memory-bound at one row's
// bytes plus query overhead.
foreach (var rowId in rowIds)
{
await using var rowScope = scopeFactory.CreateAsyncScope();
var rowDb = rowScope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var storageScoped = rowScope.ServiceProvider.GetRequiredService<IObjectStorage>();
var row = await rowDb.RoomDataBlobs
.AsNoTracking()
.Where(b => b.Id == rowId)
.Select(b => new { b.Id, b.BlobName, b.Bytes })
.FirstOrDefaultAsync();
if (row is null || row.Bytes is null || row.Bytes.Length == 0)
{
Bump(s => s with { Skipped = s.Skipped + 1 });
continue;
}
var (bucket, key) = BlobRouter.Route(row.BlobName);
try
{
if (dryRun)
{
log.LogInformation(
"[backfill:dry] {Blob} → {Bucket}/{Key} ({Bytes} B)",
row.BlobName, bucket, key, row.Bytes.Length);
Bump(s => s with { Uploaded = s.Uploaded + 1, LastBlobName = row.BlobName });
continue;
}
// Upload unconditionally (S3 PUT is idempotent /
// overwriting). Verifying with ExistsAsync first
// would let us skip already-mirrored rows, but a
// mismatch between S3 and DB bytes (corrupted
// earlier upload, partial backfill, etc.) would
// leave the divergence in place. Re-uploading
// guarantees S3 == DB before we clear DB.
var contentType = MimeFromName(row.BlobName);
// Per-row timeout: 4 minutes generous enough for a
// 100 MB blob over a 5 Mbps origin link, well
// inside the 5-minute S3 SDK ceiling. Without a
// CT, a hung S3 connection could freeze the whole
// drain indefinitely.
using var putCt = new CancellationTokenSource(TimeSpan.FromMinutes(4));
await storageScoped.PutAsync(bucket, key, row.Bytes, contentType, putCt.Token);
// SAFETY GATE: confirm S3 actually persisted the
// object before dropping the DB copy. PutAsync
// returning success isn't enough — some S3 impls
// can ack a write that disappears (eventual
// consistency, replication lag, write buffer
// dropped on shutdown). HEAD costs nothing.
using var headCt = new CancellationTokenSource(TimeSpan.FromSeconds(15));
if (!await storageScoped.ExistsAsync(bucket, key, headCt.Token))
throw new InvalidOperationException(
$"S3 PUT {bucket}/{key} returned success but ExistsAsync says no — refusing to clear DB bytes");
await rowDb.RoomDataBlobs
.Where(b => b.Id == row.Id)
.ExecuteUpdateAsync(s => s.SetProperty(b => b.Bytes, (byte[]?)null));
Bump(s => s with { Uploaded = s.Uploaded + 1, LastBlobName = row.BlobName });
}
catch (Exception ex)
{
log.LogWarning(ex, "[backfill] failed {Blob} → {Bucket}/{Key} — DB bytes left intact",
row.BlobName, bucket, key);
Bump(s => s with { Failed = s.Failed + 1, LastBlobName = row.BlobName });
}
}
var elapsed = (int)(DateTime.UtcNow - startedAt).TotalMilliseconds;
lock (_gate) _status = _status with
{
Running = false,
FinishedAt = DateTime.UtcNow,
ElapsedMs = elapsed,
};
log.LogInformation(
"[backfill] finished — total={Total} uploaded={Up} alreadyMirrored={Already} skipped={Skip} failed={Fail} dryRun={Dry} elapsed={Ms}ms",
_status.Total, _status.Uploaded, _status.AlreadyMirrored, _status.Skipped, _status.Failed, dryRun, elapsed);
}
catch (Exception ex)
{
log.LogError(ex, "[backfill] crashed");
lock (_gate) _status = _status with { Running = false, FinishedAt = DateTime.UtcNow, Error = ex.Message };
}
}
private static void Bump(Func<BackfillStatus, BackfillStatus> mutate)
{
lock (_gate) _status = mutate(_status);
}
private static string MimeFromName(string name) => Path.GetExtension(name).ToLowerInvariant() switch
{
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".gif" => "image/gif",
".webp" => "image/webp",
".mp4" => "video/mp4",
_ => "application/octet-stream",
};
public sealed record BackfillStatus(
bool Running,
bool DryRun,
DateTime? StartedAt,
DateTime? FinishedAt,
long Total,
long Uploaded,
long AlreadyMirrored,
long Skipped,
long Failed,
string? LastBlobName,
int? ElapsedMs,
string? Error)
{
public static BackfillStatus Idle => new(false, false, null, null, 0, 0, 0, 0, 0, null, null, null);
public static BackfillStatus Starting(bool dryRun) =>
new(true, dryRun, DateTime.UtcNow, null, 0, 0, 0, 0, 0, null, null, null);
}
}
@@ -0,0 +1,93 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
namespace DorkNet.Server.Controllers.Auth;
/// <summary>
/// auth.rec.net/account/me/* — password and account-recovery endpoints
/// the client invokes from the in-game settings screen. Real persistence:
/// the password is stored as a BCrypt hash on
/// <see cref="DorkNet.Server.Data.Entities.PlayerEntity.PasswordHash"/>;
/// HasPassword reflects whether the column is non-null; ChangePassword
/// requires the old password to match (or for the column to be null —
/// first-time set); ResetPassword is admin-gated for now.
/// </summary>
[ApiController]
public class AuthAccountController(DorkNetDbContext db) : ControllerBase
{
/// <summary>GET /account/me/haspassword — returns whether the
/// caller has a password set. Client uses
/// <c>ExpectPrimitiveResponse&lt;bool&gt;</c> so the body must be
/// the literal JSON <c>true</c> or <c>false</c>.</summary>
[HttpGet("/account/me/haspassword")]
[HttpGet("/api/platformlogin/haspassword")]
[Authorize]
public async Task<IActionResult> HasPassword()
{
var pid = this.RequireCurrentPlayerId();
var hasPwd = await db.Players
.Where(p => p.Id == pid)
.Select(p => p.PasswordHash != null)
.FirstOrDefaultAsync();
return Content(hasPwd ? "true" : "false", "application/json");
}
/// <summary>POST /account/me/changepassword (and aliases). When the
/// account already has a password the caller must supply the old
/// one; first-time creation skips that check. Form-encoded body.</summary>
[HttpPost("/account/me/changepassword")]
[HttpPost("/account/me/createpassword")]
[HttpPost("/api/platformlogin/password")]
[HttpPut("/api/platformlogin/password")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
[Authorize]
public async Task<IActionResult> ChangePassword(
[FromForm] string? oldPassword,
[FromForm] string? newPassword)
{
if (string.IsNullOrEmpty(newPassword) || newPassword.Length < 6)
return Ok(new { success = false, error = "password_too_short" });
var pid = this.RequireCurrentPlayerId();
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == pid);
if (player is null) return Unauthorized();
// First-time create: PasswordHash is null and oldPassword is
// ignored. Subsequent changes require the existing password to
// verify so a stolen JWT can't silently rotate the password.
if (!string.IsNullOrEmpty(player.PasswordHash))
{
if (string.IsNullOrEmpty(oldPassword) ||
!BCrypt.Net.BCrypt.Verify(oldPassword, player.PasswordHash))
return Ok(new { success = false, error = "old_password_mismatch" });
}
player.PasswordHash = BCrypt.Net.BCrypt.HashPassword(newPassword, workFactor: 11);
await db.SaveChangesAsync();
return Ok(new { success = true, error = "" });
}
/// <summary>POST /account/me/resetpassword — sends a reset link.
/// Without an SMTP integration we have nothing to send to, so this
/// is admin-only: an admin can clear the password column on any
/// account from the admin tab and the user re-creates it via
/// changepassword on next login. Anonymous calls 401.</summary>
[HttpPost("/account/me/resetpassword")]
[HttpPost("/api/platformlogin/resetpassword")]
[Authorize]
[AdminOnly]
public async Task<IActionResult> AdminResetPassword(
[FromForm] long? targetPlayerId)
{
if (targetPlayerId is null)
return Ok(new { success = false, error = "missing_target" });
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == targetPlayerId);
if (player is null) return Ok(new { success = false, error = "unknown_player" });
player.PasswordHash = null;
await db.SaveChangesAsync();
return Ok(new { success = true, error = "" });
}
}
@@ -0,0 +1,307 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Auth;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Auth;
/// <summary>
/// auth.rec.net — OAuth-style token endpoint used by 2019+ builds alongside PlatformLogin.
/// </summary>
[ApiController]
public class AuthController(
PlayerService playerService,
AuthService authService,
LevelService level,
OrphanAccountTracker orphans,
ServerSettingsService settings,
DorkNet.Server.Data.DorkNetDbContext db,
DomainConfig domain,
ILogger<AuthController> logger) : ControllerBase
{
/// <summary>Award the per-day first-login XP if the calling
/// player hasn't been seen yet today (UTC).</summary>
private async Task TryAwardLoginXpAsync(long playerId)
{
var lastSeen = await db.Players
.Where(p => p.Id == playerId)
.Select(p => p.LastSeenAt)
.FirstOrDefaultAsync();
if (lastSeen.Date < DateTime.UtcNow.Date)
await level.AwardXpAsync(playerId, LevelService.FirstLoginXp, "first_login_today");
}
// OAuth token endpoint. The 2020 client uses three grant types here:
// - grant_type=cached_login → user clicked a remembered account on the
// account-selection screen. Body includes `account_id` (and platform
// creds). MUST issue a token for THAT specific account; otherwise the
// client receives a JWT for a different account and prompts the user
// to create a new one (because the JWT identity doesn't match what
// they picked).
// - grant_type=refresh_token → re-issue access token from the existing
// refresh JWT.
// - grant_type=password → first-time sign-in, resolve via deviceId
// (preferred) or fall back to a stable synthetic id derived from
// username so legacy callers don't share accounts.
[HttpPost("/connect/token")]
[Consumes("application/x-www-form-urlencoded")]
public async Task<IActionResult> Token(
[FromForm] string? grant_type,
[FromForm] string? username,
[FromForm] string? password,
[FromForm] string? refresh_token,
[FromForm] string? deviceId,
[FromForm(Name = "device_id")] string? device_id_snake,
[FromForm] int platform = 0,
[FromForm] string? platformId = null,
[FromForm(Name = "platform_id")] string? platform_id_snake = null,
[FromForm] long? account_id = null)
{
// The client mixes camelCase and snake_case parameter naming
// depending on grant type — accept both and merge.
var effDeviceId = !string.IsNullOrWhiteSpace(deviceId) ? deviceId : device_id_snake;
var effPlatformId = !string.IsNullOrWhiteSpace(platformId) ? platformId : platform_id_snake;
if (string.Equals(grant_type, "cached_login", StringComparison.OrdinalIgnoreCase))
{
if (account_id is not long pickedId)
return BadRequest(new { error = "invalid_request", error_description = "missing account_id" });
var picked = await playerService.GetByIdAsync(pickedId);
if (picked is null)
return Unauthorized(new { error = "invalid_grant", error_description = "unknown account_id" });
// Orphan cleanup: if the watch just created a brand-new
// account on account/create as part of its boot probe, but
// the user actually picked a different cached account here,
// the just-created row is unused. Delete it so the DB
// doesn't accumulate one orphan per game launch. When the
// user is genuinely creating a new account, the picked id
// matches the just-created id and we leave it.
var pendingId = orphans.PeekPending(effDeviceId, effPlatformId);
if (pendingId is long orphanId && orphanId != pickedId)
{
await playerService.DeleteOrphanAsync(orphanId);
logger.LogInformation(
"[orphan-cleanup] cached_login picked {PickedId}; deleted just-created {OrphanId}",
pickedId, orphanId);
}
orphans.Clear(effDeviceId, effPlatformId);
await TryAwardLoginXpAsync(picked.Id);
await playerService.TagPlatformAsync(picked.Id, platform, effPlatformId);
var (acc1, refr1) = authService.GenerateTokenPair(picked.Id);
return Ok(new
{
access_token = acc1,
token_type = "Bearer",
expires_in = 43200,
refresh_token = refr1,
scope = "openid profile",
});
}
if (string.Equals(grant_type, "refresh_token", StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(refresh_token))
return BadRequest(new { error = "invalid_request" });
var resolved = authService.ValidateToken(refresh_token);
if (resolved is not long refreshedId)
return Unauthorized(new { error = "invalid_grant" });
var existing = await playerService.GetByIdAsync(refreshedId);
if (existing is null)
return Unauthorized(new { error = "invalid_grant" });
var (acc, refr) = authService.GenerateTokenPair(existing.Id);
return Ok(new
{
access_token = acc,
token_type = "Bearer",
expires_in = 43200,
refresh_token = refr,
scope = "openid profile",
});
}
// password grant: two paths.
// • Username + password supplied: real password verify against
// PlayerEntity.PasswordHash. Wrong password → 401.
// • No password: legacy device-id synth path for the 2020
// client's first-launch flow where the player hasn't set a
// password yet (HasPassword returns false in the watch UI
// and prompts them to create one). Resolves the existing
// account (or creates a fresh one) by deviceId.
if (!string.IsNullOrEmpty(password) && !string.IsNullOrEmpty(username))
{
var byName = await playerService.GetByUsernameAsync(username);
if (byName is null || byName.PasswordHash is null)
return Unauthorized(new
{
error = "invalid_grant",
error_description = "unknown_user_or_no_password",
});
if (!BCrypt.Net.BCrypt.Verify(password, byName.PasswordHash))
return Unauthorized(new
{
error = "invalid_grant",
error_description = "password_mismatch",
});
// Tag the platform so the next boot's cached-login lookup
// finds this account — without this the watch's "remembered
// accounts" prompt would stay empty after a manual sign-in.
await playerService.TagPlatformAsync(byName.Id, platform, effPlatformId);
await TryAwardLoginXpAsync(byName.Id);
var pair = authService.GenerateTokenPair(byName.Id);
return Ok(new
{
access_token = pair.AccessToken,
token_type = "Bearer",
expires_in = 43200,
refresh_token = pair.RefreshToken,
scope = "openid profile",
});
}
// Device-id fallback path. Prefer the real deviceId; fall back
// to a stable legacy synthesis from username so older callers
// don't accidentally share an account.
var effectiveDeviceId = !string.IsNullOrWhiteSpace(effDeviceId)
? effDeviceId
: $"legacy-{(username ?? "anonymous").ToLowerInvariant()}";
// Honour the admin "signups disabled" toggle. If the device
// hasn't logged in before, GetOrCreateByDeviceAsync would mint
// a fresh account here — same outcome as hitting
// /account/create directly, which the toggle blocks. Refuse
// BEFORE creation so the flag isn't bypassable.
if (await settings.AreSignupsDisabledAsync())
{
var existing = await playerService.GetByDeviceAsync(effectiveDeviceId);
if (existing is null)
{
logger.LogInformation(
"[auth] /connect/token device fallback refused — signups disabled (device={Device})",
effectiveDeviceId);
return Unauthorized(new
{
error = "signups_disabled",
error_description = "Account creation is currently disabled by the server admin.",
});
}
}
var player = await playerService.GetOrCreateByDeviceAsync(
deviceId: effectiveDeviceId,
platform: platform,
platformId: effPlatformId,
displayName: username);
await TryAwardLoginXpAsync(player.Id);
var (access, refresh) = authService.GenerateTokenPair(player.Id);
return Ok(new
{
access_token = access,
token_type = "Bearer",
expires_in = 43200,
refresh_token = refresh,
scope = "openid profile",
});
}
[HttpGet("/connect/userinfo")]
public async Task<IActionResult> UserInfo()
{
var auth = Request.Headers.Authorization.ToString();
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return Unauthorized();
var token = auth["Bearer ".Length..].Trim();
var id = authService.ValidateToken(token);
if (id is not long playerId) return Unauthorized();
var player = await playerService.GetByIdAsync(playerId);
if (player is null) return Unauthorized();
return Ok(new { sub = player.Id.ToString(), name = player.Username });
}
// Discovery document — issuer + endpoints derived from
// DomainConfig so a single DORKNET_DOMAIN env var keeps the
// OpenID document pointed at the live deployment.
[HttpGet("/.well-known/openid-configuration")]
public IActionResult OpenIdConfig()
{
var base_ = $"https://{domain.Sub("auth")}";
return Ok(new
{
issuer = base_,
token_endpoint = $"{base_}/connect/token",
userinfo_endpoint = $"{base_}/connect/userinfo",
});
}
// Cached-logins lookup — boot-sequence call from ScreenAccountSelectionManager.
// URL: GET auth.rec.net/cachedlogin/forplatformid/{platform}/{platformId}
// Returns List<Login.CachedLogin>. Empty array = no remembered accounts.
//
// Real lookup: any account whose last platformlogin matched
// (platform, platformId) shows up here, sorted most-recent-first. On a
// single-Steam-emu setup that's one row; on a multi-account dev box
// it's the full list the user has cycled through.
[HttpGet("/cachedlogin/forplatformid/{platform:int}/{platformId}")]
public async Task<IActionResult> CachedLoginsForPlatform(int platform, string platformId)
{
var players = await playerService.GetCachedLoginsAsync(platform, platformId);
return Ok(players.Select(p => ToCachedLogin(p, platform, platformId)).ToList());
}
/// <summary>POST <c>cachedlogin/forplatformids</c> — bulk variant
/// the watch uses when hydrating multiple platform-id mappings
/// at once (e.g. populating a friend list from Steam ids). Body
/// is form-urlencoded <c>platform=&amp;platformIds=a,b,c</c> or
/// JSON with the same shape. Returns the flattened
/// <c>List&lt;CachedLogin&gt;</c> across every matched id.</summary>
[HttpPost("/cachedlogin/forplatformids")]
[HttpGet("/cachedlogin/forplatformids")]
public async Task<IActionResult> CachedLoginsForPlatformIds()
{
int platform = 0;
string? rawIds = null;
try
{
if (Request.HasFormContentType)
{
int.TryParse(Request.Form["platform"].ToString(), out platform);
rawIds = Request.Form["platformIds"].ToString();
if (string.IsNullOrWhiteSpace(rawIds))
rawIds = Request.Form["PlatformIds"].ToString();
}
}
catch { /* not form */ }
if (string.IsNullOrWhiteSpace(rawIds))
{
int.TryParse(Request.Query["platform"], out platform);
rawIds = Request.Query["platformIds"].ToString();
if (string.IsNullOrWhiteSpace(rawIds))
rawIds = Request.Query["PlatformIds"].ToString();
}
if (string.IsNullOrWhiteSpace(rawIds)) return Ok(Array.Empty<object>());
var ids = rawIds
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToList();
var results = new List<CachedLogin>();
foreach (var pid in ids)
{
var players = await playerService.GetCachedLoginsAsync(platform, pid);
foreach (var p in players)
results.Add(ToCachedLogin(p, platform, pid));
}
return Ok(results);
}
private static CachedLogin ToCachedLogin(PlayerEntity p, int platform, string platformId) => new()
{
Platform = platform,
PlatformId = platformId,
AccountId = (int)p.Id,
LastLoginTime = p.LastSeenAt,
RequirePassword = false,
};
}
@@ -0,0 +1,178 @@
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Auth;
/// <summary>
/// auth.localhost/photon/customauth — endpoint Photon Cloud calls during a
/// client connect when "Custom Authentication" is configured on the
/// Photon AppId.
///
/// Photon Cloud sends a GET (or POST) with the parameters the client
/// attached via <c>AuthenticationValues.AddAuthParameter</c>. The
/// DorkNet BepInEx plugin (DorkNet.ClientPatch) injects two:
/// <list type="bullet">
/// <item><c>userid</c> — the player's DorkNet account id (long).</item>
/// <item><c>LoginLock</c> — the per-session GUID stored in
/// <see cref="PlayerPresenceService"/> at /player/login time.</item>
/// </list>
///
/// Response shape (per
/// https://doc.photonengine.com/en-us/realtime/current/reference/custom-authentication):
/// <list type="bullet">
/// <item><c>ResultCode = 1</c> → success, allow client</item>
/// <item><c>ResultCode = 2</c> → parameter invalid, reject</item>
/// <item><c>ResultCode = 3</c> → auth failed, reject</item>
/// </list>
///
/// MODE: strict (2026-05-11). Rejects connects whose userid+LoginLock
/// don't match the most-recent /player/login. The "Reject if Auth
/// Failed" toggle in the Photon dashboard SHOULD also be on so a
/// rejected ResultCode actually closes the Photon socket — without
/// the toggle the rejection is informational only.
///
/// Fail-open on Redis errors: <see cref="PlayerPresenceService.ValidateLock"/>
/// returns true if Redis throws, so a transient infra blip doesn't
/// mass-kick every player off Photon.
/// </summary>
[ApiController]
public class PhotonCustomAuthController(
PlayerPresenceService presence,
IConfiguration config,
ILogger<PhotonCustomAuthController> log) : ControllerBase
{
[HttpGet("/photon/customauth")]
[HttpPost("/photon/customauth")]
public async Task<IActionResult> Authenticate()
{
// Photon forwards AuthGetParameters as querystring and
// AuthPostData as form body. Read both so it works whichever
// mode the dashboard picks.
//
// Multi-value handling: Photon's dashboard URL template can
// contain literal placeholders like <c>?userid={userid}</c>,
// and the watch ALSO ships AuthValues.AuthGetParameters
// (which Photon appends to the URL). The two collide in the
// querystring as <c>userid={userid}&userid=230343</c> — both
// values arrive on the same key. ASP.NET Core surfaces this
// as a multi-value <c>StringValues</c>; we keep the array so
// <see cref="PickClean"/> below can pick the first value
// that's actually a usable string (skipping unsubstituted
// <c>{…}</c> placeholders).
var qs = Request.Query
.ToDictionary(kv => kv.Key, kv => kv.Value.ToArray(),
StringComparer.OrdinalIgnoreCase);
Dictionary<string, string[]> form = new(StringComparer.OrdinalIgnoreCase);
if (Request.HasFormContentType)
{
try
{
var f = await Request.ReadFormAsync();
form = f.ToDictionary(
kv => kv.Key,
kv => kv.Value.ToArray()!,
StringComparer.OrdinalIgnoreCase);
}
catch { /* best effort */ }
}
// Pick the first non-empty, non-placeholder value across
// every alias for a parameter. A "placeholder" looks like
// <c>{userid}</c> — Photon's dashboard URL template syntax
// that Photon Cloud forgets to substitute when the same key
// is also sent as an AuthGetParameter. Falling back to the
// SECOND query value (the AuthGetParameter) gives us the
// real id.
static bool IsUsable(string? v)
=> !string.IsNullOrWhiteSpace(v)
&& !(v!.StartsWith('{') && v.EndsWith('}'));
string? PickClean(params string[] keys)
{
foreach (var k in keys)
{
if (qs.TryGetValue(k, out var arr))
foreach (var v in arr) if (IsUsable(v)) return v;
if (form.TryGetValue(k, out var farr))
foreach (var v in farr) if (IsUsable(v)) return v;
}
return null;
}
var userIdRaw = PickClean("userid", "userId", "UserId");
var loginLock = PickClean("LoginLock", "loginlock", "loginLock");
if (!config.GetValue("Photon:RequireCustomAuth", false))
{
log.LogInformation(
"[photon-auth] fail-open allow RequireCustomAuth=false userid={UserId} LoginLockPresent={Lock}",
userIdRaw ?? "<anonymous>",
!string.IsNullOrWhiteSpace(loginLock));
return Ok(new
{
ResultCode = 1,
Message = "ok (custom auth disabled)",
UserId = userIdRaw ?? string.Empty,
});
}
// Anonymous connect: pre-login Photon region pings + the watch's
// initial NameServer probe both fire CustomAuth WITHOUT
// AuthValues attached (the BepInEx plugin only injects AuthValues
// once /player/login has run and Core.LocalAccountId is set).
// Returning ResultCode 1 here lets those bootstrap connects
// succeed; the actual gameplay session has populated AuthValues
// and goes through the strict path below.
if (string.IsNullOrWhiteSpace(userIdRaw) && string.IsNullOrWhiteSpace(loginLock))
{
log.LogDebug("[photon-auth] anonymous bootstrap connect — allow");
return Ok(new { ResultCode = 1, Message = "ok (anonymous)" });
}
// Half-set is suspicious — either someone's spoofing AuthValues
// by hand, or the plugin's injector partially fired. Reject so
// we don't grant gameplay credentials to an unverifiable session.
if (string.IsNullOrWhiteSpace(userIdRaw) || string.IsNullOrWhiteSpace(loginLock))
{
log.LogWarning(
"[photon-auth] partial auth — userid={UserId} LoginLockPresent={Lock} " +
"query={Query} form={Form}",
userIdRaw ?? "<missing>",
!string.IsNullOrWhiteSpace(loginLock),
System.Text.Json.JsonSerializer.Serialize(qs),
System.Text.Json.JsonSerializer.Serialize(form));
return Ok(new
{
ResultCode = 2,
Message = "userid and LoginLock must both be sent (or both absent for anonymous bootstrap)",
});
}
if (!long.TryParse(userIdRaw, out var playerId))
{
log.LogWarning("[photon-auth] non-numeric userid={UserId}", userIdRaw);
return Ok(new { ResultCode = 2, Message = $"userid '{userIdRaw}' is not a valid account id" });
}
if (!presence.ValidateLock(playerId, loginLock))
{
log.LogWarning(
"[photon-auth] lock mismatch for player={PlayerId} — stale session, " +
"different machine, or never logged in",
playerId);
return Ok(new
{
ResultCode = 3,
Message = "LoginLock mismatch — sign in via /player/login first",
});
}
log.LogDebug("[photon-auth] OK player={PlayerId}", playerId);
return Ok(new
{
ResultCode = 1,
Message = "ok",
UserId = playerId.ToString(),
});
}
}
@@ -0,0 +1,446 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text;
using System.Text.Json;
using DorkNet.Server.Data;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Cdn;
/// <summary>
/// Catch-all binary blob server. Owns every <c>GET /{*path}</c> request
/// that hits the API host pool — cdn.* / storage.* / data.* / img.* /
/// the bare apex all funnel into this one controller and we branch on
/// the subdomain prefix inside <see cref="Serve"/>:
///
/// • <c>img.{apex}</c> → image-transform pipeline. Reads from S3
/// (BlobRouter routes by filename suffix), supports on-the-fly
/// <c>?cropSquare=1</c> / <c>?width=N</c> via
/// <see cref="ImageTransformService"/>, falls back to disk
/// (<c>data/images/</c>) and finally a 1×1 transparent PNG so the
/// 2020 watch's image cache absorbs misses without 404 retry storms.
/// • everything else (cdn.* / storage.* / data.* / unknown sub) →
/// CDN serve path. S3 first, default RoomDataBlob fallback, image
/// misses still fall back to the transparent PNG (same shape the
/// old ImgController surfaced) so the watch's room-thumbnail
/// pipeline behaves identically post-merge.
///
/// Pre-refactor the two paths lived in separate controllers
/// (ImgController + CdnController) each with their own
/// <c>[Host(...)]</c> filter; HostFilteringMiddleware now controls
/// which hosts the app accepts at all (via DomainConfig) and routing
/// is purely path-based, so the two handlers had to merge to avoid
/// AmbiguousMatchException on <c>GET /{*path}</c>.
///
/// Every response is signed via <see cref="ImageSignatureService.AddContentSignature"/>
/// before being returned — the 2020 watch's image-verify path rejects
/// any image lacking the Content-Signature header.
/// </summary>
[ApiController]
public class CdnController(
RoomDataBlobService roomDataBlob,
IObjectStorage storage,
ImageSignatureService signatures,
DorkNetDbContext db,
ILogger<CdnController> logger) : ControllerBase
{
/// <summary>1x1 transparent PNG used for missing image files.
/// The 2020 watch treats image HTTP failures harshly (retry-storms
/// on missing room thumbnails), so image paths fail soft — return
/// a valid PNG body with a short cache TTL instead of 404.</summary>
private static readonly byte[] TransparentPng = new byte[]
{
0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A,0x00,0x00,0x00,0x0D,
0x49,0x48,0x44,0x52,0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x01,
0x08,0x06,0x00,0x00,0x00,0x1F,0x15,0xC4,0x89,0x00,0x00,0x00,
0x0D,0x49,0x44,0x41,0x54,0x78,0x9C,0x63,0x00,0x01,0x00,0x00,
0x05,0x00,0x01,0x0D,0x0A,0x2D,0xB4,0x00,0x00,0x00,0x00,0x49,
0x45,0x4E,0x44,0xAE,0x42,0x60,0x82,
};
private static readonly string ImageDir =
Path.Combine(AppContext.BaseDirectory, "data", "images");
// Specific route prefixes only — NO global catch-all wildcard.
// A previous version had [Route("/{*path:minlength(1)}")] which
// matched every GET on every host, then used an in-handler host
// gate to NotFound for non-cdn/img subdomains. That broke admin
// API endpoints on admin.localhost: ASP.NET routing matched the
// wildcard endpoint at UseRouting time, StaticFileMiddleware then
// refused to serve admin SPA assets (its ValidateNoEndpoint guard
// bails when an endpoint is already matched), and MapControllers
// ran CdnController.Serve which 404'd — same dynamic broke every
// admin/site API route and asset request.
//
// The route templates below cover the watch's actual CDN URL
// shapes (confirmed via Coolify access logs):
// • cdn.{apex}/room/<blob>.dat — RoomDataBlob downloads
// • cdn.{apex}/data/<hash>.htr — room thumbnails / .htr files
// • cdn.{apex}/config/<name> — LoadingScreenTipData and similar
// • img.{apex}/<file>.{png,jpg,jpeg,webp,gif} — image fetches
// • img.{apex}/img/<file> — legacy path-prefixed images
//
// The in-handler host gate stays as defence-in-depth so that, if
// a stray request somehow reaches one of these routes on the wrong
// host (admin.*, api.*, etc.), we still 404 cleanly instead of
// serving binary CDN bytes.
[Route("/room/{*path:minlength(1)}")]
[Route("/data/{*path:minlength(1)}")]
[Route("/config/{*path:minlength(1)}")]
[Route("/img/{*path:minlength(1)}")]
// `cdn.{apex}/video/<BlobName>` — the watch's CommunityBoard
// builds this URL via `String.Concat("/video/", blobName)` at
// RecNet/CommunityBoard.txt:1068, so the prefix path needs an
// explicit route. Without this, the catch-all regex below only
// matches single-segment names and `/video/cb_video_*.mp4` 404s.
[Route("/video/{*path:minlength(1)}")]
// ASP.NET route templates treat `[` / `]` as token markers
// (e.g. `[controller]`). To use them as literals inside a regex
// constraint they must be doubled — so `[^/]` becomes `[[^/]]`.
//
// Extension list: image formats + room/.htr/.inv blobs that the
// watch fetches directly, plus video formats (mp4/webm/mov/m4v) so
// community-board video blobs uploaded via /admin/v1/communityboard/
// video/upload (which writes `cb_video_<hash>.mp4` etc.) resolve
// through the same bare-filename catch-all.
[Route("/{path:regex(^[[^/]]+\\.(png|jpg|jpeg|webp|gif|dat|bin|holotar|inv|room|htr|mp4|webm|mov|m4v)$)}")]
[AcceptVerbs("GET", "HEAD")]
public async Task<IActionResult> Serve(string? path)
{
// Defence-in-depth host check. Without the prior global wildcard
// these routes are narrow enough that stray hits on admin/api/etc.
// should be rare, but if they do land here we 404 cleanly instead
// of serving binary CDN bytes that would confuse the caller.
var host = Request.Host.Host;
var isImg = DomainConfig.MatchesSubdomain(host, "img");
var isCdn = DomainConfig.MatchesSubdomain(host, "cdn")
|| DomainConfig.MatchesSubdomain(host, "storage")
|| DomainConfig.MatchesSubdomain(host, "data");
if (!isImg && !isCdn) return NotFound();
// Use the full request path (e.g. "config/LoadingScreenTipData",
// "room/foo.dat") rather than the route-captured suffix so the
// downstream switch logic — which still keys on the literal
// "config/LoadingScreenTipData" — keeps working regardless of
// which of the multiple [Route] templates above matched.
var fullPath = (Request.Path.Value ?? string.Empty).TrimStart('/');
if (isImg) return await ServeImage(fullPath);
return await ServeCdn(fullPath);
}
// ── Image pipeline (img.{apex}) ────────────────────────────────────
private async Task<IActionResult> ServeImage(string? path)
{
var safe = Path.GetFileName(path ?? string.Empty);
// Strip the leading '$' sigil that ImageData.image_name carries
// for hash-addressed assets. The watch URL-escapes it (%24) and
// the route binder decodes it back, so what arrives here is
// literally "$<hash>.jpg" — but the bytes are stored at
// "<hash>.jpg" (the bare BlobName the importer writes). Without
// this strip, every placed in-world polaroid 404s into the
// transparent-PNG fallback and renders as "?" in-game.
if (safe.StartsWith('$')) safe = safe[1..];
if (string.IsNullOrWhiteSpace(safe))
{
signatures.AddContentSignature(Response, TransparentPng);
return File(TransparentPng, "image/png");
}
var ext = Path.GetExtension(safe).ToLowerInvariant();
var contentType = ext switch
{
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".webp" => "image/webp",
".gif" => "image/gif",
_ => "image/png", // default to PNG for unknown — better than octet-stream for browser <img> tags
};
// Three-tier resolution: S3 first (canonical post-migration),
// then on-disk legacy folder (room thumbnails shipped with the
// server), then RoomDataBlobs.Bytes column (uploads that
// pre-date the S3 migration). Once the migrator has uploaded
// everything to S3 and PR 4 drops the Bytes column, the disk
// and DB fallbacks become rare cases.
byte[]? s3Bytes = null;
var (s3Bucket, s3Key) = BlobRouter.Route(safe);
try
{
using var s3Timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2));
s3Bytes = await storage.GetAsync(s3Bucket, s3Key, s3Timeout.Token);
}
catch (Exception ex)
{
logger.LogWarning(ex,
"[img] S3 lookup threw host={Host} path='{Path}' file='{Safe}' bucket={Bucket} key={Key}; falling back to disk / DB / placeholder",
Request.Host.Host, path, safe, s3Bucket, s3Key);
}
// Extension-less hash fallback. The 2020 watch references
// some persistence-view image slots as a bare 32-char hex hash
// (no .jpg, no .png) — distinct from ImageData.image_name which
// carries "$<hash>.jpg". The importer stores everything from
// SubRooms/<scene>/Image/ with an extension, so a bare-hash
// request misses on first lookup. Retry with the two common
// image extensions before giving up.
if ((s3Bytes is null || s3Bytes.Length == 0) && string.IsNullOrEmpty(ext) && LooksLikeHash(safe))
{
foreach (var altExt in new[] { ".jpg", ".png" })
{
var altName = safe + altExt;
var (altBucket, altKey) = BlobRouter.Route(altName);
try
{
using var altTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(2));
var altBytes = await storage.GetAsync(altBucket, altKey, altTimeout.Token);
if (altBytes is { Length: > 0 })
{
logger.LogInformation(
"[img] s3 hit (ext-fallback) host={Host} path={Path} resolvedAs={Alt} bytes={Bytes}",
Request.Host.Host, safe, altName, altBytes.Length);
s3Bytes = altBytes;
contentType = altExt == ".png" ? "image/png" : "image/jpeg";
break;
}
}
catch (Exception ex)
{
logger.LogWarning(ex,
"[img] ext-fallback S3 lookup threw for {Name} bucket={Bucket} key={Key}",
altName, altBucket, altKey);
}
}
}
if (s3Bytes is { Length: > 0 })
{
logger.LogInformation("[img] s3 hit host={Host} path={Path} bytes={Bytes}",
Request.Host.Host, safe, s3Bytes.Length);
Response.Headers.CacheControl = "public, max-age=300";
return RespondWithTransforms(s3Bytes, contentType);
}
var full = Path.Combine(ImageDir, safe);
if (System.IO.File.Exists(full))
{
logger.LogInformation("[img] disk hit host={Host} path={Path} → {Full}",
Request.Host.Host, path, full);
// Short TTL on the public CF edge so swapping a thumbnail
// (e.g. switching from full-res to a resized cached PNG)
// propagates within minutes instead of being pinned for
// hours by CF's default image-cache TTL. The watch keeps
// its own per-session local cache regardless.
var diskBytes = await System.IO.File.ReadAllBytesAsync(full);
Response.Headers.CacheControl = "public, max-age=300";
return RespondWithTransforms(diskBytes, contentType);
}
// S3 is the only canonical store — DB carries text-only
// metadata, never bytes. If S3 misses, the bytes don't exist
// on this server; fall through to the transparent-PNG miss.
// Stop the 404 storm — the watch retries hard on missing room
// thumbnails. Return a 1x1 transparent PNG so it caches a
// valid (empty) image and stops asking. Log loudly so the
// user can see what filename the watch tried but disk lookup
// failed at: tells us at a glance whether the client is even
// hitting this controller (no log = wrong host / DNS issue),
// and if so, what filename it expects vs what's on disk.
logger.LogWarning(
"[img] MISS host={Host} path='{Path}' file='{Safe}' lookedAt='{Full}' query='{Query}'",
Request.Host.Host, path, safe, full, Request.QueryString);
// Don't let CF pin the empty-fallback for hours — once the real
// image lands on disk the next request should pick it up.
Response.Headers.CacheControl = "public, max-age=60";
signatures.AddContentSignature(Response, TransparentPng);
return File(TransparentPng, "image/png");
}
// ── CDN serve path (cdn.*, storage.*, data.*, apex) ────────────────
private async Task<IActionResult> ServeCdn(string? path)
{
if (string.Equals(path, "config/LoadingScreenTipData", StringComparison.OrdinalIgnoreCase))
{
// Tips live in the LoadingScreenTips DB table; admins
// edit them via the SPA. Wire shape per
// LoadingScreenTip.cs: List of {Title, Message,
// ImageName, Context, PlatformMask, HasImage, RoomNames}.
// HasImage MUST be set explicitly — without it the watch
// waits forever on the image promise and never renders.
var rows = await db.LoadingScreenTips
.Where(t => t.IsActive)
.OrderBy(t => t.SortOrder).ThenBy(t => t.Id)
.AsNoTracking()
.ToListAsync();
var tips = rows.Select(t => new
{
Title = t.Title,
Message = t.Message,
ImageName = t.ImageName,
Context = t.Context,
PlatformMask = t.PlatformMask,
HasImage = !string.IsNullOrEmpty(t.ImageName),
RoomNames = string.IsNullOrEmpty(t.RoomNamesCsv)
? Array.Empty<string>()
: t.RoomNamesCsv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
}).ToArray();
var payload = JsonSerializer.SerializeToUtf8Bytes(tips);
signatures.AddContentSignature(Response, payload);
return new FileContentResult(payload, "application/json");
}
var fileName = string.IsNullOrEmpty(path)
? string.Empty
: path.Split('/', StringSplitOptions.RemoveEmptyEntries).LastOrDefault() ?? string.Empty;
// Strip the leading '$' sigil that ImageData.image_name uses
// for hash-addressed assets. The img subdomain branch above
// already handles this; defense-in-depth for any client that
// fetches the same name via cdn.* / storage.* / data.* hosts.
if (!string.IsNullOrEmpty(fileName) && fileName[0] == '$') fileName = fileName[1..];
if (string.IsNullOrEmpty(fileName))
{
logger.LogWarning("[cdn] empty filename host={Host} path={Path} — default blob",
Request.Host.Host, path);
var defaultBlob = roomDataBlob.GetDefaultBlob();
signatures.AddContentSignature(Response, defaultBlob);
return new FileContentResult(defaultBlob, "application/octet-stream");
}
// Two-tier read: S3 first (the canonical store post-migration),
// then RoomDataBlobs.Bytes as a backstop for any blob that was
// uploaded before the storage migration ran. Once the migrator
// has uploaded all existing bytes and the Bytes column is
// dropped, this fallback becomes a no-op.
//
// Defensive: ObjectStorageService.GetAsync only catches 404; an
// S3 misconfig (wrong endpoint, dead creds, bucket missing) or
// a network blip throws → we'd otherwise return 500 and the
// watch's RecNet.Rooms.GetRoomData logs "Failed to download
// room data" + Photon disconnects the player. Catching here
// and falling through to the DB / default-blob path keeps the
// dorm load resilient to S3 outages — players boot into the
// stock dorm scene instead of getting kicked.
byte[]? s3Bytes = null;
var (s3Bucket, s3Key) = BlobRouter.Route(fileName);
try
{
// 15 seconds for room/data blobs (vs 2 seconds on the image
// path further up): the watch's room-load flow downloads
// PersistedRoomData and DESERIALISES it as the player enters
// the room — a default-empty blob means the saved dorm
// appears completely reset. Better to wait for the real
// bytes than hand back zeros and silently lose the player's
// build. Garage on the same host typically responds in
// 50-300ms; the larger budget only burns on cold-start /
// network blips.
using var s3Timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
s3Bytes = await storage.GetAsync(s3Bucket, s3Key, s3Timeout.Token);
}
catch (Exception ex)
{
logger.LogWarning(ex, "[cdn] S3 GetAsync threw for {Bucket}/{Key}; serving default blob",
s3Bucket, s3Key);
}
if (s3Bytes is { Length: > 0 })
{
logger.LogInformation("[cdn] s3 hit host={Host} file={File} bytes={Bytes}",
Request.Host.Host, fileName, s3Bytes.Length);
// Allow CF edge to cache for an hour. RoomDataBlobs are
// content-addressed by hash so the same BlobName never
// serves different bytes — safe to cache aggressively.
Response.Headers.CacheControl = "public, max-age=3600";
signatures.AddContentSignature(Response, s3Bytes);
return new FileContentResult(s3Bytes, MimeFromName(fileName));
}
// S3 is the only canonical store — DB carries text-only
// metadata (BlobName, owner, timestamps) and never holds
// bytes. If S3 misses, the bytes don't exist on this server.
if (IsImageName(fileName))
{
logger.LogWarning("[cdn] image MISS host={Host} file={File} query='{Query}' -> transparent png",
Request.Host.Host, fileName, Request.QueryString);
Response.Headers.CacheControl = "public, max-age=60";
signatures.AddContentSignature(Response, TransparentPng);
return new FileContentResult(TransparentPng, "image/png");
}
logger.LogInformation("[cdn] MISS host={Host} file={File} -> default blob",
Request.Host.Host, fileName);
var fallbackBlob = roomDataBlob.GetDefaultBlob();
signatures.AddContentSignature(Response, fallbackBlob);
return new FileContentResult(fallbackBlob, "application/octet-stream");
}
/// <summary>
/// Apply the watch's <c>?cropSquare=1</c> + <c>?width=N</c> transforms
/// per-request on the way out and sign the *resulting* bytes — the
/// originals stay untouched in S3/disk/DB so a later
/// <c>?width=512</c> request can re-render from full resolution. If
/// the transform pipeline can't decode the bytes (corrupt file, GIF
/// that Skia doesn't support, etc.) we fall back to serving the raw
/// bytes rather than 5xxing the whole image request.
/// </summary>
private IActionResult RespondWithTransforms(byte[] sourceBytes, string sourceContentType)
{
var cropSquare = ParseFlag(Request.Query["cropSquare"]);
var width = ParseInt(Request.Query["width"]);
var transformed = (cropSquare || width is not null)
? ImageTransformService.TryTransform(sourceBytes, sourceContentType, cropSquare, width)
: null;
if (transformed is { } t)
{
signatures.AddContentSignature(Response, t.Bytes);
return File(t.Bytes, t.ContentType);
}
signatures.AddContentSignature(Response, sourceBytes);
return File(sourceBytes, sourceContentType);
}
private static bool ParseFlag(Microsoft.Extensions.Primitives.StringValues v)
{
var s = v.ToString();
return !string.IsNullOrEmpty(s) && s != "0" && !s.Equals("false", StringComparison.OrdinalIgnoreCase);
}
private static int? ParseInt(Microsoft.Extensions.Primitives.StringValues v)
=> int.TryParse(v.ToString(), out var n) && n > 0 ? n : null;
private static bool LooksLikeHash(string s)
{
if (s.Length is not (32 or 40)) return false;
foreach (var c in s)
{
var isHex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
if (!isHex) return false;
}
return true;
}
private static string MimeFromName(string name)
{
var ext = Path.GetExtension(name).ToLowerInvariant();
return ext switch
{
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".gif" => "image/gif",
".webp" => "image/webp",
".mp4" or ".m4v" => "video/mp4",
".webm" => "video/webm",
".mov" => "video/quicktime",
_ => "application/octet-stream",
};
}
private static bool IsImageName(string name)
{
var ext = Path.GetExtension(name).ToLowerInvariant();
return ext is ".png" or ".jpg" or ".jpeg" or ".gif" or ".webp";
}
}
@@ -0,0 +1,823 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
using System.Text.Json;
namespace DorkNet.Server.Controllers.Chat;
/// <summary>
/// chat.rec.net — DM / group-chat endpoints. Three entities back this:
/// <list type="bullet">
/// <item><see cref="ChatMessageEntity"/> — append-only message log
/// keyed by thread.</item>
/// <item><see cref="ChatThreadEntity"/> — optional per-thread metadata
/// (custom name, creator) for group chats and renamed DMs.</item>
/// <item><see cref="ChatThreadMemberEntity"/> — per-(thread, player)
/// row carrying snooze timestamp and last-read message pointer.
/// For unmodified 2-person DMs we create rows lazily on first
/// mutation; the message table is the source of truth for
/// participants.</item>
/// </list>
/// </summary>
[ApiController]
[Authorize]
public class ChatController(
DorkNetDbContext db,
NotificationService notifications) : ControllerBase
{
private const int ChatSuccess = 0;
private const int ChatInvalidArguments = 1;
private const int ChatMembershipNotFound = 3;
private const int ChatPlayerAlreadyOnThread = 4;
private long Me => this.RequireCurrentPlayerId();
/// <summary>List the caller's chat threads, most-recently-active
/// first. Each thread carries the most recent message preview so
/// the watch can render the inbox without per-thread fetches.</summary>
[HttpGet("/thread")]
public async Task<IActionResult> GetThreads([FromQuery] int? maxCount)
{
var take = Math.Clamp(maxCount ?? 50, 1, 100);
// Two sources of "threads I see in my inbox":
// 1. Explicit membership rows (group chats — leaving deletes
// the row, so its presence means "still in").
// 2. DM threads where I sent or received at least one message.
// We union both, then filter out group keys I'm NOT a member
// of (in case I sent a message to a group then left).
var memberKeys = (await db.ChatThreadMembers
.Where(m => m.PlayerId == Me)
.Select(m => m.ThreadKey)
.ToListAsync()).ToHashSet();
var sentOrReceived = await db.ChatMessages
.Where(c => c.SenderPlayerId == Me ||
c.ThreadKey.StartsWith($"dm:{Me}:") ||
c.ThreadKey.EndsWith($":{Me}"))
.Select(c => c.ThreadKey)
.Distinct()
.ToListAsync();
var keys = sentOrReceived
.Concat(memberKeys)
.Distinct()
.Where(k => !k.StartsWith("group:") || memberKeys.Contains(k))
.ToList();
if (keys.Count == 0) return Ok(Array.Empty<object>());
var latest = await db.ChatMessages
.Where(c => keys.Contains(c.ThreadKey))
.GroupBy(c => c.ThreadKey)
.Select(g => g.OrderByDescending(c => c.SentAt).First())
.Take(take)
.ToListAsync();
var threadMeta = await EnsureThreadRowsAsync(keys);
var myMembership = await db.ChatThreadMembers
.Where(m => m.PlayerId == Me && keys.Contains(m.ThreadKey))
.ToDictionaryAsync(m => m.ThreadKey, m => m);
var participants = await LoadParticipantsAsync(keys);
return Ok(latest
.OrderByDescending(c => c.SentAt)
.Select(c =>
{
var meta = threadMeta[c.ThreadKey];
myMembership.TryGetValue(c.ThreadKey, out var member);
participants.TryGetValue(c.ThreadKey, out var playerIds);
return ToWireThread(
meta,
member,
playerIds ?? new List<int>(),
latestMessage: c);
})
.ToList());
}
/// <summary>Return the single thread by its key, with the most
/// recent N messages.</summary>
[HttpGet("/thread/{chatThreadId}")]
public async Task<IActionResult> GetThreadById(string chatThreadId,
[FromQuery] int maxCount = 50)
{
var take = Math.Clamp(maxCount, 1, 200);
var key = await ResolveThreadKeyAsync(chatThreadId);
var rows = await db.ChatMessages
.Where(c => c.ThreadKey == key)
.OrderByDescending(c => c.SentAt)
.Take(take)
.ToListAsync();
if (rows.Count == 0) return NotFound();
var meta = (await EnsureThreadRowsAsync(new[] { key }))[key];
var member = await db.ChatThreadMembers
.FirstOrDefaultAsync(m => m.ThreadKey == key && m.PlayerId == Me);
var participants = await LoadParticipantsAsync(new[] { key });
participants.TryGetValue(key, out var playerIds);
return Ok(ToWireThread(
meta,
member,
playerIds ?? new List<int>(),
messages: rows.OrderBy(c => c.SentAt).ToList()));
}
/// <summary>Recent messages for a thread, paginated by
/// <paramref name="beforeMessageId"/>.</summary>
[HttpGet("/thread/{chatThreadId}/message")]
public async Task<IActionResult> GetMessages(string chatThreadId,
[FromQuery] int maxCount = 50,
[FromQuery] int? messageCount = null,
[FromQuery] long? beforeMessageId = null,
[FromQuery] long? referenceMessageId = null)
{
var take = Math.Clamp(messageCount ?? maxCount, 1, 200);
var key = await ResolveThreadKeyAsync(chatThreadId);
var thread = await db.ChatThreads.FirstOrDefaultAsync(t => t.ThreadKey == key);
if (thread is null) return Ok(Array.Empty<object>());
var q = db.ChatMessages.Where(c => c.ThreadKey == key);
var before = beforeMessageId ?? referenceMessageId;
if (before is long b)
q = q.Where(c => c.Id < b);
var rows = await q
.OrderByDescending(c => c.SentAt)
.Take(take)
.ToListAsync();
return Ok(rows.OrderBy(c => c.SentAt).Select(c => ToWireMessage(c, thread.Id)).ToList());
}
/// <summary>Unread-thread count from per-thread last-read pointers.
/// A thread counts as unread when there's at least one message
/// after the caller's <c>LastReadMessageId</c> (or any message at
/// all when the caller has never read).</summary>
[HttpGet("/thread/unreadcount")]
public async Task<IActionResult> UnreadCount()
{
// Threads the caller participates in via membership row.
var memberKeys = await db.ChatThreadMembers
.Where(m => m.PlayerId == Me)
.ToListAsync();
var memberKeyToLastRead = memberKeys.ToDictionary(m => m.ThreadKey, m => m.LastReadMessageId);
// DM threads also count even without an explicit member row.
var dmKeys = await db.ChatMessages
.Where(c => c.ThreadKey.StartsWith($"dm:{Me}:") || c.ThreadKey.EndsWith($":{Me}"))
.Select(c => c.ThreadKey)
.Distinct()
.ToListAsync();
var keys = memberKeyToLastRead.Keys.Concat(dmKeys).Distinct().ToList();
if (keys.Count == 0) return Ok(0);
var perThreadMax = await db.ChatMessages
.Where(c => keys.Contains(c.ThreadKey) && c.SenderPlayerId != Me)
.GroupBy(c => c.ThreadKey)
.Select(g => new { Key = g.Key, MaxId = g.Max(c => c.Id) })
.ToListAsync();
var unread = perThreadMax.Count(t =>
{
memberKeyToLastRead.TryGetValue(t.Key, out var lastRead);
return lastRead is null || t.MaxId > lastRead.Value;
});
return Ok(unread);
}
public sealed record SendChatRequest(string? ChatThreadId, long? RecipientId, string Body);
/// <summary>Append a message to a thread. The thread is either
/// referenced by id (existing thread) or by RecipientId (new DM
/// to that player; we synthesise the canonical dm key). JSON shape
/// used by the admin SPA / API callers; the 2020 watch posts the
/// form-urlencoded variant in <see cref="SendForm"/> with multi-
/// valued <c>ids</c> + a <c>messageContents</c> JSON blob.</summary>
[HttpPost("/thread")]
[HttpPost("/thread/{chatThreadId}/message")]
[Consumes("application/json")]
public async Task<IActionResult> Send(string? chatThreadId,
[FromBody] SendChatRequest body)
{
if (string.IsNullOrWhiteSpace(body.Body)) return BadRequest("empty");
var trimmed = body.Body.Trim();
if (trimmed.Length > 2000) trimmed = trimmed[..2000];
var key = chatThreadId is null ? null : await ResolveThreadKeyAsync(chatThreadId);
key ??= body.ChatThreadId is null ? null : await ResolveThreadKeyAsync(body.ChatThreadId);
key ??= body.RecipientId is long otherId
? ChatMessageEntity.DmKey(Me, otherId)
: null;
if (key is null) return BadRequest("missing_thread");
var thread = await EnsureThreadRowAsync(key);
var entry = new ChatMessageEntity
{
ThreadKey = key,
SenderPlayerId = Me,
Body = trimmed,
};
db.ChatMessages.Add(entry);
await db.SaveChangesAsync();
// Notify every participant — INCLUDING the sender. The 2020
// watch's chat send path doesn't update its local thread cache
// off the HTTP 200 response, so without a self-targeted push
// the sender's chat UI doesn't show its own outgoing message
// until the next thread refresh. DM keys encode the two
// participants directly; group chat threads fan out across the
// explicit membership table.
var recipients = new List<long>();
if (key.StartsWith("dm:") && TryParseDmKey(key, out var a, out var b))
{
var other = a == Me ? b : a;
recipients.Add(other);
}
else if (key.StartsWith("group:"))
{
recipients = await db.ChatThreadMembers
.Where(m => m.ThreadKey == key && m.PlayerId != Me)
.Select(m => m.PlayerId)
.ToListAsync();
}
recipients.Add(Me);
foreach (var rid in recipients)
{
await notifications.NotifyAsync(rid,
PushNotificationId.ChatMessageReceived,
ToWireMessage(entry, thread.Id));
}
return Ok(ToWireSendMessageResponse(entry, thread.Id));
}
/// <summary>Set the caller's last-read pointer on the thread to
/// the most recent message id. Drives the inbox's unread badge.</summary>
[HttpPut("/thread/{chatThreadId}/read")]
[HttpPost("/thread/{chatThreadId}/read")]
public async Task<IActionResult> MarkRead(string chatThreadId) =>
await UpdateLastRead(await ResolveThreadKeyAsync(chatThreadId), latestOnly: true, explicitMessageId: null);
/// <summary>PUT <c>/thread/{id}/message/{messageId}/read</c> — set
/// the caller's last-read pointer to a specific message (not just
/// "latest"). The 2020 watch uses this when the inbox view scrolls
/// to a particular message rather than the bottom. URL verified
/// at stringliteral.json: <c>"thread/{0}/message/{1}/read"</c>.</summary>
[HttpPut("/thread/{chatThreadId}/message/{messageId:long}/read")]
[HttpPost("/thread/{chatThreadId}/message/{messageId:long}/read")]
public async Task<IActionResult> MarkMessageRead(string chatThreadId, long messageId) =>
await UpdateLastRead(await ResolveThreadKeyAsync(chatThreadId), latestOnly: false, explicitMessageId: messageId);
private async Task<IActionResult> UpdateLastRead(string chatThreadId, bool latestOnly, long? explicitMessageId)
{
long? latestId = explicitMessageId;
if (latestId is null)
{
latestId = await db.ChatMessages
.Where(c => c.ThreadKey == chatThreadId)
.OrderByDescending(c => c.Id)
.Select(c => (long?)c.Id)
.FirstOrDefaultAsync();
}
if (latestId is null) return Ok(ChatSuccess);
_ = latestOnly;
var row = await db.ChatThreadMembers
.FirstOrDefaultAsync(m => m.ThreadKey == chatThreadId && m.PlayerId == Me);
if (row is null)
{
row = new ChatThreadMemberEntity
{
ThreadKey = chatThreadId,
PlayerId = Me,
};
db.ChatThreadMembers.Add(row);
}
row.LastReadMessageId = latestId;
await db.SaveChangesAsync();
return Ok(ChatSuccess);
}
public sealed record SnoozeRequest(DateTime? Until);
/// <summary>Mute notifications for the thread until
/// <c>Until</c> (UTC). Null/missing payload clears the snooze.</summary>
[HttpPost("/thread/{chatThreadId}/snooze")]
public async Task<IActionResult> Snooze(string chatThreadId, [FromBody] SnoozeRequest? body)
{
chatThreadId = await ResolveThreadKeyAsync(chatThreadId);
var row = await db.ChatThreadMembers
.FirstOrDefaultAsync(m => m.ThreadKey == chatThreadId && m.PlayerId == Me);
if (row is null)
{
row = new ChatThreadMemberEntity
{
ThreadKey = chatThreadId,
PlayerId = Me,
};
db.ChatThreadMembers.Add(row);
}
row.SnoozeUntil = body?.Until;
await db.SaveChangesAsync();
var thread = await EnsureThreadRowAsync(chatThreadId);
return Ok(new Dictionary<string, object?>
{
["chatResult"] = ChatSuccess,
["snoozedUntil"] = row.SnoozeUntil,
});
}
/// <summary>Remove the caller from the thread's membership. They
/// stop receiving new messages and the thread drops off their
/// inbox. DMs can't be left — there are only two participants and
/// removing one effectively ends the conversation; the watch
/// surfaces a "delete chat" option for DMs instead.</summary>
[HttpDelete("/thread/{chatThreadId}/leave")]
public async Task<IActionResult> Leave(string chatThreadId)
{
chatThreadId = await ResolveThreadKeyAsync(chatThreadId);
if (chatThreadId.StartsWith("dm:"))
return Ok(ChatInvalidArguments);
var removed = await db.ChatThreadMembers
.Where(m => m.ThreadKey == chatThreadId && m.PlayerId == Me)
.ExecuteDeleteAsync();
return Ok(removed > 0 ? ChatSuccess : ChatMembershipNotFound);
}
public sealed record RenameRequest(string Name);
/// <summary>Set the thread's display name. Permission: any member
/// of the thread can rename. For DMs the watch typically nudges
/// users to pick a nickname after first send; group-chat creators
/// usually set the name at creation time.</summary>
[HttpPut("/thread/{chatThreadId}/rename")]
public async Task<IActionResult> Rename(string chatThreadId, [FromBody] RenameRequest body)
{
chatThreadId = await ResolveThreadKeyAsync(chatThreadId);
var name = (body.Name ?? string.Empty).Trim();
if (name.Length > 128) name = name[..128];
var iAmMember = chatThreadId.StartsWith("dm:")
? IsDmParticipant(chatThreadId, Me)
: await db.ChatThreadMembers
.AnyAsync(m => m.ThreadKey == chatThreadId && m.PlayerId == Me);
if (!iAmMember) return Ok(ChatMembershipNotFound);
var meta = await db.ChatThreads.FirstOrDefaultAsync(t => t.ThreadKey == chatThreadId);
if (meta is null)
{
meta = new ChatThreadEntity
{
ThreadKey = chatThreadId,
CreatorPlayerId = Me,
};
db.ChatThreads.Add(meta);
}
meta.Name = name;
meta.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ChatSuccess);
}
public sealed record CreateGroupChatRequest(List<long> MemberIds, string? Name);
/// <summary>Create a group chat. Generates a fresh
/// <c>group:&lt;guid&gt;</c> key, inserts membership rows for the
/// creator + each invited player, and returns the new thread id.
/// Empty or duplicate member lists are tolerated (the caller is
/// always added as a member regardless). JSON variant — the 2020
/// watch hits the form-urlencoded form in <see cref="WithMembersForm"/>.
/// </summary>
[HttpPost("/thread/withmembers")]
[Consumes("application/json")]
public async Task<IActionResult> CreateGroup([FromBody] CreateGroupChatRequest body)
{
var key = $"group:{Guid.NewGuid():N}";
var members = (body?.MemberIds ?? new())
.Where(id => id > 0 && id != Me)
.Distinct()
.ToList();
members.Add(Me);
db.ChatThreads.Add(new ChatThreadEntity
{
ThreadKey = key,
Name = (body?.Name ?? string.Empty).Trim(),
CreatorPlayerId = Me,
});
foreach (var pid in members)
{
db.ChatThreadMembers.Add(new ChatThreadMemberEntity
{
ThreadKey = key,
PlayerId = pid,
});
}
await db.SaveChangesAsync();
var thread = await db.ChatThreads.FirstAsync(t => t.ThreadKey == key);
var playerIds = members.Select(x => (int)x).ToList();
return Ok(new Dictionary<string, object?>
{
["chatThread"] = ToWireThread(thread, null, playerIds),
["chatResult"] = ChatSuccess,
});
}
/// <summary>Form-urlencoded variant of <see cref="CreateGroup"/>
/// used by the 2020 watch. Wire shape verified from the live
/// request: <c>ids=A&amp;ids=B&amp;messageCount=N</c>. The watch
/// calls this to "open a chat with these people" — semantics are
/// fetch-or-create-then-return-recent-messages, NOT just create.
/// We resolve <c>ids</c> to a canonical thread key (DM if two
/// participants total including the caller, otherwise group), pull
/// the latest <c>messageCount</c> messages, and return a shape the
/// watch's inbox can render directly.</summary>
[HttpPost("/thread/withmembers")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> WithMembersForm()
{
var (ids, _) = ParseIdsAndContents(Request.Form);
if (ids.Count == 0) return BadRequest(new { error = "missing_ids" });
var count = Math.Clamp(
int.TryParse(Request.Form["messageCount"], out var mc) ? mc : 50,
1, 200);
var key = await ResolveOrCreateThreadAsync(ids);
var rows = await db.ChatMessages
.Where(c => c.ThreadKey == key)
.OrderByDescending(c => c.SentAt)
.Take(count)
.ToListAsync();
var meta = await EnsureThreadRowAsync(key);
var participants = ids.Concat(new[] { Me }).Distinct().Select(x => (int)x).ToList();
return Ok(ToWireThread(meta, null, participants, messages: rows.OrderBy(c => c.SentAt).ToList()));
}
/// <summary>Form-urlencoded variant of <see cref="Send"/> used by
/// the 2020 watch. Wire shape: <c>ids=A&amp;ids=B&amp;
/// messageContents={JSON}</c> where <c>messageContents</c> is the
/// JSON-encoded <c>RecNet.ChatMessage.Contents</c> blob
/// (<c>{Type,Version,Data}</c>) — Data is the actual text. The
/// watch sends this to BOTH create-or-find-thread AND post-message
/// in one call.</summary>
[HttpPost("/thread")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> SendForm()
{
var (ids, contents) = ParseIdsAndContents(Request.Form);
if (ids.Count == 0) return BadRequest(new { error = "missing_ids" });
var text = ExtractChatDataField(contents);
if (string.IsNullOrWhiteSpace(text)) return BadRequest(new { error = "empty_body" });
if (text.Length > 2000) text = text[..2000];
var key = await ResolveOrCreateThreadAsync(ids);
var thread = await EnsureThreadRowAsync(key);
var entry = new ChatMessageEntity
{
ThreadKey = key,
SenderPlayerId = Me,
Body = text,
};
db.ChatMessages.Add(entry);
await db.SaveChangesAsync();
// Notify every participant, sender INCLUDED — see SendMessageJson
// above for the rationale; the 2020 watch's chat send path needs
// the self-push to refresh its own UI.
var recipients = new List<long>();
if (key.StartsWith("dm:") && TryParseDmKey(key, out var a, out var b))
{
var other = a == Me ? b : a;
if (other != Me) recipients.Add(other);
}
else if (key.StartsWith("group:"))
{
recipients = await db.ChatThreadMembers
.Where(m => m.ThreadKey == key && m.PlayerId != Me)
.Select(m => m.PlayerId)
.ToListAsync();
}
recipients.Add(Me);
foreach (var rid in recipients)
{
await notifications.NotifyAsync(rid,
PushNotificationId.ChatMessageReceived,
ToWireMessage(entry, thread.Id));
}
var participants = await LoadParticipantsAsync(new[] { key });
participants.TryGetValue(key, out var playerIds);
return Ok(new Dictionary<string, object?>
{
["chatThread"] = ToWireThread(thread, null, playerIds ?? new List<int>(), latestMessage: entry),
["chatResult"] = ChatSuccess,
});
}
/// <summary>Form-urlencoded send used by the 2020 watch:
/// <c>POST /thread/{chatId}</c> with <c>messageContents</c>.
/// The response is a SendMessageResponse wrapper, not a raw message.</summary>
[HttpPost("/thread/{chatThreadId}")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> SendMessageForm(string chatThreadId)
{
var key = await ResolveThreadKeyAsync(chatThreadId);
var text = ExtractChatDataField(Request.Form["messageContents"].ToString());
if (string.IsNullOrWhiteSpace(text)) return Ok(new Dictionary<string, object?>
{
["chatResult"] = ChatInvalidArguments,
});
if (text.Length > 2000) text = text[..2000];
var thread = await EnsureThreadRowAsync(key);
var entry = new ChatMessageEntity
{
ThreadKey = key,
SenderPlayerId = Me,
Body = text,
};
db.ChatMessages.Add(entry);
await db.SaveChangesAsync();
var recipients = new List<long>();
if (key.StartsWith("dm:") && TryParseDmKey(key, out var a, out var b))
{
var other = a == Me ? b : a;
if (other != Me) recipients.Add(other);
}
else if (key.StartsWith("group:"))
{
recipients = await db.ChatThreadMembers
.Where(m => m.ThreadKey == key && m.PlayerId != Me)
.Select(m => m.PlayerId)
.ToListAsync();
}
// Include the sender so the form-POST send path also refreshes
// its own chat thread — same reason as the JSON send path above.
recipients.Add(Me);
foreach (var rid in recipients)
{
await notifications.NotifyAsync(rid,
PushNotificationId.ChatMessageReceived,
ToWireMessage(entry, thread.Id));
}
return Ok(ToWireSendMessageResponse(entry, thread.Id));
}
/// <summary>Pull <c>ids</c> (multi-valued) + <c>messageContents</c>
/// out of the watch's form post. <c>ids</c> excludes the caller
/// (the caller is implied by their JWT).</summary>
private (List<long> ids, string? contents) ParseIdsAndContents(
Microsoft.AspNetCore.Http.IFormCollection form)
{
var ids = new List<long>();
foreach (var v in form["ids"])
{
if (long.TryParse(v, out var id) && id > 0 && id != Me)
ids.Add(id);
}
ids = ids.Distinct().ToList();
var contents = form["messageContents"].ToString();
return (ids, string.IsNullOrEmpty(contents) ? null : contents);
}
/// <summary>Resolve <paramref name="otherIds"/> + caller to a
/// canonical thread key. 1 other → DM key. 2+ others → existing
/// group with the exact same membership, or a fresh
/// <c>group:&lt;guid&gt;</c> if none. Membership rows are created
/// lazily so the inbox listing path can find the thread.</summary>
private async Task<string> ResolveOrCreateThreadAsync(List<long> otherIds)
{
if (otherIds.Count == 1)
{
var key = ChatMessageEntity.DmKey(Me, otherIds[0]);
// Ensure a ChatThreadEntity row exists for DMs the watch
// is mutating, so /thread inbox listings carry consistent
// metadata. The membership rows aren't needed for DM
// routing (the key carries the pair) but the inbox queries
// are simpler when every active thread has a row.
if (!await db.ChatThreads.AnyAsync(t => t.ThreadKey == key))
{
db.ChatThreads.Add(new ChatThreadEntity
{
ThreadKey = key,
CreatorPlayerId = Me,
});
await db.SaveChangesAsync();
}
return key;
}
var full = otherIds.Concat(new[] { Me }).Distinct().OrderBy(x => x).ToList();
// Look for an existing group whose membership matches exactly.
// Counts the rows per thread that intersect `full`, then takes
// the threads where total membership == |full| AND intersection
// size == |full|.
var candidateGroups = await db.ChatThreadMembers
.Where(m => full.Contains(m.PlayerId) && m.ThreadKey.StartsWith("group:"))
.GroupBy(m => m.ThreadKey)
.Where(g => g.Count() == full.Count)
.Select(g => g.Key)
.ToListAsync();
foreach (var k in candidateGroups)
{
var rows = await db.ChatThreadMembers
.Where(m => m.ThreadKey == k)
.Select(m => m.PlayerId)
.ToListAsync();
if (rows.OrderBy(x => x).SequenceEqual(full)) return k;
}
// No matching group → create one.
var newKey = $"group:{Guid.NewGuid():N}";
db.ChatThreads.Add(new ChatThreadEntity
{
ThreadKey = newKey,
CreatorPlayerId = Me,
});
foreach (var pid in full)
{
db.ChatThreadMembers.Add(new ChatThreadMemberEntity
{
ThreadKey = newKey,
PlayerId = pid,
});
}
await db.SaveChangesAsync();
return newKey;
}
/// <summary>Pull the <c>Data</c> field out of the watch's
/// <c>messageContents</c> JSON: <c>{"Type":0,"Version":1,"Data":"hi"}</c>.
/// Returns null/empty when the body isn't well-formed.</summary>
private static string ExtractChatDataField(string? messageContents)
{
if (string.IsNullOrWhiteSpace(messageContents)) return string.Empty;
try
{
using var doc = System.Text.Json.JsonDocument.Parse(messageContents);
if (doc.RootElement.TryGetProperty("Data", out var data)
&& data.ValueKind == System.Text.Json.JsonValueKind.String)
return data.GetString() ?? string.Empty;
}
catch { }
return string.Empty;
}
/// <summary>Add a player to an existing group chat. Idempotent —
/// re-adding an existing member is a no-op.</summary>
[HttpPost("/thread/{chatThreadId}/member/{playerId:long}")]
public async Task<IActionResult> AddMember(string chatThreadId, long playerId)
{
chatThreadId = await ResolveThreadKeyAsync(chatThreadId);
if (!chatThreadId.StartsWith("group:"))
return Ok(ChatInvalidArguments);
var iAmMember = await db.ChatThreadMembers
.AnyAsync(m => m.ThreadKey == chatThreadId && m.PlayerId == Me);
if (!iAmMember) return Ok(ChatMembershipNotFound);
var exists = await db.ChatThreadMembers
.AnyAsync(m => m.ThreadKey == chatThreadId && m.PlayerId == playerId);
if (exists) return Ok(ChatPlayerAlreadyOnThread);
if (!exists)
{
db.ChatThreadMembers.Add(new ChatThreadMemberEntity
{
ThreadKey = chatThreadId,
PlayerId = playerId,
});
await db.SaveChangesAsync();
}
return Ok(ChatSuccess);
}
private async Task<string> ResolveThreadKeyAsync(string idOrKey)
{
if (long.TryParse(idOrKey, out var id))
{
var key = await db.ChatThreads
.Where(t => t.Id == id)
.Select(t => t.ThreadKey)
.FirstOrDefaultAsync();
return key ?? idOrKey;
}
return idOrKey;
}
private async Task<Dictionary<string, ChatThreadEntity>> EnsureThreadRowsAsync(IEnumerable<string> keys)
{
var distinct = keys.Distinct().ToList();
var rows = await db.ChatThreads
.Where(t => distinct.Contains(t.ThreadKey))
.ToDictionaryAsync(t => t.ThreadKey, t => t);
foreach (var key in distinct.Where(k => !rows.ContainsKey(k)))
{
var row = new ChatThreadEntity
{
ThreadKey = key,
CreatorPlayerId = Me,
};
db.ChatThreads.Add(row);
rows[key] = row;
}
if (db.ChangeTracker.HasChanges())
await db.SaveChangesAsync();
return rows;
}
private async Task<ChatThreadEntity> EnsureThreadRowAsync(string key) =>
(await EnsureThreadRowsAsync(new[] { key }))[key];
private async Task<Dictionary<string, List<int>>> LoadParticipantsAsync(IEnumerable<string> keys)
{
var distinct = keys.Distinct().ToList();
var result = distinct.ToDictionary(k => k, _ => new List<int>());
foreach (var key in distinct)
{
if (TryParseDmKey(key, out var a, out var b))
{
result[key] = new List<int> { (int)a, (int)b };
}
}
var groupKeys = distinct.Where(k => k.StartsWith("group:")).ToList();
if (groupKeys.Count > 0)
{
var members = await db.ChatThreadMembers
.Where(m => groupKeys.Contains(m.ThreadKey))
.Select(m => new { m.ThreadKey, m.PlayerId })
.ToListAsync();
foreach (var group in members.GroupBy(m => m.ThreadKey))
result[group.Key] = group.Select(m => (int)m.PlayerId).Distinct().ToList();
}
return result;
}
private static Dictionary<string, object?> ToWireThread(
ChatThreadEntity thread,
ChatThreadMemberEntity? member,
List<int> playerIds,
ChatMessageEntity? latestMessage = null,
List<ChatMessageEntity>? messages = null)
{
var dto = new Dictionary<string, object?>
{
["chatThreadId"] = thread.Id,
["chatThreadName"] = thread.Name ?? string.Empty,
["lastReadMessageId"] = member?.LastReadMessageId ?? 0,
["snoozedUntil"] = member?.SnoozeUntil,
["playerIds"] = playerIds,
};
if (messages is not null)
{
dto["messages"] = messages.Select(m => ToWireMessage(m, thread.Id)).ToList();
}
else if (latestMessage is not null)
{
dto["latestMessage"] = ToWireMessage(latestMessage, thread.Id);
}
return dto;
}
private static Dictionary<string, object?> ToWireMessage(ChatMessageEntity c, long chatThreadId) => new()
{
["chatMessageId"] = c.Id,
["chatThreadId"] = chatThreadId,
["senderPlayerId"] = (int)c.SenderPlayerId,
["timeSent"] = c.SentAt,
["contents"] = JsonSerializer.Serialize(new
{
Type = 0,
Version = 1,
Data = c.Body,
}),
};
private static Dictionary<string, object?> ToWireSendMessageResponse(ChatMessageEntity c, long chatThreadId) => new()
{
["chatMessage"] = ToWireMessage(c, chatThreadId),
["chatResult"] = ChatSuccess,
};
private static bool TryParseDmKey(string key, out long a, out long b)
{
a = b = 0;
var parts = key.Split(':');
return parts.Length == 3 && parts[0] == "dm" &&
long.TryParse(parts[1], out a) &&
long.TryParse(parts[2], out b);
}
private static bool IsDmParticipant(string key, long playerId) =>
TryParseDmKey(key, out var a, out var b) && (a == playerId || b == playerId);
}
@@ -0,0 +1,212 @@
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Commerce;
/// <summary>
/// commerce.rec.net/api/catalog/* — the actual catalog endpoint the
/// 2020 watch's Shop tab fetches when it loads. The previous
/// implementation routed everything on commerce.rec.net through the
/// generic StubController which always returned <c>[]</c>; this
/// controller wins on specific routes via ASP.NET Core's
/// specific-over-wildcard precedence and returns the real
/// <see cref="StoreService"/> catalog.
///
/// Wire shape verified against the watch's Commerce.Catalog
/// deserialiser logging "no items" when we returned an empty array
/// vs populating the Shop UI when each item carries SkuId/Name/Cost
/// keys. Each catalog DTO ships every common alias (PascalCase,
/// camelCase, snake_case) so whichever JSON field the watch's
/// strict-key reader expects, it finds.
/// </summary>
[ApiController]
public class CommerceCatalogController(StoreService store, DomainConfig domain) : ControllerBase
{
[HttpGet("/api/catalog/v1/all")]
[HttpGet("/api/catalog/v2/all")]
[HttpGet("/api/catalog/v3/all")]
public async Task<IActionResult> All([FromQuery] bool onlyAvailableSkus = false)
{
var items = onlyAvailableSkus
? await store.GetAllActiveAsync()
: await store.GetAllActiveAsync(); // same set; we don't distinguish yet
return Ok(items.Select(i => ToCatalogDto(i, domain.Apex)).ToArray());
}
[HttpGet("/api/catalog/v1/sku/{slug}")]
[HttpGet("/api/catalog/v2/sku/{slug}")]
public async Task<IActionResult> Sku(string slug)
{
var item = await store.GetBySlugAsync(slug);
if (item is null) return NotFound();
return Ok(ToCatalogDto(item, domain.Apex));
}
/// <summary>POST /api/catalog/v1/byids — bulk lookup by id array.
/// The watch sends {Ids:[1,2,3]} as form-urlencoded; accept both
/// JSON and form bodies for safety.</summary>
[HttpPost("/api/catalog/v1/byids")]
[HttpPost("/api/catalog/v2/byids")]
public async Task<IActionResult> ByIds()
{
string? raw = null;
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
raw = form["Ids"].ToString();
if (string.IsNullOrWhiteSpace(raw)) raw = form["ids"].ToString();
}
if (string.IsNullOrWhiteSpace(raw)) raw = Request.Query["Ids"].ToString();
if (string.IsNullOrWhiteSpace(raw)) raw = Request.Query["ids"].ToString();
if (string.IsNullOrWhiteSpace(raw)) return Ok(Array.Empty<object>());
var ids = raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(s => long.TryParse(s, out var v) ? v : 0L)
.Where(v => v > 0)
.Distinct()
.ToArray();
if (ids.Length == 0) return Ok(Array.Empty<object>());
var all = await store.GetAllActiveAsync();
return Ok(all.Where(i => ids.Contains(i.Id)).Select(i => ToCatalogDto(i, domain.Apex)).ToArray());
}
/// <summary>Catalog wire shape — every common key alias present
/// (PascalCase + camelCase + snake_case) so the watch's
/// strict-key deserialiser hits whichever name it's coded
/// against without crashing on a missing field.
///
/// Implemented as a Dictionary because System.Text.Json's
/// case-insensitive collision check rejects anonymous types whose
/// property names differ only in casing (<c>SkuId</c> vs
/// <c>skuId</c>, etc.) — same trick we use in
/// RelationshipsController.BuildRelationshipResponse.</summary>
private static Dictionary<string, object?> ToCatalogDto(Data.Entities.StoreItemEntity i, string apex)
{
var available = i.IsActive && (i.AvailableUntil == null || i.AvailableUntil > DateTime.UtcNow);
var imageUrl = string.IsNullOrEmpty(i.ImageName) ? "" : $"https://cdn.{apex}/{i.ImageName}";
return new Dictionary<string, object?>
{
// Identity / naming — every alias the watch's strict
// deserialiser might key on.
//
// CRITICAL: <c>SkuId</c> is typed <c>int</c> on
// <c>RecNet.Commerce+SKU</c>
// (Cpp2IL_CS/.../RecNet/Commerce.cs:80
// <c>private int &lt;SkuId&gt;k__BackingField</c>).
// Util.GetKey&lt;int&gt;("SkuId") calls Convert.ChangeType,
// which throws FormatException if the value isn't
// parseable as an int. The previous shape shipped
// <c>i.Slug</c> (a "wardrobe-&lt;guid&gt;" string) under
// <c>SkuId</c> — that's the FormatException at
// <c>output_log.txt:1655</c> which corrupts the catalog
// load and downstream destabilises the watch's player
// object (manifests as the dorm-load destroy crash).
// Ship the int row id under <c>SkuId</c>; keep the slug
// under <c>Slug</c>/<c>Sku</c> aliases for any string
// consumer.
["SkuId"] = i.Id,
["skuId"] = i.Id,
["Sku"] = i.Slug,
["sku"] = i.Slug,
["ItemId"] = i.Id,
["itemId"] = i.Id,
["Id"] = i.Id,
["id"] = i.Id,
["Slug"] = i.Slug,
["slug"] = i.Slug,
["Name"] = i.DisplayName,
["name"] = i.DisplayName,
["DisplayName"] = i.DisplayName,
["displayName"] = i.DisplayName,
["Title"] = i.DisplayName,
["title"] = i.DisplayName,
["Description"] = i.Description,
["description"] = i.Description,
["Category"] = i.Category,
["category"] = i.Category,
["ItemType"] = i.Category,
["itemType"] = i.Category,
// Cost / price — flat + nested forms.
["Price"] = i.Price,
["price"] = i.Price,
["Cost"] = new Dictionary<string, object?> { ["Amount"] = i.Price, ["CurrencyType"] = i.CurrencyType },
["cost"] = new Dictionary<string, object?> { ["amount"] = i.Price, ["currencyType"] = i.CurrencyType },
["CurrencyType"] = i.CurrencyType,
["currencyType"] = i.CurrencyType,
["Amount"] = i.Price,
["amount"] = i.Price,
["ImageName"] = i.ImageName,
["imageName"] = i.ImageName,
["Image"] = i.ImageName,
["image"] = i.ImageName,
["ImageUrl"] = imageUrl,
["imageUrl"] = imageUrl,
["Available"] = available,
["available"] = available,
["IsAvailable"] = available,
["isAvailable"] = available,
["IsActive"] = i.IsActive,
["isActive"] = i.IsActive,
["IsLimitedTime"] = i.IsLimitedTime,
["isLimitedTime"] = i.IsLimitedTime,
["AvailableUntil"] = i.AvailableUntil,
["availableUntil"] = i.AvailableUntil,
["Storefront"] = i.Storefront,
["storefront"] = i.Storefront,
["CreatedAt"] = i.CreatedAt,
["createdAt"] = i.CreatedAt,
// Required RecNet.Commerce+SKU fields the watch's strict
// Util.GetKey deserialiser throws KeyNotFoundException on
// when missing. Verified at
// Cpp2IL_CS/.../RecNet/Commerce.cs:80-102 — SKU has
// OculusSkuId / AppleProductId / PSNProductLabel /
// IsSingleUse / Data / DisplayPrice / LongDescription as
// public-private (read+private-set) properties; missing
// any of them at deserialize time causes the SKU import
// to abort halfway and leaves the catalog in a half-built
// state that the StoreScreen UI binding then native-crashes
// on (output_log.txt:853 — last log line is a
// RRUI.Data.TypedDataSource resolving List<string>.Count
// followed by silent process exit).
["OculusSkuId"] = string.Empty,
["oculusSkuId"] = string.Empty,
["AppleProductId"] = string.Empty,
["appleProductId"] = string.Empty,
["PSNProductLabel"] = string.Empty,
["psnProductLabel"] = string.Empty,
["IsSingleUse"] = false,
["isSingleUse"] = false,
["DisplayPrice"] = i.Price.ToString(),
["displayPrice"] = i.Price.ToString(),
["LongDescription"] = i.Description ?? string.Empty,
["longDescription"] = i.Description ?? string.Empty,
// SKUData is itself an IRecNetObject with required keys —
// GiftDropIds (List<int>), Message (string),
// SubscriptionPurchase (object). Empty list / empty
// string / null sub-purchase are valid.
["Data"] = new Dictionary<string, object?>
{
["GiftDropIds"] = Array.Empty<int>(),
["Message"] = string.Empty,
["SubscriptionPurchase"] = (object?)null,
},
["data"] = new Dictionary<string, object?>
{
["giftDropIds"] = Array.Empty<int>(),
["message"] = string.Empty,
["subscriptionPurchase"] = (object?)null,
},
};
}
}
@@ -0,0 +1,30 @@
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Discovery;
/// <summary>
/// discovery.{rec.net,localhost} — the watch's room-discovery surface
/// (rooms tab "For You" feed). Backed by the same RoomService used
/// elsewhere so what's hot here matches the main browser.
/// </summary>
[ApiController]
public class DiscoveryController(RoomService rooms) : ControllerBase
{
[HttpGet("/discover/v2/rooms")]
public async Task<IActionResult> GetRooms([FromQuery] string? tag, [FromQuery] int take = 50)
=> Ok((await rooms.HotAsync(tag, Math.Clamp(take, 1, 100)))
.Select(RoomService.ToWireRoom).ToList());
[HttpGet("/discover/v2/feed")]
public async Task<IActionResult> GetFeed([FromQuery] int take = 50)
=> Ok((await rooms.HotAsync(null, Math.Clamp(take, 1, 100)))
.Select(RoomService.ToWireRoom).ToList());
[HttpGet("/discover/v2/featured")]
public async Task<IActionResult> GetFeatured([FromQuery] int take = 50)
// Featured = tagged "featured" — same set as the watch's
// primary "Featured" filter chip.
=> Ok((await rooms.HotAsync("featured", Math.Clamp(take, 1, 100)))
.Select(RoomService.ToWireRoom).ToList());
}
@@ -0,0 +1,81 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Services;
using System.Text.Json.Serialization;
namespace DorkNet.Server.Controllers.Econ;
/// <summary>
/// econ.{rec.net,localhost} — token balance + inventory queries used
/// by older client flows. Reads from <see cref="LevelService"/>
/// (currency type 0 = Token per <c>CurrencyType.cs</c>) and the
/// <see cref="PlayerInventoryEntity"/> table. Note: the primary
/// store/inventory surface is api.*/api/storefronts/* + api/inventory/*;
/// this host is a legacy alias.
/// </summary>
[ApiController]
[Authorize]
public class EconController(DorkNetDbContext db, LevelService level) : ControllerBase
{
[HttpGet("/econ/v1/balance/{accountId:long}")]
public async Task<ActionResult<TokenBalance>> GetBalance(long accountId)
{
var tokens = await level.GetBalanceAsync(accountId, 0);
return Ok(new TokenBalance
{
AccountId = accountId,
Tokens = (int)tokens,
BonusTokens = 0,
});
}
[HttpGet("/econ/v1/ownershipbatch")]
public async Task<IActionResult> GetOwnershipBatch([FromQuery] string? ids)
{
var pid = this.RequireCurrentPlayerId();
var slugs = (ids ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToList();
var q = db.PlayerInventory.Where(p => p.PlayerId == pid);
if (slugs.Count > 0) q = q.Where(p => slugs.Contains(p.ItemSlug));
var rows = await q.Select(p => new { p.ItemSlug, p.Quantity }).ToListAsync();
return Ok(rows);
}
[HttpGet("/econ/v1/inventory/{accountId:long}")]
public async Task<IActionResult> GetInventory(long accountId)
{
var rows = await db.PlayerInventory
.Where(p => p.PlayerId == accountId)
.Select(p => new { p.ItemSlug, p.Quantity })
.ToListAsync();
return Ok(rows);
}
[HttpGet("/econ/v1/products")]
public async Task<IActionResult> GetProducts()
{
var rows = await db.StoreItems
.Where(s => s.IsActive)
.Select(s => new
{
Id = s.Id,
Slug = s.Slug,
Name = s.DisplayName,
Price = s.Price,
CurrencyType = s.CurrencyType,
})
.ToListAsync();
return Ok(rows);
}
}
public class TokenBalance
{
[JsonPropertyName("AccountId")] public long AccountId { get; set; }
[JsonPropertyName("Tokens")] public int Tokens { get; set; }
[JsonPropertyName("BonusTokens")] public int BonusTokens { get; set; }
}
@@ -0,0 +1,30 @@
using Microsoft.AspNetCore.Mvc;
namespace DorkNet.Server.Controllers.Geo;
/// <summary>
/// geo.{apex}/v1/regions — region/server geography. Returns a single
/// configured region (we don't actually run multi-region; the watch's
/// region picker just needs one entry to populate the dropdown).
///
/// The <c>/v1/ping</c> endpoint that previously lived here was
/// removed during the [Host]-strip refactor — both geo and ns
/// answered the same path (NsController.Ping is the canonical
/// one), and with host filters gone the two routes would have
/// ambiguous-matched on any subdomain. NsController wins because
/// its ping body carries the timestamp the keep-alive loop reads;
/// the geo version returned a bare {Ok:true} that nothing consumed.
/// </summary>
[ApiController]
public class GeoController(IConfiguration config) : ControllerBase
{
[HttpGet("/v1/regions")]
public ActionResult<List<object>> GetRegions()
{
var region = config["Photon:CloudRegion"] ?? "us";
return Ok(new[]
{
new { Id = region, Name = region.ToUpperInvariant(), Ping = 50 },
});
}
}
@@ -0,0 +1,66 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Data;
namespace DorkNet.Server.Controllers.Health;
/// <summary>
/// GET <c>/healthz</c> — liveness/readiness probe for Coolify rolling deploys.
///
/// Returns 503 until BOTH conditions hold:
/// 1. <see cref="MigrationsComplete"/> flag has been set by Program.cs after
/// <c>db.Database.Migrate()</c> returns (so a new replica that's still
/// running migrations doesn't get traffic shifted to it before its schema
/// is current).
/// 2. <c>db.Database.CanConnectAsync()</c> succeeds (catches transient DB
/// blips and prevents traffic during a Postgres restart).
///
/// Coolify's rolling-deploy strategy waits for the new container's healthz
/// to return 200 before draining the old one. Combined with a brief
/// (~5–10s) `start-first` overlap, this is the closest thing to zero
/// downtime we get with single-region hosting.
///
/// HostFilteringOptions is configured (via DomainConfig) to accept
/// <c>localhost</c> + <c>127.0.0.1</c> alongside the apex pool so the
/// Coolify internal healthcheck (which hits <c>localhost:8080/healthz</c>
/// from inside the container) reaches this handler — earlier
/// per-controller <c>[Host(...)]</c> filters would have 404ed it.
///
/// Distinct from <c>/v1/ping</c> on the ns subdomain — that returns a
/// timestamped payload the watch's keep-alive loop reads and is part of
/// the wire contract. We deliberately don't change it.
/// </summary>
[ApiController]
public class HealthController(DorkNetDbContext db) : ControllerBase
{
/// <summary>Set to true by Program.cs after the EF Core
/// <c>Migrate()</c> call returns successfully. Volatile because
/// it's read from request threads concurrently with the startup
/// thread's write. Process-local — every replica sets its own
/// flag once its own startup migrations finish.</summary>
public static volatile bool MigrationsComplete = false;
// Order is more-negative than CdnController.Serve's wildcard
// (Order = -100). Without this the catch-all matched <c>/healthz</c>
// first on the <c>localhost</c> host (no subdomain) and returned
// 404 → Docker's HEALTHCHECK <c>curl --fail</c> exited non-zero →
// Coolify rolled the new container back.
[HttpGet("/healthz", Order = -1000)]
public async Task<IActionResult> Healthz()
{
if (!MigrationsComplete)
return StatusCode(503, new { status = "starting", reason = "migrations_pending" });
// CanConnectAsync uses a cheap "SELECT 1"-equivalent — fast enough
// to run on every probe (~1ms for a warm pool). Cheaper than the
// alternative of failing only on actual query timeouts.
bool dbOk;
try { dbOk = await db.Database.CanConnectAsync(); }
catch { dbOk = false; }
if (!dbOk)
return StatusCode(503, new { status = "unhealthy", reason = "db_unreachable" });
return Ok(new { status = "ok" });
}
}
@@ -0,0 +1,933 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json.Serialization;
using DorkNet.Server.Auth;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Match;
/// <summary>
/// match.rec.net/goto/* — matchmaking endpoints used by
/// RecNet.Matchmaking.GoToHelper / GoToDormRoom (the latter is what
/// BootSequence.LoadDormRoom invokes during initial scene load).
///
/// Response shape verified by disassembling
/// RecNet.Matchmaking.MatchmakingResponse.Deserialize (RVA 0x1447DD0):
/// "errorCode" - int, optional (Util.GetKeyOrDefault&lt;int&gt;)
/// "roomInstance" - RoomInstance object, REQUIRED. The deserializer
/// throws explicitly with the error
/// "Matchmaking response has no RoomInstance field"
/// when this key is missing or non-object.
///
/// The nested RoomInstance shape (RVA 0x114DAB0) requires:
/// roomInstanceId, roomId, subRoomId, location, photonRegionId,
/// photonRoomId, maxCapacity, isFull, isPrivate, isInProgress
/// (all required) plus optional dataBlob, eventId, name.
///
/// The client then JOINS the Photon room named by photonRoomId in the
/// region photonRegionId — these two values are what tie our HTTP
/// matchmaking to the actual realtime networking.
/// </summary>
[ApiController]
public class GoToController(
IConfiguration config,
PlayerPresenceService presence,
PrivateInstanceService privateInstances,
RoomService rooms,
DorkNet.Server.Data.DorkNetDbContext db,
NotificationService notifications,
ILogger<GoToController> logger) : ControllerBase
{
[HttpPost("/goto/room/{roomName}")]
public async Task<ActionResult<MatchmakingResponseDto>> GoToRoom(
string roomName,
[FromForm(Name = "CreatePrivateInstance")] bool createPrivateInstance = false)
{
// Unknown room name shouldn't silently drop the player into a
// synth'd dorm scene any more — that masked typo bug reports
// ("why does /goto/^Crescendoo land me in my dorm?") and let
// dead links sit unfixed. Surface the real "no such room"
// error so the watch shows its standard "room not found" toast
// and the typo is obvious. DormRoom is a special case (per-player
// lazy-create), everything else MUST exist in db.Rooms.
if (!roomName.Equals("DormRoom", StringComparison.OrdinalIgnoreCase))
{
var exists = await rooms.GetByNameAsync(roomName);
if (exists is null)
{
logger.LogWarning("[goto] unknown room name='{Name}' — returning RoomDoesNotExist", roomName);
return Ok(new MatchmakingResponseDto
{
// 4 = RoomDoesNotExist on
// RecNet.Matchmaking.CreateModifyRoomStatus enum.
// The watch reads ErrorCode != 0 and surfaces the
// standard "Room not found" toast.
ErrorCode = 4,
RoomInstance = null,
});
}
}
var resp = await BuildResponseAsync(roomName);
if (createPrivateInstance) await ApplyPrivateInstanceAsync(resp, subRoomId: 0);
await RecordResponseAsync(resp);
return Ok(resp);
}
/// <summary>POST <c>/goto/room/{room}/{subRoom}</c> — sub-room form
/// from <c>RecNet.Matchmaking.GoToSubRoom</c>
/// (<c>Cpp2IL_ISIL/.../Matchmaking.txt:4682-4702</c>):
/// <c>String.Concat("goto/room/", urlEncode(room), "/", urlEncode(subRoom))</c>.
/// Used by baked AG-Original rooms with multiple scenes (e.g.
/// <c>StuntRunner/TheMainEvent</c>, the playable gauntlet inside
/// the StuntRunner lobby).
///
/// We resolve to the parent room's <see cref="RoomEntity"/> for
/// the canonical RoomId + LocationReplicationId, then expose the
/// sub-room name via the <c>name</c> field so the client can
/// render the right scene. If we ever build a per-sub-room scene
/// location lookup table, switch <see cref="RoomInstanceDto.Location"/>
/// to the matching scene's ReplicationId.</summary>
[HttpPost("/goto/room/{roomName}/{subRoomName}")]
public async Task<ActionResult<MatchmakingResponseDto>> GoToSubRoom(
string roomName, string subRoomName,
[FromForm(Name = "CreatePrivateInstance")] bool createPrivateInstance = false)
{
// Build (don't record yet) — we need to mutate the RoomInstance
// for the sub-room before presence is recorded, otherwise the
// server presence has the lobby's RoomInstance but the client
// thinks it's in the sub-room → heartbeat sees a mismatch and
// the watch logs "presence out-of-sync" / Error -2.
var resp = await BuildResponseAsync(roomName);
// Imported multi-scene rooms (admin Import Room) get one
// RoomSceneEntity row per chapter — prefer that when the
// (room, subRoom) pair matches. Falls back to the hardcoded
// baked-room map for AG-Originals like StuntRunner/TheMainEvent.
//
// Lookup is tolerant: in-game portals don't always pass the
// canonical scene Name. We match in this order:
// 1. exact (case-insensitive — RoomScenes.Name is NOCASE collated)
// 2. EndsWith(subRoomName) — handles portals that omit
// a "Ch0_" / "Chapter1_" prefix
// 3. Contains(subRoomName) — last-resort fuzzy match
// and fall back to the hardcoded location map only when no
// RoomScenes row matches at all.
var allScenes = await db.RoomScenes
.Where(s => s.RoomId == resp.RoomInstance.RoomId)
.Select(s => new { s.OrderIndex, s.Name, s.RoomSceneLocationId, s.DataBlobName })
.ToListAsync();
var dbScene =
allScenes.FirstOrDefault(s =>
string.Equals(s.Name, subRoomName, StringComparison.OrdinalIgnoreCase))
?? allScenes.FirstOrDefault(s =>
s.Name.EndsWith(subRoomName, StringComparison.OrdinalIgnoreCase))
?? allScenes.FirstOrDefault(s =>
s.Name.IndexOf(subRoomName, StringComparison.OrdinalIgnoreCase) >= 0);
long subId;
string? subLocation;
string? subDataBlob;
if (dbScene is not null)
{
logger.LogInformation(
"[goto-subroom] {Room}/{Requested} → matched scene '{MatchedName}' idx={Idx} loc={Loc} blob={Blob}",
roomName, subRoomName, dbScene.Name, dbScene.OrderIndex,
dbScene.RoomSceneLocationId, dbScene.DataBlobName);
subId = dbScene.OrderIndex;
subLocation = dbScene.RoomSceneLocationId;
subDataBlob = dbScene.DataBlobName;
}
else
{
logger.LogWarning(
"[goto-subroom] {Room}/{Requested} — no RoomScenes match (room has {Count} scenes: {Names}). Falling back to hardcoded MapSubRoomToLocationId.",
roomName, subRoomName, allScenes.Count,
string.Join(", ", allScenes.Select(s => s.Name)));
subId = Math.Abs((long)subRoomName.GetHashCode());
subLocation = MapSubRoomToLocationId($"{roomName}/{subRoomName}".ToLowerInvariant());
subDataBlob = null;
}
// Both PhotonRoomId AND RoomInstanceId must differ for the
// watch's transition state machine to fire. Empirical from
// comparing the two failure modes:
// Same photon + same roomInstanceId → blob downloads (because
// dataBlob field changed) but watch never transitions; the
// "Going to X" dialog hangs because nothing the state
// machine tracks signalled "I'm in a different match".
// Different photon + same roomInstanceId → watch doesn't
// even fetch the new dataBlob; it sees roomInstanceId
// unchanged so treats the response as a no-op presence
// update and skips the join flow.
// Folding subId into RoomInstanceId AND forking PhotonRoomId
// gives the watch the two signals it needs: "new instance"
// (roomInstanceId differs → reset state machine) plus "new
// photon match" (photonRoomId differs → leave/rejoin →
// SceneLoaded events that ultimately dismiss the dialog).
resp.RoomInstance.RoomInstanceId = resp.RoomInstance.RoomId * 100_000L + subId;
resp.RoomInstance.PhotonRoomId = $"{resp.RoomInstance.PhotonRoomId}_sub{subId}";
resp.RoomInstance.Name = subRoomName;
resp.RoomInstance.SubRoomId = subId;
if (subLocation is not null) resp.RoomInstance.Location = subLocation;
// dataBlob on the matchmaking response tells the client which
// PersistedRoomData blob to download for the entered scene.
// RoomInstance.Deserialize at RVA 0x114DAB0 reads it via the
// string-with-default reader (call 0x180EE6210), so empty is
// technically legal — but if it's empty the watch falls back
// to the parent room's RoomDataBlobName, which is the lobby's
// blob, so the sub-scene visually never changes.
if (!string.IsNullOrEmpty(subDataBlob)) resp.RoomInstance.DataBlob = subDataBlob;
// Private instance mutation goes AFTER sub-room mutation so the
// PhotonRoomId we register includes the _sub{N} suffix as its
// base — invitees coming in via /goto/instance/{id} land in the
// exact same sub-room photon match, not the lobby's.
if (createPrivateInstance) await ApplyPrivateInstanceAsync(resp, subRoomId: subId);
await RecordResponseAsync(resp);
return Ok(resp);
}
/// <summary>Per-sub-room scene location overrides for baked
/// AG-Original rooms with multiple scenes. Each entry maps a
/// <c>"{room}/{subRoom}"</c> key (lowercase) to the
/// <c>RoomSceneLocationId</c> GUID baked into the client's
/// AGRoomRuntimeConfig.Locations table. Without an entry the
/// parent room's location is reused (lobby loads, door opens
/// but transition no-ops).
///
/// Add entries here as you confirm each sub-scene's GUID — the
/// client's <c>AGRoomSettings.TryGetRoomSceneLocationById</c>
/// rejects unknown GUIDs with "RecNet game session contains
/// unknown scene location ID" and refuses to load.</summary>
/// <summary>Sub-room scene GUIDs extracted from the bundled
/// <c>resources.assets</c> AGRoomRuntimeConfig.Rooms[].Scenes[]
/// using <c>tools/extract-rooms-binary.py</c>. Each entry maps
/// <c>"{room}/{subRoom}"</c> (lowercase) to the target
/// <c>RoomSceneLocationId</c> GUID. Missing sub-rooms reuse the
/// parent room's lobby location (door opens but transition no-ops).</summary>
private static string? MapSubRoomToLocationId(string subKey) => subKey switch
{
// ── StuntRunner ──
// Lobby is the implicit Scene[0]; TheMainEvent is the
// playable gauntlet behind the door.
"stuntrunner/themainevent" => "3a636bd2-f896-424c-9225-c184522c0d87",
// ── Paintball (River map + variants) ──
// The Paintball Room has nested sub-rooms for each map; the
// lobby acts as a hub and players pick a map from a board.
"paintball/river" => "e122fe98-e7db-49e8-a1b1-105424b6e1f0",
"paintball/homestead" => "a785267d-c579-42ea-be43-fec1992d1ca7",
"paintball/quarry" => "ff4c6427-7079-4f59-b22a-69b089420827",
"paintball/clearcut" => "380d18b5-de9c-49f3-80f7-f4a95c1de161",
"paintball/spillway" => "58763055-2dfb-4814-80b8-16fac5c85709",
"paintballvr/river" => "e122fe98-e7db-49e8-a1b1-105424b6e1f0",
"paintballvr/homestead" => "a785267d-c579-42ea-be43-fec1992d1ca7",
"paintballvr/quarry" => "ff4c6427-7079-4f59-b22a-69b089420827",
"paintballvr/clearcut" => "380d18b5-de9c-49f3-80f7-f4a95c1de161",
"paintballvr/spillway" => "58763055-2dfb-4814-80b8-16fac5c85709",
// ── LaserTag ──
"lasertag/cyberjunkcity" => "9d6456ce-6264-48b4-808d-2d96b3d91038",
_ => null,
};
[HttpPost("/goto/event/{eventId:long}")]
public async Task<ActionResult<MatchmakingResponseDto>> GoToEvent(long eventId)
{
var resp = await BuildResponseAsync("DormRoom");
await RecordResponseAsync(resp);
return Ok(resp);
}
/// <summary>POST <c>/goto/instance/{roomInstanceId}</c> — invite-
/// accept route. The watch posts here when joining a private
/// match via a Message-attached invite. We resolve the instance
/// id to the registered <see cref="PrivateInstanceService.PrivateInstance"/>
/// and return a RoomInstance pinned to the inviter's PhotonRoomId
/// so both players land in the same Photon match. Falls back to
/// dorm if the instance was forgotten (server restart, owner
/// already left and nobody re-created it).</summary>
[HttpPost("/goto/instance/{roomInstanceId:long}")]
public async Task<ActionResult<MatchmakingResponseDto>> GoToInstance(long roomInstanceId)
{
var resp = await BuildPrivateInstanceJoinResponseAsync(roomInstanceId);
await RecordResponseAsync(resp);
return Ok(resp);
}
[HttpPost("/goto/invite/{inviteId:long}")]
public async Task<ActionResult<MatchmakingResponseDto>> GoToInvite(long inviteId)
{
var callerId = this.CurrentPlayerId();
if (callerId is null)
{
var unauth = await BuildResponseAsync("DormRoom");
await RecordResponseAsync(unauth);
return Ok(unauth);
}
var invite = await db.Messages.FirstOrDefaultAsync(m =>
m.Id == inviteId
&& m.RecipientPlayerId == callerId.Value
&& (m.Type == 6 || m.Type == 7));
long? roomInstanceId = null;
if (invite is not null && TryParseRoomInstanceId(invite.Body, out var parsed))
roomInstanceId = parsed;
// The 2020 watch's accept-invite flow fires
// POST /api/messages/v3/delete AND POST /goto/invite/{id} in
// parallel — verified in the live server log:
// 09:53:47.161Z DELETE messageIds=[60]
// 09:53:47.245Z /goto/invite/60 → [goto-invite] invite 60 not found
// DELETE usually wins the race, so the message row is gone
// by the time we run FirstOrDefaultAsync. The
// (instance, player) → LatestInviteMessageId mapping that
// SendInvite writes onto the PrivateInstanceInviteeEntity
// survives the message deletion and lets us recover
// roomInstanceId here.
if (roomInstanceId is null)
{
var inviteeRoomInstance = await db.PrivateInstanceInvitees
.Where(i => i.PlayerId == callerId.Value && i.LatestInviteMessageId == inviteId)
.Select(i => (long?)i.PrivateInstanceId)
.FirstOrDefaultAsync();
if (inviteeRoomInstance is long inst)
{
roomInstanceId = inst;
logger.LogInformation(
"[goto-invite] message {InviteId} missing (likely raced with DELETE); resolved roomInstance={Instance} via invitee fallback for caller {Caller}",
inviteId, inst, callerId);
}
}
if (roomInstanceId is null)
{
logger.LogWarning(
"[goto-invite] invite {InviteId} not found and no invitee fallback for caller {Caller}",
inviteId, callerId);
return Ok(new MatchmakingResponseDto
{
ErrorCode = 40, // RoomInviteExpired
RoomInstance = new RoomInstanceDto(),
});
}
// Mark the message read. The watch's parallel DELETE may have
// wiped the row between our initial read and this save —
// swallow the resulting DbUpdateConcurrencyException since the
// accept has already succeeded at this point.
if (invite is not null && invite.ReadAt is null)
{
invite.ReadAt = DateTime.UtcNow;
try
{
await db.SaveChangesAsync();
}
catch (DbUpdateException)
{
db.Entry(invite).State = EntityState.Detached;
}
}
var resp = await BuildPrivateInstanceJoinResponseAsync(roomInstanceId.Value);
await RecordResponseAsync(resp);
return Ok(resp);
}
private async Task<MatchmakingResponseDto> BuildPrivateInstanceJoinResponseAsync(long roomInstanceId)
{
var inst = await privateInstances.GetAsync(roomInstanceId);
if (inst is null)
{
logger.LogWarning(
"[goto-instance] {Id} → no registered private instance; routing to dorm",
roomInstanceId);
return await BuildResponseAsync("DormRoom");
}
// Enforce invite-list membership. Owner is always allowed (so
// they can rejoin their own match after a disconnect). Anyone
// else needs to have been invited via POST /invite. We refuse
// with errorCode != 0 so the watch surfaces a "join failed"
// toast instead of silently routing to the wrong photon room.
if (this.CurrentPlayerId() is long callerId
&& !await privateInstances.CanJoinAsync(roomInstanceId, callerId))
{
logger.LogWarning(
"[goto-instance] {Id} → caller {Caller} is not on the invite list (owner={Owner}); rejecting",
roomInstanceId, callerId, inst.OwnerPlayerId);
// MatchmakingErrorCode 26 = RoomInstanceIsPrivate — the
// dedicated "you're not invited" error from the
// RecNet.Matchmaking+MatchmakingErrorCode enum (verified in
// Cpp2IL_CS/.../Matchmaking.cs line 166). Surfaces a clearer
// "this is a private match" message on the watch than
// 9 = RoomNotJoinable does.
return new MatchmakingResponseDto
{
ErrorCode = 26,
RoomInstance = new RoomInstanceDto(),
};
}
return new MatchmakingResponseDto
{
ErrorCode = 0,
RoomInstance = new RoomInstanceDto
{
RoomInstanceId = inst.InstanceId,
RoomId = inst.RoomId,
SubRoomId = inst.SubRoomId,
Location = inst.Location,
PhotonRegionId = inst.PhotonRegion,
PhotonRoomId = inst.PhotonRoomId,
Name = inst.Name,
MaxCapacity = inst.MaxCapacity,
IsPrivate = true,
IsInProgress = true,
DataBlob = inst.DataBlob,
},
};
}
// Invite-message Body can be any of three shapes depending on how /
// when it was written:
// 1. Modern (current): JSON {"inviteId":N,"name":"…","roomInstanceId":N}
// — extract roomInstanceId from the JSON property.
// 2. Legacy (pre-V2 fix): bare integer = roomInstanceId.
// 3. Ancient (pre-Type-column): text "Invite to private match
// (instance N)" — parse the trailing integer out of the literal.
// Honour all three so old rows in the DB still resolve.
private static bool TryParseRoomInstanceId(string body, out long instanceId)
{
instanceId = 0;
if (string.IsNullOrWhiteSpace(body)) return false;
if (body.TrimStart().StartsWith('{'))
{
try
{
using var doc = System.Text.Json.JsonDocument.Parse(body);
if (doc.RootElement.TryGetProperty("roomInstanceId", out var v))
{
// String form first — the SendInvite serializer now
// emits roomInstanceId as a quoted string because
// LitJson on the watch fails to parse a JSON object
// that contains a 64-bit numeric literal larger than
// int32 (returns null from the dict cast → NRE in
// Util.GetKey downstream). Number form stays
// supported for any old rows that pre-dated the
// stringify fix.
if (v.ValueKind == System.Text.Json.JsonValueKind.String
&& long.TryParse(v.GetString(), System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture, out var strInst))
{ instanceId = strInst; return true; }
if (v.ValueKind == System.Text.Json.JsonValueKind.Number
&& v.TryGetInt64(out var numInst))
{ instanceId = numInst; return true; }
}
}
catch { /* fall through to legacy formats */ }
}
if (long.TryParse(body, out instanceId)) return true;
var open = body.IndexOf("instance ", StringComparison.Ordinal);
if (open < 0) return false;
var tail = body[(open + "instance ".Length)..].TrimEnd(')', '.', ' ');
return long.TryParse(tail, out instanceId);
}
/// <summary>POST <c>/goto/player/{playerId}</c> — join the room
/// the target player is currently in.
///
/// For PUBLIC rooms: mirror the target's room name and rebuild a
/// fresh RoomInstance (the caller may end up in a different
/// matchmade instance, which is fine for public rooms).
///
/// For PRIVATE rooms: this route must NOT bypass the invite list.
/// Earlier we mirrored the target's RoomInstance verbatim under
/// the assumption "Go to player only shows up for friends" —
/// that's wrong: friend ≠ invited, and the owner picked Private
/// specifically to control who joins. Caller must be the owner
/// or on the invite list per <see cref="PrivateInstanceService.CanJoin"/>;
/// otherwise we refuse with errorCode=9 (RoomNotJoinable).</summary>
[HttpPost("/goto/player/{playerId:long}")]
public async Task<ActionResult<MatchmakingResponseDto>> GoToPlayer(long playerId)
{
var theirRoom = presence.GetRoom(playerId);
MatchmakingResponseDto resp;
if (theirRoom is null)
{
resp = await BuildResponseAsync("DormRoom");
}
else if (theirRoom.IsPrivate)
{
// RoomInstanceId IS the registry key for private matches —
// generated by PrivateInstanceService.Register. Caller must
// be invited (or owner) to land in the same Photon room.
var callerId = this.CurrentPlayerId();
if (callerId is null
|| !await privateInstances.CanJoinAsync(theirRoom.RoomInstanceId, callerId.Value))
{
logger.LogWarning(
"[goto-player] caller {Caller} tried to follow {Target} into private instance {Id} but isn't invited; rejecting",
callerId, playerId, theirRoom.RoomInstanceId);
// Notify the OWNER that someone tried to follow them in.
// Helps surface the rejection so the owner knows there's
// an interested party — they can choose to send an invite.
await notifications.PrivateInstanceJoinRejected(
ownerPlayerId: playerId, attemptedByPlayerId: callerId ?? 0);
return Ok(new MatchmakingResponseDto
{
ErrorCode = 26, // RoomInstanceIsPrivate
RoomInstance = new RoomInstanceDto(),
});
}
// Allowed — mirror the target's RoomInstance so we land
// in the same Photon match.
resp = new MatchmakingResponseDto { ErrorCode = 0, RoomInstance = theirRoom };
}
else
{
var roomName = await db.Rooms
.Where(r => r.Id == theirRoom.RoomId)
.Select(r => r.Name)
.FirstOrDefaultAsync();
resp = await BuildResponseAsync(roomName ?? "DormRoom");
}
await RecordResponseAsync(resp);
return Ok(resp);
}
/// <summary>Record the just-built response against the bearer-token
/// player so the next heartbeat reflects the room they joined.
/// Anonymous /goto calls (no JWT) skip recording — the heartbeat is
/// authenticated, so a match only happens when the same JWT shows
/// up there. Without this, heartbeats return roomInstance:null and
/// the watch trips its "presence out-of-sync" detector → "Error: -2".
/// Caller is responsible for any sub-room or private-instance
/// mutations BEFORE handing the response in here.</summary>
private async Task RecordResponseAsync(MatchmakingResponseDto response)
{
if (this.CurrentPlayerId() is not long playerId) return;
presence.SetRoom(playerId, response.RoomInstance);
// Real visit tracking: upsert the per-(room, player) row so
// we get distinct VisitorCount (unique players ever) AND the
// gross VisitCount (every join, including rejoins). Skip the
// canonical dorm (RoomId=1) so per-day boots don't skew it.
if (response.RoomInstance.RoomId != 1)
await RecordVisitAsync(playerId, response.RoomInstance.RoomId);
// Push a presence-update notification to every accepted-friend
// so their watch's "friends" list shows the new room. Cheap
// query — Relationships is small, and we only fan out to the
// actually-connected friends.
await NotifyFriendsOfMoveAsync(playerId, response.RoomInstance);
// NOTE: we INTENTIONALLY do NOT self-push SubscriptionUpdateRoom
// from /goto. The watch's Rooms.OnSubscriptionUpdateRoom
// (Cpp2IL_ISIL/.../RecNet/Rooms.txt:2401-2496) is gated on
// (Rooms.cachedRoomId == roomId && Rooms.cachedSubRoomId == subRoomId):
// the FIRST push for a (room, subRoom) tuple does the fetch +
// set_LocalRoomDetails; subsequent pushes for the same tuple
// exit early at line 055, NEVER firing the
// RoomDetailsUpdated event. The save coroutine's
// RoomDetailsUpdatedCallback is registered to that event
// (RoomPersistenceManager+DisplayClass174_0:14-48) and only
// sets the roomDetailsUpdated flag when the event fires —
// which is the ONLY thing that flips the b__4 predicate true
// and dismisses the save spinner. Pre-pushing here primes the
// cached tuple so the save's own push gets deduplicated → no
// event → spinner wedges forever. Let the save's push be the
// first one for any (room, subRoom).
}
/// <summary>Mutate the response so it represents a fresh invite-only
/// match: forks the PhotonRoomId with a per-(player, nonce) suffix
/// so randos can't matchmake into it, sets isPrivate=true, and
/// registers the instance in <see cref="PrivateInstanceService"/>
/// so invitees coming in via /goto/instance/{id} can find this
/// exact Photon match.</summary>
private async Task ApplyPrivateInstanceAsync(MatchmakingResponseDto resp, long subRoomId)
{
if (this.CurrentPlayerId() is not long ownerId)
{
// Anonymous private-instance creation makes no sense — the
// owner is the keying signal for the photon room name and
// for invite resolution. Fall through with isPrivate flag
// only; the watch won't be able to invite anyone but at
// least won't mismatch the requested form value.
resp.RoomInstance.IsPrivate = true;
return;
}
var inst = await privateInstances.RegisterAsync(
roomId: resp.RoomInstance.RoomId,
subRoomId: subRoomId,
ownerPlayerId: ownerId,
baseName: resp.RoomInstance.Name.ToLowerInvariant(),
location: resp.RoomInstance.Location,
dataBlob: resp.RoomInstance.DataBlob,
photonRegion: resp.RoomInstance.PhotonRegionId,
maxCapacity: resp.RoomInstance.MaxCapacity);
resp.RoomInstance.RoomInstanceId = inst.InstanceId;
resp.RoomInstance.PhotonRoomId = inst.PhotonRoomId;
resp.RoomInstance.IsPrivate = true;
logger.LogInformation(
"[goto-private] room={Room} sub={Sub} owner={Owner} → instId={Id} photon={Photon}",
resp.RoomInstance.RoomId, subRoomId, ownerId, inst.InstanceId, inst.PhotonRoomId);
}
/// <summary>Upsert the (room, player) <see cref="RoomVisitEntity"/>
/// row for this join. New rows bump
/// <see cref="RoomEntity.VisitorCount"/> (unique-visitor metric);
/// every join — new or repeat — bumps
/// <see cref="RoomEntity.VisitCount"/> + the per-player counter.</summary>
private async Task RecordVisitAsync(long playerId, long roomId)
{
var visit = await db.RoomVisits
.FirstOrDefaultAsync(v => v.RoomId == roomId && v.PlayerId == playerId);
var isFirst = visit is null;
if (isFirst)
{
visit = new DorkNet.Server.Data.Entities.RoomVisitEntity
{
RoomId = roomId,
PlayerId = playerId,
FirstVisitAt = DateTime.UtcNow,
};
db.RoomVisits.Add(visit);
}
visit!.LastVisitAt = DateTime.UtcNow;
visit.VisitCount += 1;
// Counter bumps go through ExecuteUpdate to avoid re-reading
// the room — atomic increment under concurrent /goto calls.
// Also bumps HotScore so the "Hot" tab self-reorders as
// players actually use rooms. Weight: each first-time visit
// adds 2.0, each repeat adds 0.25 (revisits matter less than
// new players). Cheers also feed in via PlayerStateController
// / MissingEndpointsController by the same factor.
if (isFirst)
{
await db.Rooms
.Where(r => r.Id == roomId)
.ExecuteUpdateAsync(s => s
.SetProperty(r => r.VisitCount, r => r.VisitCount + 1)
.SetProperty(r => r.VisitorCount, r => r.VisitorCount + 1)
.SetProperty(r => r.HotScore, r => r.HotScore + 2.0));
}
else
{
await db.Rooms
.Where(r => r.Id == roomId)
.ExecuteUpdateAsync(s => s
.SetProperty(r => r.VisitCount, r => r.VisitCount + 1)
.SetProperty(r => r.HotScore, r => r.HotScore + 0.25));
}
await db.SaveChangesAsync();
}
private async Task NotifyFriendsOfMoveAsync(long playerId, RoomInstanceDto room)
{
var friendIds = await db.Relationships
.Where(r => r.Status == DorkNet.Server.Data.Entities.RelationshipStatus.Friend &&
(r.RequesterId == playerId || r.TargetId == playerId))
.Select(r => r.RequesterId == playerId ? r.TargetId : r.RequesterId)
.ToListAsync();
logger.LogInformation(
"[goto-fanout] player={PlayerId} moved to room={RoomId}/{RoomName} → notifying {FriendCount} friend(s)",
playerId, room.RoomId, room.Name, friendIds.Count);
if (friendIds.Count == 0) return;
await notifications.PlayerEnteredRoom(friendIds, playerId, room);
}
private async Task<MatchmakingResponseDto> BuildResponseAsync(string roomName)
{
// Try to resolve from the Rooms DB first — that gives us the real
// RoomId (so subsequent /api/rooms/v4/details/{id} hits the same
// entity) and the canonical LocationReplicationId. Fall back to the
// baked-in DormRoom mapping for unknown names.
long roomId;
string locationId;
if (roomName.Equals("DormRoom", StringComparison.OrdinalIgnoreCase))
{
// Each player has their own dorm row (created at signup,
// lazy-created here for legacy accounts that pre-date the
// per-player dorm work). Resolve to the caller's personal
// dorm so /api/rooms/v4/details/{id} hits a real Rooms.id
// and storage.rec.net/upload's room lookup succeeds.
if (this.CurrentPlayerId() is long callerPid)
{
var personal = await rooms.EnsurePersonalDormAsync(callerPid);
roomId = personal.Id;
locationId = personal.LocationReplicationId;
}
else
{
// Anonymous /goto/room/DormRoom shouldn't happen in the
// 2020 watch (the watch always authenticates first), but
// fall back to the canonical scene id for safety.
roomId = 1;
locationId = "76d98498-60a1-430c-ab76-b54a29b7a163";
}
}
else
{
var room = await rooms.GetByNameAsync(roomName);
if (room is not null)
{
roomId = room.Id;
locationId = room.LocationReplicationId;
}
else
{
roomId = MapRoomNameToId(roomName);
locationId = MapRoomNameToReplicationId(roomName);
}
}
var photonRegion = (config["Photon:CloudRegion"] ?? "us").ToLowerInvariant();
// DormRoom is per-player (each account has its own dorm); Rec Center
// and other public rooms are shared by name. Including the player's
// id in the dorm room name avoids the case where rebuilding the DB
// gives a fresh accountId but Photon still considers the old
// "recroom_dormroom_1" room locked from the previous account's
// session — manifested as a successful /goto but no actual scene
// load (black screen).
var isDorm = roomName.Equals("DormRoom", StringComparison.OrdinalIgnoreCase);
var dormOwnerId = this.CurrentPlayerId();
var photonRoom = (isDorm && dormOwnerId is long ownerForName)
? $"^dormroom_p{ownerForName}"
: $"^{roomName.ToLowerInvariant()}_{roomId}";
var dataBlob = await ResolveInitialDataBlobAsync(roomId, isDorm, dormOwnerId);
var resp = new MatchmakingResponseDto
{
ErrorCode = 0,
RoomInstance = new RoomInstanceDto
{
// Deterministic: same name → same instance id, every time.
RoomInstanceId = roomId * 100_000L,
RoomId = roomId,
SubRoomId = 0,
// location is a STRING replicationId, parsed via
// RoomSceneLocations.TryGetRoomSceneLocationById (RVA 0x10F57D0).
// The value is matched against AGRoomRuntimeConfig.Room.ReplicationId
// entries baked into resources.assets. Sending an int (or wrong
// string) makes RoomInstance.Deserialize log "RecNet game session
// contains unknown scene location ID" and the scene never loads.
Location = locationId,
// photonRegionId is a STRING that maps to CloudRegionCode enum
// (eu, us, asia, jp, none, au, usw, sa, cae, kr, in, ru, rue).
// Lowercase enum names — that's the wire format Photon uses too.
PhotonRegionId = photonRegion,
PhotonRoomId = photonRoom,
Name = roomName,
MaxCapacity = 8,
IsFull = false,
IsPrivate = false,
IsInProgress = true,
DataBlob = dataBlob,
},
};
logger.LogInformation(
"[goto] room instance room={RoomId} name={RoomName} player={PlayerId} dataBlob={DataBlob}",
roomId, roomName, dormOwnerId, dataBlob);
// Dorms are always private — the per-player photon-room name
// (^dormroom_p{owner}) prevents matchmaking, but without
// IsPrivate=true the /goto/player path mirrors the room
// verbatim and lets uninvited friends drop straight in. Stamp
// the response with a deterministic-id PrivateInstance owned
// by the dorm's owner so CanJoinAsync gates non-owners.
if (isDorm && dormOwnerId is long ownerPid)
{
var dorm = await privateInstances.EnsureForDormAsync(
ownerPlayerId: ownerPid,
roomId: resp.RoomInstance.RoomId,
subRoomId: 0,
baseName: resp.RoomInstance.Name,
location: resp.RoomInstance.Location,
dataBlob: resp.RoomInstance.DataBlob,
photonRegion: resp.RoomInstance.PhotonRegionId,
maxCapacity: resp.RoomInstance.MaxCapacity,
photonRoomId: resp.RoomInstance.PhotonRoomId);
resp.RoomInstance.RoomInstanceId = dorm.InstanceId;
resp.RoomInstance.IsPrivate = true;
}
return resp;
}
private async Task<string> ResolveInitialDataBlobAsync(long roomId, bool isDorm, long? dormOwnerId)
{
var room = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return string.Empty;
if (isDorm && dormOwnerId is long ownerId)
{
var dormBlob = await db.DormStates.AsNoTracking()
.Where(d => d.PlayerId == ownerId)
.Select(d => d.CurrentDataBlobName)
.FirstOrDefaultAsync();
if (!string.IsNullOrWhiteSpace(dormBlob)) return dormBlob;
}
if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName;
var customisable = room.IsDormRoom || room.CreatorPlayerId != 1;
return customisable ? $"room_{room.Id}_v1.dat" : string.Empty;
}
/// <summary>
/// Stable id-per-room-name map for well-known rooms; unknown names get
/// a hash-based id so each room name routes to its own roomId space.
/// </summary>
private static long MapRoomNameToId(string name) => name.ToLowerInvariant() switch
{
"dormroom" => 1,
"rec_center" or "reccenter" => 2,
"lobby" => 3,
_ => Math.Abs(name.GetHashCode()) % 100_000 + 1000,
};
/// <summary>
/// Maps a friendly room name to the RoomScene.RoomSceneLocationId GUID
/// baked into resources.assets's AGRoomRuntimeConfig.Rooms[].Scenes[0].
/// This is the foreign key that links a Room's "Home" scene to an
/// AGRoomRuntimeConfig.Locations entry — and is what
/// AGRoomSettings.TryGetRoomSceneLocationById (RVA 0x10F57D0) matches
/// against Location.ReplicationId.
///
/// The asset stores each Room as [Name] [Room.ReplicationId]
/// [Description] [Scene.Name="Home"] [Scene.ReplicationId]
/// [Scene.RoomSceneLocationId] — so the THIRD GUID after the room name
/// is the one we want. Earlier I sent the FIRST (Room.ReplicationId)
/// which is a different namespace entirely; the deserializer logged
/// "unknown scene location ID" because no Location had that GUID.
/// </summary>
private static string MapRoomNameToReplicationId(string name) => name.ToLowerInvariant() switch
{
"dormroom" => "76d98498-60a1-430c-ab76-b54a29b7a163",
"rec_center" or "reccenter" => "cbad71af-0831-44d8-b8ef-69edafa841f6",
"3dcharades" or "charades" => "4078dfed-24bb-4db7-863f-578ba48d726b",
"discgolflake" or "lake" => "f6f7256c-e438-4299-b99e-d20bef8cf7e0",
"discgolfpropulsion" or "propulsion" => "d9378c9f-80bc-46fb-ad1e-1bed8a674f55",
"dodgeball" => "3d474b26-26f7-45e9-9a36-9b02847d5e6f",
"paddleball" => "d89f74fa-d51e-477a-a425-025a891dd499",
"river" => "e122fe98-e7db-49e8-a1b1-105424b6e1f0",
"homestead" => "a785267d-c579-42ea-be43-fec1992d1ca7",
"quarry" => "ff4c6427-7079-4f59-b22a-69b089420827",
"clearcut" => "380d18b5-de9c-49f3-80f7-f4a95c1de161",
"spillway" => "58763055-2dfb-4814-80b8-16fac5c85709",
"drive-in" or "drivein" => "65ddbb48-5a01-4e3e-972d-e5c7419e2bc3",
"soccer" => "7e01cfe0-820a-406f-b1b3-0a5bf575235c",
"lasertag" => "6d5eea4b-f069-4ed0-9916-0e2f07df0d03",
"cyberjunkcity" or "lasertagcyberjunk" => "9d6456ce-6264-48b4-808d-2d96b3d91038",
"recroyalesquads" => "253fa009-6e65-4c90-91a1-7137a56a267f",
"recroyalesolos" => "85b43509-77f7-3884-3a6b-9a4e737d6d11",
_ => "76d98498-60a1-430c-ab76-b54a29b7a163", // DormRoom
};
}
public class MatchmakingResponseDto
{
[JsonPropertyName("errorCode")]
public int ErrorCode { get; set; }
[JsonPropertyName("roomInstance")]
public RoomInstanceDto? RoomInstance { get; set; } = new();
}
/// <summary>
/// Mirrors RecNet.RoomInstance — Deserialize at RVA 0x114DAB0. Keys
/// verified by disassembly. JSON casing is camelCase throughout.
/// </summary>
public class RoomInstanceDto
{
[JsonPropertyName("roomInstanceId")]
public long RoomInstanceId { get; set; }
[JsonPropertyName("roomId")]
public long RoomId { get; set; }
[JsonPropertyName("subRoomId")]
public long SubRoomId { get; set; }
// STRING RoomSceneLocationId GUID — foreign-keys to
// AGRoomRuntimeConfig.Locations[].ReplicationId. Resolved via
// AGRoomSettings.TryGetRoomSceneLocationById (RVA 0x10F57D0). Default
// = DormRoom's home-scene location ID extracted from resources.assets
// at offset 0x65107ec.
[JsonPropertyName("location")]
public string Location { get; set; } = "76d98498-60a1-430c-ab76-b54a29b7a163";
// STRING enum-name — CloudRegionCode is parsed by name: eu, us, asia,
// jp, none, au, usw, sa, cae, kr, in, ru, rue. Lowercase.
[JsonPropertyName("photonRegionId")]
public string PhotonRegionId { get; set; } = "us";
[JsonPropertyName("photonRoomId")]
public string PhotonRoomId { get; set; } = string.Empty;
[JsonPropertyName("maxCapacity")]
public int MaxCapacity { get; set; } = 8;
[JsonPropertyName("isFull")]
public bool IsFull { get; set; } = false;
[JsonPropertyName("isPrivate")]
public bool IsPrivate { get; set; } = false;
[JsonPropertyName("isInProgress")]
public bool IsInProgress { get; set; } = true;
// Optional keys per disassembly (Util.GetKeyOrDefault).
[JsonPropertyName("dataBlob")]
public string DataBlob { get; set; } = string.Empty;
[JsonPropertyName("eventId")]
public long EventId { get; set; } = 0;
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
/// <summary>
/// Returns a copy of <paramref name="room"/> with <c>photonRoomId</c>
/// and <c>dataBlob</c> blanked out — safe to send to OTHER players
/// (friend-list presence, push updates). The watch only needs
/// <c>roomId</c> / <c>name</c> / <c>isPrivate</c> to render the
/// friend-list row; it joins via <c>/goto/player/{id}</c> or
/// <c>/goto/instance/{id}</c>, both of which enforce
/// <see cref="PrivateInstanceService.CanJoinAsync"/>.
///
/// Without this redaction, anyone could read another player's
/// presence and use the leaked <c>photonRoomId</c> to JoinByName
/// in Photon directly, bypassing our invite-list check.
/// </summary>
public static RoomInstanceDto? Redact(RoomInstanceDto? room)
{
if (room is null) return null;
return new RoomInstanceDto
{
RoomInstanceId = room.RoomInstanceId,
RoomId = room.RoomId,
SubRoomId = room.SubRoomId,
Location = room.Location,
PhotonRegionId = room.PhotonRegionId,
PhotonRoomId = string.Empty,
MaxCapacity = room.MaxCapacity,
IsFull = room.IsFull,
IsPrivate = room.IsPrivate,
IsInProgress = room.IsInProgress,
DataBlob = string.Empty,
EventId = room.EventId,
Name = room.Name,
};
}
}
@@ -0,0 +1,275 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.GameSessions;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Match;
/// <summary>
/// match.rec.net — dedicated matchmaking service used by later 2020 builds.
/// Delegates to the Postgres-backed GameSessionService.
/// </summary>
[ApiController]
[Authorize]
public class MatchController(
GameSessionService sessionService,
PlayerPresenceService presence,
PrivateInstanceService privateInstances,
DorkNetDbContext db,
ILogger<MatchController> logger) : ControllerBase
{
/// <summary>POST <c>match.*/roominstance/{id}/reportjoinresult</c> —
/// the watch fires this AFTER its Photon connect+join attempt,
/// reporting how it went via <c>RecNet.Matchmaking+GameJoinResult</c>
/// (Matchmaking.cs:121):
/// 0 = Success, 1 = FailedToConnectToRegion,
/// 2 = FailedToConnectToRoom, 3 = FailedToSpawnPlayer.
/// Body is form-urlencoded <c>result=N</c>. Watch ignores the
/// response body and just needs 2xx (Matchmaking.txt:6668-6699), so
/// we mostly use this for visibility into Photon-side issues —
/// without it, all we see in the catch-all is an opaque "200, empty
/// reply" and no clue why a player crashed back to dorm.</summary>
[HttpPost("/roominstance/{instanceId:long}/reportjoinresult")]
public IActionResult ReportJoinResult(long instanceId, [FromForm(Name = "result")] int? result)
{
var code = result ?? -1;
var label = code switch
{
0 => "Success",
1 => "FailedToConnectToRegion",
2 => "FailedToConnectToRoom",
3 => "FailedToSpawnPlayer",
_ => $"Unknown({code})",
};
var pid = this.RequireCurrentPlayerId();
if (code == 0)
{
logger.LogInformation(
"[joinresult] player={Player} instance={Instance} OK ({Label})",
pid, instanceId, label);
}
else
{
logger.LogWarning(
"[joinresult] player={Player} instance={Instance} FAILED code={Code} ({Label}) — Photon-side error, check Photon AppId/region/auth",
pid, instanceId, code, label);
}
return Ok();
}
/// <summary>PUT <c>/roominstance/{id}/inprogress</c> — fired by
/// <c>RecNet.Matchmaking.SetGameInProgress(bool inProgress)</c>
/// (Matchmaking.cs:333). Body is form-urlencoded
/// <c>inProgress=true|false</c>. The watch uses this to mark a
/// match as "in progress" so newcomers are sent to a fresh instance
/// instead of joining a game already underway (e.g. paintball
/// during a round). Our matchmaker doesn't gate on this yet — log
/// + 200 to take it off the catch-all.</summary>
[HttpPut("/roominstance/{instanceId:long}/inprogress")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public IActionResult SetInProgress(
long instanceId,
[FromForm(Name = "inProgress")] bool? inProgress)
{
var pid = this.RequireCurrentPlayerId();
logger.LogInformation(
"[set-inprogress] player={Player} instance={Instance} inProgress={Flag}",
pid, instanceId, inProgress);
return Ok();
}
[HttpPost("/v1/find")]
public async Task<ActionResult<JoinGameSessionResponse>> Find([FromBody] JoinRandomGameSessionRequest req)
{
var session = await sessionService.JoinOrCreateAsync(req.RoomId, req.ActivityLevelId, req.RegionId);
return Ok(new JoinGameSessionResponse { Result = JoinGameErrorCode.Success, GameSession = session });
}
[HttpPost("/v1/join/{sessionId:long}")]
public async Task<ActionResult<JoinGameSessionResponse>> Join(long sessionId)
{
var session = await sessionService.GetByIdAsync(sessionId);
if (session is null) return Ok(new JoinGameSessionResponse { Result = JoinGameErrorCode.NotFound });
if (session.IsFull) return Ok(new JoinGameSessionResponse { Result = JoinGameErrorCode.Full });
return Ok(new JoinGameSessionResponse { Result = JoinGameErrorCode.Success, GameSession = session });
}
[HttpPost("/v1/leave/{sessionId:long}")]
public async Task<IActionResult> Leave(long sessionId)
{
await sessionService.PlayerLeftAsync(sessionId);
return Ok();
}
[HttpGet("/v1/session/{sessionId:long}")]
public async Task<ActionResult<GameSession>> GetSession(long sessionId)
{
var session = await sessionService.GetByIdAsync(sessionId);
if (session is null) return NotFound();
return Ok(session);
}
/// <summary>GET <c>match.*/room/{id}/instances</c> — the
/// "RoomInstanceBrowser" the watch shows when you tap a room tile.
/// Returns the currently-active instances so the player can see
/// which one their friends are in (and join it) instead of being
/// matchmade into a fresh empty room. Verified URL at
/// Matchmaking.txt:6286; response is a
/// <c>List&lt;SimpleRoomInstance&gt;</c> per
/// <c>SimpleRoomInstance.cs</c>: RoomInstanceId/RoomId/SubRoomId,
/// IsFull, CreatedAt, PlayerIds, SubroomName, PlayerCount,
/// HasModPresent, HashedInstanceId.
///
/// We derive instances from PlayerPresenceService's per-player
/// RoomInstanceDto: every distinct PhotonRoomId in the same RoomId
/// is one instance, with the players currently presenced there.
/// PrivateInstances are surfaced as well so the inviter and
/// invitee see the same room in the browser.</summary>
[HttpGet("/room/{roomId:long}/instances")]
[AllowAnonymous]
public async Task<IActionResult> RoomInstances(long roomId)
{
var presenced = presence.EnumerateActiveInstances(roomId).ToList();
// Only surface the caller's OWN private instances — we never
// leak someone else's PhotonRoomId into the browser. The Photon
// join check still requires CanJoinAsync, but redacting at the
// list level matches the existing RoomInstanceDto.Redact policy.
var pid = this.CurrentPlayerId() ?? 0;
var privates = pid == 0
? new List<Data.Entities.PrivateInstanceEntity>()
: await db.PrivateInstances
.Where(p => p.RoomId == roomId && p.OwnerPlayerId == pid)
.ToListAsync();
// Merge: presence rows are authoritative (player currently
// there); add any owned private instance the inviter created
// but nobody's joined yet so the inviter can still see it.
var byKey = new Dictionary<string, SimpleInstance>(StringComparer.Ordinal);
foreach (var (room, playerIds) in presenced)
{
if (string.IsNullOrEmpty(room.PhotonRoomId)) continue;
byKey[room.PhotonRoomId] = new SimpleInstance(
RoomInstanceId: room.RoomInstanceId,
RoomId: room.RoomId,
SubRoomId: room.SubRoomId,
IsFull: false,
CreatedAt: DateTime.UtcNow,
PlayerIds: playerIds.Select(p => (int)p).ToList(),
SubroomName: room.Name ?? string.Empty,
PlayerCount: playerIds.Count,
HasModPresent: false,
HashedInstanceId: room.PhotonRoomId);
}
foreach (var p in privates)
{
if (string.IsNullOrEmpty(p.PhotonRoomId) || byKey.ContainsKey(p.PhotonRoomId)) continue;
byKey[p.PhotonRoomId] = new SimpleInstance(
RoomInstanceId: p.Id,
RoomId: p.RoomId,
SubRoomId: p.SubRoomId,
IsFull: false,
CreatedAt: DateTime.SpecifyKind(p.CreatedAt, DateTimeKind.Utc),
PlayerIds: new List<int>(),
SubroomName: p.Name,
PlayerCount: 0,
HasModPresent: false,
HashedInstanceId: p.PhotonRoomId);
}
// Wire shape: SimpleRoomInstance.Deserialize reads CAMELCASE
// keys — verified at Cpp2IL_ISIL/.../RecNet/SimpleRoomInstance.txt
// lines 765-808 ({roomInstanceId, roomId, subRoomId, isFull,
// createdAt, playerIds}). SubroomName / PlayerCount /
// HasModPresent / HashedInstanceId are populated locally on the
// watch (PlayerIds.Count, local hash, etc.) — not read from
// JSON, so we don't send them. Earlier PascalCase response made
// every field default to 0/null on the watch, breaking the
// browser.
return Ok(byKey.Values.Select(i => new
{
roomInstanceId = i.RoomInstanceId,
roomId = i.RoomId,
subRoomId = i.SubRoomId,
isFull = i.IsFull,
createdAt = i.CreatedAt,
playerIds = i.PlayerIds.ToArray(),
}));
}
private sealed record SimpleInstance(
long RoomInstanceId, long RoomId, long SubRoomId, bool IsFull,
DateTime CreatedAt, List<int> PlayerIds, string SubroomName,
int PlayerCount, bool HasModPresent, string HashedInstanceId);
/// <summary>DELETE <c>match.*/invite/{id}</c> — Matchmaking.cs
/// DeclineRoomInvite. Verified URL at Matchmaking.txt:6505 (method
/// enum 4 = DELETE). The id is a MessageEntity row id (V2 game and
/// party invites are stored as Type 6/7 messages). Mark it read so the inviter's
/// inbox stops nagging, and remove the invitee from the private
/// instance's allow-list so they can't change their mind without
/// a fresh invite.</summary>
[HttpDelete("/invite/{messageId:long}")]
public async Task<IActionResult> DeclineInvite(long messageId)
{
var pid = this.RequireCurrentPlayerId();
var msg = await db.Messages
.FirstOrDefaultAsync(m => m.Id == messageId && m.RecipientPlayerId == pid);
if (msg is null) return NotFound();
if (msg.ReadAt is null)
{
msg.ReadAt = DateTime.UtcNow;
}
// Current invite messages carry the bare roomInstanceId in
// Data/Body. Older local DBs may still have the legacy
// "Invite to private match (instance N)" text, so accept both.
if (TryParseRoomInstanceId(msg.Body, out var instId))
{
await privateInstances.RevokeInviteAsync(instId, pid);
}
await db.SaveChangesAsync();
logger.LogInformation("[invite] declined messageId={Id} by player={Player}", messageId, pid);
return Ok();
}
// Same three-shape parser as GoToController.TryParseRoomInstanceId
// (see that file for the rationale): JSON for current Type-6/7
// invites, bare int for the pre-fix shape, "Invite to private match
// (instance N)" text for ancient rows.
private static bool TryParseRoomInstanceId(string body, out long instanceId)
{
instanceId = 0;
if (string.IsNullOrWhiteSpace(body)) return false;
if (body.TrimStart().StartsWith('{'))
{
try
{
using var doc = System.Text.Json.JsonDocument.Parse(body);
if (doc.RootElement.TryGetProperty("roomInstanceId", out var v))
{
// See GoToController.TryParseRoomInstanceId for the
// rationale on accepting both string and number forms.
if (v.ValueKind == System.Text.Json.JsonValueKind.String
&& long.TryParse(v.GetString(), System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture, out var strInst))
{ instanceId = strInst; return true; }
if (v.ValueKind == System.Text.Json.JsonValueKind.Number
&& v.TryGetInt64(out var numInst))
{ instanceId = numInst; return true; }
}
}
catch { /* fall through to legacy formats */ }
}
if (long.TryParse(body, out instanceId)) return true;
var open = body.IndexOf("instance ", StringComparison.Ordinal);
if (open < 0) return false;
var tail = body[(open + "instance ".Length)..].TrimEnd(')', '.', ' ');
return long.TryParse(tail, out instanceId);
}
}
@@ -0,0 +1,374 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Services;
using System.Text.Json.Serialization;
namespace DorkNet.Server.Controllers.Match;
/// <summary>
/// match.rec.net/player/* — endpoints that operate on the local player's
/// matchmaking session. These were previously falling through to the
/// GlobalCatchAllController which returned the kitchen-sink "RecNetResult
/// shotgun" body. That body lacks the required keys for the actual response
/// types and the client crashes with KeyNotFoundException.
///
/// The endpoints here use the same camelCase PlayerPresence shape as
/// PlayerPresenceController (verified at RVA 0x1458920).
/// </summary>
[ApiController]
public class MatchPlayerController(
PlayerPresenceService presence,
NotificationService notifications,
DorkNetDbContext db,
ILogger<MatchPlayerController> log) : ControllerBase
{
/// <summary>
/// POST <c>/player/login</c> — the 2020 watch starts every session by
/// posting here with a fresh <c>LoginLock</c> GUID it generated client-side.
/// We persist the lock as the canonical "active session" marker for this
/// player so subsequent heartbeats can be validated against it; a NEW
/// login overwrites the lock and any older session's heartbeats start
/// failing, which is the intended kick-the-first-session behaviour.
/// </summary>
[HttpPost("/player/login")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> PlayerLogin([FromForm(Name = "LoginLock")] string? loginLock)
{
var playerId = TryGetCurrentPlayerId();
if (playerId != 0 && !string.IsNullOrWhiteSpace(loginLock))
{
var replaced = presence.SetLock(playerId, loginLock);
if (replaced)
{
log.LogWarning(
"[player-login] concurrent session for player={PlayerId}; kicking old session(s) via ModerationKick push",
playerId);
// The new session hasn't opened its SignalR socket yet
// (negotiate fires AFTER /player/login on the watch's
// boot sequence — verified in the live trace), so the
// ModerationKick push hits only the OLDER session's
// existing connections. They'll show the "you've been
// kicked" dialog and gracefully disconnect via the
// watch's LocalModerationKickSelf path.
await notifications.KickStaleSession(playerId,
"Your account was signed in on another device.");
}
}
return Ok(Array.Empty<object>());
}
/// <summary>
/// POST /player/logout — fires when the client gives up on a session
/// (e.g. Photon connection couldn't be established, client aborts).
/// Clear the cached presence so the next /goto starts fresh.
/// </summary>
[HttpPost("/player/logout")]
public IActionResult PlayerLogout()
{
var playerId = TryGetCurrentPlayerId();
if (playerId != 0) presence.Clear(playerId);
return Ok(new { success = true, error = "" });
}
/// <summary>
/// POST <c>/player/notifydisconnect</c> — fired by
/// <c>RecNet.Matchmaking.NotifyPlayerDisconnected(int otherPlayerId)</c>
/// (Matchmaking.cs:326). Body is form-urlencoded
/// <c>otherPlayerId=&lt;id&gt;</c>. The watch uses this to tell the
/// server "I just saw this other Photon participant disappear" —
/// e.g. when someone leaves a room via the menu. Real Rec Room
/// likely used it to GC sticky presence rows; we just acknowledge
/// and log so it stops falling through to GlobalCatchAll.
///
/// Returns 200 with no body (BestHTTP ignores the response per
/// Matchmaking.txt:6668-6699).
/// </summary>
[HttpPost("/player/notifydisconnect")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public IActionResult NotifyDisconnect([FromForm(Name = "otherPlayerId")] int? otherPlayerId)
{
var caller = TryGetCurrentPlayerId();
log.LogInformation(
"[player-notify-disconnect] caller={Caller} reported other={Other} left",
caller, otherPlayerId);
return Ok();
}
/// <summary>
/// POST /player/heartbeat — every ~5–30s while connected. Client expects
/// a PlayerPresence response that mirrors its own cached state.
/// CRITICAL: must include the player's CURRENT roomInstance, not null.
/// Returning null while the client knows it's in DormRoom triggers
/// "presence heartbeat response indicates local presence is out-of-sync"
/// repeatedly → eventual "Error: -2" disconnect dialog.
///
/// We pull the current room from PlayerPresenceService, which records
/// each successful /goto/room/* call.
/// </summary>
[HttpPost("/player/heartbeat")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<ActionResult<PresenceDto>> Heartbeat([FromForm(Name = "LoginLock")] string? loginLock)
{
// We track the LoginLock per player in /player/login (still useful
// for admin diagnostics + future session-management) but
// intentionally DO NOT 401 here on stale locks. Returning 401 on
// a heartbeat causes the watch to yank the player out of the
// current Photon room mid-session — symptoms include
// "Cannot send messages when not connected" + "Head/LeftHand/
// RightHand being destroyed" + "presence heartbeat response
// indicates local presence is out-of-sync" — which is worse than
// letting two sessions co-exist briefly. If we want true
// single-session enforcement we'll do it via SignalR push to the
// older session and a graceful client-side disconnect, not via
// an HTTP 401 mid-heartbeat.
var playerId = TryGetCurrentPlayerId();
var room = await GetPresenceRoomForResponseAsync(playerId);
return Ok(new PresenceDto
{
PlayerId = playerId,
StatusVisibility = 1,
DeviceClass = 0,
VrMovementMode = 0,
RoomInstance = room,
});
}
[HttpPut("/player/statusvisibility")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<ActionResult<PresenceDto>> SetStatusVisibility([FromForm(Name = "statusVisibility")] int? value)
{
var playerId = TryGetCurrentPlayerId();
var room = await GetPresenceRoomForResponseAsync(playerId);
return Ok(new PresenceDto
{
PlayerId = playerId,
StatusVisibility = value ?? 1,
DeviceClass = 0,
VrMovementMode = 0,
RoomInstance = room,
});
}
[HttpPut("/player/vrmovementmode")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<ActionResult<PresenceDto>> SetVrMovementMode([FromForm(Name = "vrMovementMode")] int? value)
{
var playerId = TryGetCurrentPlayerId();
var room = await GetPresenceRoomForResponseAsync(playerId);
return Ok(new PresenceDto
{
PlayerId = playerId,
StatusVisibility = 1,
DeviceClass = 0,
VrMovementMode = value ?? 0,
RoomInstance = room,
});
}
/// <summary>
/// GET <c>/player/avoidjuniors</c> — returns a primitive boolean.
/// Verified against
/// Cpp2IL_ISIL/.../RecNet/Matchmaking.txt:7728 (URL) and 7737
/// (<c>PromiseExtensions.ExpectPrimitiveResponse</c>) — the watch
/// expects a bare JSON <c>true</c>/<c>false</c>, not an array or
/// object. Returning <c>[]</c> from the catch-all triggered
/// "Failed to get avoid juniors status: Malformed Response" + a
/// Convert.ChangeType InvalidCastException, which left the
/// matchmaking init in a never-completes state on some boot flows.
/// </summary>
[HttpGet("/player/avoidjuniors")]
public ActionResult<bool> GetAvoidJuniors() => Ok(false);
/// <summary>PUT <c>/player/avoidjuniors</c> — body
/// <c>avoidJuniors=true|false</c> (Matchmaking.txt:7883). Stub for
/// now; the multi-tenant server doesn't currently track this
/// preference per-player. Returns the new value as a JSON
/// primitive so the watch's ExpectPrimitiveResponse is happy.</summary>
[HttpPut("/player/avoidjuniors")]
[HttpPost("/player/avoidjuniors")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public ActionResult<bool> SetAvoidJuniors([FromForm(Name = "avoidJuniors")] bool? value)
=> Ok(value ?? false);
/// <summary>POST <c>/player/photonregionpings</c> — watch reports
/// its measured Photon region ping vector so the server can pick
/// the best region for this player's matchmaking. Body is form-
/// urlencoded: <c>region=eu&amp;ping=42&amp;region=us&amp;ping=80&amp;...</c>
/// (one (region, ping) pair per row, repeated). Persists the
/// freshest measurement per (player, region) pair in
/// <see cref="PlayerPresenceService"/>'s in-memory map — the
/// matchmaking GoTo flow reads this to bias instance creation
/// toward the player's lowest-ping region.
/// Verified URL at Matchmaking.txt:7280.</summary>
[HttpPost("/player/photonregionpings")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public IActionResult PhotonRegionPings()
{
var playerId = TryGetCurrentPlayerId();
if (playerId == 0) return Ok();
var form = Request.Form;
var regions = form["region"].ToArray();
var pings = form["ping"].ToArray();
var pairs = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < Math.Min(regions.Length, pings.Length); i++)
{
var r = regions[i]?.Trim();
if (string.IsNullOrEmpty(r)) continue;
if (!int.TryParse(pings[i], out var p)) continue;
// Sanity bound — discard wild values from misbehaving clients.
pairs[r] = Math.Clamp(p, 0, 2000);
}
if (pairs.Count > 0)
presence.SetPhotonRegionPings(playerId, pairs);
log.LogInformation("[player-region-pings] player={Player} regions={Count}", playerId, pairs.Count);
return Ok();
}
// Heartbeat / logout are unauthenticated endpoints (the client hits them
// before any token is acquired in some boot flows), so we use the
// optional `CurrentPlayerId()` extension and treat 0 as "anonymous".
// Cast to int because the wire shape's PlayerId field is an int — fits
// every accountId we mint (random in [100_000, 9_999_999)).
private int TryGetCurrentPlayerId() =>
this.CurrentPlayerId() is long id ? (int)id : 0;
private async Task<RoomInstanceDto?> GetPresenceRoomForResponseAsync(int playerId)
{
if (playerId == 0) return null;
var room = presence.GetRoom(playerId);
if (room is null)
{
// Boot-time race: the watch hits /player/heartbeat before
// /goto/room/DormRoom has finished recording presence. The
// watch's OnPresenceHeartbeatResponse compares
// (cachedRoomInstance != null) XOR (serverRoomInstance != null)
// (Cpp2IL_ISIL/.../RecNet/Matchmaking.txt:8584) — if its local
// cache already has a dorm but we hand back null, it fires
// "RecNet presence heartbeat response indicates local presence
// is out-of-sync!" and tears down the local player (the
// RigidbodyEx Head/Hand "Cannot set the parent" warnings at
// [line 585] are the destruction shrapnel). Synthesise a
// default dorm RoomInstance instead so the heartbeat never
// returns null for an authenticated player. Matches what
// /goto/room/DormRoom builds — same RoomId, same
// PhotonRoomId pattern, same location GUID — so the watch's
// RoomInstancesAreDifferent finds them identical.
var fallback = await BuildDefaultDormPresenceAsync(playerId);
if (fallback is not null)
{
log.LogInformation(
"[player-heartbeat] synthesised dorm fallback player={PlayerId} room={RoomId} dataBlob={DataBlob} (presence cache miss)",
playerId, fallback.RoomId, fallback.DataBlob);
presence.SetRoom(playerId, fallback);
return fallback;
}
log.LogInformation("[player-heartbeat] player={PlayerId} room=null", playerId);
return null;
}
if (string.IsNullOrWhiteSpace(room.DataBlob))
{
var resolved = await ResolveDataBlobForPresenceAsync(room.RoomId, playerId);
if (!string.IsNullOrWhiteSpace(resolved))
{
log.LogWarning(
"[player-heartbeat] repairing empty presence dataBlob player={PlayerId} room={RoomId} instance={InstanceId} resolved={DataBlob}",
playerId, room.RoomId, room.RoomInstanceId, resolved);
room.DataBlob = resolved;
presence.SetRoom(playerId, room);
}
}
log.LogInformation(
"[player-heartbeat] player={PlayerId} room={RoomId} instance={InstanceId} subRoom={SubRoomId} location={Location} photon={PhotonRoomId} dataBlob={DataBlob}",
playerId, room.RoomId, room.RoomInstanceId, room.SubRoomId, room.Location, room.PhotonRoomId, room.DataBlob);
return room;
}
/// <summary>Build a RoomInstance that matches the shape
/// <c>/goto/room/DormRoom</c> would have produced for this player
/// — used as a heartbeat fallback when presence is empty (boot
/// race, replica restart, Redis TTL expiry). Same RoomInstanceId
/// math, same PhotonRoomId pattern, same location GUID; the
/// watch's RoomInstancesAreDifferent finds the fallback identical
/// to its own cached state, so no out-of-sync. Returns null only
/// if we can't find the player's dorm row in the DB.</summary>
private async Task<RoomInstanceDto?> BuildDefaultDormPresenceAsync(int playerId)
{
var dormRow = await db.Rooms.AsNoTracking()
.Where(r => r.IsDormRoom && r.CreatorPlayerId == playerId)
.Select(r => new { r.Id, r.CurrentDataBlobName, r.LocationReplicationId })
.FirstOrDefaultAsync();
// Fall back to the canonical dorm template row if the player
// doesn't yet have their own dorm record — same id space
// /goto uses.
long roomId = dormRow?.Id ?? 1;
string dataBlob = dormRow?.CurrentDataBlobName ?? string.Empty;
if (string.IsNullOrWhiteSpace(dataBlob))
dataBlob = await ResolveDataBlobForPresenceAsync(roomId, playerId);
return new RoomInstanceDto
{
RoomInstanceId = roomId * 100_000L,
RoomId = roomId,
SubRoomId = 0,
Location = dormRow?.LocationReplicationId ?? "76d98498-60a1-430c-ab76-b54a29b7a163",
PhotonRegionId = "us",
PhotonRoomId = $"^dormroom_p{playerId}",
Name = "DormRoom",
MaxCapacity = 8,
IsFull = false,
IsPrivate = true,
IsInProgress = true,
DataBlob = dataBlob ?? string.Empty,
EventId = 0,
};
}
private async Task<string> ResolveDataBlobForPresenceAsync(long roomId, int playerId)
{
var room = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return string.Empty;
if (room.IsDormRoom)
{
var dormBlob = await db.DormStates.AsNoTracking()
.Where(d => d.PlayerId == playerId)
.Select(d => d.CurrentDataBlobName)
.FirstOrDefaultAsync();
if (!string.IsNullOrWhiteSpace(dormBlob)) return dormBlob;
}
if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName;
var customisable = room.IsDormRoom || room.CreatorPlayerId != 1;
return customisable ? $"room_{room.Id}_v1.dat" : string.Empty;
}
}
/// <summary>
/// Wire shape of RecNet.PlayerPresence (RVA 0x1458920). Lives here as a
/// local DTO; same fields as PlayerPresenceController.PlayerPresenceDto.
/// </summary>
public class PresenceDto
{
[JsonPropertyName("playerId")]
public int PlayerId { get; set; }
[JsonPropertyName("statusVisibility")]
public int StatusVisibility { get; set; }
[JsonPropertyName("deviceClass")]
public int DeviceClass { get; set; }
[JsonPropertyName("vrMovementMode")]
public int VrMovementMode { get; set; }
[JsonPropertyName("roomInstance")]
public object? RoomInstance { get; set; } = null;
}
@@ -0,0 +1,127 @@
using Microsoft.AspNetCore.Mvc;
using System.Text.Json.Serialization;
using DorkNet.Server.Auth;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Match;
/// <summary>
/// match.rec.net/player — bulk player-presence query used by
/// <c>RecNet.Matchmaking.RefreshCachedPlayerPresences</c>.
///
/// URL pattern verified against
/// Cpp2IL_ISIL/IsilDump/Assembly-CSharp/RecNet/Matchmaking.txt
/// lines 3565-3568:
/// <c>String.Join("&amp;", ids)</c> → <c>String.Concat("player?", joined)</c>.
/// So the request URL is literally <c>player?{id1}&amp;{id2}&amp;{id3}...</c>
/// — every query-string KEY is one account id, with no value. The previous
/// <c>[FromQuery(Name="id")] int[] ids</c> binding only matched
/// <c>?id=X&amp;id=Y</c> and never picked up any of the watch's bare-id
/// requests, which is why every other player rendered as offline: with no
/// ids parsed, the response was <c>[]</c> and IsOnline (which checks
/// <c>roomInstance != null</c>) defaulted to false.
///
/// Response shape: <c>List&lt;PlayerPresence&gt;</c> — keys verified by
/// disassembling <c>RecNet.PlayerPresence.Deserialize</c>:
/// <c>"playerId"</c> (int, required)
/// <c>"statusVisibility"</c> (int, required)
/// <c>"deviceClass"</c> (int, required)
/// <c>"vrMovementMode"</c> (only read when deviceClass == 1)
/// <c>"roomInstance"</c> (object, optional)
///
/// <c>PlayerPresence.IsOnline</c> returns true iff
/// <c>roomInstance</c> is non-null — so we surface the live RoomInstance
/// from <see cref="PlayerPresenceService"/> rather than always sending
/// null. Players who haven't /goto'd anywhere still report offline.
/// </summary>
[ApiController]
public class PlayerPresenceController(PlayerPresenceService presence) : ControllerBase
{
[HttpGet("/player")]
public ActionResult<List<PlayerPresenceDto>> GetPresences()
{
// The watch sends two URL flavours for this endpoint:
// 1. Bulk: <c>/player?{id1}&amp;{id2}&amp;{id3}</c> — bare keys,
// from RefreshCachedPlayerPresences (Matchmaking.txt:3565).
// 2. Single: <c>/player?id={N}</c> — observed in the live trace
// from a different code path (probably the per-player presence
// probe). Both must work; an empty response makes
// PUNNetworkManager skip Photon region setup, which then
// crashes <c>PingPhotonRegionsInternal</c> with
// "An item with the same key has already been added. Key: none"
// and leaves the player on a black screen post-dorm-load.
//
// We accept ANY query parameter whose value parses as a long, plus
// any bare key that does. Same dedup → same DB lookup → same
// response shape regardless.
var ids = new HashSet<long>();
foreach (var (key, values) in Request.Query)
{
if (long.TryParse(key, out var fromKey) && fromKey > 0)
ids.Add(fromKey);
foreach (var v in values)
{
if (long.TryParse(v, out var fromValue) && fromValue > 0)
ids.Add(fromValue);
}
}
// Caller's own id gets the FULL RoomInstance (photonRoomId +
// dataBlob) so the watch's cached PlayerPresence for itself
// matches what /player/heartbeat returns. The watch calls
// /player/?id=<self>&id=<friend>&... at boot via
// RefreshCachedPlayerPresences and then caches each entry; if
// self's entry is redacted (photonRoomId="" / dataBlob="") but
// heartbeat later returns the full values, RoomInstance.
// RoomInstancesAreDifferent fires → "presence heartbeat
// response indicates local presence is out-of-sync!" →
// OnPresenceHeartbeatResponse tears the player out of the
// current room and re-joins, killing any in-flight save flow
// mid-coroutine. Only redact for OTHER players where the
// privacy concern actually applies.
var callerId = this.CurrentPlayerId();
var result = ids.Select(id =>
{
var room = presence.GetRoom(id);
return new PlayerPresenceDto
{
PlayerId = (int)id,
StatusVisibility = 1, // Visible
DeviceClass = 0, // Desktop / unknown — not VR
VrMovementMode = 0,
RoomInstance = id == callerId ? room : RoomInstanceDto.Redact(room),
};
}).ToList();
return Ok(result);
}
}
public class PlayerPresenceDto
{
[JsonPropertyName("playerId")]
public int PlayerId { get; set; }
/// <summary>PlayerStatusVisibility enum (int):
/// 0 = Hidden / Offline-appearing, 1 = Visible, 2 = FriendsOnly.</summary>
[JsonPropertyName("statusVisibility")]
public int StatusVisibility { get; set; }
/// <summary>RecNet.DeviceClass enum (int).</summary>
[JsonPropertyName("deviceClass")]
public int DeviceClass { get; set; }
/// <summary>VRMovementMode enum (int) — only consulted when
/// deviceClass == 1.</summary>
[JsonPropertyName("vrMovementMode")]
public int VrMovementMode { get; set; }
/// <summary>Read by Util.GetObjectKey&lt;RoomInstance&gt; — must
/// be a nested object (or null), NOT a string. Empty string ""
/// triggers
/// <c>InvalidCastException: Unable to cast object of type 'String'
/// to type 'Dictionary'2'</c>. <c>null</c> means "player not in
/// any room" → IsOnline = false.</summary>
[JsonPropertyName("roomInstance")]
public object? RoomInstance { get; set; } = null;
}
@@ -0,0 +1,21 @@
using Microsoft.AspNetCore.Mvc;
namespace DorkNet.Server.Controllers.Notify;
/// <summary>
/// notify.rec.net — legacy raw-WebSocket entrypoint that the 2020 client
/// briefly used before switching to the SignalR hub at
/// <c>notify.rec.net/hub/v1</c>. Kept as a 410 Gone so any straggling
/// boot path that hits it gets a clear "use the hub" signal instead of
/// a silent connection upgrade.
/// </summary>
[ApiController]
public class NotifyController : ControllerBase
{
[HttpGet("/notify/v1")]
public IActionResult Connect() => StatusCode(StatusCodes.Status410Gone, new
{
error = "use_signalr_hub",
hint = "connect to /hub/v1 instead",
});
}
@@ -0,0 +1,63 @@
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Ns;
#pragma warning disable IDE0290
/// <summary>
/// ns.{apex} — RecNet name server. The very first endpoint the client
/// queries on boot. Returns the service URL map so the client knows
/// where every other subdomain lives. Also serves the bare apex
/// <c>GET /</c> (service catalog) and <c>/v1/ping</c> for the
/// keep-alive loop. Apex is read from <see cref="DomainConfig"/>
/// (driven by <c>DORKNET_DOMAIN</c>) so every URL the client sees
/// flows through one source of truth.
/// </summary>
[ApiController]
public class NsController : ControllerBase
{
private readonly PlayerService _playerService;
private readonly DomainConfig _domain;
public NsController(PlayerService playerService, DomainConfig domain)
{
_playerService = playerService;
_domain = domain;
}
// Root: service URL discovery (primary boot call from RecNet.Core.ConnectToRecNet)
// The game's TrySetServiceUri parses this as a flat { "ServiceName": "https://..." } dictionary.
// Wrapping it breaks lookup — all URIs come back null which crashes EnsureEndsWith.
//
// Order is more-negative than CdnController.Serve's wildcard route
// (Order = -100) because the catch-all <c>{*path}</c> matches the
// empty path "/" too — without this, GET ns.{apex}/ falls into the
// CDN serve handler and the client gets binary back instead of the
// service-map JSON, triggering "Failed to connect to RecNet (error
// code: locker)" in BootSequence.
[HttpGet("/", Order = -1000)]
public ActionResult<Dictionary<string, string>> Root() =>
Ok(ConfigService.BuildServiceUrlMap(_domain.Apex));
// Some builds fetch /v1 or /v1/services directly
[HttpGet("/v1")]
[HttpGet("/v1/services")]
public ActionResult<Dictionary<string, string>> Services() =>
Ok(ConfigService.BuildServiceUrlMap(_domain.Apex));
// Ping / health used by the client keep-alive loop
[HttpGet("/v1/ping")]
[HttpGet("/ping")]
public ActionResult<object> Ping()
=> Ok(new { Status = "OK", Time = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() });
// Player name → accountId lookup (used for friend invites etc.)
[HttpGet("/v1/player/{name}")]
public async Task<IActionResult> PlayerLookup(string name)
{
var p = await _playerService.GetByUsernameAsync(name);
return Ok(p is null
? new { Username = name, AccountId = 0L, Found = false }
: new { Username = p.Username, AccountId = p.Id, Found = true });
}
}
@@ -0,0 +1,65 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using System.Text.Json.Serialization;
namespace DorkNet.Server.Controllers.PlayerSettings;
/// <summary>
/// playersettings.rec.net — dedicated player settings service.
/// </summary>
[ApiController]
[Authorize]
public class PlayerSettingsController(DorkNetDbContext db) : ControllerBase
{
private long CurrentPlayerId => this.RequireCurrentPlayerId();
[HttpGet("/settings/v1/{accountId:long}")]
public async Task<ActionResult<Dictionary<string, string>>> Get(long accountId)
{
// Settings are private — only the owner can read them. Returning
// 403 instead of leaking the dict.
if (accountId != CurrentPlayerId) return Forbid();
var settings = await db.PlayerSettings
.Where(s => s.PlayerId == accountId)
.ToDictionaryAsync(s => s.Key, s => s.Value);
return Ok(settings);
}
[HttpPut("/settings/v1/{accountId:long}")]
public async Task<IActionResult> Set(long accountId, [FromBody] Dictionary<string, string> updates)
{
if (accountId != CurrentPlayerId) return Forbid();
foreach (var (key, value) in updates)
{
var existing = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == accountId && s.Key == key);
if (existing is null)
db.PlayerSettings.Add(new PlayerSettingEntity { PlayerId = accountId, Key = key, Value = value });
else
existing.Value = value;
}
await db.SaveChangesAsync();
return Ok();
}
[HttpDelete("/settings/v1/{accountId:long}/{key}")]
public async Task<IActionResult> Delete(long accountId, string key)
{
if (accountId != CurrentPlayerId) return Forbid();
var setting = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == accountId && s.Key == key);
if (setting is null) return NotFound();
db.PlayerSettings.Remove(setting);
await db.SaveChangesAsync();
return Ok();
}
}
@@ -0,0 +1,60 @@
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Rooms;
/// <summary>
/// rooms.{rec.net,localhost} — secondary room-listing host (the
/// primary surface is api.*/api/rooms/v2/* via
/// <c>Controllers/API/Rooms/V2/RoomsController</c>). Some older
/// 2020 client paths still hit this host for the room-tile lookup
/// during share-link resolution and friend-room-join probes.
/// Both controllers share <see cref="RoomService"/> so the data is
/// identical regardless of which host the watch chose.
/// </summary>
[ApiController]
public class RoomsController(RoomService rooms) : ControllerBase
{
[HttpGet("/rooms/v3/{roomId:long}")]
public async Task<IActionResult> GetById(long roomId)
{
var r = await rooms.GetByIdAsync(roomId);
return r is null ? NotFound() : Ok(RoomService.ToWireRoom(r));
}
[HttpGet("/rooms/v3/search")]
public async Task<IActionResult> Search([FromQuery] string? query, [FromQuery] int take = 20)
{
var rows = await rooms.SearchAsync(query ?? string.Empty, Math.Clamp(take, 1, 100));
return Ok(rows.Select(RoomService.ToWireRoom).ToList());
}
[HttpGet("/rooms/v3/hot")]
public async Task<IActionResult> Hot([FromQuery] string? tag, [FromQuery] int take = 50)
=> Ok((await rooms.HotAsync(tag, Math.Clamp(take, 1, 100))).Select(RoomService.ToWireRoom).ToList());
[HttpGet("/rooms/v3/new")]
public async Task<IActionResult> New([FromQuery] int take = 50)
{
// No dedicated "new rooms" path on RoomService — order by
// CreatedAt is acceptable as a fallback since RoomEntity has
// both UpdatedAt + CreatedAt.
var rows = await rooms.HotAsync(null, Math.Clamp(take, 1, 100));
return Ok(rows.Select(RoomService.ToWireRoom).ToList());
}
[HttpGet("/rooms/v3/{roomId:long}/stats")]
public async Task<IActionResult> GetStats(long roomId)
{
var r = await rooms.GetByIdAsync(roomId);
if (r is null) return NotFound();
return Ok(new
{
RoomId = r.Id,
VisitCount = r.VisitCount,
CheerCount = r.CheerCount,
FavoriteCount = r.FavoriteCount,
VisitorCount = r.VisitorCount,
});
}
}
@@ -0,0 +1,31 @@
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers;
[ApiController]
public class RootController(ConfigService configService) : ControllerBase
{
// Legacy MOTD for pre-Dorm Room builds
[HttpGet("motd")]
public ActionResult<string> GetMotd()
{
var cfg = configService.GetConfig($"{Request.Scheme}://{Request.Host}");
return Ok(cfg.MessageOfTheDay);
}
// Serve images from data/images/
[HttpGet("img/{name}")]
public ActionResult GetImage(string name)
{
var safeFileName = Path.GetFileName(name); // prevent path traversal
var path = Path.Combine(AppContext.BaseDirectory, "data", "images", safeFileName);
if (!System.IO.File.Exists(path))
return NotFound();
var ext = Path.GetExtension(safeFileName).ToLowerInvariant();
var contentType = ext == ".png" ? "image/png" : "image/jpeg";
return PhysicalFile(path, contentType);
}
}
@@ -0,0 +1,246 @@
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();
}
}
@@ -0,0 +1,429 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Storage;
/// <summary>
/// storage.rec.net — file upload endpoint the 2020 client posts to when
/// the master client persists a room save. Verified against the IL2CPP
/// disassembly of <c>RecNet.Storage.UploadFile</c>:
/// <list type="bullet">
/// <item>Method: POST.</item>
/// <item>URL: <c>https://storage.rec.net/upload</c> (Service=Storage,
/// requestUri="upload"; per Service enum at dump.cs:586534).</item>
/// <item>Body: <c>multipart/form-data</c> with fields
/// <c>File</c> (binary, content-type from caller or
/// <c>application/octet-stream</c>), <c>FileType</c>
/// (Storage.FileType: 0=Unknown, 1=RoomSave, 2=Holotar, 3=Image,
/// 4=Video, 5=Invention), and optional <c>References</c>
/// (comma-joined filenames).</item>
/// <item>Response: <c>UploadFileResponseDTO</c> with one key
/// <c>Filename</c> — the cdn URL segment the client will request via
/// <c>cdn.rec.net/room/{Filename}</c> the next time anyone enters
/// the room.</item>
/// </list>
///
/// Permission rule: only the room's creator (or the dorm owner for a
/// dorm save) can persist a new blob. Saves from other players in the
/// same room are silently rejected with 403 — they can build with the
/// Maker Pen but their changes won't outlive the session.
/// </summary>
[ApiController]
[Authorize]
public class StorageController(
DorkNetDbContext db,
PlayerPresenceService presence,
IObjectStorage objectStorage,
ILogger<StorageController> logger) : ControllerBase
{
/// <summary>
/// FileType enum values from the client's <c>RecNet.Storage+FileType</c>
/// (dump.cs:586846). Kept as a private enum so callers can pattern-
/// match without leaking client-side numbering elsewhere.
/// </summary>
private enum FileType
{
Unknown = 0,
RoomSave = 1,
Holotar = 2,
Image = 3,
Video = 4,
Invention = 5,
}
[HttpPost("/upload", Order = -2000)]
[Consumes("multipart/form-data")]
public async Task<IActionResult> Upload()
{
// Form parsing — ASP.NET Core deserialises the multipart body
// automatically. The two field names are the literal strings the
// client uses (capitalised; verified in
// RecNet.Storage.UploadFile ISIL state {028, 044}).
if (!Request.HasFormContentType)
return BadRequest(new { error = "expected multipart/form-data" });
var form = await Request.ReadFormAsync();
var file = form.Files["File"];
if (file is null || file.Length == 0)
return BadRequest(new { error = "missing or empty File part" });
if (!int.TryParse(form["FileType"].ToString(), out var fileTypeRaw))
return BadRequest(new { error = "missing or non-numeric FileType" });
var fileType = (FileType)fileTypeRaw;
var playerId = this.RequireCurrentPlayerId();
var references = form["References"].ToString() ?? string.Empty;
logger.LogInformation(
"[storage] upload accepted host={Host} fileType={FileType} player={PlayerId} bytes={Bytes} refs={ReferenceCount}",
Request.Host.Host,
fileType,
playerId,
file.Length,
string.IsNullOrWhiteSpace(references)
? 0
: references.Split(',', StringSplitOptions.RemoveEmptyEntries).Length);
// Read the upload bytes upfront. SQLite BLOB column expects a
// byte[]; the upload is small (KB to ~1MB) so memory is fine.
byte[] bytes;
using (var ms = new MemoryStream())
{
await file.CopyToAsync(ms);
bytes = ms.ToArray();
}
return fileType switch
{
FileType.RoomSave => await UploadRoomSaveAsync(playerId, bytes, references),
FileType.Invention => await UploadInventionAsync(playerId, bytes, references),
FileType.Image => await UploadImageAsync(playerId, bytes),
FileType.Holotar => await UploadGenericAsync(playerId, bytes, "holotar"),
FileType.Video => await UploadGenericAsync(playerId, bytes, "video"),
_ => Ok(new
{
filename = $"stub_{Guid.NewGuid():N}.{Extension(fileType)}",
}),
};
}
private async Task<IActionResult> UploadRoomSaveAsync(
long playerId, byte[] bytes, string references)
{
// Determine which room the upload belongs to. The client doesn't
// include the room id in the request — it just posts a save and
// expects the server to know based on session context. We use
// PlayerPresenceService, which is updated by /goto/room/* and
// is the canonical "what room is this player currently in"
// store.
var presenceRoom = presence.GetRoom(playerId);
if (presenceRoom is null)
{
logger.LogWarning(
"[storage] room save from player {PlayerId} had no active presence; saving to personal dorm",
playerId);
var dorm = await db.Rooms.FirstOrDefaultAsync(r => r.IsDormRoom && r.CreatorPlayerId == playerId);
if (dorm is not null)
return await UploadDormSaveAsync(playerId, bytes, references, dorm.Id);
return BadRequest(new { error = "no active room session" });
}
var roomId = presenceRoom.RoomId;
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null)
{
var roomName = string.IsNullOrWhiteSpace(presenceRoom.Name)
? $"Room_{roomId}"
: presenceRoom.Name;
if (await db.Rooms.AnyAsync(r => r.Name == roomName))
roomName = $"Room_{roomId}";
logger.LogWarning(
"[storage] room save from player {PlayerId} referenced missing room {RoomId} ({RoomName}); creating owner room row",
playerId, roomId, roomName);
room = new RoomEntity
{
Id = roomId,
Name = roomName,
Description = "Created from an active game session.",
CreatorPlayerId = playerId,
Accessibility = presenceRoom.IsPrivate ? 0 : 1,
IsAGRoom = true,
IsDormRoom = false,
LocationReplicationId = string.IsNullOrWhiteSpace(presenceRoom.Location)
? "76d98498-60a1-430c-ab76-b54a29b7a163"
: presenceRoom.Location,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
db.Rooms.Add(room);
await db.SaveChangesAsync();
}
// Dorm saves go to a per-player DormStateEntity instead of the
// shared RoomEntity. Pass the real dorm Room.Id through so the
// saved blob is keyed against the player's own dorm row (every
// player has their own dorm RoomEntity now, not the legacy
// canonical sentinel id=1).
if (room.IsDormRoom)
return await UploadDormSaveAsync(playerId, bytes, references, room.Id);
// Permission: only the room owner can save. (Future Phase 5
// expansion: co-owners + admins. For now, strict owner-only —
// matches the 2020 official server's "RoomRolePermissions
// .CanUseMakerPen" behaviour, which rejects upload at the wire
// for non-owners.)
if (room.CreatorPlayerId != playerId)
{
logger.LogWarning(
"[storage] player {PlayerId} tried to save room {RoomId} (creator={CreatorId})",
playerId, room.Id, room.CreatorPlayerId);
return Forbid();
}
// Allocate the next BlobName for this room. We use a monotonic
// counter per-room: the (N+1)th save becomes "room_<id>_v<N+1>.dat".
// SQLite handles the count() query fast enough for our scale; if
// this grows too big, switch to a per-room counter column on
// RoomEntity.
var versionNumber = await db.RoomDataBlobs
.Where(b => b.RoomId == roomId)
.CountAsync() + 1;
var blobName = $"room_{roomId}_v{versionNumber}.dat";
var (bucket, key) = BlobRouter.Route(blobName);
await StoreBlobObjectAsync(bucket, key, bytes, "application/octet-stream");
var entry = new RoomDataBlobEntity
{
RoomId = roomId,
BlobName = blobName,
UploadedByPlayerId = playerId,
UploadedAt = DateTime.UtcNow,
ReferencedFilenamesCsv = references,
};
db.RoomDataBlobs.Add(entry);
// Update RoomEntity.CurrentDataBlobName so the next visitor's
// /api/rooms/v4/details/{id} response carries the new name and
// the persistence flow downloads the latest bytes.
room.CurrentDataBlobName = blobName;
room.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
logger.LogInformation(
"[storage] room save: room={RoomId} player={PlayerId} blob={Blob} size={Size}B",
roomId, playerId, blobName, bytes.Length);
return Ok(new { filename = blobName });
}
/// <summary>
/// Per-player dorm save. Stores bytes in <see cref="RoomDataBlobEntity"/>
/// keyed under <c>RoomId=1</c> (matchmaking is unchanged) and
/// upserts the <see cref="DormStateEntity"/> row for this player so
/// next time they enter their dorm, RoomsController.Details serves
/// the right BlobName from their personal state row instead of the
/// shared RoomEntity row.
/// </summary>
private async Task<IActionResult> UploadDormSaveAsync(
long playerId, byte[] bytes, string references, long dormRoomId)
{
// Migration backfill: pre-personal-dorm saves used the legacy
// canonical RoomId=1. Re-key any of those rows for THIS player
// to their actual dorm RoomId so the version count below
// doesn't collide with existing blob names. Idempotent — runs
// a no-op once everything's migrated.
var blobNamePrefix = $"dorm_p{playerId}_v";
await db.RoomDataBlobs
.Where(b => b.UploadedByPlayerId == playerId
&& b.BlobName.StartsWith(blobNamePrefix)
&& b.RoomId != dormRoomId)
.ExecuteUpdateAsync(s => s.SetProperty(b => b.RoomId, dormRoomId));
// Per-player blob-name-based versioning. Counting by blob-name
// prefix is more robust than RoomId because it survives any
// future migrations of the dorm row identity.
var versionNumber = await db.RoomDataBlobs
.Where(b => b.UploadedByPlayerId == playerId
&& b.BlobName.StartsWith(blobNamePrefix))
.CountAsync() + 1;
var blobName = $"dorm_p{playerId}_v{versionNumber}.dat";
var (bucket, key) = BlobRouter.Route(blobName);
await StoreBlobObjectAsync(bucket, key, bytes, "application/octet-stream");
db.RoomDataBlobs.Add(new RoomDataBlobEntity
{
RoomId = dormRoomId,
BlobName = blobName,
UploadedByPlayerId = playerId,
UploadedAt = DateTime.UtcNow,
ReferencedFilenamesCsv = references,
});
// Upsert DormStateEntity for this player.
var dormState = await db.DormStates
.FirstOrDefaultAsync(d => d.PlayerId == playerId);
if (dormState is null)
{
dormState = new DormStateEntity { PlayerId = playerId };
db.DormStates.Add(dormState);
}
dormState.CurrentDataBlobName = blobName;
dormState.UpdatedAt = DateTime.UtcNow;
// Bump the dorm room's UpdatedAt timestamp so the watch's
// freshness check (DataModifiedAt comparison) sees this as the
// latest version and stops showing the "room is not up to
// date" warning after a save.
await db.Rooms
.Where(r => r.Id == dormRoomId)
.ExecuteUpdateAsync(s => s
.SetProperty(r => r.UpdatedAt, DateTime.UtcNow)
.SetProperty(r => r.CurrentDataBlobName, blobName));
await db.SaveChangesAsync();
logger.LogInformation(
"[storage] dorm save: player={PlayerId} blob={Blob} size={Size}B",
playerId, blobName, bytes.Length);
return Ok(new { filename = blobName });
}
/// <summary>
/// Per-creation invention upload. Stores bytes in
/// <see cref="RoomDataBlobEntity"/> with a recognisable
/// <c>invention_p&lt;id&gt;_<guid>.dat</c> blob name; the
/// metadata row in <see cref="InventionEntity"/> is created
/// separately by the client's POST to
/// <c>api/inventions/v3/save</c> referencing the returned
/// Filename. We don't enforce a per-invention id at upload time
/// (the inventon row may not exist yet) — the unique BlobName
/// guards uniqueness on its own.
/// </summary>
private async Task<IActionResult> UploadInventionAsync(
long playerId, byte[] bytes, string references)
{
var blobName = $"invention_p{playerId}_{Guid.NewGuid():N}.dat";
var (bucket, key) = BlobRouter.Route(blobName);
await StoreBlobObjectAsync(bucket, key, bytes, "application/octet-stream");
db.RoomDataBlobs.Add(new RoomDataBlobEntity
{
// RoomId=0 is the "no specific room — invention library blob"
// sentinel. The cdn lookup keys on BlobName so RoomId is
// informational only here.
RoomId = 0,
BlobName = blobName,
UploadedByPlayerId = playerId,
UploadedAt = DateTime.UtcNow,
ReferencedFilenamesCsv = references,
});
await db.SaveChangesAsync();
logger.LogInformation(
"[storage] invention upload: player={PlayerId} blob={Blob} size={Size}B",
playerId, blobName, bytes.Length);
return Ok(new { filename = blobName });
}
/// <summary>Profile image / screenshot upload. Stored as bytes on
/// the same RoomDataBlobs table (RoomId=0 is the
/// "no specific room" sentinel). The catch-all serves them via
/// <c>cdn.rec.net/{Filename}</c> like every other blob. The
/// caller is expected to follow up with
/// <c>POST account/v1/profileimage</c> to wire the returned
/// filename into <see cref="DorkNet.Server.Data.Entities.PlayerEntity.ProfileImageName"/>.
/// </summary>
private async Task<IActionResult> UploadImageAsync(long playerId, byte[] bytes)
{
var blobName = $"img_p{playerId}_{Guid.NewGuid():N}.png";
var (bucket, key) = BlobRouter.Route(blobName);
await StoreBlobObjectAsync(bucket, key, bytes, "image/png");
db.RoomDataBlobs.Add(new RoomDataBlobEntity
{
RoomId = 0,
BlobName = blobName,
UploadedByPlayerId = playerId,
UploadedAt = DateTime.UtcNow,
ReferencedFilenamesCsv = string.Empty,
});
await db.SaveChangesAsync();
logger.LogInformation(
"[storage] image upload: player={PlayerId} blob={Blob} size={Size}B",
playerId, blobName, bytes.Length);
return Ok(new { filename = blobName });
}
/// <summary>Catch-all bytes-on-disk upload used for FileTypes that
/// don't have specialised behaviour (Holotar, Video). Stored
/// identically to Image; callers reference the returned Filename
/// from wherever they need it.</summary>
private async Task<IActionResult> UploadGenericAsync(
long playerId, byte[] bytes, string kind)
{
var ext = kind switch { "video" => "mp4", _ => "bin" };
var blobName = $"{kind}_p{playerId}_{Guid.NewGuid():N}.{ext}";
var contentType = kind switch
{
"video" => "video/mp4",
_ => "application/octet-stream",
};
var (bucket, key) = BlobRouter.Route(blobName);
await StoreBlobObjectAsync(bucket, key, bytes, contentType);
db.RoomDataBlobs.Add(new RoomDataBlobEntity
{
RoomId = 0,
BlobName = blobName,
UploadedByPlayerId = playerId,
UploadedAt = DateTime.UtcNow,
ReferencedFilenamesCsv = string.Empty,
});
await db.SaveChangesAsync();
logger.LogInformation(
"[storage] {Kind} upload: player={PlayerId} blob={Blob} size={Size}B",
kind, playerId, blobName, bytes.Length);
return Ok(new { filename = blobName });
}
private async Task StoreBlobObjectAsync(string bucket, string key, byte[] bytes, string contentType)
{
try
{
using var s3Timeout = new CancellationTokenSource(TimeSpan.FromSeconds(8));
var written = await objectStorage.PutAsync(bucket, key, bytes, contentType, s3Timeout.Token);
logger.LogInformation(
"[storage] object write: bucket={Bucket} key={Key} bytes={Bytes} mode={Mode}",
bucket,
key,
written,
objectStorage.IsS3Configured ? "s3" : "disk-fallback");
}
catch (Exception ex)
{
logger.LogError(
ex,
"[storage] object write failed: bucket={Bucket} key={Key}; keeping DB fallback so the client request can complete",
bucket,
key);
}
}
private static string Extension(FileType type) => type switch
{
FileType.RoomSave => "dat",
FileType.Image => "png",
FileType.Video => "mp4",
FileType.Holotar => "holotar",
FileType.Invention => "inv",
_ => "bin",
};
}
@@ -0,0 +1,18 @@
using Microsoft.AspNetCore.Mvc;
namespace DorkNet.Server.Controllers.Strings;
/// <summary>
/// strings.rec.net — game localisation strings.
/// Returns an empty table so the client falls back to its bundled defaults.
/// </summary>
[ApiController]
public class StringsController : ControllerBase
{
[HttpGet("/strings/v1")]
public ActionResult<Dictionary<string, string>> GetStrings() => Ok(new Dictionary<string, string>());
[HttpGet("/strings/v1/{locale}")]
public ActionResult<Dictionary<string, string>> GetLocale(string locale)
=> Ok(new Dictionary<string, string>());
}