feat: Update 2018 client support with registration defaults and WebSocket handling

This commit is contained in:
Alexa
2026-06-07 16:35:07 +03:00
parent 579071858a
commit 2eba82b342
7 changed files with 138 additions and 24 deletions
@@ -1,29 +1,65 @@
using System.Net.WebSockets;
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Notification.V2;
/// <summary>
/// Legacy raw-WebSocket notification endpoint. Replaced by the SignalR
/// hub at <c>notify.{apex}/hub/v1</c>. Returns 410 Gone for any client
/// that still tries the old path.
/// api/notification/v2 — the JUNE-2018 client opens a raw WebSocket here for
/// push notifications (RecNet.cs:48895, RecNet.IJADBHMBMPG wraps a
/// BestHTTP.WebSocket with open/message(string)/error events). The 2020 client
/// replaced this with the SignalR hub at notify.{apex}/hub/v1.
///
/// This branch targets 2018, so we ACCEPT the WebSocket and hold it open. We
/// don't push anything yet (server-driven notifications are a later feature) —
/// an open, quiet socket keeps the client's notification subsystem "connected"
/// instead of erroring/retry-looping on the old 410 Gone. Incoming frames
/// (subscribe/auth) are drained and ignored.
/// </summary>
[ApiController]
[Route("api/[controller]/v2")]
public class NotificationController(DomainConfig domain) : ControllerBase
public class NotificationController(
DomainConfig domain,
ILogger<NotificationController> log) : ControllerBase
{
[HttpGet]
public IActionResult Connect() => StatusCode(StatusCodes.Status410Gone, new
public async Task Connect()
{
error = "use_signalr_hub",
hint = $"connect to {domain.Sub("notify")}/hub/v1 instead",
});
if (!HttpContext.WebSockets.IsWebSocketRequest)
{
// Non-WS probe (or a curl check) — return an empty notification
// list rather than 410 so HTTP callers don't treat it as an error.
Response.StatusCode = StatusCodes.Status200OK;
Response.ContentType = "application/json";
await Response.WriteAsync("[]");
return;
}
/// <summary>GET <c>api/notification/hub/v1</c> — returns the
/// SignalR hub URL the client should open. Without this the
/// watch falls back to its own default and may try the wrong
/// scheme. URL is built off the configured deployment apex
/// (DORKNET_DOMAIN).</summary>
using var ws = await HttpContext.WebSockets.AcceptWebSocketAsync();
log.LogInformation("[notify] 2018 client opened notification WebSocket");
var buf = new byte[4096];
try
{
while (ws.State == WebSocketState.Open)
{
var result = await ws.ReceiveAsync(
new ArraySegment<byte>(buf), HttpContext.RequestAborted);
if (result.MessageType == WebSocketMessageType.Close)
{
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure,
"bye", HttpContext.RequestAborted);
break;
}
// Drain & ignore subscribe/auth frames — no server push yet.
}
}
catch (OperationCanceledException) { /* client navigated away */ }
catch (WebSocketException) { /* abrupt disconnect — normal */ }
log.LogDebug("[notify] notification WebSocket closed");
}
/// <summary>GET <c>api/notification/hub/v1</c> — 2020 SignalR hub pointer
/// (unused by the 2018 client; kept for any 2019/2020 caller).</summary>
[HttpGet("/api/notification/hub/v1")]
public IActionResult HubInfo() => Ok(new
{
@@ -166,10 +166,15 @@ public class PlatformLoginV1Controller(
// 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)
public async Task<IActionResult> GetCachedLogins()
{
var players = await playerService.GetCachedLoginsAsync(Platform, PlatformId);
// The 2018 client sends Platform/PlatformId in the FORM BODY
// (application/x-www-form-urlencoded), not the query string — so read
// from form/query/json. Reading [FromQuery] only returned [] (empty
// account grid + broke +profile auto-login). Verified from the wire:
// req=Platform=0&PlatformId=76561198969189675.
var p = await ReadParamsAsync();
var players = await playerService.GetCachedLoginsAsync(p.GetInt("Platform"), p.Get("PlatformId"));
return Ok(players.Select(ToPlayer2018).ToList());
}
+5
View File
@@ -886,6 +886,11 @@ using (var scope = app.Services.CreateScope())
// that's already there (so spent-down accounts aren't refilled).
await playerService.BackfillStarterWalletsAsync();
// 2018 client: complete any account missing birthday/email/password so the
// (screen-mode-broken) signup step flow never appears. See
// PlayerService.ApplyRegistrationDefaults.
await playerService.BackfillAccountCompletionAsync();
// Backfill the per-player dorm room + DormStateEntity for any
// legacy account that signed in via a path that didn't run
// EnsurePersonalDormAsync (early-bird signups, manual SQL
+39
View File
@@ -517,6 +517,7 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger<Playe
HairColor = StarterHairColor,
SkinColor = StarterSkinColor,
};
ApplyRegistrationDefaults(player);
db.Players.Add(player);
await db.SaveChangesAsync();
@@ -596,6 +597,7 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger<Playe
HairColor = StarterHairColor,
SkinColor = StarterSkinColor,
};
ApplyRegistrationDefaults(player);
db.Players.Add(player);
await db.SaveChangesAsync();
@@ -629,6 +631,43 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger<Playe
return true;
}
// 2018 client runs a signup step-flow (video→birthday→email→complete) for
// accounts missing these — and its screen-mode UI is unusable, so accounts
// are born COMPLETE: an adult birthday, a synthetic email, and a default
// password ("dorknet"). RegistrationStatus is reported as complete(10) in
// the Player2018 wire shape.
private static readonly DateTime DefaultBirthday = new(1990, 1, 1, 0, 0, 0, DateTimeKind.Utc);
private static readonly string DefaultPasswordHash = BCrypt.Net.BCrypt.HashPassword("dorknet", 11);
private static void ApplyRegistrationDefaults(PlayerEntity p)
{
p.Birthday ??= DefaultBirthday;
if (string.IsNullOrEmpty(p.Email))
p.Email = $"{(string.IsNullOrEmpty(p.Username) ? "player" : p.Username).ToLowerInvariant()}@dorknet.local";
if (string.IsNullOrEmpty(p.PasswordHash)) p.PasswordHash = DefaultPasswordHash;
// 2018 SCREEN MODE is a developer-gated feature: SessionManager.BIAOMKDOLDL
// (which keeps screen mode enabled post-login) = account.Developer
// (RecNet EEBPLECPEGD.Deserialize "Developer" key). Without it the client
// demands a headset (vr_device_required) after login. Grant it so the
// screen-mode (non-VR) client is playable on this local-test branch.
p.IsDeveloper = true;
}
/// <summary>Complete any account missing birthday/email/password so the 2018
/// client never enters its (broken-in-screen-mode) signup step flow. Runs at
/// startup like the other backfills.</summary>
public async Task BackfillAccountCompletionAsync()
{
var incomplete = await db.Players
.Where(p => p.Birthday == null || p.Email == null || p.PasswordHash == null || !p.IsDeveloper)
.ToListAsync();
if (incomplete.Count == 0) return;
foreach (var p in incomplete) ApplyRegistrationDefaults(p);
await db.SaveChangesAsync();
logger.LogInformation(
"[backfill] completed registration (birthday/email/password) for {N} account(s)", incomplete.Count);
}
public async Task<PlayerEntity?> GetByIdAsync(long id) =>
await db.Players.Include(p => p.Avatar).FirstOrDefaultAsync(p => p.Id == id);
+22
View File
@@ -0,0 +1,22 @@
{
"Jwt": {
"Secret": "dorknet-local-dev-secret-please-change-0123456789abcdef"
},
"Domain": {
"Apex": "localhost",
"Scheme": "http",
"SingleOriginBaseUrl": "http://127.0.0.1:5005"
},
"Database": {
"Provider": "sqlite"
},
"Photon": {
"AppId": "",
"VoiceAppId": "",
"CloudRegion": "us",
"RequireCustomAuth": false
},
"Server": {
"MOTD": "DorkNet 2018 local test server"
}
}