From 2892cb169810435303888d827281e46454cfc88a Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Thu, 25 Jun 2026 01:23:57 +0300 Subject: [PATCH] feat: Add account privacy settings endpoint - Implemented new endpoints for retrieving account privacy settings. - Added fields for privacy preferences such as profile visibility, online status, and activity visibility. feat: Extend play menu tags functionality - Updated PlayMenuTagsRequest to include TrendingTags. - Modified SetPlayMenuTags to handle trending tags. - Enhanced logging to include trending tags. fix: Increase maximum archive size for room zip imports - Updated MaxArchiveBytes from 12GB to 25GB in RoomZipImportController. feat: Improve CDN image handling - Added local image path resolution for room images. - Implemented lazy loading of room image aliases from JSON file. feat: Enhance match player presence resolution - Updated ResolveDataBlobForPresenceAsync to consider sub-room IDs and private instance status. feat: Extend RoomEntity with new properties - Added AllowNewUsers, MinLevel, MaxPlayerCalculationMode, LoadScreensJson, PromoImagesJson, and PromoExternalContentJson fields. feat: Update database schema for new room properties - Added new columns to the Rooms table for AllowNewUsers, MinLevel, MaxPlayerCalculationMode, LoadScreensJson, PromoImagesJson, and PromoExternalContentJson. feat: Implement platform notifications controller - Created PlatformNotificationsController for managing user notification preferences. - Added endpoints for retrieving and setting notification preferences and categories. --- DorkNet.ClientMod/Mod.cs | 534 +++++++++++------- .../CustomAvatarItemsController.cs | 21 + .../API/Images/ImagesController.cs | 52 +- .../API/Keepsakes/KeepsakesController.cs | 24 + .../API/Rooms/V2/RoomsController.cs | 471 ++++++++++++++- .../API/Rooms/V2/RoomsModerationController.cs | 372 ++++++++++++ .../Accounts/AccountsController.cs | 25 + .../Controllers/Admin/AdminController.cs | 8 +- .../Admin/RoomZipImportController.cs | 2 +- .../Controllers/Cdn/CdnController.cs | 69 ++- .../Controllers/Match/GoToController.cs | 3 + .../Match/MatchPlayerController.cs | 24 +- .../PlatformNotificationsController.cs | 122 ++++ DorkNet.Server/Data/Entities/RoomEntity.cs | 12 + DorkNet.Server/Services/RoomService.cs | 14 + .../Services/ServerSettingsService.cs | 32 +- DorkNet.Server/Startup/DatabaseBootstrap.cs | 54 ++ DorkNet.Server/admin-ui/src/pages/Rooms.tsx | 15 +- 18 files changed, 1615 insertions(+), 239 deletions(-) create mode 100644 DorkNet.Server/Controllers/PlatformNotifications/PlatformNotificationsController.cs diff --git a/DorkNet.ClientMod/Mod.cs b/DorkNet.ClientMod/Mod.cs index 3279a5e..44b42e1 100644 --- a/DorkNet.ClientMod/Mod.cs +++ b/DorkNet.ClientMod/Mod.cs @@ -49,6 +49,7 @@ public class Mod : MelonMod private static readonly Dictionary ResolvedTypeCache = new(); private readonly HashSet _diagnosticPatchLabels = new(); private bool _networkPatchesRegistered; + private bool _httpDiagnosticsRegistered; private bool _joinTracePatchesRegistered; private bool _quitTraceCoreRegistered; private bool _quitTraceGameComplete; @@ -86,6 +87,9 @@ public class Mod : MelonMod // is a UnityEngine.KeyCode name; BackQuote is the `~` key. public static bool EnableDebugConsole = false; public static string DebugConsoleToggleKey = "BackQuote"; + public static string[] DevCommands = Array.Empty(); + public static bool DiagnoseDevMenu = false; + public static bool EnableBuiltInDevDebugTools = false; // ── Desktop Screen Sharing gadget FPS ── // RecRoom.Tools.Productivity.DesktopScreenSharingDisplay broadcasts @@ -110,29 +114,6 @@ public class Mod : MelonMod public static int DesktopScreenShareResolution = 0; // horizontal px public static int DesktopScreenShareQuality = 0; // JPEG quality - // One-shot dev-UI diagnostic (no behaviour change). When true, - // ~5s after load the mod logs whether the watch's native developer - // menu content actually exists in this build (HomeScreenFlow. - // devMenuPanel / devButtonPrefab present vs NULL), the dev-flow list - // counts, and whether RecRoom.Debugging.DebugConsole is in the scene - // + whether its static Execute() command path works. This tells us - // stripped-vs-gated so we know whether to un-gate the game's own dev - // menu or build a custom watch entry. Output goes to the MelonLoader - // console + dorknet-diagnostics.log. - public static bool DiagnoseDevMenu = false; - - // Inject a "Dev Console" button into the watch's native dev-menu - // panel (HomeScreenFlow.devMenuPanel) by cloning the game's own - // devButtonPrefab. The panel/prefab exist in this build (just gated - // off), so we instantiate + activate + label, then wire its click to - // run console commands. Native Button3D → works in VR and Screen Mode. - public static bool EnableDevWatchButton = false; - // Preset dev commands shown as watch buttons (when EnableDevWatchButton - // is on). Each entry is "Label=command"; clicking runs - // DebugConsole.Execute(command). Edit to match this build's actual - // DebugConsoleCommandConfig command names/args. - public static string[] DevCommands = { "Help=help", "Fly=fly", "NoClip=noclip" }; - // Raise RRO quest party size past the baked 4. The cap lives in each // quest GameConfigurationAsset.TeamConfigurations[].MaxTeamSize // ScriptableObject (baked in the client bundle; no server lever). The @@ -186,116 +167,10 @@ public class Mod : MelonMod "NotifyServerCertificate", prefix: nameof(TlsPatches.NotifyServerCertificate_Prefix)); } - if (Cfg.EnableDebugConsole || Cfg.EnableDevWatchButton) - Log.Msg($"[debugconsole] armed: toggleKey={Cfg.DebugConsoleToggleKey}, commands={DebugConsolePatches.FormatConfiguredCommandsForLog()}"); - // Image CDN signature verification. Server responses still include - // Content-Signature, but the official RSA public key is baked into - // GameAssembly and the matching private key is not available. This - // method is the verifier in the 2023 build (JFDPIJFDHAD.HOCPMNEKIHP); - // skipping it lets signed local CDN/image bytes load. - TryPatchByName("JFDPIJFDHAD", - "HOCPMNEKIHP", - args: new[] - { - typeof(string), - typeof(Il2CppStructArray), - typeof(Il2CppStructArray), - typeof(Il2CppStructArray), - typeof(Il2CppStructArray) - }, - prefix: nameof(ImageSignaturePatches.VerifyImageSignature_Prefix)); RegisterQuitTracePatches(logMisses: true); RegisterAntiTamperCallbackPatches(logMisses: true); RegisterToxModPatches(logMisses: true); PatchFileHashCheckerCallback(); - // Post-save blob-name refresh. The 2020 watch's - // RoomPersistenceManager.RoomDataBlobName is only ever set by - // /goto-driven flows — neither the SubscriptionUpdateRoom push - // nor SubscriptionUpdatePresence pushes the server fires after - // save propagate into this field. So the PostSaveReloading - // sub-state's MasterReloadRoomAsync deserializes the cached - // bytes from the OLD blob name (the one /goto returned at boot), - // wiping any MakerPen object the user just placed even though - // the new blob is already on S3. - // - // Verified live: after a dorm save with object placement, server - // pushes SubscriptionUpdateRoom + PresenceUpdate both carrying - // dataBlob="dorm_p_v(N+1).dat", but the watch's - // set_RoomDataBlobName trace immediately after the save shows - // old="v(N)" new="v(N)" — no propagation, no fresh download, - // no respawn. Leave+return triggers /goto which DOES update - // RoomDataBlobName, so the fix is to do the same write - // ourselves: when entering the SavingRoomSuccess state on the - // RoomPersistenceManager, read the just-pushed - // Rooms.LocalRoomScene.DataBlobName and force-set - // __instance.RoomDataBlobName to it. That fires the real - // setter → OnRoomDataBlobNameChanged → DownloadRoomDataBlobAsync - // for the new blob → PostSaveReloading picks up the fresh - // bytes and respawns the MakerPen objects. - TryPatchByName("RecRoom.Persistence.RoomPersistenceManager", - "set_RoomDataBlobName", - args: new[] { typeof(string) }, - prefix: nameof(SavePatches.SetRoomDataBlobName_Prefix)); - // Intercept the SubscriptionUpdateRoom push BEFORE the watch's - // (cachedRoomId, cachedSubRoomId) early-out gate drops it. We - // stash the just-arrived Scenes[0].DataBlobName into a static - // so the set_RoomDataBlobName hijack can use it — otherwise - // LocalRoomDetails / LocalRoomScene never get the new blob - // name on the second-or-later push for the same (room, sub) - // tuple (= every post-save push in a dorm). - TryPatchByName("RecNet.Rooms", - "OnSubscriptionUpdateRoom", - prefix: nameof(SavePatches.OnSubscriptionUpdateRoom_Prefix)); - // Force AccountExtensions.CanLocalPlayerChat to return true. The - // 2020 watch's room-chat send path (PlayerEmotes.SendChatEmote - // Coroutine d__53 offset 040) gates on this — if false, the - // coroutine bails before raising the Photon RPC, so the sender - // doesn't even see their own message. The check ANDs three - // signals: LocalAccount.TreatAsJunior (ObscuredBool at offset - // 0x40), a PlatformManager flag, and a ModerationManager - // singleton field. At least one returns the wrong value against - // our server. Force-allow — single-tenant private server, text - // moderation isn't a concern. - TryPatchByName("RecNet.AccountExtensions", - "CanLocalPlayerChat", - args: Type.EmptyTypes, - postfix: nameof(ChatPatches.CanLocalPlayerChat_Postfix)); - // PlayerEmotes.RemoveInvalidCharactersFromMessage walks the chat - // font asset's character table via TMP_FontAsset.HasCharacters - // to strip glyphs the font can't render. On 2020 Rec Room the - // chat font field can be null at the moment this fires (font - // asset bundle hasn't loaded yet?) and HasCharacters NREs deep - // inside Unity's TMP — which propagates out through the chat - // send callback and aborts the RPC. The watch's own ISIL has - // "skipping invalid character check" log paths for missing - // assets but doesn't actually short-circuit on the null field - // we hit. Skip the method entirely; chat lines will just keep - // any glyphs the font can't render (most common: emoji). - TryPatchByName("RecRoom.Players.PlayerEmotes", - "RemoveInvalidCharactersFromMessage", - prefix: nameof(ChatPatches.RemoveInvalidCharactersFromMessage_Prefix)); - // Trace hooks so we can see exactly where chat dies. Send path: - // KeyboardInputField.submit → RoomChatMenu.SendEmoteMessage(string) - // → PlayerEmotes.SendChatEmote(msg, true) - // → SendChatEmoteCoroutine d__53 → PurifyString HTTP call - // → b__0 callback (error, cleanVersion) — fires Photon RPC - // AND local-echoes via ProcessNewChatMessageReceived - // → on each receiver (including self): RpcChatEmote(msg) → - // ProcessNewChatMessageReceived → ReceiveRoomChat appends to - // the chat log buffer. - TryPatchByName("RecRoom.Players.PlayerEmotes", - "SendChatEmote", - args: new[] { typeof(string), typeof(bool) }, - prefix: nameof(ChatPatches.SendChatEmote_Prefix)); - TryPatchByName("RecRoom.Players.PlayerEmotes", - "ProcessNewChatMessageReceived", - args: new[] { typeof(string), typeof(bool) }, - prefix: nameof(ChatPatches.ProcessNewChatMessageReceived_Prefix)); - TryPatchByName("RecRoom.Players.PlayerEmotes", - "RpcChatEmote", - args: new[] { typeof(string) }, - prefix: nameof(ChatPatches.RpcChatEmote_Prefix)); - // Desktop Screen Sharing FPS override. Only register when a target // is set; the postfix on the gadget's Awake rewrites the baked // refresh-frequency field (and, if enabled, raises the Photon @@ -309,11 +184,6 @@ public class Mod : MelonMod $"(raisePhotonRate={Cfg.DesktopScreenShareRaisePhotonRate})"); } - TryPatchByName("RecRoom.Tools.MakerPenVisuals", - "set_LaserPointerEnabled", - args: new[] { typeof(bool) }, - prefix: nameof(MakerPenGiftPreviewPatches.LaserPointerEnabled_Prefix)); - // RRO quest party-size bump. Prefix the runtime config-apply path so // we can read the config's Name (scope to the allowlisted quests) and // bump GameConfigurationData.TeamConfigurations[].TeamSize before the @@ -338,9 +208,30 @@ public class Mod : MelonMod RegisterStudioTracePatches(); + if (Cfg.EnableBuiltInDevDebugTools) + RegisterBuiltInDevDebugTools(); + Log.Msg("=== Client patches registered ==="); } + private void RegisterBuiltInDevDebugTools() + { + Log.Msg("=== Registering built-in Dev Debug Tools hooks ==="); + TryPatchByName("RRUI.Data.GlobalModelController+HideIfNotDeveloperImpl", + "DGFIJKPCBLK", args: Type.EmptyTypes, + prefix: nameof(BuiltInDevDebugToolsPatches.KeepVisible_Prefix)); + TryPatchByName("RRUI.Data.GlobalModelController+HideIfNotDeveloperAndNotDebugBuildImpl", + "DGFIJKPCBLK", args: Type.EmptyTypes, + prefix: nameof(BuiltInDevDebugToolsPatches.KeepVisible_Prefix)); + TryPatchByName("RRUI.Data.GlobalModelController+HideIfNotDebugBuildImpl", + "DGFIJKPCBLK", args: Type.EmptyTypes, + prefix: nameof(BuiltInDevDebugToolsPatches.KeepVisible_Prefix)); + TryPatchByName("RRUI.Data.DevDebugBuildInformationModel", + "get_BuildInfoFormatted", args: Type.EmptyTypes, + postfix: nameof(BuiltInDevDebugToolsPatches.BuildInfoFormatted_Postfix)); + Log.Msg("=== Built-in Dev Debug Tools hooks registered ==="); + } + // ── Room-join + watch-button trace ──────────────────────────────── // Traces the full "press the Dorm Room button → join the room" chain // so a stuck join (e.g. the watch's Dorm Room button doing nothing) @@ -631,12 +522,6 @@ public class Mod : MelonMod new[] { typeof(string), typeof(UriKind) }, prefix: nameof(UriPatches.UriStringCtor_Prefix)); complete &= TryPatchHttpRequestUriConstructors(); - TryPatchByName("BestHTTP.HTTPRequest", "set_Uri", - args: new[] { typeof(Uri) }, - prefix: nameof(UriPatches.HttpRequestSetUri_Prefix)); - TryPatchByName("BestHTTP.HTTPRequest", "PrepareUri", - args: new[] { typeof(Uri) }, - prefix: nameof(UriPatches.HttpRequestPrepareUri_Prefix)); complete &= TryPatchByName("BestHTTP.HTTPRequest", "Send", prefix: nameof(UriPatches.HttpRequestSend_Prefix)); complete &= TryPatchHttpManagerStringSendRequestOverloads(); @@ -644,9 +529,90 @@ public class Mod : MelonMod args: new[] { ResolveType("BestHTTP.HTTPRequest") ?? typeof(object) }, prefix: nameof(UriPatches.HttpManagerSendRequestObject_Prefix)); + RegisterHttpDiagnostics(); _networkPatchesRegistered = complete; } + private void RegisterHttpDiagnostics() + { + if (_httpDiagnosticsRegistered) return; + + var complete = true; + complete &= TryPatchBndRequestBuilderDiagnostics(); + complete &= TryPatchDiagnosticAllOverloads("BestHTTP.HTTPRequest", "CallCallback", + postfix: nameof(HttpTracePatches.CallCallback_Postfix), + finalizer: nameof(HttpTracePatches.CallCallback_Finalizer)); + complete &= TryPatchByName("BestHTTP.HTTPRequest", "AddHeader", + args: new[] { typeof(string), typeof(string) }, + prefix: nameof(HttpTracePatches.HeaderSet_Prefix)); + complete &= TryPatchByName("BestHTTP.HTTPRequest", "SetHeader", + args: new[] { typeof(string), typeof(string) }, + prefix: nameof(HttpTracePatches.HeaderSet_Prefix)); + + _httpDiagnosticsRegistered = complete; + } + + private bool TryPatchBndRequestBuilderDiagnostics() + { + const string label = "BNDIAONDFFF.request-builder"; + if (_diagnosticPatchLabels.Contains(label)) + return true; + + var type = ResolveType("BNDIAONDFFF"); + if (type is null) + { + Log.Warning("[patch-miss] BNDIAONDFFF request-builder: type not found"); + return false; + } + + var prefix = new HarmonyMethod(GetPatchMethod(nameof(HttpTracePatches.RecNetRequestBuilderCtor_Prefix))); + var postfix = new HarmonyMethod(GetPatchMethod(nameof(HttpTracePatches.RecNetRequestBuilderCtor_Postfix))); + var patched = 0; + + foreach (var ctor in type.GetConstructors(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)) + { + var parameters = ctor.GetParameters(); + if (parameters.Length != 3 || parameters[2].ParameterType != typeof(string)) continue; + + try + { + HarmonyInstance.Patch(ctor, prefix: prefix, postfix: postfix); + patched++; + } + catch (Exception ex) + { + Log.Warning($"[patch-fail] BNDIAONDFFF..ctor({FormatParameterTypes(parameters)}): {ex.Message}"); + } + } + + foreach (var method in AccessTools.GetDeclaredMethods(type)) + { + if (method.Name != "FILJELOLBKK") continue; + var parameters = method.GetParameters(); + if (parameters.Length != 3 || parameters[2].ParameterType != typeof(string)) continue; + + try + { + HarmonyInstance.Patch(method, prefix: prefix, postfix: postfix); + patched++; + } + catch (Exception ex) + { + Log.Warning($"[patch-fail] BNDIAONDFFF.FILJELOLBKK({FormatParameterTypes(parameters)}): {ex.Message}"); + } + } + + if (patched == 0) + { + Log.Warning("[patch-miss] BNDIAONDFFF request-builder overloads"); + return false; + } + + _diagnosticPatchLabels.Add(label); + Log.Msg($"[patch-ok] BNDIAONDFFF request-builder x{patched}"); + return true; + } + private bool TryPatchHttpRequestUriConstructors() { var type = ResolveType("BestHTTP.HTTPRequest"); @@ -753,10 +719,10 @@ public class Mod : MelonMod RegisterToxModPatches(logMisses: false); } - if (Cfg.EnableDebugConsole || Cfg.EnableDevWatchButton) + if (Cfg.EnableDebugConsole) DebugConsolePatches.PollToggleKey(); - if (Cfg.DiagnoseDevMenu || Cfg.EnableDevWatchButton) + if (Cfg.DiagnoseDevMenu) DevMenuProbe.Tick(); } @@ -783,13 +749,22 @@ public class Mod : MelonMod if (TryGetConfigValue(r, "ServerHost", out var v)) Cfg.ServerHost = v.GetString() ?? Cfg.ServerHost; if (TryGetConfigValue(r, "EnableTlsTrustBypass", out v)) Cfg.EnableTlsTrustBypass = v.GetBoolean(); if (TryGetConfigValue(r, "TraceRegistrationDialog", out v)) Cfg.TraceRegistrationDialog = v.GetBoolean(); - if (TryGetConfigValue(r, "EnableDebugConsole", out v)) Cfg.EnableDebugConsole = v.GetBoolean(); - if (TryGetConfigValue(r, "DebugConsoleToggleKey", out v)) Cfg.DebugConsoleToggleKey = v.GetString() ?? Cfg.DebugConsoleToggleKey; + if (TryGetConfigValue(r, "EnableDebugConsole", out v)) Cfg.EnableDebugConsole = v.GetBoolean(); + if (TryGetConfigValue(r, "DebugConsoleToggleKey", out v)) Cfg.DebugConsoleToggleKey = v.GetString() ?? Cfg.DebugConsoleToggleKey; + if (TryGetConfigValue(r, "DiagnoseDevMenu", out v)) Cfg.DiagnoseDevMenu = v.GetBoolean(); + if (TryGetConfigValue(r, "EnableBuiltInDevDebugTools", out v)) Cfg.EnableBuiltInDevDebugTools = v.GetBoolean(); + if (TryGetConfigValue(r, "DevCommands", out v) && v.ValueKind == JsonValueKind.Array) + { + var commands = new List(); + foreach (var e in v.EnumerateArray()) + if (e.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(e.GetString())) + commands.Add(e.GetString()!); + Cfg.DevCommands = commands.ToArray(); + } if (TryGetConfigValue(r, "DesktopScreenShareFps", out v)) Cfg.DesktopScreenShareFps = (float)v.GetDouble(); if (TryGetConfigValue(r, "DesktopScreenShareRaisePhotonRate", out v)) Cfg.DesktopScreenShareRaisePhotonRate = v.GetBoolean(); if (TryGetConfigValue(r, "DesktopScreenShareResolution", out v)) Cfg.DesktopScreenShareResolution = v.GetInt32(); if (TryGetConfigValue(r, "DesktopScreenShareQuality", out v)) Cfg.DesktopScreenShareQuality = v.GetInt32(); - if (TryGetConfigValue(r, "DiagnoseDevMenu", out v)) Cfg.DiagnoseDevMenu = v.GetBoolean(); if (TryGetConfigValue(r, "QuestMaxTeamSize", out v)) Cfg.QuestMaxTeamSize = v.GetInt32(); if (TryGetConfigValue(r, "QuestMaxTeamSizeRooms", out v) && v.ValueKind == JsonValueKind.Array) { @@ -799,17 +774,6 @@ public class Mod : MelonMod rooms.Add(e.GetString()!); Cfg.QuestMaxTeamSizeRooms = rooms.ToArray(); // explicit (incl. empty = none) } - if (TryGetConfigValue(r, "EnableDevWatchButton", out v)) Cfg.EnableDevWatchButton = v.GetBoolean(); - if (TryGetConfigValue(r, "DevCommands", out v) && v.ValueKind == JsonValueKind.Array) - { - var list = new List(); - foreach (var e in v.EnumerateArray()) - { - var s = e.GetString(); - if (!string.IsNullOrWhiteSpace(s)) list.Add(s!); - } - if (list.Count > 0) Cfg.DevCommands = list.ToArray(); - } Log.Msg($"[config] loaded from {path}: ServerHost={Cfg.ServerHost}, " + $"EnableTlsTrustBypass={Cfg.EnableTlsTrustBypass}"); } @@ -1063,7 +1027,7 @@ public class Mod : MelonMod { // Look in all three patch holder classes — small enough that a // linear scan is cheaper than per-class lookups. - foreach (var holder in new[] { typeof(UriPatches), typeof(HttpTracePatches), typeof(AuthPatches), typeof(AntiCheatPatches), typeof(AntiTamperPatches), typeof(FileHashCheckerPatches), typeof(ToxModPatches), typeof(TlsPatches), typeof(ImageSignaturePatches), typeof(QuitTracePatches), typeof(DiagnosticPatches), typeof(RoomLoadDiagnostics), typeof(SavePatches), typeof(ChatPatches), typeof(JoinPatches), typeof(RegistrationPatches), typeof(DebugConsolePatches), typeof(ScreenSharePatches), typeof(MakerPenGiftPreviewPatches), typeof(QuestTeamSize) }) + foreach (var holder in new[] { typeof(UriPatches), typeof(HttpTracePatches), typeof(AuthPatches), typeof(AntiCheatPatches), typeof(AntiTamperPatches), typeof(FileHashCheckerPatches), typeof(ToxModPatches), typeof(TlsPatches), typeof(QuitTracePatches), typeof(DiagnosticPatches), typeof(RoomLoadDiagnostics), typeof(SavePatches), typeof(ChatPatches), typeof(JoinPatches), typeof(RegistrationPatches), typeof(BuiltInDevDebugToolsPatches), typeof(DebugConsolePatches), typeof(ScreenSharePatches), typeof(MakerPenGiftPreviewPatches), typeof(QuestTeamSize) }) { var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static); if (m is not null) return m; @@ -1085,17 +1049,6 @@ public class Mod : MelonMod } var complete = true; - complete &= TryPatchDiagnosticByName("SessionManager", "TryApplicationQuit", - prefix: nameof(QuitTracePatches.SessionManagerTryApplicationQuit_Prefix), - logMiss: logMisses); - complete &= TryPatchDiagnosticByName("SessionManager", "TryApplicationQuit", - args: new[] { typeof(int) }, - prefix: nameof(QuitTracePatches.SessionManagerTryApplicationQuitInt_Prefix), - logMiss: logMisses); - complete &= TryPatchDiagnosticByName("SessionManager", "FatalApplicationQuit", - args: new[] { typeof(int), typeof(string) }, - prefix: nameof(QuitTracePatches.SessionManagerFatalApplicationQuit_Prefix), - logMiss: logMisses); complete &= TryPatchDiagnosticByName("OPEOLPDLEBO", "GDNAOLMIDLM", prefix: nameof(QuitTracePatches.ApplicationWantsToQuit_Prefix), logMiss: logMisses); @@ -1145,17 +1098,6 @@ public class Mod : MelonMod logMiss: logMisses); } - TryPatchDiagnosticByName("SessionManager", "TryApplicationQuit", - prefix: nameof(DiagnosticPatches.SessionManagerTryApplicationQuit_Prefix), - logMiss: logMisses); - TryPatchDiagnosticByName("SessionManager", "TryApplicationQuit", - args: new[] { typeof(int) }, - prefix: nameof(DiagnosticPatches.SessionManagerTryApplicationQuitInt_Prefix), - logMiss: logMisses); - TryPatchDiagnosticByName("SessionManager", "FatalApplicationQuit", - args: new[] { typeof(int), typeof(string) }, - prefix: nameof(DiagnosticPatches.SessionManagerFatalApplicationQuit_Prefix), - logMiss: logMisses); RegisterDeepRoomDiagnostics(logMisses); // SteamManager.Awake + SteamPlatformManager.*LoginInitialize were // pure-logging diagnostics that fired during Unity's very first @@ -1732,6 +1674,31 @@ internal static class RoomLoadDiagnostics } // ── Debug console access ─────────────────────────────────────────────── +// Built-in watch Dev Debug Tools hooks. The 2023 RRUI page already exists, +// but retail rows are hidden by GlobalModelController hide gates. We keep +// those rows visible and append the debug-console command table to the +// built-in Build Information text so the watch page reflects the real client +// command config. +internal static class BuiltInDevDebugToolsPatches +{ + public static bool KeepVisible_Prefix() => false; + + public static void BuildInfoFormatted_Postfix(ref string __result) + { + try + { + var commands = DebugConsolePatches.BuildCommandReferenceText("Debug Console Commands", maxCommands: 60); + __result = string.IsNullOrWhiteSpace(__result) + ? commands + : __result.TrimEnd() + "\n\n" + commands; + } + catch (Exception ex) + { + Mod.Log.Warning($"[devtools] failed to append command list: {ex.Message}"); + } + } +} + // The 2020 client ships RecRoom.Debugging.DebugConsole — a real in-game // dev console (text input + a DebugConsoleCommandConfig command table: // SetTimeScale, Fly, Teleport, GoToRoom, KillAllEnemies, DebugCompleteDaily, @@ -1766,6 +1733,7 @@ internal static class DebugConsolePatches private static Type? _consoleType; private static MethodInfo? _toggleMethod; // ShowInputField(bool) / MCNBJFECHLJ(bool) private static MethodInfo? _setConsoleText; // DebugConsole.SetConsoleText(string) + private static List? _commandCache; // Harmony prefix returning false → skips the original CheatManager // detector, so a tripped heuristic never runs its punish/drop path. @@ -1773,7 +1741,7 @@ internal static class DebugConsolePatches public static string FormatConfiguredCommandsForLog() { - var commands = GetConfiguredCommands(); + var commands = GetAvailableCommands(); if (commands.Count == 0) return ""; var parts = new List(); @@ -1971,27 +1939,49 @@ internal static class DebugConsolePatches private static string BuildConfiguredCommandText() { - var commands = GetConfiguredCommands(); + return BuildCommandReferenceText("DorkNet debug commands", maxCommands: 80) + "\n\nType a command and press Enter."; + } + + public static string BuildCommandReferenceText(string header, int maxCommands = int.MaxValue) + { + var commands = GetAvailableCommands(); var sb = new StringBuilder(); - sb.AppendLine("DorkNet debug commands"); + sb.AppendLine(header); sb.AppendLine(); if (commands.Count == 0) { - sb.AppendLine("No commands configured in DevCommands."); + sb.AppendLine("No DebugConsoleCommandConfig commands found."); } else { - for (var i = 0; i < commands.Count; i++) + var visible = Math.Min(commands.Count, maxCommands); + for (var i = 0; i < visible; i++) sb.AppendLine($"{i + 1}. {commands[i].Label}: {commands[i].Command}"); + if (visible < commands.Count) + { + sb.AppendLine(); + sb.AppendLine($"+ {commands.Count - visible} more commands available through the debug console."); + } } - sb.AppendLine(); - sb.AppendLine("Type a command and press Enter."); return sb.ToString().TrimEnd(); } - private static List GetConfiguredCommands() + internal static List GetAvailableCommands() { + if (_commandCache is not null) return _commandCache; + var commands = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + AddConfiguredCommands(commands, seen); + AddRuntimeCommands(commands, seen); + commands.Sort((a, b) => string.Compare(a.Label, b.Label, StringComparison.OrdinalIgnoreCase)); + _commandCache = commands; + Mod.Log.Msg($"[debugconsole] command table loaded: {commands.Count} command(s)"); + return commands; + } + + private static void AddConfiguredCommands(List commands, HashSet seen) + { foreach (var entry in Mod.Cfg.DevCommands) { if (string.IsNullOrWhiteSpace(entry)) continue; @@ -2000,12 +1990,117 @@ internal static class DebugConsolePatches var command = split >= 0 ? entry[(split + 1)..].Trim() : entry.Trim(); if (string.IsNullOrWhiteSpace(command)) continue; if (string.IsNullOrWhiteSpace(label)) label = command; - commands.Add(new ConfiguredCommand(label, command)); + AddCommand(commands, seen, label, command); } - return commands; } - private readonly struct ConfiguredCommand + private static void AddRuntimeCommands(List commands, HashSet seen) + { + try + { + var cfgType = Mod.ResolveType("RecRoom.Debugging.DebugConsoleCommandConfig"); + if (cfgType is null) + { + Mod.Log.Warning("[debugconsole] DebugConsoleCommandConfig type not found"); + return; + } + + foreach (var method in cfgType.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)) + { + if (method.GetParameters().Length != 0) continue; + if (!cfgType.IsAssignableFrom(method.ReturnType)) continue; + try { AddCommandConfig(method.Invoke(null, Array.Empty()), commands, seen); } + catch { } + } + + var il2cppType = ToIl2CppType(cfgType); + var resources = Mod.ResolveType("UnityEngine.Resources"); + if (il2cppType is not null && resources is not null) + { + var all = OneArgMethod(resources, "FindObjectsOfTypeAll", il2cppType)?.Invoke(null, new[] { il2cppType }); + var count = ArrLen(all); + for (var i = 0; i < count; i++) + AddCommandConfig(ArrItem(all, i), commands, seen); + } + } + catch (Exception ex) + { + Mod.Log.Warning($"[debugconsole] runtime command scan failed: {ex.Message}"); + } + } + + private static void AddCommandConfig(object? config, List commands, HashSet seen) + { + if (config is null) return; + var metas = GetMemberValue(config, "Metas"); + var count = ArrLen(metas); + for (var i = 0; i < count; i++) + { + var meta = ArrItem(metas, i); + if (meta is null) continue; + var methodName = ReadStringMember(meta, "MethodName"); + if (string.IsNullOrWhiteSpace(methodName)) continue; + var declaringType = ReadStringMember(meta, "DeclaringType"); + var label = string.IsNullOrWhiteSpace(declaringType) + ? methodName + : $"{ShortTypeName(declaringType)}.{methodName}"; + AddCommand(commands, seen, label, methodName); + } + } + + private static void AddCommand(List commands, HashSet seen, string label, string command) + { + label = CleanLabel(label); + command = (command ?? string.Empty).Trim(); + if (command.Length == 0 || !seen.Add(command)) return; + commands.Add(new ConfiguredCommand(label, command)); + } + + private static object? GetMemberValue(object instance, string name) + { + var type = instance.GetType(); + var prop = type.GetProperty(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + if (prop is not null && prop.CanRead && prop.GetIndexParameters().Length == 0) + { + try { return prop.GetValue(instance); } catch { } + } + + var field = type.GetField(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + if (field is not null) + { + try { return field.GetValue(instance); } catch { } + } + + return null; + } + + private static string ReadStringMember(object instance, string name) + { + return GetMemberValue(instance, name)?.ToString() ?? string.Empty; + } + + private static string CleanLabel(string label) + { + label = (label ?? string.Empty).Trim(); + if (label.Length == 0) return "Command"; + + var cleaned = new char[label.Length]; + for (var i = 0; i < label.Length; i++) + { + var ch = label[i]; + cleaned[i] = char.IsLetterOrDigit(ch) || ch is '.' or '_' or '-' or ' ' ? ch : '_'; + } + + return new string(cleaned); + } + + private static string ShortTypeName(string typeName) + { + var idx = typeName.LastIndexOf('.'); + return idx >= 0 && idx < typeName.Length - 1 ? typeName[(idx + 1)..] : typeName; + } + + internal readonly struct ConfiguredCommand { public ConfiguredCommand(string label, string command) { @@ -2677,6 +2772,8 @@ internal static class HttpTracePatches private static int _failureLogCount; private static int _responseTupleLogCount; private static int _builderSendLogCount; + private static int _callbackLogCount; + private static int _uriUnreadDumpCount; public static void RecNetRequestSend_Prefix(object __instance, MethodBase __originalMethod) { @@ -2827,6 +2924,7 @@ internal static class HttpTracePatches { try { + if (!ShouldLogCallback(__instance)) return; DiagnosticPatches.Write( $"[http-callback] EXIT {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " + $"{DescribeRequestAndResponse(__instance)}"); @@ -2837,6 +2935,27 @@ internal static class HttpTracePatches } } + public static Exception? CallCallback_Finalizer(Exception? __exception, object __instance, MethodBase __originalMethod) + { + if (__exception is not null) + { + try + { + DiagnosticPatches.Write( + $"[http-callback] EXCEPTION {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " + + $"{DescribeRequestAndResponse(__instance)} " + + $"{RoomLoadDiagnostics.FormatException(__exception, __exception)} " + + $"state={DumpObjectFields(__instance, 32, 900)}"); + } + catch (Exception ex) + { + LogFailure("callback-finalizer", ex); + } + } + + return __exception; + } + public static void TraceUrl(string? url, string source) { if (string.IsNullOrWhiteSpace(url)) return; @@ -2855,14 +2974,18 @@ internal static class HttpTracePatches try { var id = NextRequestId(); - var uri = TryReadRequestUri(request)?.ToString() ?? ""; + var uriValue = TryReadRequestUri(request); + var uri = uriValue?.ToString() ?? ""; var method = TryReadHttpMethod(request); var headers = TryReadHeaders(request); var body = TryReadBodyPreview(request); + var fallback = uriValue is null && System.Threading.Interlocked.Increment(ref _uriUnreadDumpCount) <= 40 + ? $" requestFields={DumpObjectFields(request, 24, 260)}" + : ""; DiagnosticPatches.Write( $"[http-trace] #{id} {source} method={method} uri={SanitizeInline(uri)} " + - $"headers={headers} body={body}"); + $"headers={headers} body={body}{fallback}"); } catch (Exception ex) { @@ -3019,6 +3142,14 @@ internal static class HttpTracePatches return ""; } + private static bool ShouldLogCallback(object request) + { + var response = TryReadResponseObject(request); + var status = response is null ? null : TryReadStatusCode(response); + if (status >= 400) return true; + return System.Threading.Interlocked.Increment(ref _callbackLogCount) <= 80; + } + private static string DescribeRequestAndResponse(object request) { var uri = TryReadRequestUri(request)?.ToString() ?? ""; @@ -3064,6 +3195,17 @@ internal static class HttpTracePatches return null; } + private static int? TryReadStatusCode(object response) + { + if (TryReadMemberValue(response, "StatusCode", out var value)) + { + try { return Convert.ToInt32(value); } + catch { } + } + + return null; + } + private static string DescribeResponseObject(object response) { var known = DescribeKnownMembers(response, @@ -3806,14 +3948,6 @@ internal static class TlsPatches } } -internal static class ImageSignaturePatches -{ - public static bool VerifyImageSignature_Prefix() - { - return false; - } -} - internal static class QuitTracePatches { public static bool ApplicationQuit_Prefix() diff --git a/DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs b/DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs index f2414eb..47e359d 100644 --- a/DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs +++ b/DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs @@ -13,6 +13,7 @@ namespace DorkNet.Server.Controllers.API.CustomAvatarItems; [ApiController] [Route("api/customAvatarItems")] [Route("api/customAvatarItems/v1")] +[Route("api/customAvatarItems/v2")] public class CustomAvatarItemsController( DorkNetDbContext db, LevelService level, @@ -110,6 +111,26 @@ public class CustomAvatarItemsController( return Ok(rows.Select(ToWire)); } + [HttpGet("fromCreator/{creatorId:long}")] + [AllowAnonymous] + public async Task FromCreator( + long creatorId, + [FromQuery] int skip = 0, + [FromQuery] int take = 100) + { + skip = Math.Max(0, skip); + take = Math.Clamp(take, 1, 100); + var currentPlayerId = this.CurrentPlayerId(); + var rows = await db.CustomAvatarItems + .Where(i => i.CreatorPlayerId == creatorId + && (i.IsPublic || i.CreatorPlayerId == currentPlayerId)) + .OrderByDescending(i => i.UpdatedAt) + .Skip(skip) + .Take(take) + .ToListAsync(); + return Ok(rows.Select(ToWire)); + } + [HttpGet("me")] [Authorize] public async Task Me([FromQuery] int skip = 0, [FromQuery] int take = 100) diff --git a/DorkNet.Server/Controllers/API/Images/ImagesController.cs b/DorkNet.Server/Controllers/API/Images/ImagesController.cs index b169f68..27d2dcc 100644 --- a/DorkNet.Server/Controllers/API/Images/ImagesController.cs +++ b/DorkNet.Server/Controllers/API/Images/ImagesController.cs @@ -396,19 +396,63 @@ public class ImagesController( [HttpGet("api/images/v3/byaccount/{accountId:long}")] [HttpGet("api/images/v4/byaccount/{accountId:long}")] [HttpGet("api/images/v5/byaccount/{accountId:long}")] + [HttpGet("api/images/v2/player/{accountId:long}")] + [HttpGet("api/images/v3/player/{accountId:long}")] + [HttpGet("api/images/v4/player/{accountId:long}")] + [HttpGet("api/images/v5/player/{accountId:long}")] public async Task GetByAccount(long accountId, - [FromQuery] int take = 50, [FromQuery] int skip = 0) + [FromQuery] int take = 50, [FromQuery] int skip = 0, [FromQuery] int sort = 0) { take = Math.Clamp(take, 1, 200); skip = Math.Max(0, skip); - var rows = await db.Photos + var query = db.Photos .Where(p => p.UploaderPlayerId == accountId && p.IsPublic && p.DeletedAt == null) - .OrderByDescending(p => p.CreatedAt) - .Skip(skip).Take(take) + .AsQueryable(); + query = sort switch + { + 1 => query.OrderByDescending(p => p.CheerCount).ThenByDescending(p => p.CreatedAt), + 2 => query.OrderByDescending(p => p.ViewCount).ThenByDescending(p => p.CreatedAt), + _ => query.OrderByDescending(p => p.CreatedAt), + }; + + var rows = await query + .Skip(skip) + .Take(take) .ToListAsync(); return Ok(rows.Select(p => BuildImageInfo(p, accountId))); } + [HttpGet("api/images/v2/room/{roomId:long}")] + [HttpGet("api/images/v3/room/{roomId:long}")] + [HttpGet("api/images/v4/room/{roomId:long}")] + [HttpGet("api/images/v5/room/{roomId:long}")] + public async Task GetByRoom( + long roomId, + [FromQuery] int take = 50, + [FromQuery] int skip = 0, + [FromQuery] int sort = 0, + [FromQuery] int filter = 0) + { + take = Math.Clamp(take, 1, 200); + skip = Math.Max(0, skip); + + var query = db.Photos + .Where(p => p.RoomId == roomId && p.IsPublic && p.DeletedAt == null) + .AsQueryable(); + query = sort switch + { + 1 => query.OrderByDescending(p => p.CheerCount).ThenByDescending(p => p.CreatedAt), + 2 => query.OrderByDescending(p => p.ViewCount).ThenByDescending(p => p.CreatedAt), + _ => query.OrderByDescending(p => p.CreatedAt), + }; + + var rows = await query + .Skip(skip) + .Take(take) + .ToListAsync(); + return Ok(rows.Select(p => BuildImageInfo(p, p.UploaderPlayerId))); + } + /// POST api/images/v{2-5}/share/{id} — flip a saved photo /// to public so it shows up on feed.rec.net. The watch's "Share" /// button hits this; if the watch posts to a different URL we'll diff --git a/DorkNet.Server/Controllers/API/Keepsakes/KeepsakesController.cs b/DorkNet.Server/Controllers/API/Keepsakes/KeepsakesController.cs index 026c75a..73ae682 100644 --- a/DorkNet.Server/Controllers/API/Keepsakes/KeepsakesController.cs +++ b/DorkNet.Server/Controllers/API/Keepsakes/KeepsakesController.cs @@ -56,6 +56,30 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con return Ok(rows.Select(ToWire)); } + [HttpGet("events/{eventId:long}")] + public async Task EventInstances(long eventId) + { + var eventKey = eventId.ToString(System.Globalization.CultureInfo.InvariantCulture); + var rows = await db.Keepsakes + .Where(k => k.PlayerId == Me + && k.Category == "event" + && (k.EventKey == eventKey + || k.EventKey == $"event:{eventKey}" + || k.EventKey == $"event/{eventKey}" + || EF.Functions.Like(k.EventKey, $"event:{eventKey}:%") + || EF.Functions.Like(k.EventKey, $"event/{eventKey}/%"))) + .OrderByDescending(k => k.EarnedAt) + .ToListAsync(); + + return Ok(new + { + KeepsakeProgressionEventId = eventId, + Instances = rows.Select(ToWire), + CollectionRecords = Array.Empty(), + KeepsakeProgressionEventIds = new[] { eventId }, + }); + } + [HttpGet("rooms/{roomId:long}")] public async Task RoomKeepsakes(long roomId) { diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs index d0f4315..0dab111 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs @@ -74,6 +74,95 @@ public class RoomsController( }); } + [HttpGet("rooms/topcreators")] + [HttpGet("roomserver/rooms/topcreators")] + public async Task TopCreators([FromQuery] int skip = 0, [FromQuery] int take = 100) + { + var rows = await PublicRoomQuery() + .OrderByDescending(r => r.HotScore) + .ThenByDescending(r => r.VisitCount) + .Take(250) + .ToListAsync(); + var page = rows + .GroupBy(r => r.CreatorPlayerId) + .Select(g => g.First()) + .Skip(Math.Max(skip, 0)) + .Take(Math.Clamp(take, 1, 100)) + .ToList(); + return Ok(await BuildRoomServerListAsync(page)); + } + + [HttpGet("rooms/contestwinners")] + [HttpGet("roomserver/rooms/contestwinners")] + public async Task ContestWinners([FromQuery] int skip = 0, [FromQuery] int take = 100) + { + var rows = await PublicRoomQuery() + .Where(r => + EF.Functions.Like(r.TagsCsv, "%contest%") || + EF.Functions.Like(r.TagsCsv, "%winner%") || + EF.Functions.Like(r.TagsCsv, "%featured%")) + .OrderByDescending(r => r.HotScore) + .ThenBy(r => r.Name) + .Skip(Math.Max(skip, 0)) + .Take(Math.Clamp(take, 1, 100)) + .ToListAsync(); + + if (rows.Count == 0) + { + rows = await PublicRoomQuery() + .OrderByDescending(r => r.HotScore) + .ThenBy(r => r.Name) + .Skip(Math.Max(skip, 0)) + .Take(Math.Clamp(take, 1, 100)) + .ToListAsync(); + } + + return Ok(await BuildRoomServerListAsync(rows)); + } + + [HttpGet("rooms/fromcreators")] + [HttpPost("rooms/fromcreators")] + [HttpGet("roomserver/rooms/fromcreators")] + [HttpPost("roomserver/rooms/fromcreators")] + public async Task FromCreators([FromQuery] int skip = 0, [FromQuery] int take = 100) + { + var creatorIds = Request.Query["creatorId"] + .Concat(Request.Query["creatorIds"]) + .Concat(Request.Query["accountId"]) + .Concat(Request.Query["accountIds"]) + .Concat(Request.Query["id"]) + .SelectMany(v => (v ?? string.Empty).Split(',', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + .Select(v => long.TryParse(v, out var id) ? id : 0) + .Where(id => id > 0) + .Distinct() + .ToList(); + + var query = PublicRoomQuery(); + if (creatorIds.Count > 0) + query = query.Where(r => creatorIds.Contains(r.CreatorPlayerId)); + + var rows = await query + .OrderByDescending(r => r.HotScore) + .ThenByDescending(r => r.UpdatedAt) + .Skip(Math.Max(skip, 0)) + .Take(Math.Clamp(take, 1, 100)) + .ToListAsync(); + return Ok(await BuildRoomServerListAsync(rows)); + } + + [HttpGet("rooms/magic_door")] + [HttpGet("roomserver/rooms/magic_door")] + public async Task MagicDoor() + { + var room = await PublicRoomQuery() + .OrderByDescending(r => r.HotScore) + .ThenBy(r => r.Id) + .FirstOrDefaultAsync(); + if (room is null) return NotFound(); + return Ok((await BuildRoomServerListAsync(new[] { room })).First()); + } + /// /// GET /roomserver/rooms/recommendations — the home-tab /// "Recommended" carousel. Wire shape is NOT a flat @@ -127,6 +216,38 @@ public class RoomsController( return Ok(result); } + [HttpGet("rooms/{roomId:long}/similar")] + [HttpGet("roomserver/rooms/{roomId:long}/similar")] + public async Task SimilarRooms(long roomId, [FromQuery] int take = 12) + { + var seed = await rooms.GetByIdAsync(roomId); + if (seed is null) return NotFound(); + + var tags = (seed.TagsCsv ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToArray(); + var query = PublicRoomQuery().Where(r => r.Id != roomId); + if (tags.Length > 0) + { + var primaryTag = tags[0]; + query = query.Where(r => EF.Functions.Like(r.TagsCsv, $"%{primaryTag}%")); + } + + var similar = await query + .OrderByDescending(r => r.HotScore) + .ThenBy(r => r.Name) + .Take(Math.Clamp(take, 1, 100)) + .ToListAsync(); + + var seedWire = (await BuildRoomServerListAsync(new[] { seed })).First(); + var similarWire = await BuildRoomServerListAsync(similar); + return Ok(new Dictionary(StringComparer.Ordinal) + { + ["SeedRoom"] = seedWire, + ["Rooms"] = similarWire, + }); + } + /// /// `Rooms.SearchForRooms(query)` — free-text room search. /// @@ -172,6 +293,7 @@ public class RoomsController( { PinnedFilters = tags.PinnedTags, PopularFilters = tags.PopularTags, + TrendingFilters = tags.TrendingTags, }); } @@ -179,7 +301,11 @@ public class RoomsController( public async Task Tags() { var tags = await serverSettings.GetPlayMenuTagsAsync(); - return Ok(tags.PinnedTags.Concat(tags.PopularTags).Distinct(StringComparer.OrdinalIgnoreCase).ToArray()); + return Ok(tags.PinnedTags + .Concat(tags.PopularTags) + .Concat(tags.TrendingTags) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray()); } [HttpGet("api/rooms/v1/pinnedtags")] @@ -190,6 +316,10 @@ public class RoomsController( public async Task PopularTags() => Ok((await serverSettings.GetPlayMenuTagsAsync()).PopularTags); + [HttpGet("api/rooms/v1/trendingtags")] + public async Task TrendingTags() + => Ok((await serverSettings.GetPlayMenuTagsAsync()).TrendingTags); + // ── Single room lookups ────────────────────────────────────────────── /// @@ -1388,10 +1518,19 @@ public class RoomsController( SupportsWalkVR = src.SupportsWalkVR, SupportsTeleportVR = src.SupportsTeleportVR, AllowsJuniors = src.AllowsJuniors, + AllowNewUsers = src.AllowNewUsers, + MinLevel = src.MinLevel, + MaxPlayerCalculationMode = src.MaxPlayerCalculationMode, + LoadScreensJson = src.LoadScreensJson, + PromoImagesJson = src.PromoImagesJson, + PromoExternalContentJson = src.PromoExternalContentJson, RoomWarningMask = src.RoomWarningMask, CustomRoomWarning = src.CustomRoomWarning, DisableMicAutoMute = src.DisableMicAutoMute, LocationReplicationId = src.LocationReplicationId, + IsStudioRoom = src.IsStudioRoom, + IsRoomLinkedToRecRoomStudio = src.IsRoomLinkedToRecRoomStudio, + StudioSessionId = src.StudioSessionId, TagsCsv = src.TagsCsv, CheerCount = src.CheerCount, FavoriteCount = src.FavoriteCount, @@ -1425,7 +1564,12 @@ public class RoomsController( ? Array.Empty() : room.TagsCsv .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Select(t => (object)new { Type = 0, Tag = t }) + .Select(t => (object)new + { + Type = RoomTagTypeForWire(t), + Tag = t, + IsPrimaryGenre = false, + }) .ToArray(); // Scenes population: every room must produce a Scenes[0] entry @@ -1710,7 +1854,12 @@ public class RoomsController( ? Array.Empty() : room.TagsCsv .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Select(t => (object)new { Type = 0, Tag = t }) + .Select(t => (object)new + { + Type = RoomTagTypeForWire(t), + Tag = t, + IsPrimaryGenre = false, + }) .ToArray(); return new @@ -1733,6 +1882,9 @@ public class RoomsController( SupportsQuest2 = true, SupportsMobile = room.SupportsMobile, SupportsJuniors = room.AllowsJuniors, + AllowNewUsers = room.AllowNewUsers, + AllowsNewUsers = room.AllowNewUsers, + MinLevel = room.MinLevel, CreatedAt = (room.CreatedAt == default ? DateTime.UtcNow : room.CreatedAt) .ToString("yyyy-MM-ddTHH:mm:ssZ"), Stats = new @@ -1743,10 +1895,14 @@ public class RoomsController( VisitCount = room.VisitCount, }, IsDorm = room.IsDormRoom, + IsStudioRoom = room.IsStudioRoom, + IsRoomLinkedToRecRoomStudio = room.IsRoomLinkedToRecRoomStudio, + StudioSessionId = room.StudioSessionId, CloningAllowed = room.CloningAllowed, DisableMicAutoMute = room.DisableMicAutoMute, DisableRoomComments = false, EncryptVoiceChat = false, + LoadScreenLocked = false, // Studio/UGC version family. The 2023 client reads UgcVersion to // decide the asset-bundle version it requests // (unity_assets/{id}/{target}/{UgcVersion}) and PersistenceVersion @@ -1758,17 +1914,74 @@ public class RoomsController( UgcVersion = room.IsStudioRoom ? StudioUgcVersion : 0, UgcSubVersion = room.IsStudioRoom ? StudioPersistenceVersion : 0, MinUgcSubVersion = room.IsStudioRoom ? StudioPersistenceVersion : 0, + IsDeveloperOwned = room.IsStudioRoom || HasRoomTag(room, "developer"), + IsJuniorCreated = false, + IsRecRoomApproved = false, + ExcludeFromLists = false, + BoostCount = 0, + RestrictedCircuitsAllowListNames = Array.Empty(), + PublishStateAvailability = new + { + CanSaveAsBeta = false, + CanSaveAsUpdate = true, + AvailableUpdateTokenCount = 3, + NextAvailableUpdateDateTimeUtc = (string?)null, + }, PublishState = 0, - MaxPlayerCalculationMode = 0, + MaxPlayerCalculationMode = room.MaxPlayerCalculationMode, SubRooms = subRooms, Roles = wireRoles, - LoadScreens = Array.Empty(), - PromoImages = Array.Empty(), - PromoExternalContent = Array.Empty(), + LoadScreens = JsonArrayOrEmpty(room.LoadScreensJson), + PromoImages = JsonStringArrayOrEmpty(room.PromoImagesJson), + PromoExternalContent = JsonArrayOrEmpty(room.PromoExternalContentJson), Tags = tags, }; } + private static int RoomTagTypeForWire(string tag) + { + if (string.Equals(tag, "beta", StringComparison.OrdinalIgnoreCase)) return 1; + return tag.ToLowerInvariant() switch + { + "rrstudio" or "community" or "screen" or "walkvr" or "teleportvr" or "junior" or "pickup" => 2, + _ => 0, + }; + } + + private static bool HasRoomTag(RoomEntity room, string tag) + { + return !string.IsNullOrWhiteSpace(room.TagsCsv) && + room.TagsCsv + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Any(t => string.Equals(t, tag, StringComparison.OrdinalIgnoreCase)); + } + + private static JsonElement[] JsonArrayOrEmpty(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return Array.Empty(); + try + { + return JsonSerializer.Deserialize(json) ?? Array.Empty(); + } + catch (JsonException) + { + return Array.Empty(); + } + } + + private static string[] JsonStringArrayOrEmpty(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return Array.Empty(); + try + { + return JsonSerializer.Deserialize(json) ?? Array.Empty(); + } + catch (JsonException) + { + return Array.Empty(); + } + } + /// PersistenceVersion / UgcSubVersion stamped on a baked Studio /// scene's CurrentSave. The 2023 client's room-load gate treats anything /// below the Studio-asset-bundle era as a legacy non-baked save and skips @@ -1845,7 +2058,9 @@ public class RoomsController( [HttpGet("api/rooms/v2/createdby/{otherPlayerId:long}")] [HttpGet("api/rooms/v3/createdby/{otherPlayerId:long}")] [HttpGet("roomserver/rooms/createdby/{otherPlayerId:long}")] + [HttpGet("roomserver/rooms/ownedby/{otherPlayerId:long}")] [HttpGet("rooms/createdby/{otherPlayerId:long}")] + [HttpGet("rooms/ownedby/{otherPlayerId:long}")] public async Task CreatedByOther(long otherPlayerId) => Ok((await rooms.CreatedByAsync(otherPlayerId)).Select(RoomService.ToWireRoom).ToList()); @@ -1855,7 +2070,36 @@ public class RoomsController( [HttpGet("roomserver/rooms/visitedby/me")] [HttpGet("rooms/visitedby/me")] [Authorize] - public IActionResult VisitedByMe() => Ok(Array.Empty()); + public async Task VisitedByMe() + { + var pid = CurrentPlayerId; + if (pid is null) return Ok(Array.Empty()); + return await VisitedBy(pid.Value); + } + + [HttpGet("rooms/visitedby/{playerId:long}")] + [HttpGet("roomserver/rooms/visitedby/{playerId:long}")] + public async Task VisitedBy(long playerId) + { + var ids = await db.RoomVisits.AsNoTracking() + .Where(v => v.PlayerId == playerId) + .OrderByDescending(v => v.LastVisitAt) + .Select(v => v.RoomId) + .Take(100) + .ToListAsync(); + if (ids.Count == 0) return Ok(Array.Empty()); + + var rows = await db.Rooms.AsNoTracking() + .Where(r => ids.Contains(r.Id)) + .ToListAsync(); + var byId = rows.ToDictionary(r => r.Id); + var ordered = ids + .Select(id => byId.TryGetValue(id, out var room) ? room : null) + .Where(r => r is not null) + .Select(r => r!) + .ToList(); + return Ok(await BuildRoomServerListAsync(ordered)); + } [HttpGet("api/rooms/v1/moderatedby/me")] [HttpGet("api/rooms/v2/moderatedby/me")] @@ -1863,7 +2107,23 @@ public class RoomsController( [HttpGet("roomserver/rooms/moderatedby/me")] [HttpGet("rooms/moderatedby/me")] [Authorize] - public IActionResult ModeratedByMe() => Ok(Array.Empty()); + public async Task ModeratedByMe() + { + var pid = CurrentPlayerId; + if (pid is null) return Ok(Array.Empty()); + var ids = await db.RoomRoles.AsNoTracking() + .Where(r => r.PlayerId == pid.Value && r.Accepted) + .OrderByDescending(r => r.GrantedAt) + .Select(r => r.RoomId) + .Distinct() + .Take(100) + .ToListAsync(); + var rows = await db.Rooms.AsNoTracking() + .Where(r => ids.Contains(r.Id)) + .OrderByDescending(r => r.UpdatedAt) + .ToListAsync(); + return Ok(await BuildRoomServerListAsync(rows)); + } [HttpGet("api/rooms/v1/favoritedby/me")] [HttpGet("api/rooms/v2/favoritedby/me")] @@ -1871,7 +2131,13 @@ public class RoomsController( [HttpGet("roomserver/rooms/favoritedby/me")] [HttpGet("rooms/favoritedby/me")] [Authorize] - public IActionResult FavoritedByMe() => Ok(Array.Empty()); + public async Task FavoritedByMe() + { + var pid = CurrentPlayerId; + if (pid is null) return Ok(Array.Empty()); + var rows = await rooms.BookmarkedByAsync(pid.Value); + return Ok(await BuildRoomServerListAsync(rows)); + } [HttpGet("api/rooms/v1/cheeredby/me")] [HttpGet("api/rooms/v2/cheeredby/me")] @@ -1879,7 +2145,23 @@ public class RoomsController( [HttpGet("roomserver/rooms/cheeredby/me")] [HttpGet("rooms/cheeredby/me")] [Authorize] - public IActionResult CheeredByMe() => Ok(Array.Empty()); + public async Task CheeredByMe() + { + var pid = CurrentPlayerId; + if (pid is null) return Ok(Array.Empty()); + var ids = await db.Cheers.AsNoTracking() + .Where(c => c.FromPlayerId == pid.Value && c.TargetRoomId != 0) + .OrderByDescending(c => c.CheeredAt) + .Select(c => c.TargetRoomId) + .Distinct() + .Take(100) + .ToListAsync(); + var rows = await db.Rooms.AsNoTracking() + .Where(r => ids.Contains(r.Id)) + .OrderByDescending(r => r.HotScore) + .ToListAsync(); + return Ok(await BuildRoomServerListAsync(rows)); + } [HttpGet("api/rooms/v2/mybookmarks")] [HttpGet("api/rooms/v2/mybookmarkedrooms")] @@ -2061,6 +2343,40 @@ public class RoomsController( return Ok(new { success = true, error = "" }); } + private IQueryable PublicRoomQuery() => + db.Rooms.AsNoTracking().Where(r => + r.State == 0 && + r.Accessibility == 1 && + !r.IsDormRoom && + !r.HiddenFromBrowse); + + private async Task> BuildRoomServerListAsync(IReadOnlyCollection roomRows) + { + if (roomRows.Count == 0) return new List(); + + var ids = roomRows.Select(r => r.Id).Distinct().ToList(); + var sceneRows = await db.RoomScenes.AsNoTracking() + .Where(s => ids.Contains(s.RoomId)) + .OrderBy(s => s.OrderIndex) + .ToListAsync(); + var scenesByRoom = sceneRows + .GroupBy(s => s.RoomId) + .ToDictionary(g => g.Key, g => (IReadOnlyList)g.ToList()); + var roleRows = await db.RoomRoles.AsNoTracking() + .Where(r => ids.Contains(r.RoomId)) + .ToListAsync(); + var rolesByRoom = roleRows + .GroupBy(r => r.RoomId) + .ToDictionary(g => g.Key, g => (IReadOnlyList)g.ToList()); + + return roomRows + .Select(r => BuildRoomServerDetails( + r, + scenesByRoom.GetValueOrDefault(r.Id), + roles: rolesByRoom.GetValueOrDefault(r.Id))) + .ToList(); + } + // ── Helpers ────────────────────────────────────────────────────────── /// GET api/rooms/v1/agRoomIds — list of every @@ -2585,7 +2901,12 @@ public class RoomsController( ? new List() : p.TagsCsv .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Select(t => (object)new { Type = 0, Tag = t }) + .Select(t => (object)new + { + Type = RoomTagTypeForWire(t), + Tag = t, + IsPrimaryGenre = false, + }) .ToList(); // Start from BuildPlaylistUnionEntry for base + PlaylistId, then @@ -2865,6 +3186,15 @@ public class RoomsController( public string? RoomSceneLocationId { get; set; } } public sealed class SubRoomMoveRequest { public int? NewIndex { get; set; } } + public sealed class PublishSubRoomSaveRequest + { + public long? SubRoomDataSaveId { get; set; } + public long? SaveId { get; set; } + public long? StagedSubRoomDataSaveId { get; set; } + public string? DataBlob { get; set; } + public string? DataBlobName { get; set; } + public string? Filename { get; set; } + } [HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")] [HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")] @@ -2878,6 +3208,95 @@ public class RoomsController( public Task SubRoomAccessibility(long roomId, long subRoomId, [FromBody] SubRoomBoolRequest req) => MutateScene(roomId, subRoomId, s => { if (req.Value is bool v) s.CanMatchmakeInto = v; }); + [HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")] + [HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")] + [HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")] + [HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")] + [Authorize] + public Task SubRoomPermissions(long roomId, long subRoomId, [FromBody] SubRoomModifyRequest? req) => + MutateScene(roomId, subRoomId, s => + { + if (req?.CanMatchmakeInto is bool canMatchmakeInto) s.CanMatchmakeInto = canMatchmakeInto; + if (req?.MaxPlayers is int maxPlayers) s.MaxPlayers = Math.Max(1, maxPlayers); + if (req?.IsSandbox is bool isSandbox) s.IsSandbox = isSandbox; + }); + + [HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")] + [HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")] + [HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")] + [HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")] + [Authorize] + public async Task PublishSubRoomSave(long roomId, long subRoomId) + { + var pid = this.RequireCurrentPlayerId(); + var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); + if (room is null) return NotFound(); + if (room.CreatorPlayerId != pid) return Forbid(); + + var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId); + if (scene is null) return NotFound(); + + var request = await ReadPublishSubRoomSaveRequestAsync(); + var saveId = request.SubRoomDataSaveId ?? request.SaveId ?? request.StagedSubRoomDataSaveId; + var dataBlob = request.DataBlobName ?? request.DataBlob ?? request.Filename; + + if (!string.IsNullOrWhiteSpace(dataBlob)) + { + var blobExists = await db.RoomDataBlobs.AnyAsync(b => b.RoomId == roomId && b.BlobName == dataBlob); + if (!blobExists) return NotFound(new { error = "blob_not_found", dataBlob }); + scene.DataBlobName = dataBlob; + if (scene.OrderIndex == 0) room.CurrentDataBlobName = dataBlob; + } + + if (saveId is > 0) + scene.StudioSubRoomDataSaveId = saveId.Value; + + scene.DataModifiedAt = DateTime.UtcNow; + room.UpdatedAt = scene.DataModifiedAt; + await db.SaveChangesAsync(); + return Ok(SceneWire(scene)); + } + + private async Task ReadPublishSubRoomSaveRequestAsync() + { + var request = new PublishSubRoomSaveRequest(); + if (Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(); + if (long.TryParse(FirstFormValue(form, "subRoomDataSaveId", "SubRoomDataSaveId", "saveId", "SaveId", "stagedSubRoomDataSaveId", "StagedSubRoomDataSaveId"), out var saveId)) + request.SubRoomDataSaveId = saveId; + request.DataBlobName = FirstFormValue(form, "dataBlobName", "DataBlobName", "dataBlob", "DataBlob", "filename", "Filename"); + return request; + } + + if (long.TryParse(Request.Query["subRoomDataSaveId"].FirstOrDefault() + ?? Request.Query["SubRoomDataSaveId"].FirstOrDefault() + ?? Request.Query["saveId"].FirstOrDefault() + ?? Request.Query["SaveId"].FirstOrDefault() + ?? Request.Query["stagedSubRoomDataSaveId"].FirstOrDefault() + ?? Request.Query["StagedSubRoomDataSaveId"].FirstOrDefault(), out var querySaveId)) + request.SubRoomDataSaveId = querySaveId; + request.DataBlobName = Request.Query["dataBlobName"].FirstOrDefault() + ?? Request.Query["DataBlobName"].FirstOrDefault() + ?? Request.Query["dataBlob"].FirstOrDefault() + ?? Request.Query["DataBlob"].FirstOrDefault() + ?? Request.Query["filename"].FirstOrDefault() + ?? Request.Query["Filename"].FirstOrDefault(); + + if ((Request.ContentLength ?? 0) <= 0) return request; + try + { + var body = await JsonSerializer.DeserializeAsync( + Request.Body, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + return body ?? request; + } + catch (JsonException) + { + return request; + } + } + /// POST rooms/{id}/subrooms/{sub}/clone — duplicate /// a scene (DataBlobName + location pointer). New scene goes at the /// end with " Copy" suffix; per-room name uniqueness preserved. @@ -3033,8 +3452,7 @@ public class RoomsController( var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); if (room is null) return NotFound(); if (room.CreatorPlayerId != pid) return Forbid(); - var scene = await db.RoomScenes - .FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId); + var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId); if (scene is null) return NotFound(); mutator(scene); scene.DataModifiedAt = DateTime.UtcNow; @@ -3042,6 +3460,28 @@ public class RoomsController( return Ok(SceneWire(scene)); } + private async Task GetOrCreateSceneForMutationAsync(RoomEntity room, long subRoomId) + { + var scene = await db.RoomScenes + .FirstOrDefaultAsync(s => s.RoomId == room.Id && s.OrderIndex == subRoomId); + if (scene is not null || subRoomId != 0) return scene; + + scene = new RoomSceneEntity + { + RoomId = room.Id, + OrderIndex = 0, + Name = "Home", + RoomSceneLocationId = room.LocationReplicationId, + DataBlobName = CurrentOrSyntheticDataBlobName(room), + MaxPlayers = room.MaxCapacity, + IsSandbox = false, + CanMatchmakeInto = true, + DataModifiedAt = DateTime.UtcNow, + }; + db.RoomScenes.Add(scene); + return scene; + } + private static object SceneWire(RoomSceneEntity s) => new { Result = 0, @@ -3079,6 +3519,9 @@ public class RoomsController( if (room.IsDormRoom) return RoomService.ResolveWireRoomDataBlobName(room.Id, room.CurrentDataBlobName); + if (RoomService.IsBakedOriginalRoom(room) && string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) + return string.Empty; + return !string.IsNullOrWhiteSpace(room.CurrentDataBlobName) ? room.CurrentDataBlobName : RoomService.SyntheticDefaultRoomDataBlobName(room.Id); diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs index 61e9410..a765c78 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs @@ -527,6 +527,204 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase return null; } + + private async Task ReadBareLongAsync(params string[] keys) + { + if (Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(); + foreach (var key in keys) + if (long.TryParse(form[key].FirstOrDefault(), out var value)) return value; + return null; + } + + foreach (var key in keys) + if (long.TryParse(Request.Query[key].FirstOrDefault(), out var value)) return value; + + if ((Request.ContentLength ?? 0) <= 0) return null; + try + { + using var doc = await JsonDocument.ParseAsync(Request.Body); + if (doc.RootElement.ValueKind != JsonValueKind.Object) return null; + foreach (var key in keys) + { + if (!doc.RootElement.TryGetProperty(key, out var prop)) continue; + if (prop.ValueKind == JsonValueKind.Number && prop.TryGetInt64(out var n)) return n; + if (prop.ValueKind == JsonValueKind.String && long.TryParse(prop.GetString(), out n)) return n; + } + } + catch (JsonException) + { + return null; + } + + return null; + } + + private async Task ReadBareBoolAsync(params string[] keys) + { + if (Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(); + foreach (var key in keys) + if (TryParseBool(form[key].FirstOrDefault(), out var value)) return value; + return null; + } + + foreach (var key in keys) + if (TryParseBool(Request.Query[key].FirstOrDefault(), out var value)) return value; + + if ((Request.ContentLength ?? 0) <= 0) return null; + try + { + using var doc = await JsonDocument.ParseAsync(Request.Body); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + return doc.RootElement.ValueKind == JsonValueKind.True + ? true + : doc.RootElement.ValueKind == JsonValueKind.False + ? false + : null; + foreach (var key in keys) + { + if (!doc.RootElement.TryGetProperty(key, out var prop)) continue; + if (prop.ValueKind == JsonValueKind.True) return true; + if (prop.ValueKind == JsonValueKind.False) return false; + if (prop.ValueKind == JsonValueKind.Number && prop.TryGetInt32(out var n)) return n != 0; + if (prop.ValueKind == JsonValueKind.String && TryParseBool(prop.GetString(), out var parsed)) return parsed; + } + } + catch (JsonException) + { + return null; + } + + return null; + } + + private async Task ReadStringValueAsync(params string[] keys) + { + if (Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(); + foreach (var key in keys) + { + var value = form[key].FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(value)) return value; + } + return null; + } + + foreach (var key in keys) + { + var value = Request.Query[key].FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(value)) return value; + } + + if ((Request.ContentLength ?? 0) <= 0) return null; + try + { + using var doc = await JsonDocument.ParseAsync(Request.Body); + if (doc.RootElement.ValueKind == JsonValueKind.String) + return doc.RootElement.GetString(); + if (doc.RootElement.ValueKind != JsonValueKind.Object) return null; + foreach (var key in keys) + { + if (!doc.RootElement.TryGetProperty(key, out var prop)) continue; + if (prop.ValueKind == JsonValueKind.String) return prop.GetString(); + if (prop.ValueKind == JsonValueKind.Number || prop.ValueKind == JsonValueKind.True || prop.ValueKind == JsonValueKind.False) + return prop.ToString(); + } + } + catch (JsonException) + { + return null; + } + + return null; + } + + private async Task ReadJsonElementAsync(params string[] stringKeys) + { + if (Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(); + var dict = new Dictionary(StringComparer.Ordinal); + foreach (var key in form.Keys) + { + var value = form[key].FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(value)) dict[key] = value; + } + if (dict.Count == 0) return null; + return JsonSerializer.SerializeToElement(dict); + } + + if ((Request.ContentLength ?? 0) > 0) + { + try + { + using var doc = await JsonDocument.ParseAsync(Request.Body); + return doc.RootElement.Clone(); + } + catch (JsonException) + { + return null; + } + } + + foreach (var key in stringKeys) + { + var value = Request.Query[key].FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(value)) return JsonSerializer.SerializeToElement(value); + } + + return null; + } + + private static bool TryParseBool(string? raw, out bool value) + { + if (bool.TryParse(raw, out value)) return true; + if (int.TryParse(raw, out var n)) + { + value = n != 0; + return true; + } + value = false; + return false; + } + + private static List ReadJsonElementList(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return new List(); + try + { + return JsonSerializer.Deserialize(json)?.ToList() ?? new List(); + } + catch (JsonException) + { + return new List(); + } + } + + private static List ReadStringList(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return new List(); + try + { + return JsonSerializer.Deserialize(json)?.Where(v => !string.IsNullOrWhiteSpace(v)).ToList() + ?? new List(); + } + catch (JsonException) + { + return new List(); + } + } + + private static string SerializeLimited(IEnumerable values, int maxChars = 4096) + { + var json = JsonSerializer.Serialize(values); + return json.Length <= maxChars ? json : "[]"; + } + [HttpPost("rooms/{roomId:long}/cloning")] [HttpPut("rooms/{roomId:long}/cloning")] public async Task BareCloning(long roomId, [FromBody] BareBoolRequest? body, @@ -569,6 +767,171 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase if (body?.CustomRoomWarning is not null) r.CustomRoomWarning = body.CustomRoomWarning; }); + [HttpPost("rooms/{roomId:long}/creator")] + [HttpPut("rooms/{roomId:long}/creator")] + [HttpPost("roomserver/rooms/{roomId:long}/creator")] + [HttpPut("roomserver/rooms/{roomId:long}/creator")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public async Task BareCreator(long roomId) + { + var creatorId = await ReadBareLongAsync( + "creatorAccountId", "CreatorAccountId", + "creatorPlayerId", "CreatorPlayerId", + "accountId", "AccountId", + "value", "Value"); + if (creatorId is null or <= 0) return BadRequest(new { error = "missing_creator" }); + var exists = await db.Players.AnyAsync(p => p.Id == creatorId.Value); + if (!exists) return NotFound(new { error = "creator_not_found", creatorId }); + return await ApplyAndReturn(roomId, r => r.CreatorPlayerId = creatorId.Value); + } + + [HttpPost("rooms/{roomId:long}/allow_new_users")] + [HttpPut("rooms/{roomId:long}/allow_new_users")] + [HttpPost("roomserver/rooms/{roomId:long}/allow_new_users")] + [HttpPut("roomserver/rooms/{roomId:long}/allow_new_users")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public async Task BareAllowNewUsers(long roomId) + { + var value = await ReadBareBoolAsync("allowNewUsers", "AllowNewUsers", "value", "Value"); + return await ApplyAndReturn(roomId, r => + { + if (value is bool v) r.AllowNewUsers = v; + }); + } + + [HttpPost("rooms/{roomId:long}/min_level")] + [HttpPut("rooms/{roomId:long}/min_level")] + [HttpPost("roomserver/rooms/{roomId:long}/min_level")] + [HttpPut("roomserver/rooms/{roomId:long}/min_level")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public async Task BareMinLevel(long roomId) + { + var value = await ReadBareIntAsync("minLevel", "MinLevel", "level", "Level", "value", "Value"); + return await ApplyAndReturn(roomId, r => + { + if (value is int v) r.MinLevel = Math.Max(0, v); + }); + } + + [HttpPost("rooms/{roomId:long}/max_player_calculation_mode")] + [HttpPut("rooms/{roomId:long}/max_player_calculation_mode")] + [HttpPost("roomserver/rooms/{roomId:long}/max_player_calculation_mode")] + [HttpPut("roomserver/rooms/{roomId:long}/max_player_calculation_mode")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public async Task BareMaxPlayerCalculationMode(long roomId) + { + var value = await ReadBareIntAsync( + "maxPlayerCalculationMode", "MaxPlayerCalculationMode", + "mode", "Mode", + "value", "Value"); + return await ApplyAndReturn(roomId, r => + { + if (value is int v) r.MaxPlayerCalculationMode = Math.Max(0, v); + }); + } + + [HttpPost("rooms/{roomId:long}/loadscreen")] + [HttpPut("rooms/{roomId:long}/loadscreen")] + [HttpPost("roomserver/rooms/{roomId:long}/loadscreen")] + [HttpPut("roomserver/rooms/{roomId:long}/loadscreen")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public async Task BareLoadScreen(long roomId) + { + var payload = await ReadJsonElementAsync("loadScreen", "LoadScreen", "value", "Value"); + return await ApplyAndReturn(roomId, r => + { + if (payload is JsonElement p) + r.LoadScreensJson = p.ValueKind == JsonValueKind.Array + ? p.GetRawText() + : SerializeLimited(new[] { p }); + }); + } + + [HttpPost("rooms/{roomId:long}/promo_images")] + [HttpPut("rooms/{roomId:long}/promo_images")] + [HttpPost("roomserver/rooms/{roomId:long}/promo_images")] + [HttpPut("roomserver/rooms/{roomId:long}/promo_images")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public async Task BarePromoImagesAdd(long roomId) + { + var image = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value"); + if (string.IsNullOrWhiteSpace(image)) return BadRequest(new { error = "missing_image" }); + return await ApplyAndReturn(roomId, r => + { + var images = ReadStringList(r.PromoImagesJson); + if (!images.Contains(image, StringComparer.OrdinalIgnoreCase)) + images.Add(image); + r.PromoImagesJson = SerializeLimited(images); + }); + } + + [HttpDelete("rooms/{roomId:long}/promo_images")] + [HttpDelete("roomserver/rooms/{roomId:long}/promo_images")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public async Task BarePromoImagesRemove(long roomId) + { + var image = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value"); + if (string.IsNullOrWhiteSpace(image)) return BadRequest(new { error = "missing_image" }); + return await ApplyAndReturn(roomId, r => + { + var images = ReadStringList(r.PromoImagesJson) + .Where(v => !string.Equals(v, image, StringComparison.OrdinalIgnoreCase)) + .ToList(); + r.PromoImagesJson = SerializeLimited(images); + }); + } + + [HttpPost("rooms/{roomId:long}/promo_external")] + [HttpPut("rooms/{roomId:long}/promo_external")] + [HttpPost("roomserver/rooms/{roomId:long}/promo_external")] + [HttpPut("roomserver/rooms/{roomId:long}/promo_external")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public async Task BarePromoExternalAdd(long roomId) + { + var payload = await ReadJsonElementAsync("url", "Url", "value", "Value"); + if (payload is null) return BadRequest(new { error = "missing_promo_external" }); + return await ApplyAndReturn(roomId, r => + { + var items = ReadJsonElementList(r.PromoExternalContentJson); + var raw = payload.Value.GetRawText(); + if (!items.Any(i => string.Equals(i.GetRawText(), raw, StringComparison.Ordinal))) + items.Add(payload.Value); + r.PromoExternalContentJson = SerializeLimited(items); + }); + } + + [HttpDelete("rooms/{roomId:long}/promo_external")] + [HttpDelete("roomserver/rooms/{roomId:long}/promo_external")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public async Task BarePromoExternalRemove(long roomId) + { + var match = await ReadStringValueAsync("id", "Id", "url", "Url", "value", "Value"); + if (string.IsNullOrWhiteSpace(match)) return BadRequest(new { error = "missing_promo_external" }); + return await ApplyAndReturn(roomId, r => + { + var items = ReadJsonElementList(r.PromoExternalContentJson) + .Where(i => !PromoExternalMatches(i, match)) + .ToList(); + r.PromoExternalContentJson = SerializeLimited(items); + }); + } + + private static bool PromoExternalMatches(JsonElement item, string match) + { + if (string.Equals(item.GetRawText(), match, StringComparison.OrdinalIgnoreCase)) return true; + if (item.ValueKind == JsonValueKind.String) + return string.Equals(item.GetString(), match, StringComparison.OrdinalIgnoreCase); + if (item.ValueKind != JsonValueKind.Object) return false; + foreach (var key in new[] { "Id", "id", "Url", "url", "Value", "value" }) + { + if (item.TryGetProperty(key, out var prop) && + prop.ValueKind == JsonValueKind.String && + string.Equals(prop.GetString(), match, StringComparison.OrdinalIgnoreCase)) + return true; + } + return false; + } + /// rooms/{id}/comments + voice_chat_encryption /// — fields not persisted in the 2020 RoomEntity. Owner-gated /// acknowledgement so the watch's settings panel doesn't surface @@ -639,10 +1002,19 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase SupportsWalkVR = source.SupportsWalkVR, SupportsTeleportVR = source.SupportsTeleportVR, AllowsJuniors = source.AllowsJuniors, + AllowNewUsers = source.AllowNewUsers, + MinLevel = source.MinLevel, + MaxPlayerCalculationMode = source.MaxPlayerCalculationMode, + LoadScreensJson = source.LoadScreensJson, + PromoImagesJson = source.PromoImagesJson, + PromoExternalContentJson = source.PromoExternalContentJson, RoomWarningMask = source.RoomWarningMask, CustomRoomWarning = source.CustomRoomWarning, DisableMicAutoMute = source.DisableMicAutoMute, LocationReplicationId = source.LocationReplicationId, + IsStudioRoom = source.IsStudioRoom, + IsRoomLinkedToRecRoomStudio = source.IsRoomLinkedToRecRoomStudio, + StudioSessionId = source.StudioSessionId, TagsCsv = source.TagsCsv, CurrentDataBlobName = source.CurrentDataBlobName, }; diff --git a/DorkNet.Server/Controllers/Accounts/AccountsController.cs b/DorkNet.Server/Controllers/Accounts/AccountsController.cs index d8532ee..6ce13ea 100644 --- a/DorkNet.Server/Controllers/Accounts/AccountsController.cs +++ b/DorkNet.Server/Controllers/Accounts/AccountsController.cs @@ -667,6 +667,31 @@ public class AccountsController( [HttpGet("/account/v1/{accountId:long}/platformid")] public IActionResult GetPlatformId(long accountId) => Ok(new { PlatformId = 0L }); + [HttpGet("/accountprivacysettings/{accountId:long}")] + [HttpGet("/account/v1/privacysettings/{accountId:long}")] + [HttpGet("/account/{accountId:long}/privacysettings")] + public IActionResult GetAccountPrivacySettings(long accountId) => Ok(new + { + AccountId = (int)accountId, + IsProfileVisible = true, + IsOnlineStatusVisible = true, + IsActivityVisible = true, + ShowOnlineStatus = true, + ShowActivity = true, + ShowCurrentRoom = true, + ReceiveFriendRequests = true, + ReceiveInvites = true, + ReceiveMessages = true, + WhoCanSeeOnlineStatus = 0, + WhoCanSeeActivity = 0, + WhoCanSeeCurrentRoom = 0, + WhoCanSendFriendRequests = 0, + WhoCanInviteMe = 0, + WhoCanMessageMe = 0, + VoicePrivacy = 0, + TextChatPrivacy = 0, + }); + // Previously had a /{*path} catch-all that returned [] / {}. Removed — // unknown accounts URLs now 404 so we notice them in logs and wire // them properly rather than silently masking missing endpoints. diff --git a/DorkNet.Server/Controllers/Admin/AdminController.cs b/DorkNet.Server/Controllers/Admin/AdminController.cs index 5c5bb9f..ae022cf 100644 --- a/DorkNet.Server/Controllers/Admin/AdminController.cs +++ b/DorkNet.Server/Controllers/Admin/AdminController.cs @@ -1609,16 +1609,18 @@ public class AdminController( public sealed record PlayMenuTagsRequest( List? PinnedTags, - List? PopularTags); + List? PopularTags, + List? TrendingTags); [HttpPost("settings/play-menu-tags")] public async Task SetPlayMenuTags([FromBody] PlayMenuTagsRequest body) { var tags = await serverSettings.SetPlayMenuTagsAsync( body.PinnedTags ?? [], - body.PopularTags ?? []); + body.PopularTags ?? [], + body.TrendingTags ?? []); await LogAsync("play_menu_tags_updated", "system", 0, - $"pinned={string.Join(",", tags.PinnedTags)} popular={string.Join(",", tags.PopularTags)}"); + $"pinned={string.Join(",", tags.PinnedTags)} popular={string.Join(",", tags.PopularTags)} trending={string.Join(",", tags.TrendingTags)}"); await db.SaveChangesAsync(); return Ok(tags); } diff --git a/DorkNet.Server/Controllers/Admin/RoomZipImportController.cs b/DorkNet.Server/Controllers/Admin/RoomZipImportController.cs index d119c60..d1e6ec2 100644 --- a/DorkNet.Server/Controllers/Admin/RoomZipImportController.cs +++ b/DorkNet.Server/Controllers/Admin/RoomZipImportController.cs @@ -59,7 +59,7 @@ public class RoomZipImportController( ILogger logger) : ControllerBase { private long CurrentAdminId => this.RequireCurrentPlayerId(); - private const long MaxArchiveBytes = 12_000_000_000L; + private const long MaxArchiveBytes = 25_000_000_000L; // ── Room-blob normaliser toggle ───────────────────────────────────── // The normaliser does two things now (see RoomBlobNormalizerService): diff --git a/DorkNet.Server/Controllers/Cdn/CdnController.cs b/DorkNet.Server/Controllers/Cdn/CdnController.cs index 1fba869..490f60b 100644 --- a/DorkNet.Server/Controllers/Cdn/CdnController.cs +++ b/DorkNet.Server/Controllers/Cdn/CdnController.cs @@ -60,6 +60,8 @@ public class CdnController( private static readonly string ImageDir = Path.Combine(AppContext.BaseDirectory, "data", "images"); + private static readonly Lazy> RoomImageAliases = + new(LoadRoomImageAliases); // Specific route prefixes only — NO global catch-all wildcard. // A previous version had [Route("/{*path:minlength(1)}")] which @@ -340,8 +342,8 @@ public class CdnController( return RespondWithTransforms(s3Bytes, contentType); } - var full = Path.Combine(ImageDir, safe); - if (System.IO.File.Exists(full)) + var full = ResolveLocalImagePath(safe); + if (full is not null) { logger.LogInformation("[img] disk hit host={Host} path={Path} → {Full}", Request.Host.Host, path, full); @@ -506,10 +508,21 @@ public class CdnController( // S3 is the only canonical store — DB carries text-only // metadata (BlobName, owner, timestamps) and never holds - // bytes. If S3 misses, the bytes don't exist on this server. - + // bytes. RRO thumbnail images are the exception for local dev: + // tools/fetch-room-images.py stores them under data/images/, and + // the 2023 client requests them through cdn.* rather than img.*. if (IsImageName(fileName)) { + var imagePath = ResolveLocalImagePath(fileName); + if (imagePath is not null) + { + var imageBytes = await System.IO.File.ReadAllBytesAsync(imagePath); + logger.LogInformation("[cdn] disk image hit host={Host} file={File} -> {Path} bytes={Bytes}", + Request.Host.Host, fileName, imagePath, imageBytes.Length); + Response.Headers.CacheControl = "public, max-age=300"; + return RespondWithTransforms(imageBytes, MimeFromName(fileName)); + } + logger.LogWarning("[cdn] image MISS host={Host} file={File} query='{Query}' -> transparent png", Request.Host.Host, fileName, Request.QueryString); Response.Headers.CacheControl = "public, max-age=60"; @@ -630,4 +643,52 @@ public class CdnController( var ext = Path.GetExtension(name).ToLowerInvariant(); return ext is ".png" or ".jpg" or ".jpeg" or ".gif" or ".webp"; } + + private static string? ResolveLocalImagePath(string fileName) + { + var direct = Path.Combine(ImageDir, fileName); + if (System.IO.File.Exists(direct)) return direct; + + var baseName = Path.GetFileNameWithoutExtension(fileName); + if (!baseName.StartsWith("image_", StringComparison.OrdinalIgnoreCase)) + return null; + + var roomName = baseName["image_".Length..]; + if (!RoomImageAliases.Value.TryGetValue(roomName, out var mappedName)) + return null; + + var mapped = Path.Combine(ImageDir, mappedName); + return System.IO.File.Exists(mapped) ? mapped : null; + } + + private static Dictionary LoadRoomImageAliases() + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + var candidates = new[] + { + Path.Combine(AppContext.BaseDirectory, "data", "room_images.json"), + Path.Combine(Directory.GetCurrentDirectory(), "data", "room_images.json"), + }; + + foreach (var path in candidates) + { + if (!System.IO.File.Exists(path)) continue; + try + { + using var fs = System.IO.File.OpenRead(path); + var map = JsonSerializer.Deserialize>(fs); + if (map is null) continue; + foreach (var (roomName, imageName) in map) + if (!string.IsNullOrWhiteSpace(roomName) && !string.IsNullOrWhiteSpace(imageName)) + result[roomName] = imageName; + break; + } + catch + { + // Fall back to direct filename lookup. + } + } + + return result; + } } diff --git a/DorkNet.Server/Controllers/Match/GoToController.cs b/DorkNet.Server/Controllers/Match/GoToController.cs index 1bf0221..008a271 100644 --- a/DorkNet.Server/Controllers/Match/GoToController.cs +++ b/DorkNet.Server/Controllers/Match/GoToController.cs @@ -1300,6 +1300,8 @@ public class GoToController( if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName; + if (RoomService.IsBakedOriginalRoom(room)) return string.Empty; + return RoomService.SyntheticDefaultRoomDataBlobName(room.Id); } @@ -1358,6 +1360,7 @@ public class GoToController( "cyberjunkcity" or "lasertagcyberjunk" => "9d6456ce-6264-48b4-808d-2d96b3d91038", "recroyalesquads" => "253fa009-6e65-4c90-91a1-7137a56a267f", "recroyalesolos" => "85b43509-77f7-3884-3a6b-9a4e737d6d11", + "recrally" => "56193568-9ae0-498c-8a77-4df79dec91f5", _ => "76d98498-60a1-430c-ab76-b54a29b7a163", // DormRoom }; } diff --git a/DorkNet.Server/Controllers/Match/MatchPlayerController.cs b/DorkNet.Server/Controllers/Match/MatchPlayerController.cs index 70c453d..f4f09f3 100644 --- a/DorkNet.Server/Controllers/Match/MatchPlayerController.cs +++ b/DorkNet.Server/Controllers/Match/MatchPlayerController.cs @@ -329,7 +329,12 @@ public class MatchPlayerController( return null; } - var resolvedDataBlob = await ResolveDataBlobForPresenceAsync(room.RoomId, playerId, room.RoomInstanceId); + var resolvedDataBlob = await ResolveDataBlobForPresenceAsync( + room.RoomId, + playerId, + room.RoomInstanceId, + room.SubRoomId, + room.IsPrivate); if (!string.IsNullOrWhiteSpace(resolvedDataBlob) && !string.Equals(room.DataBlob, resolvedDataBlob, StringComparison.Ordinal)) { @@ -434,7 +439,9 @@ public class MatchPlayerController( private async Task ResolveDataBlobForPresenceAsync( long roomId, int playerId, - long? roomInstanceId = null) + long? roomInstanceId = null, + long? subRoomId = null, + bool isPrivateInstance = false) { var room = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(r => r.Id == roomId); if (room is null) return string.Empty; @@ -445,7 +452,7 @@ public class MatchPlayerController( if (!string.IsNullOrWhiteSpace(dormBlob)) return dormBlob; } - if (roomInstanceId is > 0) + if (isPrivateInstance && roomInstanceId is > 0) { var instanceBlob = await db.PrivateInstances.AsNoTracking() .Where(instance => instance.Id == roomInstanceId && instance.RoomId == roomId) @@ -454,8 +461,19 @@ public class MatchPlayerController( if (!string.IsNullOrWhiteSpace(instanceBlob)) return instanceBlob; } + if (subRoomId is >= 0) + { + var sceneBlob = await db.RoomScenes.AsNoTracking() + .Where(scene => scene.RoomId == roomId && scene.OrderIndex == subRoomId) + .Select(scene => scene.DataBlobName) + .FirstOrDefaultAsync(); + if (!string.IsNullOrWhiteSpace(sceneBlob)) return sceneBlob; + } + if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName; + if (RoomService.IsBakedOriginalRoom(room)) return string.Empty; + return RoomService.SyntheticDefaultRoomDataBlobName(room.Id); } } diff --git a/DorkNet.Server/Controllers/PlatformNotifications/PlatformNotificationsController.cs b/DorkNet.Server/Controllers/PlatformNotifications/PlatformNotificationsController.cs new file mode 100644 index 0000000..4769789 --- /dev/null +++ b/DorkNet.Server/Controllers/PlatformNotifications/PlatformNotificationsController.cs @@ -0,0 +1,122 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; + +namespace DorkNet.Server.Controllers.PlatformNotifications; + +[ApiController] +[Authorize] +public class PlatformNotificationsController(DorkNetDbContext db) : ControllerBase +{ + private long Me => this.RequireCurrentPlayerId(); + + [HttpGet("/preferences")] + [HttpGet("/platformnotifications/preferences")] + public async Task Preferences() + { + var row = await db.NotificationPrefs.FirstOrDefaultAsync(p => p.PlayerId == Me); + return Ok(ToPreferencesWire(row)); + } + + [HttpPut("/preferences")] + [HttpPost("/preferences")] + [HttpPut("/platformnotifications/preferences")] + [HttpPost("/platformnotifications/preferences")] + public async Task SetPreferences([FromBody] PlatformNotificationPrefsRequest? body) + { + var row = await db.NotificationPrefs.FirstOrDefaultAsync(p => p.PlayerId == Me); + if (row is null) + { + row = new NotificationPrefsEntity + { + PlayerId = Me, + Platform = "platform", + }; + db.NotificationPrefs.Add(row); + } + + if (body is not null) + { + row.AllowAll = body.AllowAll ?? body.Enabled ?? row.AllowAll; + row.AllowMessage = body.AllowMessages ?? body.Messages ?? row.AllowMessage; + row.AllowFriendRequest = body.AllowFriendRequests ?? body.FriendRequests ?? row.AllowFriendRequest; + row.AllowEventInvite = body.AllowEventInvites ?? body.EventInvites ?? row.AllowEventInvite; + row.AllowAnnouncements = body.AllowAnnouncements ?? body.Announcements ?? row.AllowAnnouncements; + row.UpdatedAt = DateTime.UtcNow; + } + + await db.SaveChangesAsync(); + return Ok(ToPreferencesWire(row)); + } + + [HttpGet("/config/categories")] + [HttpGet("/platformnotifications/config/categories")] + [AllowAnonymous] + public IActionResult Categories() + { + var categories = new[] + { + Category(1, "messages", "Messages"), + Category(2, "friendRequests", "Friend Requests"), + Category(3, "eventInvites", "Event Invites"), + Category(4, "announcements", "Announcements"), + }; + + return Ok(new + { + Categories = categories, + Results = categories, + TotalResults = categories.Length, + }); + } + + private static object Category(int id, string key, string name) => new + { + Id = id, + CategoryId = id, + Key = key, + Name = name, + DisplayName = name, + IsEnabled = true, + Enabled = true, + DefaultEnabled = true, + }; + + private static object ToPreferencesWire(NotificationPrefsEntity? row) => new + { + AllowAll = row?.AllowAll ?? true, + Enabled = row?.AllowAll ?? true, + AllowMessages = row?.AllowMessage ?? true, + Messages = row?.AllowMessage ?? true, + AllowFriendRequests = row?.AllowFriendRequest ?? true, + FriendRequests = row?.AllowFriendRequest ?? true, + AllowEventInvites = row?.AllowEventInvite ?? true, + EventInvites = row?.AllowEventInvite ?? true, + AllowAnnouncements = row?.AllowAnnouncements ?? true, + Announcements = row?.AllowAnnouncements ?? true, + Categories = new[] + { + new { CategoryId = 1, Key = "messages", Enabled = row?.AllowMessage ?? true }, + new { CategoryId = 2, Key = "friendRequests", Enabled = row?.AllowFriendRequest ?? true }, + new { CategoryId = 3, Key = "eventInvites", Enabled = row?.AllowEventInvite ?? true }, + new { CategoryId = 4, Key = "announcements", Enabled = row?.AllowAnnouncements ?? true }, + }, + }; + + public sealed class PlatformNotificationPrefsRequest + { + public bool? AllowAll { get; set; } + public bool? Enabled { get; set; } + public bool? AllowMessages { get; set; } + public bool? Messages { get; set; } + public bool? AllowFriendRequests { get; set; } + public bool? FriendRequests { get; set; } + public bool? AllowEventInvites { get; set; } + public bool? EventInvites { get; set; } + public bool? AllowAnnouncements { get; set; } + public bool? Announcements { get; set; } + } +} diff --git a/DorkNet.Server/Data/Entities/RoomEntity.cs b/DorkNet.Server/Data/Entities/RoomEntity.cs index 00b61cf..7add945 100644 --- a/DorkNet.Server/Data/Entities/RoomEntity.cs +++ b/DorkNet.Server/Data/Entities/RoomEntity.cs @@ -60,6 +60,9 @@ public class RoomEntity public bool SupportsWalkVR { get; set; } = true; public bool SupportsTeleportVR { get; set; } = true; public bool AllowsJuniors { get; set; } = true; + public bool AllowNewUsers { get; set; } = true; + public int MinLevel { get; set; } = 0; + public int MaxPlayerCalculationMode { get; set; } = 0; /// Max players the room advertises per instance — flows into the /// matchmaking RoomInstance.MaxCapacity and the v4/details synthesized @@ -89,6 +92,15 @@ public class RoomEntity [MaxLength(1024)] public string TagsCsv { get; set; } = string.Empty; + [MaxLength(4096)] + public string LoadScreensJson { get; set; } = string.Empty; + + [MaxLength(4096)] + public string PromoImagesJson { get; set; } = string.Empty; + + [MaxLength(4096)] + public string PromoExternalContentJson { get; set; } = string.Empty; + public int CheerCount { get; set; } = 0; public int FavoriteCount { get; set; } = 0; diff --git a/DorkNet.Server/Services/RoomService.cs b/DorkNet.Server/Services/RoomService.cs index 3ae0613..ceb4e43 100644 --- a/DorkNet.Server/Services/RoomService.cs +++ b/DorkNet.Server/Services/RoomService.cs @@ -106,6 +106,7 @@ public class RoomService(DorkNetDbContext db) ("IsleOfLostSkulls", "The Isle of Lost Skulls", "Can your pirate crew get to the Isle, defeat its fearsome guardian, and escape with the gold?", "7e01cfe0-820a-406f-b1b3-0a5bf575235c", "recroomoriginal,quest,co-op,adventure", "image_IsleOfLostSkulls.png"), ("Crescendo", "Crescendo of the Blood Moon", "Brave the haunted halls of Castle Dracula and survive the night.", "49cb8993-a956-43e2-86f4-1318f279b22a", "recroomoriginal,quest,co-op,adventure", "by3mjs9jbozpdvu6g9aje7jgz.png"), ("StuntRunner", "Stunt Runner", "A solo platforming gauntlet — sprint, climb and dodge to reach the trophy at the top.", "b7281665-a715-4051-826b-8e08e69c6172", "recroomoriginal,sport,quest,stuntrunner,parkour", "image_StuntRunner.png"), + ("RecRally", "Rec Rally", "Race across Chaparral with friends in off-road vehicles built for jumps, boosts, and tight turns.", "56193568-9ae0-498c-8a77-4df79dec91f5", "recroomoriginal,featured,sport,recrally,racing,pvp", "image_RecRally.png"), ("Drive-In", "Rec Drive-In", "Watch movies, hang out with friends, or chill at the bar.", "65ddbb48-5a01-4e3e-972d-e5c7419e2bc3", "recroomoriginal,featured,hangout,chill", "image_DriveIn.png"), // Hub / hang rooms — no specific gameplay, just shared spaces. ("Park", "The Park", "An outdoor park with picnic tables and lawn games — a chill place to hang out.", "0a864c86-5a71-4e18-8041-8124e4dc9d98", "recroomoriginal,featured,hangout,chill", "image_Park.png"), @@ -190,6 +191,7 @@ public class RoomService(DorkNetDbContext db) ["Quarry"] = "image_Quarry.png", ["Clearcut"] = "image_Clearcut.png", ["Spillway"] = "image_Spillway.png", + ["RecRally"] = "image_RecRally.png", }; var candidates = new[] { @@ -368,6 +370,7 @@ public class RoomService(DorkNetDbContext db) ("BowlingAlley", "Bowling Alley", "Classic ten-pin bowling. Roll strikes, beat your friends.", "ae929543-9a07-41d5-8ee9-dbbee8c36800", "recroomoriginal,sport", "image_BowlingAlley.png"), ("Crescendo", "Crescendo of the Blood Moon", "Brave the haunted halls of Castle Dracula and survive the night.", "49cb8993-a956-43e2-86f4-1318f279b22a", "recroomoriginal,quest", "by3mjs9jbozpdvu6g9aje7jgz.png"), ("LaserTagHangar", "Laser Tag Hangar", "Teams battle in an industrial warehouse map.", "239e676c-f12f-489f-bf3a-d4c383d692c3", "recroomoriginal,sport", "image_LaserTag.png"), + ("RecRally", "Rec Rally", "Race across Chaparral with friends in off-road vehicles built for jumps, boosts, and tight turns.", "56193568-9ae0-498c-8a77-4df79dec91f5", "recroomoriginal,featured,sport,recrally,racing,pvp", "image_RecRally.png"), ("MakerRoom", "Maker Room", "A blank-walled canvas. The classic starting point for a Maker Pen build.", "a75f7547-79eb-47c6-8986-6767abcb4f92", "recroomoriginal,template,featured", "image_RecCenter.png"), }; @@ -641,6 +644,17 @@ public class RoomService(DorkNetDbContext db) ? blobName : SyntheticDefaultRoomDataBlobName(roomId); + public static bool IsBakedOriginalRoom(RoomEntity room) + { + return !room.IsDormRoom + && !room.IsStudioRoom + && room.CreatorPlayerId == 1 + && !string.IsNullOrWhiteSpace(room.TagsCsv) + && room.TagsCsv + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Any(tag => string.Equals(tag, "recroomoriginal", StringComparison.OrdinalIgnoreCase)); + } + private async Task IsUsableDormBlobNameAsync(string? blobName, long dormRoomId) { if (string.IsNullOrWhiteSpace(blobName)) return false; diff --git a/DorkNet.Server/Services/ServerSettingsService.cs b/DorkNet.Server/Services/ServerSettingsService.cs index 72230bc..575fc7f 100644 --- a/DorkNet.Server/Services/ServerSettingsService.cs +++ b/DorkNet.Server/Services/ServerSettingsService.cs @@ -102,9 +102,10 @@ public class ServerSettingsService(DorkNetDbContext db) public async Task SetPlayMenuTagsAsync( IReadOnlyList pinned, - IReadOnlyList popular) + IReadOnlyList popular, + IReadOnlyList? trending = null) { - var normalized = NormalizePlayMenuTags(pinned, popular); + var normalized = NormalizePlayMenuTags(pinned, popular, trending); var existing = await GetTrackedRowAsync(); existing.PlayMenuTagsJson = JsonSerializer.Serialize(normalized, JsonOptions); existing.UpdatedAt = DateTime.UtcNow; @@ -212,7 +213,7 @@ public class ServerSettingsService(DorkNetDbContext db) row.PlayMenuTagsJson, JsonOptions); if (parsed is not null) - return NormalizePlayMenuTags(parsed.PinnedTags, parsed.PopularTags) + return NormalizePlayMenuTags(parsed.PinnedTags, parsed.PopularTags, parsed.TrendingTags) with { UpdatedAt = row.UpdatedAt }; } catch @@ -473,6 +474,19 @@ public class ServerSettingsService(DorkNetDbContext db) "music", "parkour", ], + TrendingTags: + [ + "featured", + "community", + "makerpen", + "pvp", + "quest", + "music", + "parkour", + "hangout", + "creative", + "recroomoriginal", + ], UpdatedAt: DateTime.UtcNow); public static RecCenterDoorSettings DefaultRecCenterDoors() => @@ -537,13 +551,14 @@ public class ServerSettingsService(DorkNetDbContext db) } private static PlayMenuTagSettings NormalizePlayMenuTags( - IReadOnlyList pinned, - IReadOnlyList popular) + IReadOnlyList? pinned, + IReadOnlyList? popular, + IReadOnlyList? trending) { - static List Normalize(IReadOnlyList tags, int max) + static List Normalize(IReadOnlyList? tags, int max) { var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - return tags + return (tags ?? []) .Select(t => (t ?? string.Empty).Trim().TrimStart('#').ToLowerInvariant()) .Where(t => t.Length > 0) .Where(t => t.All(c => char.IsLetterOrDigit(c) || c is '-' or '_')) @@ -555,9 +570,11 @@ public class ServerSettingsService(DorkNetDbContext db) var defaults = DefaultPlayMenuTags(); var pinnedTags = Normalize(pinned, 16); var popularTags = Normalize(popular, 32); + var trendingTags = Normalize(trending, 32); return new PlayMenuTagSettings( pinnedTags.Count > 0 ? pinnedTags : defaults.PinnedTags, popularTags.Count > 0 ? popularTags : defaults.PopularTags, + trendingTags.Count > 0 ? trendingTags : defaults.TrendingTags, DateTime.UtcNow); } @@ -669,6 +686,7 @@ public sealed record WeeklyChallengeReward( public sealed record PlayMenuTagSettings( List PinnedTags, List PopularTags, + List TrendingTags, DateTime UpdatedAt); public sealed record RecCenterDoorSettings( diff --git a/DorkNet.Server/Startup/DatabaseBootstrap.cs b/DorkNet.Server/Startup/DatabaseBootstrap.cs index 20f49ea..4b6bb53 100644 --- a/DorkNet.Server/Startup/DatabaseBootstrap.cs +++ b/DorkNet.Server/Startup/DatabaseBootstrap.cs @@ -283,6 +283,18 @@ public static class DatabaseBootstrap @"ALTER TABLE ""Rooms"" ADD COLUMN IF NOT EXISTS ""IsRoomLinkedToRecRoomStudio"" boolean NOT NULL DEFAULT false;"); await db.Database.ExecuteSqlRawAsync( @"ALTER TABLE ""Rooms"" ADD COLUMN IF NOT EXISTS ""StudioSessionId"" character varying(128) NOT NULL DEFAULT '';"); + await db.Database.ExecuteSqlRawAsync( + @"ALTER TABLE ""Rooms"" ADD COLUMN IF NOT EXISTS ""AllowNewUsers"" boolean NOT NULL DEFAULT true;"); + await db.Database.ExecuteSqlRawAsync( + @"ALTER TABLE ""Rooms"" ADD COLUMN IF NOT EXISTS ""MinLevel"" integer NOT NULL DEFAULT 0;"); + await db.Database.ExecuteSqlRawAsync( + @"ALTER TABLE ""Rooms"" ADD COLUMN IF NOT EXISTS ""MaxPlayerCalculationMode"" integer NOT NULL DEFAULT 0;"); + await db.Database.ExecuteSqlRawAsync( + @"ALTER TABLE ""Rooms"" ADD COLUMN IF NOT EXISTS ""LoadScreensJson"" character varying(4096) NOT NULL DEFAULT '';"); + await db.Database.ExecuteSqlRawAsync( + @"ALTER TABLE ""Rooms"" ADD COLUMN IF NOT EXISTS ""PromoImagesJson"" character varying(4096) NOT NULL DEFAULT '';"); + await db.Database.ExecuteSqlRawAsync( + @"ALTER TABLE ""Rooms"" ADD COLUMN IF NOT EXISTS ""PromoExternalContentJson"" character varying(4096) NOT NULL DEFAULT '';"); return; } @@ -315,6 +327,48 @@ public static class DatabaseBootstrap @"ALTER TABLE ""Rooms"" ADD COLUMN ""StudioSessionId"" TEXT NOT NULL DEFAULT '';"); } catch { } + + try + { + await db.Database.ExecuteSqlRawAsync( + @"ALTER TABLE ""Rooms"" ADD COLUMN ""AllowNewUsers"" INTEGER NOT NULL DEFAULT 1;"); + } + catch { } + + try + { + await db.Database.ExecuteSqlRawAsync( + @"ALTER TABLE ""Rooms"" ADD COLUMN ""MinLevel"" INTEGER NOT NULL DEFAULT 0;"); + } + catch { } + + try + { + await db.Database.ExecuteSqlRawAsync( + @"ALTER TABLE ""Rooms"" ADD COLUMN ""MaxPlayerCalculationMode"" INTEGER NOT NULL DEFAULT 0;"); + } + catch { } + + try + { + await db.Database.ExecuteSqlRawAsync( + @"ALTER TABLE ""Rooms"" ADD COLUMN ""LoadScreensJson"" TEXT NOT NULL DEFAULT '';"); + } + catch { } + + try + { + await db.Database.ExecuteSqlRawAsync( + @"ALTER TABLE ""Rooms"" ADD COLUMN ""PromoImagesJson"" TEXT NOT NULL DEFAULT '';"); + } + catch { } + + try + { + await db.Database.ExecuteSqlRawAsync( + @"ALTER TABLE ""Rooms"" ADD COLUMN ""PromoExternalContentJson"" TEXT NOT NULL DEFAULT '';"); + } + catch { } } private static async Task EnsureInventionColumnsAsync(DorkNetDbContext db) diff --git a/DorkNet.Server/admin-ui/src/pages/Rooms.tsx b/DorkNet.Server/admin-ui/src/pages/Rooms.tsx index dd5d659..2a56b50 100644 --- a/DorkNet.Server/admin-ui/src/pages/Rooms.tsx +++ b/DorkNet.Server/admin-ui/src/pages/Rooms.tsx @@ -13,6 +13,7 @@ import { Plus, RefreshCw, Trash, Upload } from '../components/Icons'; interface PlayMenuTagSettings { pinnedTags: string[]; popularTags: string[]; + trendingTags: string[]; updatedAt: string; } @@ -245,6 +246,7 @@ function PlayMenuTagsPanel({ }) { const [pinnedTags, setPinnedTags] = useState([]); const [popularTags, setPopularTags] = useState([]); + const [trendingTags, setTrendingTags] = useState([]); const [busy, setBusy] = useState(false); const toast = useToast(); @@ -252,6 +254,7 @@ function PlayMenuTagsPanel({ if (!tags) return; setPinnedTags(tags.pinnedTags); setPopularTags(tags.popularTags); + setTrendingTags(tags.trendingTags); }, [tags]); const save = async (reset = false) => { @@ -260,8 +263,8 @@ function PlayMenuTagsPanel({ await api('/settings/play-menu-tags', { method: 'POST', body: reset - ? { PinnedTags: [], PopularTags: [] } - : { PinnedTags: pinnedTags, PopularTags: popularTags }, + ? { PinnedTags: [], PopularTags: [], TrendingTags: [] } + : { PinnedTags: pinnedTags, PopularTags: popularTags, TrendingTags: trendingTags }, }); toast.push(reset ? 'Play menu tags reset.' : 'Play menu tags saved.', 'success'); onSaved(); @@ -299,7 +302,7 @@ function PlayMenuTagsPanel({ {error &&
{error}
} {loading && !tags &&
Loading tags...
} {tags && ( -
+
+
)}