using Microsoft.AspNetCore.Authorization;
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,
PlayerPresenceService presence,
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));
}
///
/// DELETE /account/me — the 2023 client's "delete local account"
/// (RecNet.Runtime OPMAPIOEIFG.ADFMLPGEBOF()). Binary evidence:
/// the request object is built at OPMAPIOEIFG.txt:8767-8777 with the
/// verb ordinal in rdx = 4 (BestHTTP.HTTPMethods.Delete) and the
/// path literal "account/me" at :8771; the failure branch at
/// :8790 carries the string "Failed to delete local account".
/// The issuing method returns LDGADANDBIO, the fire-and-forget
/// promise handle — the response BODY is never parsed, only the HTTP
/// status decides success — so the RecNetResult we emit is purely for
/// human/curl consumption. Registering only [HttpGet] on this
/// path (the previous state) produced a 405 and the in-game
/// "delete my account" button silently failed.
///
/// Erasure runs through the same
/// cascade the admin SPA uses, inside one transaction: personal rows
/// are deleted, authored durable content is reassigned to the system
/// account, then the Players row goes and the live session is kicked
/// so the bearer token stops being useful immediately.
///
[HttpDelete("/account/me")]
[HttpDelete("/account/v1/me")]
[HttpDelete("/account/v2/me")]
public async Task DeleteMe()
{
var player = await GetCurrentPlayerAsync();
if (player is null) return Unauthorized();
// The system account (id 1) owns every reassigned artefact — losing
// it would orphan the whole DB — and admin accounts must be demoted
// through the SPA first so a compromised session can't nuke the
// server's only operator. Both refusals are surfaced as a non-2xx so
// the client's promise rejects and the player sees the failure
// rather than a phantom success.
if (player.Id == 1)
{
logger.LogWarning("[accounts] DELETE account/me refused — system account");
return BadRequest(new RecNetResult
{
Success = false,
Error = "The system account cannot be deleted.",
});
}
if (player.IsAdmin)
{
logger.LogWarning(
"[accounts] DELETE account/me refused — {Id} ({Username}) is an admin",
player.Id, player.Username);
return BadRequest(new RecNetResult
{
Success = false,
Error = "Admin accounts cannot self-delete. Demote the account first.",
});
}
var playerId = player.Id;
var username = player.Username;
// Authored content is reassigned rather than destroyed. Preference
// order: the system account (id 1), else the lowest-id surviving
// account, else 0 (single-account server — nothing left to own it).
var systemPlayerId = await db.Players.AnyAsync(p => p.Id == 1)
? 1L
: await db.Players.Where(p => p.Id != playerId)
.OrderBy(p => p.Id)
.Select(p => p.Id)
.FirstOrDefaultAsync();
// GetCurrentPlayerAsync goes through PlayerService.GetByIdAsync,
// which Include()s the Avatar and leaves it tracked on this same
// scoped DbContext. PurgeAsync deletes that avatar row with
// ExecuteDelete, so leaving it tracked would make the Players.Remove
// cascade emit a second DELETE for an already-gone row and throw
// DbUpdateConcurrencyException. Drop the graph, then re-read the
// Players row on its own.
db.ChangeTracker.Clear();
var row = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
if (row is null) return Ok(new RecNetResult { Success = true, Error = string.Empty });
await using (var tx = await db.Database.BeginTransactionAsync())
{
var summary = await PlayerAccountPurge.PurgeAsync(db, playerId, systemPlayerId);
db.Players.Remove(row);
await db.SaveChangesAsync();
await tx.CommitAsync();
logger.LogInformation(
"[accounts] DELETE account/me — removed {Id} ({Username}); {Summary}",
playerId, username, summary);
}
await notifications.KickPlayerAsync(playerId, "This account has been deleted.");
presence.Clear(playerId);
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
///
/// 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]
// The 2023-03-21 client names the search term "name"; binding only "query"
// meant every player search came back empty (the handler short-circuited on
// a null term before it ever reached the service).
public async Task Search(
[FromQuery] string? query,
[FromQuery] string? name,
[FromQuery] int take = 20)
{
var term = !string.IsNullOrWhiteSpace(name) ? name : query;
if (string.IsNullOrWhiteSpace(term)) return Ok(Array.Empty