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