using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Auth;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Accounts;
///
/// accounts.rec.net — account management.
/// Shapes match dump.cs:
/// RecNet.Account { AccountId, RawUsername, Username, DisplayName,
/// ProfileImage, TreatAsJunior, HasBirthday, Platforms }
/// RecNet.SelfAccount Account + { Email, Phone, Birthday, JuniorState,
/// ParentAccountId }
/// RecNet.RecNetResult { Success, Error }
/// Accounts.CreateAccountResponse RecNetResult + { Account }
///
[ApiController]
public class AccountsController(
PlayerService playerService,
AuthService authService,
OrphanAccountTracker orphans,
DorkNetDbContext db,
NotificationService notifications,
ServerSettingsService settings,
ILogger logger) : ControllerBase
{
[HttpGet("/account/v1/savedlogins")]
[HttpGet("/account/v2/savedlogins")]
[HttpGet("/account/v1/cachedlogins")]
public async Task GetSavedLogins(
[FromQuery] int platform = 0,
[FromQuery] string? platformId = null)
{
var players = await playerService.GetCachedLoginsAsync(platform, platformId);
return Ok(players.Select(p => new CachedLogin
{
Platform = platform,
PlatformId = platformId ?? string.Empty,
AccountId = (int)p.Id,
LastLoginTime = p.LastSeenAt,
RequirePassword = false,
}).ToList());
}
// Accounts.GetLocalAccount → SelfAccount shape.
// The 2020 client calls the unversioned /account/me path (observed in
// server logs); v1/v2 forms are kept for older builds and api.rec.net
// bridging. Returns SelfAccount, NOT base Account (this is the *self*
// endpoint — RecNet.Login.<>c.b__35_4 expects a SelfAccount).
[HttpGet("/account/me")]
[HttpGet("/account/v1/me")]
[HttpGet("/account/v2/me")]
[HttpGet("/account/v1/account")]
public async Task GetMe()
{
var player = await GetCurrentPlayerAsync();
if (player is null) return Unauthorized();
return Ok(BuildSelfAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName));
}
///
/// Resolves the player tied to the bearer token on the request. Returns
/// null when no/invalid bearer is supplied — the caller decides whether
/// that is a 401 (every "self" endpoint should be) or a noisier
/// fallback. Anonymous-account creation was previously the fallback
/// path; that's gone now because every caller has gone through
/// `/account/create` (deviceId-bound) before reaching us.
///
private async Task GetCurrentPlayerAsync()
{
var auth = Request.Headers.Authorization.ToString();
var token = auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
? auth["Bearer ".Length..].Trim()
: Request.Cookies[AuthService.AccessCookieName];
if (string.IsNullOrWhiteSpace(token))
return null;
var id = authService.ValidateToken(token);
if (id is not long playerId) return null;
return await playerService.GetByIdAsync(playerId);
}
// Accounts.GetAccountById → Account shape. The 2020 watch posts to
// the BARE /account/{id} path (no v1 prefix) when
// resolving a room's creator from RoomDetails — verified in the
// output_log "Failed to GetAccountById: HTTP Error 404" trace where
// PlayerDetailsWatchUIFlow.SetAccount NREs because the GetAccountById
// promise rejected with 404. Accept both shapes.
[HttpGet("/account/{accountId:long}")]
[HttpGet("/account/v1/{accountId:long}")]
[HttpGet("/account/v1/account/{accountId:long}")]
public async Task GetAccountById(long accountId)
{
var player = await playerService.GetByIdAsync(accountId);
if (player is null) return NotFound();
return Ok(BuildAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName));
}
// Accounts.GetAccountByUsername
[HttpGet("/account/v1/username/{username}")]
public async Task GetAccountByUsername(string username)
{
var player = await playerService.GetByUsernameAsync(username);
if (player is null) return NotFound();
return Ok(BuildAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName));
}
// Accounts.CreateAccount — actual URL observed in server logs:
// POST accounts.rec.net/account/create
// body: platform=0&platformId=&deviceId=
// Game expects CreateAccountResponse {Success, Error, Account}. Lambda
// b__29_0 NPEs if response.Account is null after deserializing.
// Clean PascalCase here — multi-case keys appear to confuse LitJson's typed
// path so its TypeMap.Add throws "Key: none" or leaves Account unpopulated.
[HttpPost("/account/create")]
[HttpPost("/account/v1/create")]
[HttpPost("/account/v1")]
[HttpPost("/account/v2/create")]
public async Task CreateAccount(
[FromForm] int platform,
[FromForm] string? platformId,
[FromForm] string? deviceId)
{
// Admin kill-switch. Watch's b__29_0 lambda
// (Accounts_NestedType___c.txt:311) branches on Success: false
// → ErroredPromise with the Error string surfaced to
// the player; true → reads response.Account. Account stays a
// non-null stub even on refusal so LitJson's typed Deserialize
// path doesn't fault before the Success check runs.
if (await settings.AreSignupsDisabledAsync())
{
logger.LogInformation(
"[accounts] account/create refused — signups disabled (device={Device} platform={Platform} platformId={PlatformId})",
deviceId, platform, platformId);
return Ok(new CreateAccountResponse
{
Success = false,
Error = "Account creation is currently disabled by the server admin.",
});
}
// The 2020 watch's "Create new account" UI and its boot-time
// "ensure my account exists" probe BOTH hit this endpoint with
// identical request bodies. To support multi-account-per-device
// we always create a brand-new row; the boot-flow case (where
// the user actually wants their cached account) is handled by
// the OrphanAccountTracker — when the next cached_login picks
// a DIFFERENT account_id, that handler deletes this just-
// created row.
var player = await playerService.CreateNewAccountAsync(
deviceId: deviceId,
platform: platform,
platformId: platformId);
orphans.TrackCreation(deviceId, platformId, player.Id);
logger.LogInformation(
"[accounts] account/create created {Id} ({Username}) for device {Device} platform={Platform} platformId={PlatformId}",
player.Id, player.Username, deviceId, platform, platformId);
return Ok(new CreateAccountResponse
{
Success = true,
Error = string.Empty,
Account = BuildAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName),
});
}
// Accounts.SearchForAccounts — used by the watch's typeahead AND the
// localhost public site's account search. Anonymous-safe (no JWT
// required) so the front-facing site can call it directly.
[HttpGet("/account/search")]
[HttpGet("/account/v1/search")]
[Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task Search([FromQuery] string? query, [FromQuery] int take = 20)
{
if (string.IsNullOrWhiteSpace(query)) return Ok(Array.Empty