diff --git a/DorkNet.ClientMod/DorkNet.ClientMod.csproj b/DorkNet.ClientMod/DorkNet.ClientMod.csproj index 341adf0..9ddb55d 100644 --- a/DorkNet.ClientMod/DorkNet.ClientMod.csproj +++ b/DorkNet.ClientMod/DorkNet.ClientMod.csproj @@ -1,12 +1,13 @@ - net6.0 + net6.0 latest enable DorkNet.ClientMod DorkNet.ClientMod - false + false + true false $(NoWarn);CS8602;CS8604;CS0436 @@ -19,21 +20,28 @@ they would be derivative works of the game anyway, so we never reference them. Only MelonLoader's own DLLs are needed. - By default the project downloads the pinned MelonLoader release - into DorkNet.ClientMod/build/ and references those DLLs. Pass - -p:MelonLoaderDir= only when you - deliberately want to use a local override. + By default the project downloads the pinned desktop MelonLoader release + into DorkNet.ClientMod/build/ and references its net6 DLLs. For Quest / + LemonLoader builds, pass: + + -p:TargetFrameworks=net8.0 + -p:MelonLoaderDir= + + The reference folder follows the target framework unless explicitly + overridden with -p:MelonLoaderReferenceTfm=... --> v0.6.6 $(MSBuildThisFileDirectory)build\melonloader $(MelonLoaderCacheRoot)\MelonLoader.x64.$(MelonLoaderVersion).zip $(MelonLoaderCacheRoot)\MelonLoader + net8 + net6 + Condition="'$(MelonLoaderReferenceTfm)' == 'net6' and !Exists('$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\MelonLoader.dll')"> - $(MelonLoaderDir)\net6\MelonLoader.dll + $(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\MelonLoader.dll false - $(MelonLoaderDir)\net6\0Harmony.dll + $(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\0Harmony.dll false - $(MelonLoaderDir)\net6\Il2CppInterop.Runtime.dll + $(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\Il2CppInterop.Runtime.dll false - $(MelonLoaderDir)\net6\Il2CppInterop.Common.dll + $(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\Il2CppInterop.Common.dll false diff --git a/DorkNet.ClientMod/Mod.cs b/DorkNet.ClientMod/Mod.cs index 26ea477..94dccbc 100644 --- a/DorkNet.ClientMod/Mod.cs +++ b/DorkNet.ClientMod/Mod.cs @@ -179,10 +179,16 @@ public class Mod : MelonMod // EMGGKBALALH returns Photon.Realtime.AuthenticationValues — // verified at PUNNetworkManager.txt:7560. Builds AuthValues with // environment + accessToken AuthGetParameters. - TryPatchByName("PUNNetworkManager", "FJOLIPKKIBE", - postfix: nameof(PhotonPatches.LogAppSettings_Postfix)); - TryPatchByName("PUNNetworkManager", "EMGGKBALALH", - postfix: nameof(PhotonPatches.LogAuthValues_Postfix)); + // + // 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)); + TryPatchDeclaredMethodByReturnType("PUNNetworkManager", "AuthenticationValues", + fallbackMethodName: "EMGGKBALALH", + postfix: nameof(PhotonPatches.LogAuthValues_Postfix)); // PUNNetworkManager.OnCustomAuthenticationFailed — accepts whatever // string-like arg the IL2CPP method takes (declared `string` in // dump.cs but Il2CppInterop wraps it in a way that breaks @@ -723,6 +729,58 @@ public class Mod : MelonMod 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}."; + 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(); + 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(); + 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) { if (ResolvedTypeCache.TryGetValue(name, out var cached)) @@ -795,6 +853,15 @@ public class Mod : MelonMod 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) { if (args is null) return $"{typeName}.{methodName}"; diff --git a/DorkNet.Server/Controllers/Admin/AdminController.cs b/DorkNet.Server/Controllers/Admin/AdminController.cs index 5db95cb..5c5bb9f 100644 --- a/DorkNet.Server/Controllers/Admin/AdminController.cs +++ b/DorkNet.Server/Controllers/Admin/AdminController.cs @@ -203,6 +203,56 @@ public class AdminController( return Ok(new { player.Id, player.IsAdmin }); } + public sealed record DeletePlayerRequest(string ConfirmUsername, string ConfirmPhrase, string? Reason); + + /// Hard-remove a non-admin player account. The destructive + /// endpoint requires two exact confirmations: the current username and + /// the phrase DELETE {id}. 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. + [HttpDelete("players/{id:long}")] + public async Task 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 ──────────────────────────────────────────────────────────── public sealed record DeleteRoomRequest(string? Reason); @@ -3044,6 +3094,133 @@ public class AdminController( // ── Helpers ────────────────────────────────────────────────────────── + private async Task 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) { db.AdminActions.Add(new AdminActionEntity diff --git a/DorkNet.Server/admin-ui/src/pages/Players.tsx b/DorkNet.Server/admin-ui/src/pages/Players.tsx index aeaa644..350ea5e 100644 --- a/DorkNet.Server/admin-ui/src/pages/Players.tsx +++ b/DorkNet.Server/admin-ui/src/pages/Players.tsx @@ -219,7 +219,7 @@ function PlayerDetail({ id, onClose, onChanged }: { id: number; onClose: () => v {tab === 'mod' && } {tab === 'grants' && } {tab === 'gift' && } - {tab === 'profile' && } + {tab === 'profile' && } )} @@ -502,10 +502,11 @@ function GrantsTab({ data, onChanged }: { data: PlayerDetail; onChanged: () => v // ── 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 [displayName, setDisplayName] = useState(data.displayName); const [username, setUsername] = useState(data.username); + const [deleteOpen, setDeleteOpen] = useState(false); const [flags, setFlags] = useState({ isVerified: data.isVerified, isDeveloper: data.isDeveloper, @@ -598,6 +599,112 @@ function ProfileTab({ data, onChanged }: { data: PlayerDetail; onChanged: () => + +
+

Remove account

+

+ Deletes the account and personal rows. Rooms, inventions, clubs, and other durable authored content are reassigned to the system account. +

+ + {data.isAdmin &&

Demote this admin before removing the account.

} + {data.id === 1 &&

The system account cannot be removed.

} +
+ + setDeleteOpen(false)} + onDeleted={onDeleted} + /> ); } + +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 ( + + + + } + > +
+

+ This removes @{data.username} and cannot be undone from the admin UI. +

+ + + +
+
+ ); +} diff --git a/README.md b/README.md index 3397ada..240c8e7 100644 --- a/README.md +++ b/README.md @@ -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 `-?` 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) The 2020 client ships a built-in dev console diff --git a/docs/architecture.md b/docs/architecture.md index 0a7aa6c..8f58750 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -76,6 +76,13 @@ obscure the mirror relationship. | `Ns/` | `ns.{apex}/*` — service-URL registry the watch queries on startup | | `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