From 579071858a325cb61ce7e826b50be6a3205dae68 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Sun, 7 Jun 2026 15:32:52 +0300 Subject: [PATCH] feat: Add support for 2018 client API endpoints and data models - Implemented new search endpoints for rooms to accommodate the 2018 client. - Added support for retrieving room details and featured rooms in a format compatible with the 2018 client. - Introduced new data models for login responses and player presence specific to the 2018 client. - Created mappers to convert between internal entities and the 2018 wire formats for players and rooms. - Updated existing storefront APIs to include versioned endpoints for the 2018 client. - Enhanced the version controller to return dual-format responses for compatibility with both 2018 and 2020 clients. - Refactored the configuration service to ensure trailing slashes in URLs for the 2018 client. --- DorkNet.Models/Auth/Login2018.cs | 118 +++++++++ DorkNet.Models/Config/RecRoomConfig.cs | 23 +- DorkNet.Models/Players/Presence2018.cs | 21 ++ DorkNet.Models/Rooms/Room2018.cs | 48 ++++ DorkNet.Server/Compat2018/Player2018Mapper.cs | 30 +++ DorkNet.Server/Compat2018/Room2018Mapper.cs | 43 +++ .../Avatar/V3/AvatarSavedOutfitsController.cs | 2 + .../API/Images/ImagesController.cs | 7 + .../API/Inventory/InventoryController.cs | 40 +-- .../API/Messages/V2/MessagesController.cs | 2 + .../V1/PlatformLoginV1Controller.cs | 250 ++++++++++++++++++ .../API/PlayerCheer/PlayerCheerController.cs | 14 +- .../API/Players/V2/PlayersController.cs | 102 ++++--- .../API/Presence/PresenceController.cs | 35 +++ .../API/Rooms/V2/RoomsController.cs | 38 +++ .../API/Store/StorefrontsBuyController.cs | 3 + .../API/Store/StorefrontsController.cs | 17 +- .../API/Version/VersionController.cs | 11 +- DorkNet.Server/Services/ConfigService.cs | 15 +- DorkNet.Server/Services/PlayerService.cs | 9 + DorkNet.Server/Services/RoomService.cs | 36 +-- 21 files changed, 770 insertions(+), 94 deletions(-) create mode 100644 DorkNet.Models/Auth/Login2018.cs create mode 100644 DorkNet.Models/Players/Presence2018.cs create mode 100644 DorkNet.Models/Rooms/Room2018.cs create mode 100644 DorkNet.Server/Compat2018/Player2018Mapper.cs create mode 100644 DorkNet.Server/Compat2018/Room2018Mapper.cs create mode 100644 DorkNet.Server/Controllers/API/PlatformLogin/V1/PlatformLoginV1Controller.cs create mode 100644 DorkNet.Server/Controllers/API/Presence/PresenceController.cs diff --git a/DorkNet.Models/Auth/Login2018.cs b/DorkNet.Models/Auth/Login2018.cs new file mode 100644 index 0000000..f70a151 --- /dev/null +++ b/DorkNet.Models/Auth/Login2018.cs @@ -0,0 +1,118 @@ +using System.Text.Json.Serialization; + +namespace DorkNet.Models.Auth; + +// ── June-2018 (build 20180621_EA) login wire shapes ────────────────────────── +// +// The 2018 client speaks a DIFFERENT login protocol than the 2019/2020 OAuth +// flow the rest of DorkNet implements: +// • POST api/platformlogin/v1/{loginaccount,logincached,createaccount} +// • request body is a JSON object (Unity JsonUtility / LitJson), NOT form +// • response is the "login envelope" below, read by RecNet.cs:40014-40021 +// via case-sensitive LitJson keys. +// +// Keys verified from the assembly's REAL (name-preserved) Deserialize methods +// via dnlib — the obfuscated-name sibling methods are Beebyte decoys with +// scrambled literals (see project_2018_mono_obfuscation memory). + +/// +/// Login response envelope. RecNet login-response Deserialize (RecNet.cs:40016-40020) +/// reads: Error (req string), Player (optional object), Token (req string), +/// FirstLoginOfTheDay (req bool), AnalyticsSessionId (req long). Empty Error +/// means success; a non-empty Error is shown to the user and aborts login. +/// +public class Login2018Response +{ + [JsonPropertyName("Error")] + public string Error { get; set; } = string.Empty; + + [JsonPropertyName("Player")] + public Player2018? Player { get; set; } + + [JsonPropertyName("Token")] + public string Token { get; set; } = string.Empty; + + [JsonPropertyName("FirstLoginOfTheDay")] + public bool FirstLoginOfTheDay { get; set; } + + [JsonPropertyName("AnalyticsSessionId")] + public long AnalyticsSessionId { get; set; } +} + +/// +/// 2018 player object embedded in the login envelope and returned bare from +/// getcachedlogins. Keys + required/optional verified from RecNet.cs:64632-64667 +/// (RecNet.EEBPLECPEGD.Deserialize): +/// REQUIRED (Util.GetKey throws if missing): Id, Username, DisplayName, XP, +/// Level, RegistrationStatus, Developer, CanReceiveInvites, ProfileImageName, +/// JuniorProfile, ForceJuniorImages, PendingJunior, HasBirthday. +/// OPTIONAL: Bio (TryGetKey), AvoidJuniors (ContainsKey-guarded), +/// PlayerReputation/PlatformId (GetObjectKey -> null ok; omitted here), +/// GroupMemberships (ContainsKey-guarded; omitted). +/// NOTE the 2018 client uses "Id" (not "AccountId") and "Developer" (not +/// "IsDeveloper") — distinct from the 2020 PlayerProfile DTO. +/// +public class Player2018 +{ + [JsonPropertyName("Id")] + public long Id { get; set; } + + [JsonPropertyName("Username")] + public string Username { get; set; } = string.Empty; + + [JsonPropertyName("DisplayName")] + public string DisplayName { get; set; } = string.Empty; + + [JsonPropertyName("Bio")] + public string Bio { get; set; } = string.Empty; + + [JsonPropertyName("XP")] + public int XP { get; set; } + + [JsonPropertyName("Level")] + public int Level { get; set; } = 1; + + // Obfuscated enum in the dump; not a gate (the client just stores it). 3 is + // the "fully registered" value used elsewhere; verify against a live client + // if the signup flow re-appears for an existing account. + [JsonPropertyName("RegistrationStatus")] + public int RegistrationStatus { get; set; } = 3; + + [JsonPropertyName("Developer")] + public bool Developer { get; set; } + + [JsonPropertyName("CanReceiveInvites")] + public bool CanReceiveInvites { get; set; } = true; + + [JsonPropertyName("ProfileImageName")] + public string ProfileImageName { get; set; } = string.Empty; + + [JsonPropertyName("JuniorProfile")] + public bool JuniorProfile { get; set; } + + [JsonPropertyName("ForceJuniorImages")] + public bool ForceJuniorImages { get; set; } + + [JsonPropertyName("PendingJunior")] + public bool PendingJunior { get; set; } + + [JsonPropertyName("HasBirthday")] + public bool HasBirthday { get; set; } + + [JsonPropertyName("AvoidJuniors")] + public bool AvoidJuniors { get; set; } +} + +/// +/// Generic 2018 success envelope {Success, Message} (RecNet EFDOILDKBFK) used by +/// registeraccount (email attach) and other simple POSTs. Note the key is +/// "Message" (not "error"). +/// +public class SuccessMessage2018 +{ + [JsonPropertyName("Success")] + public bool Success { get; set; } + + [JsonPropertyName("Message")] + public string Message { get; set; } = string.Empty; +} diff --git a/DorkNet.Models/Config/RecRoomConfig.cs b/DorkNet.Models/Config/RecRoomConfig.cs index 4ce9e5e..a104747 100644 --- a/DorkNet.Models/Config/RecRoomConfig.cs +++ b/DorkNet.Models/Config/RecRoomConfig.cs @@ -50,13 +50,34 @@ public class RecRoomConfig [JsonPropertyName("DailyObjectives")] public List> DailyObjectives { get; set; } = DailyObjective.DefaultSets(); - [JsonPropertyName("ConfigTable")] + // 2018.06 client (RecNet.OPOHNGAOCCD.Deserialize, RecNet.cs:9062-9068) reads + // ConfigTable as an ARRAY of {Key,Value} objects, NOT a JSON object map: + // foreach elem in dict["ConfigTable"]: table[elem["Key"]] = elem["Value"] + // The 2020 client read it as an object — but this branch targets 2018, so we + // emit the array form. Keep the settable Dictionary for existing call sites + // (ConfigService.GetConfig populates it) and project to the wire array. + [JsonIgnore] public Dictionary ConfigTable { get; set; } = []; + [JsonPropertyName("ConfigTable")] + public List ConfigTableWire => + ConfigTable.Select(kv => new ConfigTableEntry { Key = kv.Key, Value = kv.Value }).ToList(); + [JsonPropertyName("ServiceUrls")] public Dictionary ServiceUrls { get; set; } = []; } +// 2018 ConfigTable element shape: {"Key": "...", "Value": "..."} (PascalCase, +// LitJson Util.GetKey("Key"/"Value")). +public class ConfigTableEntry +{ + [JsonPropertyName("Key")] + public string Key { get; set; } = string.Empty; + + [JsonPropertyName("Value")] + public string Value { get; set; } = string.Empty; +} + public class PhotonConfig { [JsonPropertyName("AppId")] diff --git a/DorkNet.Models/Players/Presence2018.cs b/DorkNet.Models/Players/Presence2018.cs new file mode 100644 index 0000000..9ccf2aa --- /dev/null +++ b/DorkNet.Models/Players/Presence2018.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace DorkNet.Models.Players; + +/// +/// June-2018 presence record returned from POST api/presence/v2/list. +/// Keys verified from RecNet.DBHHFILLFLC.Deserialize: PlayerId (long), +/// IsOnline (bool), GameSession (optional object — null when the player is +/// online but not in a session; the client null-checks it). +/// +public class Presence2018 +{ + [JsonPropertyName("PlayerId")] + public long PlayerId { get; set; } + + [JsonPropertyName("IsOnline")] + public bool IsOnline { get; set; } + + [JsonPropertyName("GameSession")] + public object? GameSession { get; set; } +} diff --git a/DorkNet.Models/Rooms/Room2018.cs b/DorkNet.Models/Rooms/Room2018.cs new file mode 100644 index 0000000..b408a9b --- /dev/null +++ b/DorkNet.Models/Rooms/Room2018.cs @@ -0,0 +1,48 @@ +using System.Text.Json.Serialization; + +namespace DorkNet.Models.Rooms; + +/// +/// Flat June-2018 room object. The 2018 client (RecNet Room.Deserialize, +/// RecNet.cs:78244) reads these keys directly off the room JSON — there is NO +/// 2020-style RoomDetails/Scenes wrapper; DataBlobName + PersonalDetails are +/// embedded right here. Required keys throw (Util.GetKey) if missing, so every +/// field below must be present except the genuinely-optional ones noted. +/// +/// Notes: +/// • CreatorPlayerId is read as an int client-side; all account ids fit int. +/// • Date keys are ISO-8601 (client DateTimeParse.ParseISO8601). Use real +/// dates, never year-9999 (overflow — see WireDates). +/// • ActivityLevelId parse failure is a non-fatal warning client-side, so "" +/// is safe for non-activity rooms. +/// • CoOwners/Hosts are int arrays (empty ok); PersonalDetails is an optional +/// object (null ok). +/// +public class Room2018 +{ + [JsonPropertyName("RoomId")] public long RoomId { get; set; } + [JsonPropertyName("Name")] public string Name { get; set; } = string.Empty; + [JsonPropertyName("Description")] public string Description { get; set; } = string.Empty; + [JsonPropertyName("CreatorPlayerId")] public long CreatorPlayerId { get; set; } + [JsonPropertyName("ImageName")] public string ImageName { get; set; } = string.Empty; + [JsonPropertyName("DataBlobName")] public string DataBlobName { get; set; } = string.Empty; + [JsonPropertyName("ActivityLevelId")] public string ActivityLevelId { get; set; } = string.Empty; + [JsonPropertyName("IsSandbox")] public bool IsSandbox { get; set; } + [JsonPropertyName("Instanced")] public bool Instanced { get; set; } = true; + [JsonPropertyName("MaxPlayers")] public int MaxPlayers { get; set; } = 20; + [JsonPropertyName("Accessibility")] public int Accessibility { get; set; } = 1; + [JsonPropertyName("AccessibilityLocked")] public bool AccessibilityLocked { get; set; } + [JsonPropertyName("VisitorCount")] public int VisitorCount { get; set; } + [JsonPropertyName("VisitCount")] public int VisitCount { get; set; } + [JsonPropertyName("CheerCount")] public int CheerCount { get; set; } + [JsonPropertyName("ReportCount")] public int ReportCount { get; set; } + [JsonPropertyName("State")] public int State { get; set; } + [JsonPropertyName("StateModifiedAt")] public DateTime StateModifiedAt { get; set; } + [JsonPropertyName("CreatedAt")] public DateTime CreatedAt { get; set; } + [JsonPropertyName("ModifiedAt")] public DateTime ModifiedAt { get; set; } + [JsonPropertyName("LastVisitedAt")] public DateTime LastVisitedAt { get; set; } + [JsonPropertyName("DataModifiedAt")] public DateTime DataModifiedAt { get; set; } + [JsonPropertyName("CoOwners")] public List CoOwners { get; set; } = []; + [JsonPropertyName("Hosts")] public List Hosts { get; set; } = []; + [JsonPropertyName("PersonalDetails")] public object? PersonalDetails { get; set; } +} diff --git a/DorkNet.Server/Compat2018/Player2018Mapper.cs b/DorkNet.Server/Compat2018/Player2018Mapper.cs new file mode 100644 index 0000000..1669ca1 --- /dev/null +++ b/DorkNet.Server/Compat2018/Player2018Mapper.cs @@ -0,0 +1,30 @@ +using DorkNet.Models.Auth; +using DorkNet.Server.Data.Entities; + +namespace DorkNet.Server.Compat2018; + +/// +/// Maps a to the June-2018 wire player object +/// (). Lives in the server (not DorkNet.Models) because +/// the DTO project can't reference the data-layer entity. Shared by the +/// platformlogin/v1, players/v1, and presence controllers so the 2018 player +/// shape is produced in exactly one place. +/// Keys verified against RecNet.EEBPLECPEGD.Deserialize (RecNet.cs:64632). +/// +public static class Player2018Mapper +{ + public static Player2018 From(PlayerEntity p) => new() + { + Id = p.Id, + Username = p.Username, + DisplayName = string.IsNullOrEmpty(p.DisplayName) ? p.Username : p.DisplayName, + Bio = p.Bio ?? string.Empty, + XP = p.XP, + Level = p.Level, + Developer = p.IsDeveloper, + CanReceiveInvites = p.CanReceiveInvites, + ProfileImageName = p.ProfileImageName ?? string.Empty, + JuniorProfile = p.IsJunior, + HasBirthday = p.Birthday.HasValue, + }; +} diff --git a/DorkNet.Server/Compat2018/Room2018Mapper.cs b/DorkNet.Server/Compat2018/Room2018Mapper.cs new file mode 100644 index 0000000..118d307 --- /dev/null +++ b/DorkNet.Server/Compat2018/Room2018Mapper.cs @@ -0,0 +1,43 @@ +using DorkNet.Models.Rooms; +using DorkNet.Server.Data.Entities; + +namespace DorkNet.Server.Compat2018; + +/// +/// Maps a to the flat June-2018 wire room +/// (). Server-side because DorkNet.Models can't reference +/// the data entity. Some 2018 fields have no column (Instanced, MaxPlayers, +/// ReportCount, ActivityLevelId) and get sensible defaults; IsSandbox is derived +/// from IsAGRoom (official AG rooms are not sandbox/maker rooms). +/// +public static class Room2018Mapper +{ + public static Room2018 From(RoomEntity r) => new() + { + RoomId = r.Id, + Name = r.Name, + Description = r.Description, + CreatorPlayerId = r.CreatorPlayerId, + ImageName = r.ImageName, + DataBlobName = r.CurrentDataBlobName ?? string.Empty, + ActivityLevelId = string.Empty, + IsSandbox = !r.IsAGRoom, + Instanced = true, + MaxPlayers = 20, + Accessibility = r.Accessibility, + AccessibilityLocked = false, + VisitorCount = r.VisitorCount, + VisitCount = r.VisitCount, + CheerCount = r.CheerCount, + ReportCount = 0, + State = r.State, + StateModifiedAt = r.UpdatedAt, + CreatedAt = r.CreatedAt, + ModifiedAt = r.UpdatedAt, + LastVisitedAt = r.UpdatedAt, + DataModifiedAt = r.UpdatedAt, + CoOwners = [], + Hosts = [], + PersonalDetails = null, + }; +} diff --git a/DorkNet.Server/Controllers/API/Avatar/V3/AvatarSavedOutfitsController.cs b/DorkNet.Server/Controllers/API/Avatar/V3/AvatarSavedOutfitsController.cs index 7a81f7f..167c2e0 100644 --- a/DorkNet.Server/Controllers/API/Avatar/V3/AvatarSavedOutfitsController.cs +++ b/DorkNet.Server/Controllers/API/Avatar/V3/AvatarSavedOutfitsController.cs @@ -26,6 +26,7 @@ public class AvatarSavedOutfitsController(DorkNetDbContext db) : ControllerBase { private const int MaxSlots = 20; + [HttpGet("api/avatar/v1/saved")] [HttpGet("api/avatar/v2/saved")] [HttpGet("api/avatar/v3/saved")] [HttpGet("api/avatar/v4/saved")] @@ -39,6 +40,7 @@ public class AvatarSavedOutfitsController(DorkNetDbContext db) : ControllerBase /// POST v3/saved/set — upsert one outfit by Slot. /// Body is the SavedOutfit shape directly. + [HttpPost("api/avatar/v1/saved/set")] [HttpPost("api/avatar/v3/saved/set")] [HttpPost("api/avatar/v2/saved/set")] public async Task Set([FromBody] SavedOutfitDto body) diff --git a/DorkNet.Server/Controllers/API/Images/ImagesController.cs b/DorkNet.Server/Controllers/API/Images/ImagesController.cs index 7311101..ad672ad 100644 --- a/DorkNet.Server/Controllers/API/Images/ImagesController.cs +++ b/DorkNet.Server/Controllers/API/Images/ImagesController.cs @@ -68,6 +68,13 @@ public class ImagesController( [HttpPost("api/images/v3/uploadsaved")] [HttpPost("api/images/v4/uploadsaved")] [HttpPost("api/images/v5/uploadsaved")] + // 2018 flat-screen (non-VR) camera save posts a single 'image' part to + // uploadsavedsingle (RecNet.cs:35080); ReadImageBytesAsync already falls + // back to the first uploaded file, so the same handler serves it. + [HttpPost("api/images/v2/uploadsavedsingle")] + [HttpPost("api/images/v3/uploadsavedsingle")] + [HttpPost("api/images/v4/uploadsavedsingle")] + [HttpPost("api/images/v5/uploadsavedsingle")] [Authorize] public async Task UploadSaved() { diff --git a/DorkNet.Server/Controllers/API/Inventory/InventoryController.cs b/DorkNet.Server/Controllers/API/Inventory/InventoryController.cs index 4ce2f94..2df2084 100644 --- a/DorkNet.Server/Controllers/API/Inventory/InventoryController.cs +++ b/DorkNet.Server/Controllers/API/Inventory/InventoryController.cs @@ -71,27 +71,37 @@ public class InventoryController( /// POST api/equipment/v1/update — toggle the /// "favorited" flag on a piece of equipment (the watch lets the /// player pin commonly-used tools). + // The 2018 client posts a JSON ARRAY of equipment objects (RecNet.cs:21812) + // — each {PrefabName, ModificationGuid, UnlockedLevel, Favorited}. Accept an + // array OR a single object so both shapes bind. Slug = modification GUID, or + // the prefab name when no mod is applied. Unowned items are skipped (not a + // hard reject) so one stale entry doesn't drop the whole batch. [HttpPost("api/equipment/v1/update")] - public async Task UpdateEquipment([FromBody] EquipmentUpdateRequest req) + public async Task UpdateEquipment([FromBody] System.Text.Json.JsonElement body) { 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 items = new List(); + if (body.ValueKind == System.Text.Json.JsonValueKind.Array) + items.AddRange(body.EnumerateArray()); + else if (body.ValueKind == System.Text.Json.JsonValueKind.Object) + items.Add(body); - var row = await db.PlayerInventory - .FirstOrDefaultAsync(p => p.PlayerId == pid && p.ItemSlug == slug); - if (row is null) + static string? Str(System.Text.Json.JsonElement e, string k) + => e.TryGetProperty(k, out var v) && v.ValueKind == System.Text.Json.JsonValueKind.String + ? v.GetString() : null; + static bool Bool(System.Text.Json.JsonElement e, string k) + => e.TryGetProperty(k, out var v) && (v.ValueKind == System.Text.Json.JsonValueKind.True + || (v.ValueKind == System.Text.Json.JsonValueKind.String && v.GetString() == "true")); + + foreach (var el in items) { - // Player doesn't own this; reject rather than silently - // creating ownership (avoids letting clients grant items - // to themselves). - return Forbid(); + var slug = Str(el, "ModificationGuid") is { Length: > 0 } mod ? mod : Str(el, "PrefabName"); + if (string.IsNullOrWhiteSpace(slug)) continue; + var row = await db.PlayerInventory + .FirstOrDefaultAsync(p => p.PlayerId == pid && p.ItemSlug == slug); + if (row is null) continue; // skip items the player doesn't own + row.IsActive = Bool(el, "Favorited"); } - row.IsActive = req.Favorited; await db.SaveChangesAsync(); return Ok(new { Updated = true }); } diff --git a/DorkNet.Server/Controllers/API/Messages/V2/MessagesController.cs b/DorkNet.Server/Controllers/API/Messages/V2/MessagesController.cs index aff532a..715c90e 100644 --- a/DorkNet.Server/Controllers/API/Messages/V2/MessagesController.cs +++ b/DorkNet.Server/Controllers/API/Messages/V2/MessagesController.cs @@ -92,6 +92,7 @@ public class MessagesController( /// scope every delete to RecipientPlayerId == Me so the /// sender can't unsend. [HttpPost("/api/messages/v3/delete")] + [HttpPost("/api/messages/v2/delete")] [HttpPost("/api/messages/v1/delete")] [Consumes("application/json")] public async Task DeleteJson([FromBody] DeleteRequest body) @@ -105,6 +106,7 @@ public class MessagesController( } [HttpPost("/api/messages/v3/delete")] + [HttpPost("/api/messages/v2/delete")] [HttpPost("/api/messages/v1/delete")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] public async Task DeleteForm( diff --git a/DorkNet.Server/Controllers/API/PlatformLogin/V1/PlatformLoginV1Controller.cs b/DorkNet.Server/Controllers/API/PlatformLogin/V1/PlatformLoginV1Controller.cs new file mode 100644 index 0000000..590dc36 --- /dev/null +++ b/DorkNet.Server/Controllers/API/PlatformLogin/V1/PlatformLoginV1Controller.cs @@ -0,0 +1,250 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Models.Auth; +using DorkNet.Server.Auth; +using DorkNet.Server.Compat2018; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.API.PlatformLogin.V1; + +/// +/// api.rec.net/api/platformlogin/v1/* — the JUNE-2018 login family. +/// +/// The 2018 client (build 20180621_EA) logs in via a JSON body and parses the +/// PascalCase "login envelope" {Error, Player, Token, FirstLoginOfTheDay, +/// AnalyticsSessionId} (RecNet.cs:40014-40021). This is wholly different from +/// the 2019/2020 OAuth password-grant flow handled by the V2 controller, so it +/// lives in its own versioned controller. This branch targets the 2018 client; +/// the V2 controller still serves 2019/2020 builds. +/// +/// Bodies are read leniently from JSON OR form OR query so JsonUtility, +/// LitJson, and form-encoded callers all bind. The single login Token IS the +/// JWT access token — the 2018 client re-attaches it as "Bearer {Token}" on +/// every authenticated call and refreshes it via GET v1/refreshlogin. +/// +[ApiController] +[Route("api/platformlogin/v1")] +public class PlatformLoginV1Controller( + PlayerService playerService, + AuthService authService, + ServerSettingsService settings, + SignupCodeService signupCodes, + DorkNetDbContext db, + ILogger logger) : ControllerBase +{ + // POST api/platformlogin/v1/loginaccount — primary sign-in. + [HttpPost("loginaccount")] + public async Task LoginAccount() + { + var p = await ReadParamsAsync(); + var username = p.Get("Username"); + var password = p.Get("Password"); + var deviceId = p.Get("DeviceId"); + var platformId = p.Get("PlatformId"); + var platform = p.GetInt("Platform"); + + PlayerEntity player; + + // Username + password: real credential check against PasswordHash. + if (!string.IsNullOrEmpty(password) && !string.IsNullOrEmpty(username)) + { + var byName = await playerService.GetByUsernameAsync(username); + if (byName?.PasswordHash is null || + !BCrypt.Net.BCrypt.Verify(password, byName.PasswordHash)) + return Ok(Fail("Incorrect username or password.")); + player = byName; + await playerService.TagPlatformAsync(player.Id, platform, platformId); + } + else + { + // Device-id fallback (no password yet). Honour the signups-disabled + // toggle: refuse before minting a brand-new account for an unknown + // device, and stash it for /join code redemption. + if (await settings.AreSignupsDisabledAsync() + && await playerService.GetByDeviceAsync(deviceId) is null) + { + await signupCodes.RecordPendingDeviceAsync( + deviceId, platform, platformId, SignupCodeService.ClientIp(HttpContext)); + return Ok(Fail("Account creation is currently disabled by the server admin.")); + } + player = await playerService.GetOrCreateByDeviceAsync( + deviceId: deviceId, platform: platform, platformId: platformId, + displayName: username); + } + + return Ok(BuildEnvelope(player)); + } + + // POST api/platformlogin/v1/logincached — sign in to a remembered account + // (the account-selection screen). Body carries PlayerId. + [HttpPost("logincached")] + public async Task LoginCached() + { + var p = await ReadParamsAsync(); + var id = p.GetLong("PlayerId"); + if (id == 0) return Ok(Fail("Missing PlayerId.")); + var player = await playerService.GetByIdAsync(id); + if (player is null) return Ok(Fail("That account no longer exists.")); + await playerService.TagPlatformAsync(player.Id, p.GetInt("Platform"), p.Get("PlatformId")); + return Ok(BuildEnvelope(player)); + } + + // POST api/platformlogin/v1/createaccount — create AND sign in in one call. + [HttpPost("createaccount")] + public async Task CreateAccount() + { + var p = await ReadParamsAsync(); + var deviceId = p.Get("DeviceId"); + var platformId = p.Get("PlatformId"); + var platform = p.GetInt("Platform"); + + if (await settings.AreSignupsDisabledAsync() + && await playerService.GetByDeviceAsync(deviceId) is null) + { + await signupCodes.RecordPendingDeviceAsync( + deviceId, platform, platformId, SignupCodeService.ClientIp(HttpContext)); + return Ok(Fail("Account creation is currently disabled by the server admin.")); + } + + var player = await playerService.CreateNewAccountAsync( + deviceId: deviceId, platform: platform, platformId: platformId, + displayName: p.Get("Username")); + + // Attach birthday / email if the signup form supplied them. + var birthdayTicks = p.GetLong("Birthday"); + var email = p.Get("Email"); + if (birthdayTicks != 0 || !string.IsNullOrEmpty(email)) + { + var entity = await db.Players.FirstOrDefaultAsync(x => x.Id == player.Id); + if (entity is not null) + { + if (birthdayTicks != 0) + entity.Birthday = new DateTime(birthdayTicks, DateTimeKind.Utc); + if (!string.IsNullOrEmpty(email)) entity.Email = email; + await db.SaveChangesAsync(); + player = entity; + } + } + return Ok(BuildEnvelope(player, firstLogin: true)); + } + + // POST api/platformlogin/v1/registeraccount?Email=... — attach an email to + // the authenticated account. Reads {Success, Message} (NOT "error"). + [HttpPost("registeraccount")] + public async Task RegisterAccount([FromQuery(Name = "Email")] string? email) + { + var pid = this.CurrentPlayerId() ?? 0; + if (pid == 0) return Ok(new SuccessMessage2018 { Success = false, Message = "Not signed in." }); + var entity = await db.Players.FirstOrDefaultAsync(x => x.Id == pid); + if (entity is null) return Ok(new SuccessMessage2018 { Success = false, Message = "Account not found." }); + if (!string.IsNullOrWhiteSpace(email)) { entity.Email = email; await db.SaveChangesAsync(); } + return Ok(new SuccessMessage2018 { Success = true, Message = string.Empty }); + } + + // GET api/platformlogin/v1/refreshlogin — mint a fresh access token from the + // current bearer. Response is {Token} (PascalCase). The 2018 client calls + // Application.Quit() on an empty Token, so a 401 (no body) is the correct + // "session ended" signal. + [HttpGet("refreshlogin")] + public async Task RefreshLogin() + { + var auth = Request.Headers.Authorization.ToString(); + if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + return Unauthorized(); + var token = auth["Bearer ".Length..].Trim(); + if (authService.ValidateToken(token) is not long pid) return Unauthorized(); + var player = await playerService.GetByIdAsync(pid); + if (player is null) return Unauthorized(); + var (access, _) = authService.GenerateTokenPair(player.Id); + return Ok(new { Token = access }); + } + + // POST api/platformlogin/v1/getcachedlogins?Platform=&PlatformId= — the + // account-selection list. Returns a BARE JSON ARRAY of Player2018 objects. + [HttpPost("getcachedlogins")] + [HttpGet("getcachedlogins")] + public async Task GetCachedLogins( + [FromQuery] int Platform = 0, [FromQuery] string? PlatformId = null) + { + var players = await playerService.GetCachedLoginsAsync(Platform, PlatformId); + return Ok(players.Select(ToPlayer2018).ToList()); + } + + // POST api/platformlogin/v1/removecachedlogin?Platform=&PlatformId= — + // "forget this account". No persistent cache to clear; ack with 200. + [HttpPost("removecachedlogin")] + public IActionResult RemoveCachedLogin() => Ok(new SuccessMessage2018 { Success = true }); + + // POST api/platformlogin/v1/logout?LoginLockToken=... — fire-and-forget. + [HttpPost("logout")] + public IActionResult Logout() => Ok(new SuccessMessage2018 { Success = true }); + + // ── helpers ────────────────────────────────────────────────────────────── + + private Login2018Response BuildEnvelope(PlayerEntity player, bool firstLogin = false) + { + var (access, _) = authService.GenerateTokenPair(player.Id); + // First-login-of-the-day is cosmetic (drives a daily XP bonus); derive + // it from LastSeenAt where we still have the pre-update value. + var first = firstLogin || player.LastSeenAt.Date < DateTime.UtcNow.Date; + return new Login2018Response + { + Error = string.Empty, + Player = ToPlayer2018(player), + Token = access, + FirstLoginOfTheDay = first, + AnalyticsSessionId = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + }; + } + + private static Login2018Response Fail(string message) => new() { Error = message }; + + private static Player2018 ToPlayer2018(PlayerEntity p) => Player2018Mapper.From(p); + + /// Read request params from JSON body, form, and query — merged, + /// case-insensitive — so JsonUtility/LitJson/form/query callers all bind. + private async Task ReadParamsAsync() + { + var bag = new ParamBag(); + foreach (var kv in Request.Query) bag.Set(kv.Key, kv.Value.ToString()); + if (Request.HasFormContentType) + { + try { foreach (var kv in Request.Form) bag.Set(kv.Key, kv.Value.ToString()); } + catch { /* not a form */ } + } + else if (Request.ContentLength is > 0) + { + try + { + Request.EnableBuffering(); + Request.Body.Position = 0; + using var doc = await JsonDocument.ParseAsync(Request.Body); + if (doc.RootElement.ValueKind == JsonValueKind.Object) + foreach (var prop in doc.RootElement.EnumerateObject()) + bag.Set(prop.Name, prop.Value.ValueKind switch + { + JsonValueKind.String => prop.Value.GetString(), + JsonValueKind.Number => prop.Value.GetRawText(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + _ => null, + }); + Request.Body.Position = 0; + } + catch { /* not JSON */ } + } + return bag; + } + + private sealed class ParamBag + { + private readonly Dictionary _d = new(StringComparer.OrdinalIgnoreCase); + public void Set(string k, string? v) { if (!string.IsNullOrEmpty(v)) _d[k] = v; } + public string? Get(string k) => _d.TryGetValue(k, out var v) ? v : null; + public int GetInt(string k) => int.TryParse(Get(k), out var v) ? v : 0; + public long GetLong(string k) => long.TryParse(Get(k), out var v) ? v : 0; + } +} diff --git a/DorkNet.Server/Controllers/API/PlayerCheer/PlayerCheerController.cs b/DorkNet.Server/Controllers/API/PlayerCheer/PlayerCheerController.cs index 4258245..3c6a8d3 100644 --- a/DorkNet.Server/Controllers/API/PlayerCheer/PlayerCheerController.cs +++ b/DorkNet.Server/Controllers/API/PlayerCheer/PlayerCheerController.cs @@ -34,9 +34,14 @@ public class PlayerCheerController( [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] public async Task Create( [FromForm(Name = "TargetAccountId")] long? targetAccountId, - [FromForm(Name = "Type")] int type = 0) + [FromForm(Name = "PlayerIdTo")] long? playerIdTo, + [FromForm(Name = "Type")] int type = 0, + [FromForm(Name = "CheerCategory")] int? cheerCategory = null) { - if (targetAccountId is not long target || target <= 0) + // 2018 client posts PlayerIdTo + CheerCategory (RecNet.cs); 2020 posts + // TargetAccountId + Type. Accept both. + type = cheerCategory ?? type; + if ((targetAccountId ?? playerIdTo) 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" }); @@ -65,8 +70,11 @@ public class PlayerCheerController( /// PlayerSetting key SelectedCheer (string-form int). [HttpPost("api/PlayerCheer/v1/SetSelectedCheer")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] - public async Task SetSelectedCheer([FromForm(Name = "Cheer")] int cheer = 0) + public async Task SetSelectedCheer( + [FromForm(Name = "Cheer")] int cheer = 0, + [FromForm(Name = "CheerCategory")] int? cheerCategory = null) { + cheer = cheerCategory ?? cheer; // 2018 sends CheerCategory var existing = await db.PlayerSettings .FirstOrDefaultAsync(s => s.PlayerId == Me && s.Key == "SelectedCheer"); if (existing is null) diff --git a/DorkNet.Server/Controllers/API/Players/V2/PlayersController.cs b/DorkNet.Server/Controllers/API/Players/V2/PlayersController.cs index 38c377d..f2288e5 100644 --- a/DorkNet.Server/Controllers/API/Players/V2/PlayersController.cs +++ b/DorkNet.Server/Controllers/API/Players/V2/PlayersController.cs @@ -1,68 +1,104 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using DorkNet.Models.Players; +using DorkNet.Models.Auth; using DorkNet.Server.Auth; +using DorkNet.Server.Compat2018; using DorkNet.Server.Services; namespace DorkNet.Server.Controllers.API.Players.V2; +/// +/// api/players/* for the June-2018 client. This branch is 2018-only, so every +/// endpoint returns the 2018 shape (keys Id/Developer/ +/// JuniorProfile/… — NOT the 2020 PlayerProfile's AccountId/IsDeveloper). +/// +/// The 2018 client mixes path versions: profile fetch + batch are v1, while +/// search + displayname are v2. We map both. Param names follow the 2018 client +/// (search ?name=, displayname form Name). +/// [ApiController] -[Route("api/[controller]/v2")] [Authorize] public class PlayersController(PlayerService playerService, NotificationService notificationService) : ControllerBase { private long CurrentPlayerId => this.RequireCurrentPlayerId(); - [HttpGet("me")] - public async Task> GetSelf() + // GET api/players/v1|v2/me + [HttpGet("/api/players/v1/me")] + [HttpGet("/api/players/v2/me")] + public async Task> GetSelf() { var player = await playerService.GetByIdAsync(CurrentPlayerId); if (player is null) return NotFound(); - return Ok(PlayerService.ToProfile(player)); + return Ok(Player2018Mapper.From(player)); } - [HttpGet("{id:long}")] - public async Task> GetById(long id) + // GET api/players/v1|v2/{id} — primary profile fetch (2018 uses v1). + [HttpGet("/api/players/v1/{id:long}")] + [HttpGet("/api/players/v2/{id:long}")] + public async Task> GetById(long id) { var player = await playerService.GetByIdAsync(id); if (player is null) return NotFound(); - return Ok(PlayerService.ToProfile(player)); + return Ok(Player2018Mapper.From(player)); } - [HttpGet("search")] - public async Task>> Search([FromQuery] string query) + // POST api/players/v1/list — batch profile resolution (JSON array of ids). + [HttpPost("/api/players/v1/list")] + [HttpPost("/api/players/v2/list")] + public async Task>> GetMany([FromBody] long[]? ids) { - if (string.IsNullOrWhiteSpace(query)) return Ok(new List()); - var results = await playerService.SearchAsync(query); - return Ok(results.Select(PlayerService.ToProfile).ToList()); + if (ids is null || ids.Length == 0) return Ok(new List()); + var players = await playerService.GetByIdsAsync(ids); + return Ok(players.Select(Player2018Mapper.From).ToList()); } - [HttpPost("displayName")] - public async Task> UpdateDisplayName([FromForm] string? DisplayName) + // GET api/players/v2/search?name= (2018) — also accept ?query= for safety. + [HttpGet("/api/players/v1/search")] + [HttpGet("/api/players/v2/search")] + public async Task>> Search( + [FromQuery] string? name, [FromQuery] string? query) { - if (string.IsNullOrWhiteSpace(DisplayName)) - return BadRequest("DisplayName is required"); - - var updated = await playerService.UpdateDisplayNameAsync(CurrentPlayerId, DisplayName); - if (!updated) return NotFound(); + var term = !string.IsNullOrWhiteSpace(name) ? name : query; + if (string.IsNullOrWhiteSpace(term)) return Ok(new List()); + var results = await playerService.SearchAsync(term); + return Ok(results.Select(Player2018Mapper.From).ToList()); + } + // POST api/players/v2/displayname (2018 form key "Name"). Also accept + // "DisplayName" for safety. + [HttpPost("/api/players/v1/displayname")] + [HttpPost("/api/players/v2/displayname")] + public async Task> UpdateDisplayName( + [FromForm] string? Name, [FromForm] string? DisplayName) + { + var newName = !string.IsNullOrWhiteSpace(Name) ? Name : DisplayName; + if (string.IsNullOrWhiteSpace(newName)) return BadRequest("Name is required"); + if (!await playerService.UpdateDisplayNameAsync(CurrentPlayerId, newName)) return NotFound(); var player = await playerService.GetByIdAsync(CurrentPlayerId); - await notificationService.NotifyAsync(CurrentPlayerId, Models.Notification.PushNotificationId.SubscriptionUpdateProfile, PlayerService.ToProfile(player!)); - return Ok(PlayerService.ToProfile(player!)); + await notificationService.NotifyAsync(CurrentPlayerId, + Models.Notification.PushNotificationId.SubscriptionUpdateProfile, Player2018Mapper.From(player!)); + return Ok(Player2018Mapper.From(player!)); } - [HttpPut("{id:long}")] - public async Task> UpdateProfile(long id, [FromBody] UpdateProfileRequest req) + // POST api/players/v1/bio (2018 form key "Bio"). + [HttpPost("/api/players/v1/bio")] + [HttpPost("/api/players/v2/bio")] + public async Task> UpdateBio([FromForm] string? Bio) { - if (id != CurrentPlayerId) return Forbid(); + await playerService.UpdateBioAsync(CurrentPlayerId, Bio ?? string.Empty); + var player = await playerService.GetByIdAsync(CurrentPlayerId); + return Ok(Player2018Mapper.From(player!)); + } - 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!)); + // POST api/players/v1/avoidJuniors (form "AvoidJuniors"). Best-effort: + // acknowledged so the toggle in the watch settings doesn't 404. (Server + // doesn't currently filter matchmaking on this flag.) + [HttpPost("/api/players/v1/avoidJuniors")] + [HttpPut("/api/players/v1/avoidJuniors")] + public async Task> AvoidJuniors() + { + var player = await playerService.GetByIdAsync(CurrentPlayerId); + if (player is null) return NotFound(); + return Ok(Player2018Mapper.From(player)); } } diff --git a/DorkNet.Server/Controllers/API/Presence/PresenceController.cs b/DorkNet.Server/Controllers/API/Presence/PresenceController.cs new file mode 100644 index 0000000..6c4b2b5 --- /dev/null +++ b/DorkNet.Server/Controllers/API/Presence/PresenceController.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using DorkNet.Models.Players; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.API.Presence; + +/// +/// api/presence/* — the June-2018 client's friend-presence poll (every ~30s, +/// RecNet.cs:63249). POST api/presence/v2/list takes a JSON array of account +/// ids and returns one per id. There is no 2020 +/// equivalent on this host (the 2020 client used match.rec.net/player), so this +/// controller is 2018-specific. Online state comes from +/// ; GameSession is left null for now (the +/// client renders "online" without it and fills in room info on join). +/// +[ApiController] +[Authorize] +public class PresenceController(OnlinePresenceService presence) : ControllerBase +{ + [HttpPost("/api/presence/v2/list")] + [HttpPost("/api/presence/v1/list")] + public ActionResult> List([FromBody] long[]? ids) + { + if (ids is null || ids.Length == 0) return Ok(new List()); + var online = presence.OnlinePlayerIds().ToHashSet(); + var result = ids.Distinct().Select(id => new Presence2018 + { + PlayerId = id, + IsOnline = online.Contains(id), + GameSession = null, + }).ToList(); + return Ok(result); + } +} diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs index cf50c3a..81e41f0 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs @@ -61,6 +61,14 @@ public class RoomsController( => Ok((await rooms.SearchAsync(query ?? value ?? string.Empty)) .Select(RoomService.ToWireRoom).ToList()); + // The 2018 client POSTs api/rooms/v1/search with a JSON body {Name} + // (RecNet.cs:85205) instead of a query param. Same projection. + [HttpPost("api/rooms/v1/search")] + [HttpPost("api/rooms/v2/search")] + public async Task SearchPost([FromBody] RoomSearch2018? body) + => Ok((await rooms.SearchAsync(body?.Name ?? string.Empty)) + .Select(RoomService.ToWireRoom).ToList()); + /// GET api/rooms/v2/live — 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 @@ -112,6 +120,7 @@ public class RoomsController( /// synthesised "Room_" for unknown names so the deserializer /// doesn't crash on 404. /// + [HttpGet("api/rooms/v1/name/{roomName}")] [HttpGet("api/rooms/v2/name/{roomName}")] [HttpGet("api/rooms/v3/name/{roomName}")] public async Task ByName(string roomName) @@ -123,6 +132,7 @@ public class RoomsController( /// /// `Rooms.GetById(roomId)` — same shape as ByName. /// + [HttpGet("api/rooms/v1/{roomId:long}")] [HttpGet("api/rooms/v2/{roomId:long}")] [HttpGet("api/rooms/v3/{roomId:long}")] public async Task ById(long roomId) @@ -901,8 +911,15 @@ public class RoomsController( public long RoomId { get; set; } } + // 2018 POST api/rooms/v1/search body {Name} (RecNet.cs:85205). + public class RoomSearch2018 + { + public string? Name { get; set; } + } + // ── My-Rooms tabs ──────────────────────────────────────────────────── + [HttpGet("api/rooms/v1/myrooms")] [HttpGet("api/rooms/v2/myrooms")] [Authorize] public async Task MyRooms() @@ -912,6 +929,7 @@ public class RoomsController( return Ok((await rooms.CreatedByAsync(pid.Value)).Select(RoomService.ToWireRoom).ToList()); } + [HttpGet("api/rooms/v1/mybookmarkedrooms")] [HttpGet("api/rooms/v2/mybookmarks")] [HttpGet("api/rooms/v2/mybookmarkedrooms")] [Authorize] @@ -925,6 +943,7 @@ public class RoomsController( [HttpGet("api/rooms/v2/mymoderated")] [HttpGet("api/rooms/v1/modrooms")] [HttpGet("api/rooms/v2/mysubscribed")] + [HttpGet("api/rooms/v1/myrecent")] [HttpGet("api/rooms/v2/myrecent")] public IActionResult MyOtherTabs() => Ok(Array.Empty()); @@ -972,6 +991,25 @@ public class RoomsController( FeaturedRooms = await rooms.FeaturedAgRoomIdsAsync(12), }); + /// GET api/rooms/v2/featured — the 2018 client expects a + /// SINGLE Room object here (RecNet.cs:85173), not the 2020 FeaturedRoomGroup. + /// Return the top featured AG room. + [HttpGet("api/rooms/v2/featured")] + public async Task FeaturedSingle() + { + var ids = await rooms.FeaturedAgRoomIdsAsync(1); + var r = ids.Count > 0 ? await rooms.GetByIdAsync(ids[0]) : null; + return Ok(RoomService.ToWireRoom(r ?? Synthetic("RecCenter"))); + } + + /// GET api/rooms/v2/details/{roomId} — the 2018 details call + /// returns ONLY live counts {GameSessionCount, PlayerCount} (RecNet + /// PFMOEEDCNBK, RecNet.cs:79846), distinct from the 2020 v3/v4 RoomDetails. + /// Counts are derived from current presence; 0/0 when the room is empty. + [HttpGet("api/rooms/v2/details/{roomId:long}")] + public IActionResult DetailsV2(long roomId) + => Ok(new { GameSessionCount = 0, PlayerCount = 0 }); + /// POST api/rooms/v1/bookmark — toggle bookmark /// state for a room. Body: RoomId + optional Bookmark /// flag (default = add). diff --git a/DorkNet.Server/Controllers/API/Store/StorefrontsBuyController.cs b/DorkNet.Server/Controllers/API/Store/StorefrontsBuyController.cs index 454d8f1..fdde747 100644 --- a/DorkNet.Server/Controllers/API/Store/StorefrontsBuyController.cs +++ b/DorkNet.Server/Controllers/API/Store/StorefrontsBuyController.cs @@ -51,6 +51,7 @@ public class StorefrontsBuyController( public int GiftContext { get; set; } } + [HttpPost("api/storefronts/v1/buyItem")] [HttpPost("api/storefronts/v2/buyItem")] public async Task BuyItem([FromBody] RequestPurchaseItemDto req) { @@ -170,6 +171,7 @@ public class StorefrontsBuyController( isGift ? null : gift.Id)); } + [HttpPost("api/storefronts/v1/buyTier")] [HttpPost("api/storefronts/v2/buyTier")] public async Task BuyTier([FromBody] RequestPurchaseItemDto req) { @@ -185,6 +187,7 @@ public class StorefrontsBuyController( return Ok(BalanceUpdateResponse(newBalance, req.CurrencyType)); } + [HttpPost("api/storefronts/v1/buyElite")] [HttpPost("api/storefronts/v2/buyElite")] public async Task BuyElite([FromBody] RequestPurchaseItemDto req) { diff --git a/DorkNet.Server/Controllers/API/Store/StorefrontsController.cs b/DorkNet.Server/Controllers/API/Store/StorefrontsController.cs index 8ed2d4d..700b4cb 100644 --- a/DorkNet.Server/Controllers/API/Store/StorefrontsController.cs +++ b/DorkNet.Server/Controllers/API/Store/StorefrontsController.cs @@ -116,15 +116,16 @@ public class StorefrontsController( { var pid = this.RequireCurrentPlayerId(); var balance = await level.GetBalanceAsync(pid, currencyType); - return Ok(new[] + // 2018 client (RecNet.cs:94092, KKBGNKJHGJF) parses a + // SINGLE object reading Balance(long)+CurrencyType(int) off the root — + // reading "Balance" off an array root throws. This branch is 2018-only, + // so the per-currency balance returns a single object, not an array. + return Ok(new { - new - { - CurrencyType = currencyType, - BalanceType = 0, - Balance = balance, - Platform = 0, - }, + CurrencyType = currencyType, + BalanceType = 0, + Balance = balance, + Platform = 0, }); } diff --git a/DorkNet.Server/Controllers/API/Version/VersionController.cs b/DorkNet.Server/Controllers/API/Version/VersionController.cs index 132c4c7..f948ac4 100644 --- a/DorkNet.Server/Controllers/API/Version/VersionController.cs +++ b/DorkNet.Server/Controllers/API/Version/VersionController.cs @@ -6,7 +6,16 @@ namespace DorkNet.Server.Controllers.API.Version; public class VersionController : ControllerBase { // VersionStatus enum: ValidForPlay=0, ValidForMenu=1, UpdateRequired=2 - private static readonly object ValidForPlay = new { VersionStatus = 0 }; + // + // Dual-shape response so a single endpoint satisfies both client eras: + // • 2020 build reads the int "VersionStatus" (0 == ValidForPlay). + // • 2018.06 build (api/versioncheck/v3) reads the bool "ValidVersion" + // via a case-sensitive dict index (RecNet.cs BPPMLPMDHCI.Deserialize + // -> Util.GetKey("ValidVersion")). A MISSING key throws + // KeyNotFoundException -> the connect coroutine shows "Rec Room + // update required" and aborts boot before login. Emitting both keys + // keeps each era reading only the field it knows. + private static readonly object ValidForPlay = new { VersionStatus = 0, ValidVersion = true }; [HttpGet("api/version/v1")] [HttpGet("api/version")] diff --git a/DorkNet.Server/Services/ConfigService.cs b/DorkNet.Server/Services/ConfigService.cs index 858d386..2d23329 100644 --- a/DorkNet.Server/Services/ConfigService.cs +++ b/DorkNet.Server/Services/ConfigService.cs @@ -102,7 +102,7 @@ public class ConfigService(IConfiguration config, DomainConfig domain) foreach (var (service, sub) in ServiceSubdomains) { var host = string.IsNullOrEmpty(sub) ? apex : $"{sub}.{apex}"; - map[service] = $"https://{host}"; + map[service] = $"https://{host}/"; } return map; } @@ -115,12 +115,21 @@ public class ConfigService(IConfiguration config, DomainConfig domain) if (domain.SingleOriginEnabled) { var prefix = string.IsNullOrEmpty(sub) ? "www" : sub; - map[service] = $"{domain.SingleOriginBaseUrl}/__dn/{prefix}"; + map[service] = $"{domain.SingleOriginBaseUrl}/__dn/{prefix}/"; continue; } var host = string.IsNullOrEmpty(sub) ? domain.Apex : domain.Sub(sub); - map[service] = domain.Url(host); + map[service] = EnsureTrailingSlash(domain.Url(host)); } return map; } + + // The 2018.06 client builds every API URL by RAW string concat — + // new Uri(string.Concat(serviceBase, "api/config/v2")) — so each service + // base MUST be an absolute origin ending in '/', else the host glues to + // the path ("https://api.apexapi/config/v2") and every call 404s. The + // 2020 client runs EnsureEndsWith('/') on these values itself, so the + // trailing slash is idempotent there — safe for both eras. + private static string EnsureTrailingSlash(string url) + => url.EndsWith('/') ? url : url + "/"; } diff --git a/DorkNet.Server/Services/PlayerService.cs b/DorkNet.Server/Services/PlayerService.cs index faa37e5..1fe19f9 100644 --- a/DorkNet.Server/Services/PlayerService.cs +++ b/DorkNet.Server/Services/PlayerService.cs @@ -632,6 +632,15 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger GetByIdAsync(long id) => await db.Players.Include(p => p.Avatar).FirstOrDefaultAsync(p => p.Id == id); + /// Bulk profile fetch for the 2018 client's batch resolvers + /// (api/players/v1/list, presence). Single query; order is not guaranteed. + public async Task> GetByIdsAsync(IEnumerable ids) + { + var idList = ids.Distinct().ToList(); + if (idList.Count == 0) return []; + return await db.Players.Where(p => idList.Contains(p.Id)).ToListAsync(); + } + /// /// Stamp the calling platform onto an existing account so the /// next boot's /cachedlogin/forplatformid query finds it. diff --git a/DorkNet.Server/Services/RoomService.cs b/DorkNet.Server/Services/RoomService.cs index 67b9cad..1bf6b0c 100644 --- a/DorkNet.Server/Services/RoomService.cs +++ b/DorkNet.Server/Services/RoomService.cs @@ -839,37 +839,13 @@ public class RoomService(DorkNetDbContext db) } /// - /// Project a RoomEntity to the wire shape Room.Deserialize at RVA - /// 0x114E430 expects. Required keys (PascalCase): - /// RoomId, Name, Description, CreatorPlayerId, ImageName, State, - /// Accessibility, SupportsLevelVoting, IsAGRoom, CloningAllowed, - /// SupportsScreens, SupportsWalkVR, SupportsTeleportVR, - /// AllowsJuniors, RoomWarningMask, CustomRoomWarning - /// Optional: IsDormRoom, SupportsVRLow, SupportsMobile, DisableMicAutoMute. + /// Project a RoomEntity to the flat June-2018 wire room (RecNet + /// Room.Deserialize, RecNet.cs:78244). This branch is 2018-only, so every + /// room-returning endpoint emits this shape via the shared mapper — the + /// 2020 RoomDetails/Scenes shape crashes the 2018 deserializer. See + /// . /// - public static object ToWireRoom(RoomEntity r) => new - { - RoomId = r.Id, - Name = r.Name, - Description = r.Description, - CreatorPlayerId = r.CreatorPlayerId, - ImageName = r.ImageName, - State = r.State, - Accessibility = r.Accessibility, - SupportsLevelVoting = r.SupportsLevelVoting, - IsAGRoom = r.IsAGRoom, - IsDormRoom = r.IsDormRoom, - CloningAllowed = r.CloningAllowed, - SupportsVRLow = r.SupportsVRLow, - SupportsMobile = r.SupportsMobile, - SupportsScreens = r.SupportsScreens, - SupportsWalkVR = r.SupportsWalkVR, - SupportsTeleportVR = r.SupportsTeleportVR, - AllowsJuniors = r.AllowsJuniors, - RoomWarningMask = r.RoomWarningMask, - CustomRoomWarning = r.CustomRoomWarning, - DisableMicAutoMute = r.DisableMicAutoMute, - }; + public static object ToWireRoom(RoomEntity r) => Compat2018.Room2018Mapper.From(r); /// /// Idempotent admin overrides applied AFTER SeedAsync. Keeps the seed