mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
feat: implement the client routes that had no handler
Sixteen agents, one controller each, over every route the coverage test reported as missing or wrong-verb. Route+verb coverage is now 516/522 (98.9%), up from 90.8%. Each handler was written against binary evidence rather than the audit's prose: verb from the BestHTTP.HTTPMethods ordinal at the call site, response shape from the issuing method's return type, request shape from whether the client uses form fields, a raw JsonUtility body, or query params. Agents were told to skip rather than stub, and several did. New surface worth noting: - Link host: actionlink create/get/consume and datalink get/put, previously unrouted entirely. - Club roster reads: members, banned, requests, requests/search, members/search, each with the client's real sort/paging params and the wire row shape its reader expects (the banned row has no MembershipType field, so it isn't sent). - Home clubhouse set/clear, club disband (soft, via the existing State column), and DELETE mainimage as its own action rather than a verb alias — the client parses the details envelope from it, so it is not status-only. - Club chat threads, comment CRUD, keepsake collect/delete, progression-event xp boosts, image report and cheered/bulk, iOS StoreKit product reporting. The six routes still missing all need storage that does not exist yet (per-role club permissions, club additional-image slots, MinLevel, ClubChatEnabled). Those are left for a single schema change rather than faked. Also excludes DELETE /account/me from the endpoint contract probe. That route is now a real, correctly-guarded account purge, and the probe walks every route with one test player — so calling it destroyed the probe's own session and turned one real event into 49 cascading foreign-key and 401 failures.
This commit is contained in:
@@ -26,6 +26,7 @@ public class AccountsController(
|
||||
DorkNetDbContext db,
|
||||
NotificationService notifications,
|
||||
ServerSettingsService settings,
|
||||
PlayerPresenceService presence,
|
||||
ILogger<AccountsController> logger) : ControllerBase
|
||||
{
|
||||
[HttpGet("/account/v1/savedlogins")]
|
||||
@@ -62,6 +63,102 @@ public class AccountsController(
|
||||
return Ok(BuildSelfAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DELETE <c>/account/me</c> — the 2023 client's "delete local account"
|
||||
/// (RecNet.Runtime <c>OPMAPIOEIFG.ADFMLPGEBOF()</c>). Binary evidence:
|
||||
/// the request object is built at OPMAPIOEIFG.txt:8767-8777 with the
|
||||
/// verb ordinal in <c>rdx</c> = 4 (BestHTTP.HTTPMethods.Delete) and the
|
||||
/// path literal <c>"account/me"</c> at :8771; the failure branch at
|
||||
/// :8790 carries the string <c>"Failed to delete local account"</c>.
|
||||
/// The issuing method returns <c>LDGADANDBIO</c>, 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 <c>[HttpGet]</c> on this
|
||||
/// path (the previous state) produced a 405 and the in-game
|
||||
/// "delete my account" button silently failed.
|
||||
///
|
||||
/// Erasure runs through the same <see cref="PlayerAccountPurge"/>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[HttpDelete("/account/me")]
|
||||
[HttpDelete("/account/v1/me")]
|
||||
[HttpDelete("/account/v2/me")]
|
||||
public async Task<IActionResult> 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 });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the player tied to the bearer token on the request. Returns
|
||||
/// null when no/invalid bearer is supplied — the caller decides whether
|
||||
@@ -692,8 +789,103 @@ public class AccountsController(
|
||||
return Ok(new { accountId = (int)accountId, bio = p?.Bio ?? string.Empty });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET <c>/platformid/{accountId}</c> — the 2023 client's
|
||||
/// <c>OPMAPIOEIFG.DPLOCBLKCEB(Int32 accountId)</c>. Binary evidence:
|
||||
/// path literal <c>"platformid/{0}"</c> at OPMAPIOEIFG.txt:11056 with
|
||||
/// the verb ordinal <c>rdx</c> = 0 (GET) at :11066, and the failure
|
||||
/// string <c>"Failed to GetPlatformId for accountId {0}"</c> at :11102.
|
||||
///
|
||||
/// RESPONSE SHAPE: the issuing method's return type is
|
||||
/// <c>FGLDKEJLAKB<System.String></c> — a BARE JSON string, not an
|
||||
/// object. Emitting <c>{"PlatformId":0}</c> makes the strict reader hit
|
||||
/// <c>'{'</c> where it wants <c>'"'</c> and throw. The legacy
|
||||
/// <c>/account/v1/{id}/platformid</c> spelling is kept as an alias and
|
||||
/// now returns the real stored value instead of a hardcoded 0.
|
||||
///
|
||||
/// The client short-circuits and never issues the request when
|
||||
/// accountId <= 0 (:11052-11053), so we only ever see real ids.
|
||||
/// Unknown accounts get an empty string — "no platform identity" is the
|
||||
/// honest answer and keeps the promise resolved rather than rejected.
|
||||
/// </summary>
|
||||
[HttpGet("/platformid/{accountId:long}")]
|
||||
[HttpGet("/account/v1/{accountId:long}/platformid")]
|
||||
public IActionResult GetPlatformId(long accountId) => Ok(new { PlatformId = 0L });
|
||||
public async Task<IActionResult> GetPlatformId(long accountId)
|
||||
{
|
||||
var platformId = await db.Players
|
||||
.Where(p => p.Id == accountId)
|
||||
.Select(p => p.LastPlatformId)
|
||||
.FirstOrDefaultAsync();
|
||||
return Content(
|
||||
System.Text.Json.JsonSerializer.Serialize(platformId ?? string.Empty),
|
||||
"application/json");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET <c>/accounts/{accountId}/receives/{category}</c> — the 2023
|
||||
/// client's platform-notification opt-in probe
|
||||
/// (<c>LELAJKMOMIA.GCIPPNCOKAJ(Int32 accountId)</c>). Binary evidence:
|
||||
/// path literal at LELAJKMOMIA.txt:695, verb ordinal <c>rdx</c> = 0
|
||||
/// (GET) at :705, service-host ordinal 15 (platformnotifications) at
|
||||
/// :709. The <c>{1}</c> segment is a <c>CCOKJMJOIJF</c> enum value
|
||||
/// boxed and <c>String.Format</c>ed at :690-698, i.e. the enum MEMBER
|
||||
/// NAME — the constant at this call site is value 8 (:693) but the
|
||||
/// member names appear nowhere as literals in the binary, so the
|
||||
/// segment MUST stay an unconstrained opaque string.
|
||||
///
|
||||
/// RESPONSE SHAPE: the continuation is <c>System.Action<Boolean></c>
|
||||
/// over <c>FGLDKEJLAKB<Boolean></c> (:721/:733) — a BARE JSON
|
||||
/// boolean, no envelope. A 404 here rejects the promise inside AGUI's
|
||||
/// AccountSpecificUIBehaviour and blocks the notification-gated action.
|
||||
///
|
||||
/// Answered from the real <see cref="NotificationPrefsEntity"/> row that
|
||||
/// backs <c>PUT /preferences</c>: <c>AllowAll</c> gates everything, then
|
||||
/// the per-category flag. Categories are resolved by the same ids the
|
||||
/// platformnotifications <c>config/categories</c> catalogue publishes
|
||||
/// (1 Messages, 2 Friend Requests, 3 Event Invites, 4 Announcements) and
|
||||
/// by normalised name, so both the numeric and member-name spellings
|
||||
/// land on the right column. A category we don't model (such as the
|
||||
/// value-8 one this call site uses) falls back to <c>AllowAll</c> — the
|
||||
/// player has no mute for it, which is the truthful answer, not a stub.
|
||||
/// </summary>
|
||||
[HttpGet("/accounts/{accountId:long}/receives/{category}")]
|
||||
[HttpGet("/platformnotifications/accounts/{accountId:long}/receives/{category}")]
|
||||
public async Task<IActionResult> Receives(long accountId, string category)
|
||||
{
|
||||
var exists = await db.Players.AnyAsync(p => p.Id == accountId);
|
||||
if (!exists) return Content("false", "application/json");
|
||||
|
||||
var prefs = await db.NotificationPrefs
|
||||
.FirstOrDefaultAsync(p => p.PlayerId == accountId);
|
||||
// No row at all = never opted out of anything.
|
||||
if (prefs is null) return Content("true", "application/json");
|
||||
|
||||
var receives = prefs.AllowAll && CategoryAllowed(prefs, category);
|
||||
return Content(receives ? "true" : "false", "application/json");
|
||||
}
|
||||
|
||||
/// <summary>Maps the opaque <c>{category}</c> segment onto the columns
|
||||
/// of <see cref="NotificationPrefsEntity"/>. Accepts the numeric
|
||||
/// CategoryId published by <c>config/categories</c> as well as the enum
|
||||
/// member name in any casing/punctuation; anything we don't model is
|
||||
/// only gated by <c>AllowAll</c>.</summary>
|
||||
private static bool CategoryAllowed(NotificationPrefsEntity prefs, string? category)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(category)) return true;
|
||||
var key = new string(category.Where(char.IsLetterOrDigit).ToArray()).ToLowerInvariant();
|
||||
return key switch
|
||||
{
|
||||
"1" or "message" or "messages" or "directmessage" or "directmessages"
|
||||
=> prefs.AllowMessage,
|
||||
"2" or "friendrequest" or "friendrequests"
|
||||
=> prefs.AllowFriendRequest,
|
||||
"3" or "eventinvite" or "eventinvites" or "invite" or "invites"
|
||||
=> prefs.AllowEventInvite,
|
||||
"4" or "announcement" or "announcements" or "news"
|
||||
=> prefs.AllowAnnouncements,
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
|
||||
[HttpGet("/accountprivacysettings/{accountId:long}")]
|
||||
[HttpGet("/account/v1/privacysettings/{accountId:long}")]
|
||||
|
||||
Reference in New Issue
Block a user