mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
feat: Implement admin account deletion with confirmation and update documentation
This commit is contained in:
@@ -1,12 +1,13 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net6.0</TargetFramework>
|
<TargetFramework Condition="'$(TargetFramework)' == '' and '$(TargetFrameworks)' == ''">net6.0</TargetFramework>
|
||||||
<LangVersion>latest</LangVersion>
|
<LangVersion>latest</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<RootNamespace>DorkNet.ClientMod</RootNamespace>
|
<RootNamespace>DorkNet.ClientMod</RootNamespace>
|
||||||
<AssemblyName>DorkNet.ClientMod</AssemblyName>
|
<AssemblyName>DorkNet.ClientMod</AssemblyName>
|
||||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
<AppendTargetFrameworkToOutputPath Condition="'$(TargetFrameworks)' == ''">false</AppendTargetFrameworkToOutputPath>
|
||||||
|
<AppendTargetFrameworkToOutputPath Condition="'$(TargetFrameworks)' != ''">true</AppendTargetFrameworkToOutputPath>
|
||||||
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
|
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
|
||||||
<NoWarn>$(NoWarn);CS8602;CS8604;CS0436</NoWarn>
|
<NoWarn>$(NoWarn);CS8602;CS8604;CS0436</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
@@ -19,21 +20,28 @@
|
|||||||
they would be derivative works of the game anyway, so we never
|
they would be derivative works of the game anyway, so we never
|
||||||
reference them. Only MelonLoader's own DLLs are needed.
|
reference them. Only MelonLoader's own DLLs are needed.
|
||||||
|
|
||||||
By default the project downloads the pinned MelonLoader release
|
By default the project downloads the pinned desktop MelonLoader release
|
||||||
into DorkNet.ClientMod/build/ and references those DLLs. Pass
|
into DorkNet.ClientMod/build/ and references its net6 DLLs. For Quest /
|
||||||
-p:MelonLoaderDir=<path-to-extracted-MelonLoader> only when you
|
LemonLoader builds, pass:
|
||||||
deliberately want to use a local override.
|
|
||||||
|
-p:TargetFrameworks=net8.0
|
||||||
|
-p:MelonLoaderDir=<path-to-LemonLoader-melon_data/MelonLoader>
|
||||||
|
|
||||||
|
The reference folder follows the target framework unless explicitly
|
||||||
|
overridden with -p:MelonLoaderReferenceTfm=...
|
||||||
-->
|
-->
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<MelonLoaderVersion Condition="'$(MelonLoaderVersion)' == ''">v0.6.6</MelonLoaderVersion>
|
<MelonLoaderVersion Condition="'$(MelonLoaderVersion)' == ''">v0.6.6</MelonLoaderVersion>
|
||||||
<MelonLoaderCacheRoot>$(MSBuildThisFileDirectory)build\melonloader</MelonLoaderCacheRoot>
|
<MelonLoaderCacheRoot>$(MSBuildThisFileDirectory)build\melonloader</MelonLoaderCacheRoot>
|
||||||
<MelonLoaderZip>$(MelonLoaderCacheRoot)\MelonLoader.x64.$(MelonLoaderVersion).zip</MelonLoaderZip>
|
<MelonLoaderZip>$(MelonLoaderCacheRoot)\MelonLoader.x64.$(MelonLoaderVersion).zip</MelonLoaderZip>
|
||||||
<MelonLoaderDir Condition="'$(MelonLoaderDir)' == ''">$(MelonLoaderCacheRoot)\MelonLoader</MelonLoaderDir>
|
<MelonLoaderDir Condition="'$(MelonLoaderDir)' == ''">$(MelonLoaderCacheRoot)\MelonLoader</MelonLoaderDir>
|
||||||
|
<MelonLoaderReferenceTfm Condition="'$(MelonLoaderReferenceTfm)' == '' and '$(TargetFramework)' == 'net8.0'">net8</MelonLoaderReferenceTfm>
|
||||||
|
<MelonLoaderReferenceTfm Condition="'$(MelonLoaderReferenceTfm)' == ''">net6</MelonLoaderReferenceTfm>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<Target Name="EnsureMelonLoaderReferences"
|
<Target Name="EnsureMelonLoaderReferences"
|
||||||
BeforeTargets="ResolveAssemblyReferences"
|
BeforeTargets="ResolveAssemblyReferences"
|
||||||
Condition="!Exists('$(MelonLoaderDir)\net6\MelonLoader.dll')">
|
Condition="'$(MelonLoaderReferenceTfm)' == 'net6' and !Exists('$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\MelonLoader.dll')">
|
||||||
<MakeDir Directories="$(MelonLoaderCacheRoot)" />
|
<MakeDir Directories="$(MelonLoaderCacheRoot)" />
|
||||||
<DownloadFile
|
<DownloadFile
|
||||||
SourceUrl="https://github.com/LavaGang/MelonLoader/releases/download/$(MelonLoaderVersion)/MelonLoader.x64.zip"
|
SourceUrl="https://github.com/LavaGang/MelonLoader/releases/download/$(MelonLoaderVersion)/MelonLoader.x64.zip"
|
||||||
@@ -47,19 +55,19 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Reference Include="MelonLoader">
|
<Reference Include="MelonLoader">
|
||||||
<HintPath>$(MelonLoaderDir)\net6\MelonLoader.dll</HintPath>
|
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\MelonLoader.dll</HintPath>
|
||||||
<Private>false</Private>
|
<Private>false</Private>
|
||||||
</Reference>
|
</Reference>
|
||||||
<Reference Include="0Harmony">
|
<Reference Include="0Harmony">
|
||||||
<HintPath>$(MelonLoaderDir)\net6\0Harmony.dll</HintPath>
|
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\0Harmony.dll</HintPath>
|
||||||
<Private>false</Private>
|
<Private>false</Private>
|
||||||
</Reference>
|
</Reference>
|
||||||
<Reference Include="Il2CppInterop.Runtime">
|
<Reference Include="Il2CppInterop.Runtime">
|
||||||
<HintPath>$(MelonLoaderDir)\net6\Il2CppInterop.Runtime.dll</HintPath>
|
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\Il2CppInterop.Runtime.dll</HintPath>
|
||||||
<Private>false</Private>
|
<Private>false</Private>
|
||||||
</Reference>
|
</Reference>
|
||||||
<Reference Include="Il2CppInterop.Common">
|
<Reference Include="Il2CppInterop.Common">
|
||||||
<HintPath>$(MelonLoaderDir)\net6\Il2CppInterop.Common.dll</HintPath>
|
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\Il2CppInterop.Common.dll</HintPath>
|
||||||
<Private>false</Private>
|
<Private>false</Private>
|
||||||
</Reference>
|
</Reference>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -179,9 +179,15 @@ public class Mod : MelonMod
|
|||||||
// EMGGKBALALH returns Photon.Realtime.AuthenticationValues —
|
// EMGGKBALALH returns Photon.Realtime.AuthenticationValues —
|
||||||
// verified at PUNNetworkManager.txt:7560. Builds AuthValues with
|
// verified at PUNNetworkManager.txt:7560. Builds AuthValues with
|
||||||
// environment + accessToken AuthGetParameters.
|
// environment + accessToken AuthGetParameters.
|
||||||
TryPatchByName("PUNNetworkManager", "FJOLIPKKIBE",
|
//
|
||||||
|
// Quest-Dec uses the same obfuscation seed as PC-Dec, but discover by
|
||||||
|
// return type first so a future platform/reseed does not make these
|
||||||
|
// two critical hooks depend on the private method names.
|
||||||
|
TryPatchDeclaredMethodByReturnType("PUNNetworkManager", "AppSettings",
|
||||||
|
fallbackMethodName: "FJOLIPKKIBE",
|
||||||
postfix: nameof(PhotonPatches.LogAppSettings_Postfix));
|
postfix: nameof(PhotonPatches.LogAppSettings_Postfix));
|
||||||
TryPatchByName("PUNNetworkManager", "EMGGKBALALH",
|
TryPatchDeclaredMethodByReturnType("PUNNetworkManager", "AuthenticationValues",
|
||||||
|
fallbackMethodName: "EMGGKBALALH",
|
||||||
postfix: nameof(PhotonPatches.LogAuthValues_Postfix));
|
postfix: nameof(PhotonPatches.LogAuthValues_Postfix));
|
||||||
// PUNNetworkManager.OnCustomAuthenticationFailed — accepts whatever
|
// PUNNetworkManager.OnCustomAuthenticationFailed — accepts whatever
|
||||||
// string-like arg the IL2CPP method takes (declared `string` in
|
// string-like arg the IL2CPP method takes (declared `string` in
|
||||||
@@ -723,6 +729,58 @@ public class Mod : MelonMod
|
|||||||
return TryPatch(label, type, methodName, args, prefix, postfix, logMiss);
|
return TryPatch(label, type, methodName, args, prefix, postfix, logMiss);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool TryPatchDeclaredMethodByReturnType(string typeName, string returnTypeName,
|
||||||
|
string fallbackMethodName,
|
||||||
|
string? prefix = null, string? postfix = null,
|
||||||
|
bool logMiss = true)
|
||||||
|
{
|
||||||
|
var type = ResolveType(typeName);
|
||||||
|
var label = $"{typeName}.<returns {returnTypeName}>";
|
||||||
|
if (type is null)
|
||||||
|
{
|
||||||
|
if (logMiss) Log.Warning($"[patch-miss] {label}: type not found");
|
||||||
|
return TryPatchByName(typeName, fallbackMethodName, args: null, prefix, postfix, logMiss);
|
||||||
|
}
|
||||||
|
|
||||||
|
var matches = new List<MethodInfo>();
|
||||||
|
foreach (var method in AccessTools.GetDeclaredMethods(type))
|
||||||
|
{
|
||||||
|
if (method.IsAbstract || method.ContainsGenericParameters || method.IsSpecialName) continue;
|
||||||
|
if (ReturnTypeMatches(method.ReturnType, returnTypeName)) matches.Add(method);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.Count == 1)
|
||||||
|
{
|
||||||
|
var method = matches[0];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
HarmonyInstance.Patch(method,
|
||||||
|
prefix: prefix is null ? null : new HarmonyMethod(GetPatchMethod(prefix)),
|
||||||
|
postfix: postfix is null ? null : new HarmonyMethod(GetPatchMethod(postfix)));
|
||||||
|
Log.Msg($"[patch-ok] {typeName}.{method.Name} (returns {returnTypeName})");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error($"[patch-fail] {typeName}.{method.Name} (returns {returnTypeName}): {ex.Message}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.Count > 1)
|
||||||
|
{
|
||||||
|
var names = new List<string>();
|
||||||
|
foreach (var method in matches) names.Add(method.Name);
|
||||||
|
Log.Warning($"[patch-ambiguous] {label}: {string.Join(", ", names)}; falling back to {fallbackMethodName}");
|
||||||
|
}
|
||||||
|
else if (logMiss)
|
||||||
|
{
|
||||||
|
Log.Warning($"[patch-miss] {label}; falling back to {fallbackMethodName}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return TryPatchByName(typeName, fallbackMethodName, args: null, prefix, postfix, logMiss);
|
||||||
|
}
|
||||||
|
|
||||||
internal static Type? ResolveType(string name)
|
internal static Type? ResolveType(string name)
|
||||||
{
|
{
|
||||||
if (ResolvedTypeCache.TryGetValue(name, out var cached))
|
if (ResolvedTypeCache.TryGetValue(name, out var cached))
|
||||||
@@ -795,6 +853,15 @@ public class Mod : MelonMod
|
|||||||
type.FullName?.EndsWith(".Il2Cpp" + requested, StringComparison.Ordinal) == true;
|
type.FullName?.EndsWith(".Il2Cpp" + requested, StringComparison.Ordinal) == true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool ReturnTypeMatches(Type type, string requested)
|
||||||
|
{
|
||||||
|
var prefixed = "Il2Cpp" + requested;
|
||||||
|
if (type.FullName == requested || type.FullName == prefixed) return true;
|
||||||
|
if (type.Name == requested || type.Name == prefixed) return true;
|
||||||
|
return type.FullName?.EndsWith("." + requested, StringComparison.Ordinal) == true ||
|
||||||
|
type.FullName?.EndsWith(".Il2Cpp" + requested, StringComparison.Ordinal) == true;
|
||||||
|
}
|
||||||
|
|
||||||
private static string FormatPatchLabel(string typeName, string methodName, Type[]? args)
|
private static string FormatPatchLabel(string typeName, string methodName, Type[]? args)
|
||||||
{
|
{
|
||||||
if (args is null) return $"{typeName}.{methodName}";
|
if (args is null) return $"{typeName}.{methodName}";
|
||||||
|
|||||||
@@ -203,6 +203,56 @@ public class AdminController(
|
|||||||
return Ok(new { player.Id, player.IsAdmin });
|
return Ok(new { player.Id, player.IsAdmin });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record DeletePlayerRequest(string ConfirmUsername, string ConfirmPhrase, string? Reason);
|
||||||
|
|
||||||
|
/// <summary>Hard-remove a non-admin player account. The destructive
|
||||||
|
/// endpoint requires two exact confirmations: the current username and
|
||||||
|
/// the phrase <c>DELETE {id}</c>. Personal rows are deleted, authored
|
||||||
|
/// durable content is reassigned to the system account, and live sessions
|
||||||
|
/// are kicked so stale bearer tokens stop being useful immediately.</summary>
|
||||||
|
[HttpDelete("players/{id:long}")]
|
||||||
|
public async Task<ActionResult> DeletePlayer(long id, [FromBody] DeletePlayerRequest body)
|
||||||
|
{
|
||||||
|
if (id == 1)
|
||||||
|
return BadRequest(new { Error = "Cannot delete the system account." });
|
||||||
|
if (id == CurrentAdminId)
|
||||||
|
return BadRequest(new { Error = "Cannot delete your own admin account." });
|
||||||
|
|
||||||
|
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == id);
|
||||||
|
if (player is null) return NotFound();
|
||||||
|
if (player.IsAdmin)
|
||||||
|
return BadRequest(new { Error = "Cannot delete an admin account. Demote it first." });
|
||||||
|
|
||||||
|
var expectedPhrase = $"DELETE {player.Id}";
|
||||||
|
if (!string.Equals(body.ConfirmUsername?.Trim(), player.Username, StringComparison.Ordinal)
|
||||||
|
|| !string.Equals(body.ConfirmPhrase?.Trim(), expectedPhrase, StringComparison.Ordinal))
|
||||||
|
return BadRequest(new { Error = $"Confirmation failed. Type @{player.Username} and {expectedPhrase} exactly." });
|
||||||
|
|
||||||
|
var reason = string.IsNullOrWhiteSpace(body.Reason)
|
||||||
|
? $"removed @{player.Username}"
|
||||||
|
: body.Reason.Trim();
|
||||||
|
var systemPlayerId = await db.Players.AnyAsync(p => p.Id == 1) ? 1L : CurrentAdminId;
|
||||||
|
|
||||||
|
await using var tx = await db.Database.BeginTransactionAsync();
|
||||||
|
|
||||||
|
var summary = await DeletePlayerDataAsync(id, systemPlayerId);
|
||||||
|
db.Players.Remove(player);
|
||||||
|
await LogAsync("delete_player", "player", id, $"{reason}; {summary}");
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
await tx.CommitAsync();
|
||||||
|
|
||||||
|
await notifications.KickPlayerAsync(id, "Your account has been removed by an admin.");
|
||||||
|
playerPresence.Clear(id);
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
deleted = id,
|
||||||
|
username = player.Username,
|
||||||
|
reassignedToPlayerId = systemPlayerId,
|
||||||
|
summary,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Rooms ────────────────────────────────────────────────────────────
|
// ── Rooms ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public sealed record DeleteRoomRequest(string? Reason);
|
public sealed record DeleteRoomRequest(string? Reason);
|
||||||
@@ -3044,6 +3094,133 @@ public class AdminController(
|
|||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async Task<string> DeletePlayerDataAsync(long playerId, long systemPlayerId)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var deleted = 0;
|
||||||
|
var reassigned = 0;
|
||||||
|
|
||||||
|
deleted += await db.Avatars.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||||
|
deleted += await db.PlayerSettings.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||||
|
deleted += await db.Relationships.Where(x => x.RequesterId == playerId || x.TargetId == playerId).ExecuteDeleteAsync();
|
||||||
|
deleted += await db.CurrencyBalances.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||||
|
deleted += await db.PlayerInventory.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||||
|
deleted += await db.PlayerDevices.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||||
|
deleted += await db.NotificationPrefs.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||||
|
deleted += await db.CohortAssignments.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||||
|
deleted += await db.PushTokens.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||||
|
deleted += await db.PlatformIgnores.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||||
|
deleted += await db.Cards.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||||
|
deleted += await db.ObjectiveProgress.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||||
|
deleted += await db.GameRewardSelections.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||||
|
deleted += await db.CouponRedemptions.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||||
|
deleted += await db.LeaderboardStats.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||||
|
deleted += await db.PlayerElo.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||||
|
deleted += await db.RoyalePlayerProgress.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||||
|
deleted += await db.RoyaleMatchPlayers.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||||
|
|
||||||
|
deleted += await db.Messages.Where(x => x.SenderPlayerId == playerId || x.RecipientPlayerId == playerId).ExecuteDeleteAsync();
|
||||||
|
deleted += await db.ChatMessages.Where(x => x.SenderPlayerId == playerId).ExecuteDeleteAsync();
|
||||||
|
deleted += await db.ChatThreadMembers.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||||
|
reassigned += await db.ChatThreads
|
||||||
|
.Where(x => x.CreatorPlayerId == playerId)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(x => x.CreatorPlayerId, systemPlayerId));
|
||||||
|
|
||||||
|
deleted += await db.Cheers.Where(x => x.FromPlayerId == playerId || x.TargetPlayerId == playerId).ExecuteDeleteAsync();
|
||||||
|
deleted += await db.Reports.Where(x => x.ReporterPlayerId == playerId || x.TargetPlayerId == playerId).ExecuteDeleteAsync();
|
||||||
|
deleted += await db.BugReports.Where(x => x.ReporterPlayerId == playerId).ExecuteDeleteAsync();
|
||||||
|
deleted += await db.Subscriptions.Where(x => x.SubscriberPlayerId == playerId || x.TargetPlayerId == playerId).ExecuteDeleteAsync();
|
||||||
|
|
||||||
|
deleted += await db.ClubMemberships.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||||
|
deleted += await db.ClubSubscriptions.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||||
|
deleted += await db.ClubAnnouncementReads.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||||
|
reassigned += await db.Clubs
|
||||||
|
.Where(x => x.CreatorPlayerId == playerId)
|
||||||
|
.ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(x => x.CreatorPlayerId, systemPlayerId)
|
||||||
|
.SetProperty(x => x.UpdatedAt, now));
|
||||||
|
reassigned += await db.ClubAnnouncements
|
||||||
|
.Where(x => x.AuthorPlayerId == playerId)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(x => x.AuthorPlayerId, systemPlayerId));
|
||||||
|
|
||||||
|
deleted += await db.RoomBookmarks.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||||
|
deleted += await db.RoomVisits.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||||
|
deleted += await db.RoomRoles.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||||
|
deleted += await db.RoomBans.Where(x => x.BannedPlayerId == playerId).ExecuteDeleteAsync();
|
||||||
|
reassigned += await db.RoomBans
|
||||||
|
.Where(x => x.BannedByPlayerId == playerId)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(x => x.BannedByPlayerId, systemPlayerId));
|
||||||
|
reassigned += await db.RoomRoles
|
||||||
|
.Where(x => x.GrantedByPlayerId == playerId)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(x => x.GrantedByPlayerId, systemPlayerId));
|
||||||
|
reassigned += await db.Rooms
|
||||||
|
.Where(x => x.CreatorPlayerId == playerId)
|
||||||
|
.ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(x => x.CreatorPlayerId, systemPlayerId)
|
||||||
|
.SetProperty(x => x.UpdatedAt, now));
|
||||||
|
reassigned += await db.RoomDataBlobs
|
||||||
|
.Where(x => x.UploadedByPlayerId == playerId)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(x => x.UploadedByPlayerId, systemPlayerId));
|
||||||
|
reassigned += await db.RoomKeys
|
||||||
|
.Where(x => x.CreatorPlayerId == playerId)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(x => x.CreatorPlayerId, systemPlayerId));
|
||||||
|
deleted += await db.RoomKeyPurchases.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||||
|
|
||||||
|
deleted += await db.PlaylistInteractions.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||||
|
reassigned += await db.Playlists
|
||||||
|
.Where(x => x.CreatorPlayerId == playerId)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(x => x.CreatorPlayerId, systemPlayerId));
|
||||||
|
|
||||||
|
deleted += await db.PlayerEventResponses
|
||||||
|
.Where(x => x.PlayerId == playerId
|
||||||
|
|| db.PlayerEvents.Where(e => e.CreatorPlayerId == playerId).Select(e => e.Id).Contains(x.EventId))
|
||||||
|
.ExecuteDeleteAsync();
|
||||||
|
deleted += await db.PlayerEvents.Where(x => x.CreatorPlayerId == playerId).ExecuteDeleteAsync();
|
||||||
|
|
||||||
|
deleted += await db.PrivateInstanceInvitees.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||||
|
var privateInstanceIds = await db.PrivateInstances
|
||||||
|
.Where(x => x.OwnerPlayerId == playerId)
|
||||||
|
.Select(x => x.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
if (privateInstanceIds.Count > 0)
|
||||||
|
{
|
||||||
|
deleted += await db.PrivateInstanceInvitees
|
||||||
|
.Where(x => privateInstanceIds.Contains(x.PrivateInstanceId))
|
||||||
|
.ExecuteDeleteAsync();
|
||||||
|
deleted += await db.PrivateInstances
|
||||||
|
.Where(x => privateInstanceIds.Contains(x.Id))
|
||||||
|
.ExecuteDeleteAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
deleted += await db.GiftPackages
|
||||||
|
.Where(x => x.RecipientPlayerId == playerId)
|
||||||
|
.ExecuteDeleteAsync();
|
||||||
|
if (playerId <= int.MaxValue)
|
||||||
|
{
|
||||||
|
reassigned += await db.GiftPackages
|
||||||
|
.Where(x => x.FromPlayerId == (int)playerId)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(x => x.FromPlayerId, (int?)null));
|
||||||
|
}
|
||||||
|
|
||||||
|
reassigned += await db.Inventions
|
||||||
|
.Where(x => x.CreatorPlayerId == playerId)
|
||||||
|
.ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(x => x.CreatorPlayerId, systemPlayerId)
|
||||||
|
.SetProperty(x => x.UpdatedAt, now));
|
||||||
|
reassigned += await db.Photos
|
||||||
|
.Where(x => x.UploaderPlayerId == playerId)
|
||||||
|
.ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(x => x.UploaderPlayerId, systemPlayerId)
|
||||||
|
.SetProperty(x => x.DeletedAt, now)
|
||||||
|
.SetProperty(x => x.IsPublic, false));
|
||||||
|
|
||||||
|
reassigned += await db.SignupCodes
|
||||||
|
.Where(x => x.RedeemedByPlayerId == playerId)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(x => x.RedeemedByPlayerId, (long?)null));
|
||||||
|
|
||||||
|
return $"deleted_rows={deleted} reassigned_rows={reassigned}";
|
||||||
|
}
|
||||||
|
|
||||||
private Task LogAsync(string action, string targetType, long targetId, string reason)
|
private Task LogAsync(string action, string targetType, long targetId, string reason)
|
||||||
{
|
{
|
||||||
db.AdminActions.Add(new AdminActionEntity
|
db.AdminActions.Add(new AdminActionEntity
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ function PlayerDetail({ id, onClose, onChanged }: { id: number; onClose: () => v
|
|||||||
{tab === 'mod' && <ModerationTab data={data} onChanged={onChanged} />}
|
{tab === 'mod' && <ModerationTab data={data} onChanged={onChanged} />}
|
||||||
{tab === 'grants' && <GrantsTab data={data} onChanged={reload} />}
|
{tab === 'grants' && <GrantsTab data={data} onChanged={reload} />}
|
||||||
{tab === 'gift' && <GiftPanel playerId={data.id} />}
|
{tab === 'gift' && <GiftPanel playerId={data.id} />}
|
||||||
{tab === 'profile' && <ProfileTab data={data} onChanged={reload} />}
|
{tab === 'profile' && <ProfileTab data={data} onChanged={reload} onDeleted={onChanged} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -502,10 +502,11 @@ function GrantsTab({ data, onChanged }: { data: PlayerDetail; onChanged: () => v
|
|||||||
|
|
||||||
// ── Profile: display name + role flags ───────────────────────────────
|
// ── Profile: display name + role flags ───────────────────────────────
|
||||||
|
|
||||||
function ProfileTab({ data, onChanged }: { data: PlayerDetail; onChanged: () => void }) {
|
function ProfileTab({ data, onChanged, onDeleted }: { data: PlayerDetail; onChanged: () => void; onDeleted: () => void }) {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [displayName, setDisplayName] = useState(data.displayName);
|
const [displayName, setDisplayName] = useState(data.displayName);
|
||||||
const [username, setUsername] = useState(data.username);
|
const [username, setUsername] = useState(data.username);
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
const [flags, setFlags] = useState({
|
const [flags, setFlags] = useState({
|
||||||
isVerified: data.isVerified,
|
isVerified: data.isVerified,
|
||||||
isDeveloper: data.isDeveloper,
|
isDeveloper: data.isDeveloper,
|
||||||
@@ -598,6 +599,112 @@ function ProfileTab({ data, onChanged }: { data: PlayerDetail; onChanged: () =>
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PasswordResetCard playerId={data.id} username={data.username} />
|
<PasswordResetCard playerId={data.id} username={data.username} />
|
||||||
|
|
||||||
|
<div className="card !p-4 md:col-span-2 border-danger/40 bg-danger/5">
|
||||||
|
<h3 className="text-sm font-semibold text-danger mb-1">Remove account</h3>
|
||||||
|
<p className="text-xs text-ink-300 mb-3">
|
||||||
|
Deletes the account and personal rows. Rooms, inventions, clubs, and other durable authored content are reassigned to the system account.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteOpen(true)}
|
||||||
|
className="btn-danger text-xs"
|
||||||
|
disabled={data.isAdmin || data.id === 1}
|
||||||
|
>Remove account</button>
|
||||||
|
{data.isAdmin && <p className="text-[11px] text-warn mt-2">Demote this admin before removing the account.</p>}
|
||||||
|
{data.id === 1 && <p className="text-[11px] text-warn mt-2">The system account cannot be removed.</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DeleteAccountModal
|
||||||
|
open={deleteOpen}
|
||||||
|
data={data}
|
||||||
|
onClose={() => setDeleteOpen(false)}
|
||||||
|
onDeleted={onDeleted}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DeleteAccountModal({ open, data, onClose, onDeleted }: {
|
||||||
|
open: boolean;
|
||||||
|
data: PlayerDetail;
|
||||||
|
onClose: () => void;
|
||||||
|
onDeleted: () => void;
|
||||||
|
}) {
|
||||||
|
const toast = useToast();
|
||||||
|
const [confirmUsername, setConfirmUsername] = useState('');
|
||||||
|
const [confirmPhrase, setConfirmPhrase] = useState('');
|
||||||
|
const [reason, setReason] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const expectedPhrase = `DELETE ${data.id}`;
|
||||||
|
const valid = confirmUsername.trim() === data.username && confirmPhrase.trim() === expectedPhrase;
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
if (!valid) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api(`/players/${data.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
body: {
|
||||||
|
ConfirmUsername: confirmUsername.trim(),
|
||||||
|
ConfirmPhrase: confirmPhrase.trim(),
|
||||||
|
Reason: reason.trim() || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
toast.push(`@${data.username} removed`, 'success');
|
||||||
|
onClose();
|
||||||
|
onDeleted();
|
||||||
|
} catch (e) {
|
||||||
|
toast.push((e as Error).message, 'error');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="Remove account"
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
size="sm"
|
||||||
|
footer={<>
|
||||||
|
<button onClick={onClose} className="btn-ghost text-xs" disabled={busy}>Cancel</button>
|
||||||
|
<button onClick={remove} className="btn-danger text-xs" disabled={busy || !valid}>
|
||||||
|
{busy ? 'Removing…' : 'Remove account'}
|
||||||
|
</button>
|
||||||
|
</>}
|
||||||
|
>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<p className="text-ink-200">
|
||||||
|
This removes <span className="font-mono">@{data.username}</span> and cannot be undone from the admin UI.
|
||||||
|
</p>
|
||||||
|
<label className="block">
|
||||||
|
<span className="block text-[11px] uppercase tracking-widest text-ink-400 mb-1">Type username</span>
|
||||||
|
<input
|
||||||
|
value={confirmUsername}
|
||||||
|
onChange={e => setConfirmUsername(e.target.value)}
|
||||||
|
placeholder={data.username}
|
||||||
|
className="input font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="block text-[11px] uppercase tracking-widest text-ink-400 mb-1">Type confirmation phrase</span>
|
||||||
|
<input
|
||||||
|
value={confirmPhrase}
|
||||||
|
onChange={e => setConfirmPhrase(e.target.value)}
|
||||||
|
placeholder={expectedPhrase}
|
||||||
|
className="input font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="block text-[11px] uppercase tracking-widest text-ink-400 mb-1">Reason</span>
|
||||||
|
<input
|
||||||
|
value={reason}
|
||||||
|
onChange={e => setReason(e.target.value)}
|
||||||
|
placeholder="Optional audit note"
|
||||||
|
className="input text-xs"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -102,6 +102,38 @@ self-signed certs work. (First run prints "launch the game once"; do
|
|||||||
that, then re-run with `-ResumeBuild` to finish — see the script's
|
that, then re-run with `-ResumeBuild` to finish — see the script's
|
||||||
`-?` help.)
|
`-?` help.)
|
||||||
|
|
||||||
|
### Standalone Quest build (experimental)
|
||||||
|
|
||||||
|
The December Quest APK is also IL2CPP and can build the same
|
||||||
|
`DorkNet.ClientMod` source against LemonLoader's Android runtime. The
|
||||||
|
current Quest path is still device-tested manually: build the mod as a
|
||||||
|
net8 LemonLoader DLL, install it into a LemonLoader-patched APK, then
|
||||||
|
sideload the resigned APK.
|
||||||
|
|
||||||
|
From this branch:
|
||||||
|
|
||||||
|
```pwsh
|
||||||
|
dotnet build .\DorkNet.ClientMod\DorkNet.ClientMod.csproj `
|
||||||
|
-p:TargetFrameworks=net8.0 `
|
||||||
|
-p:MelonLoaderDir="C:\path\to\LemonLoader\melon_data\MelonLoader"
|
||||||
|
```
|
||||||
|
|
||||||
|
Output lands at
|
||||||
|
`DorkNet.ClientMod\bin\Debug\net8.0\DorkNet.ClientMod.dll`. Desktop
|
||||||
|
builds still default to net6 and keep the historical flat output path.
|
||||||
|
|
||||||
|
Quest notes:
|
||||||
|
|
||||||
|
- Target package: `com.AgainstGravity.RecRoom`.
|
||||||
|
- Use a public HTTPS DorkNet host; keep `"EnableTlsTrustBypass": false`
|
||||||
|
for Quest builds.
|
||||||
|
- The Dec Photon hooks are discovered from the unique
|
||||||
|
`PUNNetworkManager` methods returning `AppSettings` and
|
||||||
|
`AuthenticationValues`, with the known Dec obfuscated names as fallback.
|
||||||
|
- Standalone APK patching/resigning is not yet wrapped by
|
||||||
|
`install-melon.ps1`; this is the porting path, not a public one-click
|
||||||
|
installer yet.
|
||||||
|
|
||||||
### Debug console (opt-in)
|
### Debug console (opt-in)
|
||||||
|
|
||||||
The 2020 client ships a built-in dev console
|
The 2020 client ships a built-in dev console
|
||||||
|
|||||||
@@ -76,6 +76,13 @@ obscure the mirror relationship.
|
|||||||
| `Ns/` | `ns.{apex}/*` — service-URL registry the watch queries on startup |
|
| `Ns/` | `ns.{apex}/*` — service-URL registry the watch queries on startup |
|
||||||
| `PlayerSettings/`, `Rooms/`, `Site/`, `Storage/`, `Strings/` | Smaller surfaces with self-explanatory scope |
|
| `PlayerSettings/`, `Rooms/`, `Site/`, `Storage/`, `Strings/` | Smaller surfaces with self-explanatory scope |
|
||||||
|
|
||||||
|
Destructive admin account removal lives in `AdminController` under
|
||||||
|
`DELETE api/admin/v1/players/{id}` and is surfaced from the Players
|
||||||
|
detail modal. It requires two exact confirmations: the current username
|
||||||
|
and `DELETE {id}`. The endpoint refuses system, self, and still-admin
|
||||||
|
accounts; personal rows are deleted while durable authored content is
|
||||||
|
reassigned to the system account.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Request lifecycle
|
## Request lifecycle
|
||||||
|
|||||||
Reference in New Issue
Block a user