mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
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.
This commit is contained in:
@@ -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).
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generic 2018 success envelope {Success, Message} (RecNet EFDOILDKBFK) used by
|
||||
/// registeraccount (email attach) and other simple POSTs. Note the key is
|
||||
/// "Message" (not "error").
|
||||
/// </summary>
|
||||
public class SuccessMessage2018
|
||||
{
|
||||
[JsonPropertyName("Success")]
|
||||
public bool Success { get; set; }
|
||||
|
||||
[JsonPropertyName("Message")]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -50,13 +50,34 @@ public class RecRoomConfig
|
||||
[JsonPropertyName("DailyObjectives")]
|
||||
public List<List<DailyObjective>> 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<string, string> ConfigTable { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("ConfigTable")]
|
||||
public List<ConfigTableEntry> ConfigTableWire =>
|
||||
ConfigTable.Select(kv => new ConfigTableEntry { Key = kv.Key, Value = kv.Value }).ToList();
|
||||
|
||||
[JsonPropertyName("ServiceUrls")]
|
||||
public Dictionary<string, string> ServiceUrls { get; set; } = [];
|
||||
}
|
||||
|
||||
// 2018 ConfigTable element shape: {"Key": "...", "Value": "..."} (PascalCase,
|
||||
// LitJson Util.GetKey<string>("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")]
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.Models.Players;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public class Presence2018
|
||||
{
|
||||
[JsonPropertyName("PlayerId")]
|
||||
public long PlayerId { get; set; }
|
||||
|
||||
[JsonPropertyName("IsOnline")]
|
||||
public bool IsOnline { get; set; }
|
||||
|
||||
[JsonPropertyName("GameSession")]
|
||||
public object? GameSession { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DorkNet.Models.Rooms;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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<int> CoOwners { get; set; } = [];
|
||||
[JsonPropertyName("Hosts")] public List<int> Hosts { get; set; } = [];
|
||||
[JsonPropertyName("PersonalDetails")] public object? PersonalDetails { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using DorkNet.Models.Auth;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Compat2018;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a <see cref="PlayerEntity"/> to the June-2018 wire player object
|
||||
/// (<see cref="Player2018"/>). 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).
|
||||
/// </summary>
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using DorkNet.Models.Rooms;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Compat2018;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a <see cref="RoomEntity"/> to the flat June-2018 wire room
|
||||
/// (<see cref="Room2018"/>). 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).
|
||||
/// </summary>
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
/// <summary>POST <c>v3/saved/set</c> — upsert one outfit by Slot.
|
||||
/// Body is the SavedOutfit shape directly.</summary>
|
||||
[HttpPost("api/avatar/v1/saved/set")]
|
||||
[HttpPost("api/avatar/v3/saved/set")]
|
||||
[HttpPost("api/avatar/v2/saved/set")]
|
||||
public async Task<IActionResult> Set([FromBody] SavedOutfitDto body)
|
||||
|
||||
@@ -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<IActionResult> UploadSaved()
|
||||
{
|
||||
|
||||
@@ -71,27 +71,37 @@ public class InventoryController(
|
||||
/// <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>
|
||||
// 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<IActionResult> UpdateEquipment([FromBody] EquipmentUpdateRequest req)
|
||||
public async Task<IActionResult> 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<System.Text.Json.JsonElement>();
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -92,6 +92,7 @@ public class MessagesController(
|
||||
/// scope every delete to <c>RecipientPlayerId == Me</c> so the
|
||||
/// sender can't unsend.</summary>
|
||||
[HttpPost("/api/messages/v3/delete")]
|
||||
[HttpPost("/api/messages/v2/delete")]
|
||||
[HttpPost("/api/messages/v1/delete")]
|
||||
[Consumes("application/json")]
|
||||
public async Task<IActionResult> 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<IActionResult> DeleteForm(
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/platformlogin/v1")]
|
||||
public class PlatformLoginV1Controller(
|
||||
PlayerService playerService,
|
||||
AuthService authService,
|
||||
ServerSettingsService settings,
|
||||
SignupCodeService signupCodes,
|
||||
DorkNetDbContext db,
|
||||
ILogger<PlatformLoginV1Controller> logger) : ControllerBase
|
||||
{
|
||||
// POST api/platformlogin/v1/loginaccount — primary sign-in.
|
||||
[HttpPost("loginaccount")]
|
||||
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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);
|
||||
|
||||
/// <summary>Read request params from JSON body, form, and query — merged,
|
||||
/// case-insensitive — so JsonUtility/LitJson/form/query callers all bind.</summary>
|
||||
private async Task<ParamBag> 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<string, string?> _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;
|
||||
}
|
||||
}
|
||||
@@ -34,9 +34,14 @@ public class PlayerCheerController(
|
||||
[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)
|
||||
[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 <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)
|
||||
public async Task<IActionResult> 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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// api/players/* for the June-2018 client. This branch is 2018-only, so every
|
||||
/// endpoint returns the 2018 <see cref="Player2018"/> 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).
|
||||
/// </summary>
|
||||
[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()
|
||||
// GET api/players/v1|v2/me
|
||||
[HttpGet("/api/players/v1/me")]
|
||||
[HttpGet("/api/players/v2/me")]
|
||||
public async Task<ActionResult<Player2018>> 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<ActionResult<PlayerProfile>> 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<ActionResult<Player2018>> 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<ActionResult<List<PlayerProfile>>> 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<ActionResult<List<Player2018>>> GetMany([FromBody] long[]? ids)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query)) return Ok(new List<PlayerProfile>());
|
||||
var results = await playerService.SearchAsync(query);
|
||||
return Ok(results.Select(PlayerService.ToProfile).ToList());
|
||||
if (ids is null || ids.Length == 0) return Ok(new List<Player2018>());
|
||||
var players = await playerService.GetByIdsAsync(ids);
|
||||
return Ok(players.Select(Player2018Mapper.From).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("displayName")]
|
||||
public async Task<ActionResult<PlayerProfile>> 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<ActionResult<List<Player2018>>> 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<Player2018>());
|
||||
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<ActionResult<Player2018>> 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<ActionResult<PlayerProfile>> 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<ActionResult<Player2018>> 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<ActionResult<Player2018>> AvoidJuniors()
|
||||
{
|
||||
var player = await playerService.GetByIdAsync(CurrentPlayerId);
|
||||
if (player is null) return NotFound();
|
||||
return Ok(Player2018Mapper.From(player));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="Presence2018"/> 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
|
||||
/// <see cref="OnlinePresenceService"/>; GameSession is left null for now (the
|
||||
/// client renders "online" without it and fills in room info on join).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class PresenceController(OnlinePresenceService presence) : ControllerBase
|
||||
{
|
||||
[HttpPost("/api/presence/v2/list")]
|
||||
[HttpPost("/api/presence/v1/list")]
|
||||
public ActionResult<List<Presence2018>> List([FromBody] long[]? ids)
|
||||
{
|
||||
if (ids is null || ids.Length == 0) return Ok(new List<Presence2018>());
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<IActionResult> SearchPost([FromBody] RoomSearch2018? body)
|
||||
=> Ok((await rooms.SearchAsync(body?.Name ?? 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
|
||||
@@ -112,6 +120,7 @@ public class RoomsController(
|
||||
/// synthesised "Room_<name>" for unknown names so the deserializer
|
||||
/// doesn't crash on 404.
|
||||
/// </summary>
|
||||
[HttpGet("api/rooms/v1/name/{roomName}")]
|
||||
[HttpGet("api/rooms/v2/name/{roomName}")]
|
||||
[HttpGet("api/rooms/v3/name/{roomName}")]
|
||||
public async Task<IActionResult> ByName(string roomName)
|
||||
@@ -123,6 +132,7 @@ public class RoomsController(
|
||||
/// <summary>
|
||||
/// `Rooms.GetById(roomId)` — same shape as ByName.
|
||||
/// </summary>
|
||||
[HttpGet("api/rooms/v1/{roomId:long}")]
|
||||
[HttpGet("api/rooms/v2/{roomId:long}")]
|
||||
[HttpGet("api/rooms/v3/{roomId:long}")]
|
||||
public async Task<IActionResult> 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<IActionResult> 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<object>());
|
||||
|
||||
@@ -972,6 +991,25 @@ public class RoomsController(
|
||||
FeaturedRooms = await rooms.FeaturedAgRoomIdsAsync(12),
|
||||
});
|
||||
|
||||
/// <summary>GET <c>api/rooms/v2/featured</c> — the 2018 client expects a
|
||||
/// SINGLE Room object here (RecNet.cs:85173), not the 2020 FeaturedRoomGroup.
|
||||
/// Return the top featured AG room.</summary>
|
||||
[HttpGet("api/rooms/v2/featured")]
|
||||
public async Task<IActionResult> 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")));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/rooms/v2/details/{roomId}</c> — 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.</summary>
|
||||
[HttpGet("api/rooms/v2/details/{roomId:long}")]
|
||||
public IActionResult DetailsV2(long roomId)
|
||||
=> Ok(new { GameSessionCount = 0, PlayerCount = 0 });
|
||||
|
||||
/// <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>
|
||||
|
||||
@@ -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<IActionResult> 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<IActionResult> 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<IActionResult> BuyElite([FromBody] RequestPurchaseItemDto req)
|
||||
{
|
||||
|
||||
@@ -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<BalanceResponseDTO>) 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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<bool>("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")]
|
||||
|
||||
@@ -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 + "/";
|
||||
}
|
||||
|
||||
@@ -632,6 +632,15 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger<Playe
|
||||
public async Task<PlayerEntity?> GetByIdAsync(long id) =>
|
||||
await db.Players.Include(p => p.Avatar).FirstOrDefaultAsync(p => p.Id == id);
|
||||
|
||||
/// <summary>Bulk profile fetch for the 2018 client's batch resolvers
|
||||
/// (api/players/v1/list, presence). Single query; order is not guaranteed.</summary>
|
||||
public async Task<List<PlayerEntity>> GetByIdsAsync(IEnumerable<long> ids)
|
||||
{
|
||||
var idList = ids.Distinct().ToList();
|
||||
if (idList.Count == 0) return [];
|
||||
return await db.Players.Where(p => idList.Contains(p.Id)).ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stamp the calling platform onto an existing account so the
|
||||
/// next boot's <c>/cachedlogin/forplatformid</c> query finds it.
|
||||
|
||||
@@ -839,37 +839,13 @@ public class RoomService(DorkNetDbContext db)
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="Compat2018.Room2018Mapper"/>.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Idempotent admin overrides applied AFTER SeedAsync. Keeps the seed
|
||||
|
||||
Reference in New Issue
Block a user