From 70980bf3b60f9a9d3df1823b6165bc7e8ab7798f Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:08:23 +0300 Subject: [PATCH] feat: Add new API controllers for progression events, room consumables, currencies, earnings distributions, screen share reports, subscription seasons, and UGC purchasables - Implemented ProgressionEventsController to handle progression event retrieval and active events. - Created RoomConsumablesController for listing and purchasing room consumables. - Developed RoomCurrenciesController for managing room currencies, including creation, updates, and balance retrieval. - Added RoomEarningsDistributionsController to fetch earnings distribution data for rooms. - Introduced ScreenShareReportsController for reporting screen share incidents. - Established SubscriptionSeasonsController to provide current subscription season details and rewards. - Launched UgcPurchasablesController for managing user-generated content purchasables, including bulk operations. - Added new data entities for customization and room economy, including custom avatar items, room currencies, and UGC purchasables. --- DorkNet.ClientMod/Mod.cs | 552 +++++++++++++++++- .../API/Account/AccountApiController.cs | 6 +- .../AgeVerificationController.cs | 31 + .../AppIntegrity/AppIntegrityController.cs | 47 ++ .../Apple/AppleMusicPromotionController.cs | 44 ++ .../API/Avatar/V4/AvatarItemsController.cs | 16 + .../CompatibilityFeatureController.cs | 5 + .../API/Config/V2/ConfigController.cs | 1 + .../CustomAvatarItemsController.cs | 369 ++++++++++++ .../API/Events/V3/EventsController.cs | 27 +- .../ExternalFriendInviteController.cs | 77 +++ .../API/FreeGifts/FreeGiftsController.cs | 124 ++++ .../API/GameSight/GameSightController.cs | 31 + .../API/Images/ImagesController.cs | 43 ++ .../IncentivizedReferralsController.cs | 57 ++ .../InfluencerPartnerProgramController.cs | 102 ++++ .../API/Inventions/InventionsController.cs | 47 +- .../API/Inventory/InventoryController.cs | 38 ++ .../ItemWishlists/ItemWishlistsController.cs | 134 +++++ .../API/Keepsakes/KeepsakesController.cs | 161 +++++ .../V2/PlatformLoginController.cs | 13 +- .../PlayStationPlusController.cs | 54 ++ .../PlayerEvents/PlayerEventsController.cs | 429 ++++++++++++++ .../PlayerWarningsController.cs | 63 ++ .../API/Players/V2/PlayersController.cs | 14 + .../ProgressionEventsController.cs | 46 ++ .../V2/RelationshipsController.cs | 68 +++ .../RoomConsumablesController.cs | 88 +++ .../RoomCurrenciesController.cs | 405 +++++++++++++ .../RoomEarningsDistributionsController.cs | 55 ++ .../API/RoomKeys/RoomKeysController.cs | 58 ++ .../ScreenShareReportsController.cs | 40 ++ .../API/Store/StorefrontsBuyController.cs | 90 +++ .../API/Store/StorefrontsController.cs | 2 + .../SubscriptionSeasonsController.cs | 40 ++ .../UgcPurchasablesController.cs | 225 +++++++ .../Accounts/AccountsController.cs | 141 ++++- .../Controllers/Auth/AuthController.cs | 128 +++- .../Auth/VersionCheckController.cs | 15 +- .../Controllers/Econ/EconController.cs | 32 + DorkNet.Server/Data/DorkNetDbContext.cs | 67 +++ .../Data/Entities/CustomizationEntities.cs | 94 +++ .../Data/Entities/InventionEntity.cs | 1 + .../Data/Entities/RoomEconomyEntities.cs | 60 ++ DorkNet.Server/Data/Entities/RoomEntity.cs | 6 + DorkNet.Server/Services/AuthService.cs | 13 +- DorkNet.Server/Services/RoomService.cs | 6 + DorkNet.Server/Startup/DatabaseBootstrap.cs | 321 ++++++++++ .../Startup/RequestTracingExtensions.cs | 14 +- .../Startup/ServiceCollectionExtensions.cs | 23 +- .../Late2020/Late2020VersionPlugin.cs | 24 +- DorkNet.Server/appsettings.json | 13 +- dorknet-reupload-staging | 1 + 53 files changed, 4470 insertions(+), 91 deletions(-) create mode 100644 DorkNet.Server/Controllers/API/AgeVerification/AgeVerificationController.cs create mode 100644 DorkNet.Server/Controllers/API/AppIntegrity/AppIntegrityController.cs create mode 100644 DorkNet.Server/Controllers/API/Apple/AppleMusicPromotionController.cs create mode 100644 DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs create mode 100644 DorkNet.Server/Controllers/API/ExternalFriendInvite/ExternalFriendInviteController.cs create mode 100644 DorkNet.Server/Controllers/API/FreeGifts/FreeGiftsController.cs create mode 100644 DorkNet.Server/Controllers/API/GameSight/GameSightController.cs create mode 100644 DorkNet.Server/Controllers/API/IncentivizedReferrals/IncentivizedReferralsController.cs create mode 100644 DorkNet.Server/Controllers/API/InfluencerPartnerProgram/InfluencerPartnerProgramController.cs create mode 100644 DorkNet.Server/Controllers/API/ItemWishlists/ItemWishlistsController.cs create mode 100644 DorkNet.Server/Controllers/API/Keepsakes/KeepsakesController.cs create mode 100644 DorkNet.Server/Controllers/API/PlayStationPlus/PlayStationPlusController.cs create mode 100644 DorkNet.Server/Controllers/API/PlayerWarnings/PlayerWarningsController.cs create mode 100644 DorkNet.Server/Controllers/API/ProgressionEvents/ProgressionEventsController.cs create mode 100644 DorkNet.Server/Controllers/API/RoomConsumables/RoomConsumablesController.cs create mode 100644 DorkNet.Server/Controllers/API/RoomCurrencies/RoomCurrenciesController.cs create mode 100644 DorkNet.Server/Controllers/API/RoomEarningsDistributions/RoomEarningsDistributionsController.cs create mode 100644 DorkNet.Server/Controllers/API/ScreenShareReports/ScreenShareReportsController.cs create mode 100644 DorkNet.Server/Controllers/API/SubscriptionSeasons/SubscriptionSeasonsController.cs create mode 100644 DorkNet.Server/Controllers/API/UgcPurchasables/UgcPurchasablesController.cs create mode 100644 DorkNet.Server/Data/Entities/CustomizationEntities.cs create mode 100644 DorkNet.Server/Data/Entities/RoomEconomyEntities.cs create mode 160000 dorknet-reupload-staging diff --git a/DorkNet.ClientMod/Mod.cs b/DorkNet.ClientMod/Mod.cs index 93c1ac5..65d5027 100644 --- a/DorkNet.ClientMod/Mod.cs +++ b/DorkNet.ClientMod/Mod.cs @@ -24,11 +24,14 @@ using HarmonyLib; using MelonLoader; using System; +using System.Collections; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Reflection; +using System.Text; using System.Text.Json; +using System.Text.RegularExpressions; [assembly: MelonInfo(typeof(DorkNet.ClientMod.Mod), "DorkNet ClientMod", "1.0.0", "Dork Squad")] [assembly: MelonGame(null, null)] @@ -197,6 +200,13 @@ public class Mod : MelonMod // proxy uses without compile-error. TryPatchByName("PUNNetworkManager", "OnCustomAuthenticationFailed", prefix: nameof(PhotonPatches.OnCustomAuthenticationFailed_Prefix)); + TryPatchByName("RecRoom.AntiCheat.EACManager", + "GenerateChallengeResponse", + args: new[] { typeof(string) }, + prefix: nameof(AntiCheatPatches.GenerateChallengeResponse_Prefix)); + TryPatchByName("LBNJFPOLCDL", "JHLKGBGJKKK", + args: new[] { typeof(string), typeof(string), typeof(string) }, + prefix: nameof(AuthPatches.CaptureTokenArgs_Prefix)); // TLS cert-validation bypass — gated behind Cfg.EnableTlsTrustBypass. // Even when the prefix's return value is "let original run", the // detour itself still has to be JIT-installed when BouncyCastle @@ -542,16 +552,81 @@ public class Mod : MelonMod complete &= TryPatch("Uri ctor (string, UriKind)", typeof(Uri), "ctor", 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)); + TryPatchByName("BestHTTP.HTTPRequest", "SetHeader", + args: new[] { typeof(string), typeof(string) }, + prefix: nameof(HttpTracePatches.HeaderSet_Prefix)); + TryPatchByName("BestHTTP.HTTPRequest", "AddHeader", + args: new[] { typeof(string), typeof(string) }, + prefix: nameof(HttpTracePatches.HeaderSet_Prefix)); complete &= TryPatchHttpManagerStringSendRequestOverloads(); complete &= TryPatchByName("BestHTTP.HTTPManager", "SendRequest", args: new[] { ResolveType("BestHTTP.HTTPRequest") ?? typeof(object) }, prefix: nameof(UriPatches.HttpManagerSendRequestObject_Prefix)); + TryPatchByName("GFOGGHLDJFF", "JBFGGIJPOLC", + prefix: nameof(HttpTracePatches.RecNetRequestSend_Prefix)); + TryPatchByName("DNKAJOCIFHA", "JBFGGIJPOLC", + prefix: nameof(HttpTracePatches.RecNetRequestSend_Prefix)); + TryPatchByName("GFOGGHLDJFF", "GMFJNBANJJA", + args: new[] { typeof(string), typeof(string) }, + prefix: nameof(HttpTracePatches.HeaderSet_Prefix)); + TryPatchByName("DNKAJOCIFHA", "GMFJNBANJJA", + args: new[] { typeof(string), typeof(string) }, + prefix: nameof(HttpTracePatches.HeaderSet_Prefix)); + var httpMethodsType = ResolveType("BestHTTP.HTTPMethods") ?? typeof(object); + var recNetServiceType = ResolveType("GJDLNNLKDIJ") ?? typeof(object); + TryPatchByName("BNDIAONDFFF", "ctor", + args: new[] { httpMethodsType, recNetServiceType, typeof(string) }, + prefix: nameof(HttpTracePatches.RecNetRequestBuilderCtor_Prefix)); _networkPatchesRegistered = complete; } + private bool TryPatchHttpRequestUriConstructors() + { + var type = ResolveType("BestHTTP.HTTPRequest"); + if (type is null) + { + Log.Warning("[patch-miss] BestHTTP.HTTPRequest..ctor(Uri): type not found"); + return false; + } + + var prefix = GetPatchMethod(nameof(UriPatches.HttpRequestCtorUri_Prefix)); + var patched = 0; + foreach (var ctor in type.GetConstructors(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)) + { + var parameters = ctor.GetParameters(); + if (parameters.Length == 0 || !IsUriType(parameters[0].ParameterType)) continue; + + try + { + HarmonyInstance.Patch(ctor, prefix: new HarmonyMethod(prefix)); + patched++; + } + catch (Exception ex) + { + Log.Warning($"[patch-fail] BestHTTP.HTTPRequest..ctor({FormatParameterTypes(parameters)}): {ex.Message}"); + } + } + + if (patched == 0) + { + Log.Warning("[patch-miss] BestHTTP.HTTPRequest..ctor(Uri)"); + return false; + } + + Log.Msg($"[patch-ok] BestHTTP.HTTPRequest..ctor(Uri first) overloads x{patched}"); + return true; + } + private bool TryPatchHttpManagerStringSendRequestOverloads() { var type = ResolveType("BestHTTP.HTTPManager"); @@ -582,6 +657,19 @@ public class Mod : MelonMod return true; } + private static bool IsUriType(Type type) + { + return type == typeof(Uri) || type.FullName == typeof(Uri).FullName || type.Name == nameof(Uri); + } + + private static string FormatParameterTypes(ParameterInfo[] parameters) + { + var names = new List(); + foreach (var parameter in parameters) + names.Add(parameter.ParameterType.Name); + return string.Join(",", names); + } + public override void OnUpdate() { if (!_firstUpdateLogged) @@ -903,7 +991,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(PhotonPatches), typeof(TlsPatches), typeof(DiagnosticPatches), 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(PhotonPatches), typeof(AntiCheatPatches), typeof(TlsPatches), typeof(DiagnosticPatches), typeof(SavePatches), typeof(ChatPatches), typeof(JoinPatches), typeof(RegistrationPatches), typeof(DebugConsolePatches), typeof(ScreenSharePatches), typeof(MakerPenGiftPreviewPatches), typeof(QuestTeamSize) }) { var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static); if (m is not null) return m; @@ -1720,40 +1808,434 @@ internal static class MakerPenGiftPreviewPatches } } +internal static class AuthPatches +{ + private static string? _latestAccessToken; + private static int _tokenLogCount; + + public static void CaptureTokenArgs_Prefix(object? __0, object? __1, object? __2) + { + try + { + var accessToken = __0?.ToString(); + if (LooksLikeJwt(accessToken)) + _latestAccessToken = accessToken; + + if (_tokenLogCount++ < 3) + { + DiagnosticPatches.Write( + "[auth-trace] token setter " + + $"access={Presence(__0)} refresh={Presence(__1)} key={Presence(__2)}"); + } + } + catch (Exception ex) + { + Mod.Log.Warning($"[auth-trace] token capture failed: {ex.Message}"); + } + } + + private static bool LooksLikeJwt(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return false; + + var dots = 0; + foreach (var ch in value) + if (ch == '.') dots++; + return dots == 2; + } + + private static string Presence(object? value) => + string.IsNullOrWhiteSpace(value?.ToString()) ? "missing" : "present"; +} + +internal static class HttpTracePatches +{ + private const int BodyPreviewBytes = 700; + private static readonly Regex SensitivePairRegex = new( + @"(?i)(password|client_secret|access_token|accessToken|refresh_token|refreshToken|authorization|token|key)=([^&\s]+)", + RegexOptions.Compiled); + private static readonly Regex BearerRegex = new( + @"(?i)Bearer\s+[-._~+/=A-Za-z0-9]+", + RegexOptions.Compiled); + private static int _requestId; + private static int _failureLogCount; + + public static void RecNetRequestSend_Prefix(object __instance, MethodBase __originalMethod) + { + TraceRequestObject(__instance, $"{__originalMethod.DeclaringType?.Name}.{__originalMethod.Name}"); + } + + public static void RecNetRequestBuilderCtor_Prefix(object? __0, object? __1, object? __2) + { + var id = NextRequestId(); + DiagnosticPatches.Write( + $"[http-trace] #{id} BNDIAONDFFF.ctor method={SanitizeInline(__0?.ToString() ?? "")} " + + $"service={SanitizeInline(__1?.ToString() ?? "")} path={SanitizeInline(__2?.ToString() ?? "")}"); + } + + public static void HeaderSet_Prefix(object __instance, object? __0, object? __1, MethodBase __originalMethod) + { + try + { + var name = __0?.ToString() ?? ""; + var value = RedactHeaderValue(name, __1?.ToString()); + var uri = TryReadRequestUri(__instance)?.ToString() ?? ""; + DiagnosticPatches.Write( + $"[http-header] {__originalMethod.DeclaringType?.Name}.{__originalMethod.Name} " + + $"{SanitizeInline(uri)} {name}: {value}"); + } + catch (Exception ex) + { + LogFailure("header", ex); + } + } + + public static void TraceUrl(string? url, string source) + { + if (string.IsNullOrWhiteSpace(url)) return; + if (!url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && + !url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + return; + + var id = NextRequestId(); + DiagnosticPatches.Write($"[http-trace] #{id} {source} url={SanitizeInline(url)}"); + } + + public static void TraceRequestObject(object? request, string source) + { + if (request is null) return; + + try + { + var id = NextRequestId(); + var uri = TryReadRequestUri(request)?.ToString() ?? ""; + var method = TryReadHttpMethod(request); + var headers = TryReadHeaders(request); + var body = TryReadBodyPreview(request); + + DiagnosticPatches.Write( + $"[http-trace] #{id} {source} method={method} uri={SanitizeInline(uri)} " + + $"headers={headers} body={body}"); + } + catch (Exception ex) + { + LogFailure(source, ex); + } + } + + private static int NextRequestId() => System.Threading.Interlocked.Increment(ref _requestId); + + private static Uri? TryReadRequestUri(object request) + { + if (TryInvokeUriMethod(request, "KPKNHFBHPHO", out var uri)) return uri; + if (TryInvokeUriMethod(request, "IBLHNHHBNNE", out uri)) return uri; + + const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; + var type = request.GetType(); + + if (TryReadUriValue(type.GetProperty("Uri", flags)?.GetValue(request), out uri)) return uri; + if (TryReadUriValue(type.GetProperty("CurrentUri", flags)?.GetValue(request), out uri)) return uri; + if (TryReadUriValue(type.GetField("k__BackingField", flags)?.GetValue(request), out uri)) return uri; + + foreach (var prop in type.GetProperties(flags)) + { + if (prop.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue; + if (!prop.CanRead || prop.GetIndexParameters().Length != 0) continue; + try + { + if (TryReadUriValue(prop.GetValue(request), out uri)) return uri; + } + catch { } + } + + foreach (var field in type.GetFields(flags)) + { + if (field.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue; + try + { + if (TryReadUriValue(field.GetValue(request), out uri)) return uri; + } + catch { } + } + + return null; + } + + private static bool TryInvokeUriMethod(object request, string methodName, out Uri? uri) + { + uri = null; + try + { + var method = AccessTools.Method(request.GetType(), methodName, Type.EmptyTypes); + if (method is null) return false; + return TryReadUriValue(method.Invoke(request, Array.Empty()), out uri); + } + catch + { + return false; + } + } + + private static bool TryReadUriValue(object? value, out Uri? uri) + { + uri = null; + if (value is null) return false; + if (value is Uri direct) + { + uri = direct; + return true; + } + + var text = value.ToString(); + if (Uri.TryCreate(text, UriKind.Absolute, out var parsed)) + { + uri = parsed; + return true; + } + + return false; + } + + private static string TryReadHttpMethod(object request) + { + foreach (var name in new[] { "MethodType", "Method", "LNJILMKOBIP" }) + { + if (TryReadMemberValue(request, name, out var value)) + return SanitizeInline(value?.ToString() ?? ""); + } + + return ""; + } + + private static string TryReadHeaders(object request) + { + try + { + var dump = AccessTools.Method(request.GetType(), "DumpHeaders", Type.EmptyTypes); + if (dump?.Invoke(request, Array.Empty()) is object rawDump) + return SanitizeInline(rawDump.ToString() ?? ""); + } + catch { } + + var known = new List(); + foreach (var name in new[] + { + "Authorization", "Cookie", "X-RNSIG", "Accept-Language", + "Content-Type", "User-Agent" + }) + { + var value = TryReadHeaderValue(request, name); + if (!string.IsNullOrWhiteSpace(value)) + known.Add($"{name}:{RedactHeaderValue(name, value)}"); + } + + return known.Count == 0 ? "" : string.Join("; ", known); + } + + private static string? TryReadHeaderValue(object request, string name) + { + foreach (var methodName in new[] { "GetFirstHeaderValue", "FKHEHIMHHBP" }) + { + try + { + var method = AccessTools.Method(request.GetType(), methodName, new[] { typeof(string) }); + var value = method?.Invoke(request, new object[] { name })?.ToString(); + if (!string.IsNullOrWhiteSpace(value)) return value; + } + catch { } + } + + return null; + } + + private static string TryReadBodyPreview(object request) + { + foreach (var methodName in new[] { "BGMFJOKNJAJ", "GetEntityBody" }) + { + try + { + var method = AccessTools.Method(request.GetType(), methodName, Type.EmptyTypes); + if (method is null) continue; + var body = method.Invoke(request, Array.Empty()); + var bytes = TryCopyBytes(body, BodyPreviewBytes + 1); + if (bytes is null) continue; + if (bytes.Length == 0) return ""; + + var shown = Math.Min(bytes.Length, BodyPreviewBytes); + var text = Encoding.UTF8.GetString(bytes, 0, shown); + var suffix = bytes.Length > BodyPreviewBytes ? "..." : ""; + return $"{bytes.Length} bytes \"{SanitizeInline(text)}{suffix}\""; + } + catch { } + } + + return ""; + } + + private static byte[]? TryCopyBytes(object? value, int limit) + { + if (value is null) return null; + if (value is byte[] direct) + return CopyLimited(direct, limit); + + if (value is IEnumerable enumerable) + { + var bytes = new List(); + foreach (var item in enumerable) + { + if (item is null) continue; + try { bytes.Add(Convert.ToByte(item)); } + catch { return null; } + if (bytes.Count >= limit) break; + } + return bytes.ToArray(); + } + + return null; + } + + private static byte[] CopyLimited(byte[] source, int limit) + { + var count = Math.Min(source.Length, limit); + var copy = new byte[count]; + Array.Copy(source, copy, count); + return copy; + } + + private static bool TryReadMemberValue(object request, string name, out object? value) + { + value = null; + const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; + var type = request.GetType(); + + try + { + var prop = type.GetProperty(name, flags); + if (prop is not null && prop.CanRead && prop.GetIndexParameters().Length == 0) + { + value = prop.GetValue(request); + return true; + } + } + catch { } + + try + { + var field = type.GetField(name, flags); + if (field is not null) + { + value = field.GetValue(request); + return true; + } + } + catch { } + + try + { + var method = AccessTools.Method(type, name, Type.EmptyTypes); + if (method is not null) + { + value = method.Invoke(request, Array.Empty()); + return true; + } + } + catch { } + + return false; + } + + private static string RedactHeaderValue(string name, string? value) + { + if (string.IsNullOrWhiteSpace(value)) return ""; + if (name.Equals("Authorization", StringComparison.OrdinalIgnoreCase) || + name.Equals("Cookie", StringComparison.OrdinalIgnoreCase) || + name.Equals("Set-Cookie", StringComparison.OrdinalIgnoreCase) || + name.Equals("X-RNSIG", StringComparison.OrdinalIgnoreCase)) + { + return ""; + } + + return SanitizeInline(value); + } + + private static string SanitizeInline(string value) + { + var redacted = SensitivePairRegex.Replace(value, "$1="); + redacted = BearerRegex.Replace(redacted, "Bearer "); + redacted = redacted.Replace("\r", "\\r").Replace("\n", "\\n").Replace("\t", "\\t"); + return redacted.Length <= 1200 ? redacted : redacted[..1200] + "..."; + } + + private static void LogFailure(string source, Exception ex) + { + if (_failureLogCount++ < 10) + Mod.Log.Warning($"[http-trace] {source} failed: {ex.GetType().Name}: {ex.Message}"); + } +} + internal static class UriPatches { + public static void HttpRequestCtorUri_Prefix(object __0) + { + HttpTracePatches.TraceUrl(__0?.ToString(), "BestHTTP.HTTPRequest..ctor(Uri)"); + } + + public static void HttpRequestSetUri_Prefix(ref Uri __0) + { + RewriteUriArg(ref __0, "httprequest-seturi-rewrite"); + } + + public static void HttpRequestPrepareUri_Prefix(ref Uri __0) + { + RewriteUriArg(ref __0, "httprequest-prepareuri-rewrite"); + } + public static void HttpManagerSendRequestString_Prefix(ref string url) { RewriteString(ref url, "httpmanager-rewrite"); + HttpTracePatches.TraceUrl(url, "BestHTTP.HTTPManager.SendRequest(string)"); } public static void HttpManagerSendRequestObject_Prefix(object request) { RewriteRequestUri(request, "httpmanager-request-rewrite"); + HttpTracePatches.TraceRequestObject(request, "BestHTTP.HTTPManager.SendRequest(HTTPRequest)"); } public static void UriStringCtor_Prefix(ref string uriString) { RewriteString(ref uriString, "uri-rewrite"); + HttpTracePatches.TraceUrl(uriString, "System.Uri..ctor(string)"); } public static void HttpRequestSend_Prefix(object __instance) { RewriteRequestUri(__instance, "http-rewrite"); + HttpTracePatches.TraceRequestObject(__instance, "BestHTTP.HTTPRequest.Send"); } private static void RewriteRequestUri(object request, string label) { try { - var uriProp = request.GetType().GetProperty("Uri", BindingFlags.Public | BindingFlags.Instance); - if (uriProp?.GetValue(request) is not Uri uri) return; + if (request is null) return; + var type = request.GetType(); + const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; - var rewritten = RewriteUri(uri); - if (rewritten is null) return; + if (TryRewriteUriProperty(request, type.GetProperty("Uri", flags), label)) return; + if (TryRewriteUriField(request, type.GetField("k__BackingField", flags), label)) return; - uriProp.SetValue(request, rewritten); - Mod.Log.Msg($"[{label}] {uri} → {rewritten}"); + foreach (var prop in type.GetProperties(flags)) + { + if (prop.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue; + if (TryRewriteUriProperty(request, prop, label)) return; + } + + foreach (var field in type.GetFields(flags)) + { + if (field.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue; + if (TryRewriteUriField(request, field, label)) return; + } } catch (Exception ex) { @@ -1761,13 +2243,53 @@ internal static class UriPatches } } + private static void RewriteUriArg(ref Uri uri, string label) + { + if (uri is null) return; + var rewritten = RewriteUri(uri); + if (rewritten is null) return; + + Mod.Log.Msg($"[{label}] {uri} -> {rewritten}"); + uri = rewritten; + } + + private static bool TryRewriteUriProperty(object request, PropertyInfo? prop, string label) + { + if (prop is null || !prop.CanRead || !prop.CanWrite) return false; + if (prop.GetIndexParameters().Length != 0) return false; + if (prop.GetValue(request) is not Uri uri) return false; + + var rewritten = RewriteUri(uri); + if (rewritten is null) return false; + + prop.SetValue(request, rewritten); + Mod.Log.Msg($"[{label}] {uri} -> {rewritten}"); + return true; + } + + private static bool TryRewriteUriField(object request, FieldInfo? field, string label) + { + if (field is null || field.IsInitOnly) return false; + if (field.GetValue(request) is not Uri uri) return false; + + var rewritten = RewriteUri(uri); + if (rewritten is null) return false; + + field.SetValue(request, rewritten); + Mod.Log.Msg($"[{label}] {uri} -> {rewritten}"); + return true; + } + private static void RewriteString(ref string uriString, string label) { if (string.IsNullOrEmpty(uriString)) return; - var host = Mod.Cfg.ServerHost; - if (string.IsNullOrEmpty(host)) return; if (uriString.IndexOf(".rec.net", StringComparison.OrdinalIgnoreCase) < 0) return; - var rewritten = uriString.Replace(".rec.net", "." + host); + if (!Uri.TryCreate(uriString, UriKind.Absolute, out var uri)) return; + + var rewrittenUri = RewriteUri(uri); + if (rewrittenUri is null) return; + + var rewritten = rewrittenUri.ToString(); if (rewritten == uriString) return; Mod.Log.Msg($"[{label}] {uriString} → {rewritten}"); uriString = rewritten; @@ -2364,6 +2886,16 @@ internal static class SavePatches private static Type? FindGameType(string name) => Mod.ResolveType(name); } +internal static class AntiCheatPatches +{ + public static bool GenerateChallengeResponse_Prefix(string FIMBPCACMKJ, ref string __result) + { + __result = "AAAAAAAAAAAAAAAAAAAAAAAA"; + Mod.Log.Msg("[eac] using local challenge response for copied test client"); + return false; + } +} + internal static class TlsPatches { // Both BouncyCastle TLS authenticators have the same `void diff --git a/DorkNet.Server/Controllers/API/Account/AccountApiController.cs b/DorkNet.Server/Controllers/API/Account/AccountApiController.cs index ca0d63d..04cd86f 100644 --- a/DorkNet.Server/Controllers/API/Account/AccountApiController.cs +++ b/DorkNet.Server/Controllers/API/Account/AccountApiController.cs @@ -106,9 +106,11 @@ public class AccountApiController( private async Task GetCurrentPlayerAsync() { var auth = Request.Headers.Authorization.ToString(); - if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + var token = auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) + ? auth["Bearer ".Length..].Trim() + : Request.Cookies[AuthService.AccessCookieName]; + if (string.IsNullOrWhiteSpace(token)) return null; - var token = auth["Bearer ".Length..].Trim(); var id = authService.ValidateToken(token); if (id is not long playerId) return null; return await playerService.GetByIdAsync(playerId); diff --git a/DorkNet.Server/Controllers/API/AgeVerification/AgeVerificationController.cs b/DorkNet.Server/Controllers/API/AgeVerification/AgeVerificationController.cs new file mode 100644 index 0000000..2c31906 --- /dev/null +++ b/DorkNet.Server/Controllers/API/AgeVerification/AgeVerificationController.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using System.Security.Cryptography; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; + +namespace DorkNet.Server.Controllers.API.AgeVerification; + +[ApiController] +[Route("api/ageverification")] +[Authorize] +public class AgeVerificationController(DorkNetDbContext db) : ControllerBase +{ + [HttpPost("generateCode")] + [HttpGet("generateCode")] + public async Task GenerateCode() + { + var pid = this.RequireCurrentPlayerId(); + var code = RandomNumberGenerator.GetInt32(100000, 999999).ToString(); + db.PlayerSettings.Add(new PlayerSettingEntity + { + PlayerId = pid, + Key = $"ageverification:{DateTime.UtcNow:yyyyMMddHHmmss}", + Value = code, + }); + await db.SaveChangesAsync(); + return Ok(new { Code = code, VerificationCode = code, ExpiresAt = DateTime.UtcNow.AddMinutes(15) }); + } +} diff --git a/DorkNet.Server/Controllers/API/AppIntegrity/AppIntegrityController.cs b/DorkNet.Server/Controllers/API/AppIntegrity/AppIntegrityController.cs new file mode 100644 index 0000000..7ec5aac --- /dev/null +++ b/DorkNet.Server/Controllers/API/AppIntegrity/AppIntegrityController.cs @@ -0,0 +1,47 @@ +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.API.AppIntegrity; + +[ApiController] +public class AppIntegrityController(DorkNetDbContext db) : ControllerBase +{ + [HttpGet("api/AppIntegrity/v1/iosproducts")] + [AllowAnonymous] + public async Task IosProducts() + { + var rows = await db.StoreItems + .Where(i => i.IsActive) + .OrderBy(i => i.Id) + .Take(100) + .Select(i => new + { + ProductId = i.Slug, + i.Price, + i.CurrencyType, + Name = i.DisplayName, + }) + .ToListAsync(); + return Ok(rows); + } + + [HttpPost("api/AppIntegrity/v1/iospaymentqueuefailed")] + [Authorize] + public async Task IosPaymentQueueFailed([FromForm] string? reason) + { + db.BugReports.Add(new BugReportEntity + { + ReporterPlayerId = this.RequireCurrentPlayerId(), + Title = "iOS payment queue failed", + Body = reason ?? Request.Query["reason"].FirstOrDefault() ?? string.Empty, + Category = "appintegrity", + CreatedAt = DateTime.UtcNow, + }); + await db.SaveChangesAsync(); + return Ok(new { Success = true }); + } +} diff --git a/DorkNet.Server/Controllers/API/Apple/AppleMusicPromotionController.cs b/DorkNet.Server/Controllers/API/Apple/AppleMusicPromotionController.cs new file mode 100644 index 0000000..a5ecb09 --- /dev/null +++ b/DorkNet.Server/Controllers/API/Apple/AppleMusicPromotionController.cs @@ -0,0 +1,44 @@ +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.API.Apple; + +[ApiController] +public class AppleMusicPromotionController(DorkNetDbContext db) : ControllerBase +{ + [HttpGet("api/apple/musicpromotion/active")] + [AllowAnonymous] + public IActionResult Active() + { + var start = new DateTime(DateTime.UtcNow.Year, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var end = start.AddYears(1); + return Ok(new { Active = true, StartAt = start, EndAt = end }); + } + + [HttpPost("api/apple/musicpromotion/code")] + [HttpGet("api/apple/musicpromotion/code")] + [Authorize] + public async Task Code() + { + var me = this.RequireCurrentPlayerId(); + var row = await db.PlayerSettings + .FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == "applemusic:promotion_code"); + if (row is null) + { + row = new PlayerSettingEntity + { + PlayerId = me, + Key = "applemusic:promotion_code", + Value = $"RR-{Guid.NewGuid():N}"[..16], + }; + db.PlayerSettings.Add(row); + await db.SaveChangesAsync(); + } + + return Ok(new { Code = row.Value, Redeemed = false }); + } +} diff --git a/DorkNet.Server/Controllers/API/Avatar/V4/AvatarItemsController.cs b/DorkNet.Server/Controllers/API/Avatar/V4/AvatarItemsController.cs index 54ab8e1..ba199b2 100644 --- a/DorkNet.Server/Controllers/API/Avatar/V4/AvatarItemsController.cs +++ b/DorkNet.Server/Controllers/API/Avatar/V4/AvatarItemsController.cs @@ -186,6 +186,22 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) : return Ok(result); } + [HttpGet("api/avatar/v1/defaultbaseavataritems")] + [AllowAnonymous] + public IActionResult DefaultBaseAvatarItems() + { + var rows = _catalog.Value.Items.Values + .OrderBy(i => i.AvatarItemType) + .ThenBy(i => i.FriendlyName) + .Take(100) + .ToList(); + return Ok(rows); + } + + [HttpGet("api/avatar/v1/defaultunlocked")] + [AllowAnonymous] + public Task>> DefaultUnlocked() => Get(); + /// The set of GUIDs the bundled /// AvatarItemWardrobeRuntimeConfig.avatarItemDataLookup /// contains. Exposed so other controllers (StoreService seed, diff --git a/DorkNet.Server/Controllers/API/Compatibility/CompatibilityFeatureController.cs b/DorkNet.Server/Controllers/API/Compatibility/CompatibilityFeatureController.cs index 4cbeaac..330320f 100644 --- a/DorkNet.Server/Controllers/API/Compatibility/CompatibilityFeatureController.cs +++ b/DorkNet.Server/Controllers/API/Compatibility/CompatibilityFeatureController.cs @@ -73,6 +73,11 @@ public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBas }); } + [HttpGet("api/CampusCard/PS5RecRoomPlusEnabledForAllPlayers")] + [AllowAnonymous] + public IActionResult Ps5RecRoomPlusEnabledForAllPlayers() + => Content("true", "application/json"); + [HttpPost("api/clubreporting/v1/report")] [Authorize] [Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")] diff --git a/DorkNet.Server/Controllers/API/Config/V2/ConfigController.cs b/DorkNet.Server/Controllers/API/Config/V2/ConfigController.cs index 69d359a..86004f6 100644 --- a/DorkNet.Server/Controllers/API/Config/V2/ConfigController.cs +++ b/DorkNet.Server/Controllers/API/Config/V2/ConfigController.cs @@ -5,6 +5,7 @@ using DorkNet.Server.Services; namespace DorkNet.Server.Controllers.API.Config.V2; [ApiController] +[Route("api/config")] [Route("api/[controller]/v2")] public class ConfigController(ConfigService configService) : ControllerBase { diff --git a/DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs b/DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs new file mode 100644 index 0000000..8021412 --- /dev/null +++ b/DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs @@ -0,0 +1,369 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using System.Text.Json; +using System.Text.Json.Serialization; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.API.CustomAvatarItems; + +[ApiController] +[Route("api/customAvatarItems")] +[Route("api/customAvatarItems/v1")] +public class CustomAvatarItemsController( + DorkNetDbContext db, + LevelService level, + DomainConfig domain) : ControllerBase +{ + [HttpGet] + [AllowAnonymous] + public async Task List( + [FromQuery] string? q = null, + [FromQuery] string? query = null, + [FromQuery] int skip = 0, + [FromQuery] int take = 50) + { + take = Math.Clamp(take, 1, 100); + skip = Math.Max(0, skip); + var needle = (q ?? query ?? string.Empty).Trim().ToLowerInvariant(); + IQueryable rows = db.CustomAvatarItems + .Where(i => i.IsPublic); + if (needle.Length > 0) + { + rows = rows.Where(i => + i.Name.ToLower().Contains(needle) || + i.Description.ToLower().Contains(needle)); + } + + var result = await rows + .OrderByDescending(i => i.UpdatedAt) + .Skip(skip) + .Take(take) + .ToListAsync(); + return Ok(result.Select(ToWire)); + } + + [HttpGet("{id:guid}")] + [AllowAnonymous] + public async Task Get(Guid id) + { + var item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id); + if (item is null) return NotFound(); + if (!item.IsPublic && item.CreatorPlayerId != this.CurrentPlayerId()) return Forbid(); + return Ok(ToWire(item)); + } + + [HttpGet("bulk")] + [HttpPost("bulk")] + [AllowAnonymous] + public async Task Bulk([FromBody] BulkRequest? body) + { + var ids = await ReadIdsAsync(body); + if (ids.Count == 0) return Ok(Array.Empty()); + var rows = await db.CustomAvatarItems + .Where(i => ids.Contains(i.PublicId) && (i.IsPublic || i.CreatorPlayerId == this.CurrentPlayerId())) + .ToListAsync(); + return Ok(rows.Select(ToWire)); + } + + [HttpGet("featured")] + [AllowAnonymous] + public async Task Featured([FromQuery] int take = 50) + { + take = Math.Clamp(take, 1, 100); + var rows = await db.CustomAvatarItems + .Where(i => i.IsPublic && i.IsFeatured) + .OrderByDescending(i => i.UpdatedAt) + .Take(take) + .ToListAsync(); + return Ok(rows.Select(ToWire)); + } + + [HttpGet("hot")] + [AllowAnonymous] + public async Task Hot([FromQuery] int take = 50) + { + take = Math.Clamp(take, 1, 100); + var rows = await db.CustomAvatarItems + .Where(i => i.IsPublic) + .OrderByDescending(i => i.CheerCount) + .ThenByDescending(i => i.PurchaseCount) + .ThenByDescending(i => i.UpdatedAt) + .Take(take) + .ToListAsync(); + return Ok(rows.Select(ToWire)); + } + + [HttpGet("new")] + [AllowAnonymous] + public async Task New([FromQuery] int take = 50) + { + take = Math.Clamp(take, 1, 100); + var rows = await db.CustomAvatarItems + .Where(i => i.IsPublic) + .OrderByDescending(i => i.CreatedAt) + .Take(take) + .ToListAsync(); + return Ok(rows.Select(ToWire)); + } + + [HttpGet("me")] + [Authorize] + public async Task Me() + { + var pid = this.RequireCurrentPlayerId(); + var created = await db.CustomAvatarItems + .Where(i => i.CreatorPlayerId == pid) + .OrderByDescending(i => i.UpdatedAt) + .ToListAsync(); + var ownedIds = await db.CustomAvatarItemOwnership + .Where(o => o.PlayerId == pid) + .Select(o => o.CustomAvatarItemId) + .ToListAsync(); + var owned = ownedIds.Count == 0 + ? new List() + : await db.CustomAvatarItems + .Where(i => ownedIds.Contains(i.Id)) + .OrderByDescending(i => i.UpdatedAt) + .ToListAsync(); + + return Ok(new + { + Created = created.Select(ToWire), + Owned = owned.Select(ToWire), + }); + } + + [HttpGet("owned")] + [Authorize] + public async Task Owned() + { + var pid = this.RequireCurrentPlayerId(); + var rows = await db.CustomAvatarItemOwnership + .Where(o => o.PlayerId == pid) + .Join(db.CustomAvatarItems, o => o.CustomAvatarItemId, i => i.Id, (o, i) => i) + .OrderByDescending(i => i.UpdatedAt) + .ToListAsync(); + return Ok(rows.Select(ToWire)); + } + + [HttpGet("isCreationAllowedForAccount")] + [Authorize] + public async Task IsCreationAllowedForAccount() + { + var pid = this.RequireCurrentPlayerId(); + var player = await db.Players + .Where(p => p.Id == pid) + .Select(p => new { p.IsJunior, p.BannedUntil }) + .FirstOrDefaultAsync(); + var allowed = player is not null + && !player.IsJunior + && (player.BannedUntil is null || player.BannedUntil <= DateTime.UtcNow); + return Ok(new { IsCreationAllowed = allowed, Allowed = allowed }); + } + + [HttpGet("isCreationEnabled")] + [AllowAnonymous] + public IActionResult IsCreationEnabled() => Ok(new { IsCreationEnabled = true, Enabled = true }); + + [HttpGet("isRenderingEnabled")] + [AllowAnonymous] + public IActionResult IsRenderingEnabled() => Ok(new { IsRenderingEnabled = true, Enabled = true }); + + [HttpGet("minPriceForPublicItem")] + [AllowAnonymous] + public IActionResult MinPriceForPublicItem() => Ok(new { MinPrice = 100, Price = 100 }); + + [HttpPost("design")] + [Authorize] + [Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")] + public async Task Design() + { + var pid = this.RequireCurrentPlayerId(); + var req = await ReadDesignRequestAsync(); + var name = (req.Name ?? string.Empty).Trim(); + if (name.Length < 3) return BadRequest(new { error = "name_too_short" }); + if (name.Length > 128) name = name[..128]; + + var description = (req.Description ?? string.Empty).Trim(); + if (description.Length > 1024) description = description[..1024]; + + CustomAvatarItemEntity item; + if (req.Id is Guid id) + { + item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id) + ?? new CustomAvatarItemEntity { PublicId = id, CreatorPlayerId = pid }; + if (item.Id != 0 && item.CreatorPlayerId != pid) return Forbid(); + if (item.Id == 0) db.CustomAvatarItems.Add(item); + } + else + { + item = new CustomAvatarItemEntity { CreatorPlayerId = pid }; + db.CustomAvatarItems.Add(item); + } + + item.Name = name; + item.Description = description; + item.Price = Math.Max(100, req.Price ?? item.Price); + item.ItemType = req.ItemType ?? item.ItemType; + item.BaseAvatarItemId = req.BaseAvatarItemId ?? item.BaseAvatarItemId; + item.Color = Trim(req.Color, 64); + item.ImageName = Trim(req.ImageName, 256); + item.AssetName = Trim(req.AssetName, 256); + item.IsPublic = req.IsPublic ?? item.IsPublic; + item.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + + await EnsureOwnershipAsync(pid, item.Id); + return Ok(ToWire(item)); + } + + [HttpPost("{id:guid}/purchase")] + [Authorize] + public async Task Purchase(Guid id) + { + var pid = this.RequireCurrentPlayerId(); + var item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id && i.IsPublic); + if (item is null) return NotFound(); + if (await db.CustomAvatarItemOwnership.AnyAsync(o => o.PlayerId == pid && o.CustomAvatarItemId == item.Id)) + return Ok(new { Success = true, AlreadyOwned = true, Item = ToWire(item) }); + + var balance = await level.GetBalanceAsync(pid, 2); + if (balance < item.Price) + return Ok(new { Success = false, Error = "insufficient_funds", Balance = balance }); + + var newBalance = await level.GrantCurrencyAsync(pid, 2, -item.Price, $"customAvatarItem:{item.PublicId}"); + await EnsureOwnershipAsync(pid, item.Id); + item.PurchaseCount += 1; + item.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return Ok(new { Success = true, Balance = newBalance, Item = ToWire(item) }); + } + + private async Task EnsureOwnershipAsync(long playerId, long itemId) + { + if (await db.CustomAvatarItemOwnership.AnyAsync(o => + o.PlayerId == playerId && o.CustomAvatarItemId == itemId)) + { + return; + } + + db.CustomAvatarItemOwnership.Add(new CustomAvatarItemOwnershipEntity + { + PlayerId = playerId, + CustomAvatarItemId = itemId, + }); + await db.SaveChangesAsync(); + } + + private async Task> ReadIdsAsync(BulkRequest? body) + { + var result = new List(); + if (body?.Ids is { Count: > 0 }) + result.AddRange(body.Ids); + if (body?.ItemIds is { Count: > 0 }) + result.AddRange(body.ItemIds); + + foreach (var pair in Request.Query) + foreach (var value in pair.Value) + foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + if (Guid.TryParse(part, out var id)) result.Add(id); + + if (Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(); + foreach (var key in new[] { "ids", "Ids", "itemIds", "ItemIds" }) + foreach (var value in form[key]) + foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + if (Guid.TryParse(part, out var id)) result.Add(id); + } + + return result.Distinct().Take(200).ToList(); + } + + private async Task ReadDesignRequestAsync() + { + if (Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(); + return new DesignRequest + { + Id = Guid.TryParse(form["id"].FirstOrDefault() ?? form["Id"].FirstOrDefault(), out var id) ? id : null, + Name = form["name"].FirstOrDefault() ?? form["Name"].FirstOrDefault(), + Description = form["description"].FirstOrDefault() ?? form["Description"].FirstOrDefault(), + Price = int.TryParse(form["price"].FirstOrDefault() ?? form["Price"].FirstOrDefault(), out var price) ? price : null, + ItemType = int.TryParse(form["itemType"].FirstOrDefault() ?? form["ItemType"].FirstOrDefault(), out var itemType) ? itemType : null, + BaseAvatarItemId = int.TryParse(form["baseAvatarItemId"].FirstOrDefault() ?? form["BaseAvatarItemId"].FirstOrDefault(), out var baseId) ? baseId : null, + Color = form["color"].FirstOrDefault() ?? form["Color"].FirstOrDefault(), + ImageName = form["imageName"].FirstOrDefault() ?? form["ImageName"].FirstOrDefault(), + AssetName = form["assetName"].FirstOrDefault() ?? form["AssetName"].FirstOrDefault(), + IsPublic = bool.TryParse(form["isPublic"].FirstOrDefault() ?? form["IsPublic"].FirstOrDefault(), out var pub) ? pub : null, + }; + } + + try + { + return await JsonSerializer.DeserializeAsync( + Request.Body, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) + ?? new DesignRequest(); + } + catch (JsonException) + { + return new DesignRequest(); + } + } + + private object ToWire(CustomAvatarItemEntity item) => new + { + Id = item.PublicId, + CustomAvatarItemId = item.PublicId, + CreatorId = (int)item.CreatorPlayerId, + CreatorPlayerId = (int)item.CreatorPlayerId, + item.Name, + item.Description, + item.Price, + item.ItemType, + item.BaseAvatarItemId, + item.Color, + item.ImageName, + ImageUrl = string.IsNullOrWhiteSpace(item.ImageName) ? string.Empty : $"https://{domain.Sub("cdn")}/{item.ImageName}", + item.AssetName, + item.IsPublic, + item.IsFeatured, + item.CheerCount, + item.PurchaseCount, + item.CreatedAt, + item.UpdatedAt, + Status = item.IsPublic ? 1 : 0, + }; + + private static string Trim(string? value, int max) + { + var s = (value ?? string.Empty).Trim(); + return s.Length <= max ? s : s[..max]; + } + + public sealed class BulkRequest + { + public List? Ids { get; set; } + public List? ItemIds { get; set; } + } + + public sealed class DesignRequest + { + public Guid? Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public int? Price { get; set; } + public int? ItemType { get; set; } + public int? BaseAvatarItemId { get; set; } + public string? Color { get; set; } + public string? ImageName { get; set; } + public string? AssetName { get; set; } + public bool? IsPublic { get; set; } + } +} diff --git a/DorkNet.Server/Controllers/API/Events/V3/EventsController.cs b/DorkNet.Server/Controllers/API/Events/V3/EventsController.cs index fac36d7..59b945a 100644 --- a/DorkNet.Server/Controllers/API/Events/V3/EventsController.cs +++ b/DorkNet.Server/Controllers/API/Events/V3/EventsController.cs @@ -1,11 +1,34 @@ using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Data; namespace DorkNet.Server.Controllers.API.Events.V3; [ApiController] [Route("api/[controller]/v3")] -public class EventsController : ControllerBase +public class EventsController(DorkNetDbContext db) : ControllerBase { [HttpGet("list")] - public ActionResult> GetActiveEvents() => Ok(new List()); + public async Task>> GetActiveEvents([FromQuery] int take = 50) + { + take = Math.Clamp(take, 1, 100); + var rows = await db.PlayerEvents + .Where(e => e.EndsAt > DateTime.UtcNow) + .OrderBy(e => e.StartsAt) + .Take(take) + .Select(e => new + { + EventId = e.Id, + PlayerEventId = e.Id, + Name = e.Title, + e.Description, + StartTime = e.StartsAt, + EndTime = e.EndsAt, + CreatorPlayerId = (int)e.CreatorPlayerId, + e.RoomId, + e.Capacity, + }) + .ToListAsync(); + return Ok(rows.Cast().ToList()); + } } diff --git a/DorkNet.Server/Controllers/API/ExternalFriendInvite/ExternalFriendInviteController.cs b/DorkNet.Server/Controllers/API/ExternalFriendInvite/ExternalFriendInviteController.cs new file mode 100644 index 0000000..30aa4e0 --- /dev/null +++ b/DorkNet.Server/Controllers/API/ExternalFriendInvite/ExternalFriendInviteController.cs @@ -0,0 +1,77 @@ +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.API.ExternalFriendInvite; + +[ApiController] +[Authorize] +public class ExternalFriendInviteController(DorkNetDbContext db) : ControllerBase +{ + private long Me => this.RequireCurrentPlayerId(); + + [HttpPost("api/externalfriendinvite/v1/createplatforminvite")] + public async Task CreatePlatformInvite([FromForm] int platform = 0, [FromForm] string? platformId = null) + { + var code = Guid.NewGuid().ToString("N")[..10]; + var key = $"externalinvite:platform:{code}"; + db.PlayerSettings.Add(new PlayerSettingEntity + { + PlayerId = Me, + Key = key, + Value = $"{platform}|{platformId ?? string.Empty}|{DateTime.UtcNow:O}", + }); + await db.SaveChangesAsync(); + var url = $"{Request.Scheme}://{Request.Host}/invite/{code}"; + return Ok(new { InviteCode = code, InviteUrl = url, Platform = platform, PlatformId = platformId ?? string.Empty }); + } + + [HttpPost("api/externalfriendinvite/v1/sendtextmessageinvite")] + public async Task SendTextMessageInvite([FromForm] string? phoneNumber, [FromForm] string? message) + { + var code = Guid.NewGuid().ToString("N")[..10]; + db.PlayerSettings.Add(new PlayerSettingEntity + { + PlayerId = Me, + Key = $"externalinvite:text:{code}", + Value = $"{phoneNumber ?? string.Empty}|{message ?? string.Empty}|{DateTime.UtcNow:O}", + }); + await db.SaveChangesAsync(); + return Ok(new { Success = true, InviteCode = code, PhoneNumber = phoneNumber ?? string.Empty }); + } + + [HttpGet("api/externalfriendinvite/v1/getplatformreferrers")] + public async Task GetPlatformReferrers() + { + var rows = await db.PlayerSettings + .Where(s => s.PlayerId == Me && s.Key.StartsWith("externalinvite:platform:")) + .OrderByDescending(s => s.Id) + .ToListAsync(); + return Ok(rows.Select(ToInviteWire)); + } + + [HttpGet("api/externalfriendinvite/v1/gettextmessagereferrers")] + public async Task GetTextMessageReferrers() + { + var rows = await db.PlayerSettings + .Where(s => s.PlayerId == Me && s.Key.StartsWith("externalinvite:text:")) + .OrderByDescending(s => s.Id) + .ToListAsync(); + return Ok(rows.Select(ToInviteWire)); + } + + private static object ToInviteWire(PlayerSettingEntity row) + { + var parts = row.Value.Split('|'); + return new + { + InviteCode = row.Key.Split(':').Last(), + Kind = row.Key.Contains(":text:") ? "text" : "platform", + Value = parts.FirstOrDefault() ?? string.Empty, + CreatedAt = parts.LastOrDefault() ?? string.Empty, + }; + } +} diff --git a/DorkNet.Server/Controllers/API/FreeGifts/FreeGiftsController.cs b/DorkNet.Server/Controllers/API/FreeGifts/FreeGiftsController.cs new file mode 100644 index 0000000..fa79373 --- /dev/null +++ b/DorkNet.Server/Controllers/API/FreeGifts/FreeGiftsController.cs @@ -0,0 +1,124 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using System.Text.Json; +using DorkNet.Models.Notification; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.API.FreeGifts; + +[ApiController] +[Authorize] +public class FreeGiftsController(DorkNetDbContext db, NotificationService notifications) : ControllerBase +{ + [HttpPost("api/freegifts/v1/sendmultiple")] + public async Task SendMultiple() + { + var fields = await ReadFieldsAsync(); + var sender = this.RequireCurrentPlayerId(); + var recipients = ReadLongList(fields, "recipientPlayerIds", "RecipientPlayerIds", "playerIds", "PlayerIds"); + if (recipients.Count == 0) return BadRequest("missing_recipients"); + + var currencyType = ReadInt(fields, "currencyType", "CurrencyType") ?? 2; + var currency = Math.Max(0, ReadInt(fields, "currency", "Currency") ?? 0); + var xp = Math.Max(0, ReadInt(fields, "xp", "Xp") ?? 0); + var message = ReadString(fields, "message", "Message") ?? string.Empty; + var created = new List<(long RecipientPlayerId, GiftPackageEntity Gift)>(); + + foreach (var recipient in recipients.Distinct().Take(100)) + { + if (!await db.Players.AnyAsync(p => p.Id == recipient)) continue; + var gift = new GiftPackageEntity + { + RecipientPlayerId = recipient, + FromPlayerId = (int)sender, + CurrencyType = currency > 0 ? currencyType : 0, + Currency = currency, + Xp = xp, + Level = 1, + GiftContext = 0, + GiftRarity = 0, + Message = message, + Platform = -1, + PackageVariant = "FreeGift", + PackageMaterial = string.Empty, + Consumed = false, + IsValid = true, + SupportsCurrentPlatform = true, + }; + db.GiftPackages.Add(gift); + created.Add((recipient, gift)); + } + + await db.SaveChangesAsync(); + foreach (var row in created) + { + await notifications.NotifyAsync(row.RecipientPlayerId, + PushNotificationId.GiftPackageReceived, + new { FromPlayerId = sender, CurrencyType = currencyType, Currency = currency, Xp = xp, Message = message }); + } + + return Ok(new { Success = true, Sent = created.Count }); + } + + private async Task> ReadFieldsAsync() + { + var fields = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var pair in Request.Query) + fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty; + if (Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(); + foreach (var pair in form) + fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty; + } + else if ((Request.ContentLength ?? 0) > 0 + && Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true) + { + try + { + using var doc = await JsonDocument.ParseAsync(Request.Body); + if (doc.RootElement.ValueKind == JsonValueKind.Object) + { + foreach (var prop in doc.RootElement.EnumerateObject()) + { + fields[prop.Name] = prop.Value.ValueKind switch + { + JsonValueKind.Array => string.Join(",", prop.Value.EnumerateArray().Select(v => v.ToString())), + JsonValueKind.String => prop.Value.GetString() ?? string.Empty, + _ => prop.Value.GetRawText(), + }; + } + } + } + catch (JsonException) + { + } + } + return fields; + } + + private static string? ReadString(Dictionary fields, params string[] names) + { + foreach (var name in names) + if (fields.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value)) + return value; + return null; + } + + private static int? ReadInt(Dictionary fields, params string[] names) => + int.TryParse(ReadString(fields, names), out var value) ? value : null; + + private static List ReadLongList(Dictionary fields, params string[] names) => + names.SelectMany(name => fields.TryGetValue(name, out var value) + ? value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + : Array.Empty()) + .Select(v => long.TryParse(v, out var id) ? id : 0) + .Where(id => id > 0) + .Distinct() + .Take(100) + .ToList(); +} diff --git a/DorkNet.Server/Controllers/API/GameSight/GameSightController.cs b/DorkNet.Server/Controllers/API/GameSight/GameSightController.cs new file mode 100644 index 0000000..dc784a1 --- /dev/null +++ b/DorkNet.Server/Controllers/API/GameSight/GameSightController.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; + +namespace DorkNet.Server.Controllers.API.GameSight; + +[ApiController] +[Authorize] +public class GameSightController(DorkNetDbContext db) : ControllerBase +{ + [HttpPost("api/gamesight/event")] + [AllowAnonymous] + public async Task Event([FromBody] object? body) + { + var playerId = this.CurrentPlayerId(); + if (playerId is null) + return Ok(new { Success = true }); + + db.ObjectiveProgress.Add(new ObjectiveProgressEntity + { + PlayerId = playerId.Value, + Key = $"gamesight:{Guid.NewGuid():N}", + IsCompleted = true, + ClearedAt = DateTime.UtcNow, + }); + await db.SaveChangesAsync(); + return Ok(new { Success = true }); + } +} diff --git a/DorkNet.Server/Controllers/API/Images/ImagesController.cs b/DorkNet.Server/Controllers/API/Images/ImagesController.cs index 1873b88..b169f68 100644 --- a/DorkNet.Server/Controllers/API/Images/ImagesController.cs +++ b/DorkNet.Server/Controllers/API/Images/ImagesController.cs @@ -274,6 +274,48 @@ public class ImagesController( return Ok(rows.Select(p => BuildImageInfo(p, pid))); } + public sealed class ImageBulkRequest + { + public List? ImageIds { get; set; } + public List? SavedImageIds { get; set; } + } + + [HttpGet("api/images/v5/bulk")] + [HttpPost("api/images/v5/bulk")] + [Authorize] + public async Task BulkImages([FromBody] ImageBulkRequest? body) + { + var pid = this.RequireCurrentPlayerId(); + var ids = (body?.ImageIds ?? body?.SavedImageIds ?? new List()) + .Concat(Request.Query.SelectMany(q => q.Value) + .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() + .Take(200) + .ToList(); + if (ids.Count == 0) return Ok(Array.Empty()); + + var rows = await db.Photos + .Where(p => ids.Contains(p.Id) && p.DeletedAt == null + && (p.IsPublic || p.UploaderPlayerId == pid)) + .ToListAsync(); + return Ok(rows.Select(p => BuildImageInfo(p, p.UploaderPlayerId))); + } + + [HttpGet("api/images/v6")] + [AllowAnonymous] + public async Task ImagesV6([FromQuery] int take = 50) + { + take = Math.Clamp(take, 1, 100); + var rows = await db.Photos + .Where(p => p.IsPublic && p.DeletedAt == null) + .OrderByDescending(p => p.CreatedAt) + .Take(take) + .ToListAsync(); + return Ok(rows.Select(p => BuildImageInfo(p, p.UploaderPlayerId))); + } + /// GET api/images/v2/named — the watch's /// Images.DownloadNamedImageMappings endpoint. Returns a /// list of NamedImageDTO entries; an empty list is a valid @@ -424,6 +466,7 @@ public class ImagesController( } [HttpPost("api/images/v4/cheered/bulk")] + [HttpPost("api/images/v5/cheered/bulk")] [Authorize] public async Task CheeredBulk([FromBody] BulkCheeredRequest? body) { diff --git a/DorkNet.Server/Controllers/API/IncentivizedReferrals/IncentivizedReferralsController.cs b/DorkNet.Server/Controllers/API/IncentivizedReferrals/IncentivizedReferralsController.cs new file mode 100644 index 0000000..366866a --- /dev/null +++ b/DorkNet.Server/Controllers/API/IncentivizedReferrals/IncentivizedReferralsController.cs @@ -0,0 +1,57 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.API.IncentivizedReferrals; + +[ApiController] +[Authorize] +[Route("api/incentivizedreferrals")] +public class IncentivizedReferralsController(DorkNetDbContext db, LevelService level) : ControllerBase +{ + private long Me => this.RequireCurrentPlayerId(); + + [HttpGet("")] + [HttpGet("progress")] + public async Task Progress() + { + var referralCount = await db.PlayerSettings + .CountAsync(s => s.Key.StartsWith($"referral:credited:{Me}:")); + var claimed = await db.PlayerSettings + .AnyAsync(s => s.PlayerId == Me && s.Key == "referral:reward_claimed"); + return Ok(new + { + ReferralCount = referralCount, + RequiredReferralCount = 1, + CanClaim = referralCount >= 1 && !claimed, + Claimed = claimed, + RewardCurrencyType = 2, + RewardCurrency = 250, + }); + } + + [HttpPost("claim")] + public async Task Claim() + { + var referralCount = await db.PlayerSettings + .CountAsync(s => s.Key.StartsWith($"referral:credited:{Me}:")); + if (referralCount < 1) + return Ok(new { Success = false, Error = "not_enough_referrals" }); + if (await db.PlayerSettings.AnyAsync(s => s.PlayerId == Me && s.Key == "referral:reward_claimed")) + return Ok(new { Success = false, Error = "already_claimed" }); + + var balance = await level.GrantCurrencyAsync(Me, 2, 250, "incentivized_referral"); + db.PlayerSettings.Add(new PlayerSettingEntity + { + PlayerId = Me, + Key = "referral:reward_claimed", + Value = DateTime.UtcNow.ToString("O"), + }); + await db.SaveChangesAsync(); + return Ok(new { Success = true, Balance = balance, CurrencyType = 2, Currency = 250 }); + } +} diff --git a/DorkNet.Server/Controllers/API/InfluencerPartnerProgram/InfluencerPartnerProgramController.cs b/DorkNet.Server/Controllers/API/InfluencerPartnerProgram/InfluencerPartnerProgramController.cs new file mode 100644 index 0000000..1b4ca2c --- /dev/null +++ b/DorkNet.Server/Controllers/API/InfluencerPartnerProgram/InfluencerPartnerProgramController.cs @@ -0,0 +1,102 @@ +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.API.InfluencerPartnerProgram; + +[ApiController] +public class InfluencerPartnerProgramController(DorkNetDbContext db) : ControllerBase +{ + [HttpGet("api/influencerpartnerprogram/influencers")] + [AllowAnonymous] + public async Task Influencers([FromQuery] int take = 50) + { + take = Math.Clamp(take, 1, 100); + var creatorIds = await db.Rooms.Select(r => r.CreatorPlayerId) + .Concat(db.Inventions.Where(i => !i.IsDeleted).Select(i => i.CreatorPlayerId)) + .Concat(db.CustomAvatarItems.Select(i => i.CreatorPlayerId)) + .Distinct() + .Take(take) + .ToListAsync(); + var players = await db.Players + .Where(p => creatorIds.Contains(p.Id)) + .OrderBy(p => p.Username) + .ToListAsync(); + return Ok(players.Select(ToInfluencerWire)); + } + + [HttpGet("api/influencerpartnerprogram/influencer")] + [AllowAnonymous] + public async Task Influencer([FromQuery] long? accountId = null, [FromQuery] string? username = null) + { + var player = accountId is long id + ? await db.Players.FirstOrDefaultAsync(p => p.Id == id) + : await db.Players.FirstOrDefaultAsync(p => p.Username == username); + if (player is null) return NotFound(); + return Ok(ToInfluencerWire(player)); + } + + [HttpGet("api/influencerpartnerprogram/myinfluencer")] + [Authorize] + public async Task MyInfluencer() + { + var me = this.RequireCurrentPlayerId(); + var setting = await db.PlayerSettings + .FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == "influencer:supported"); + if (setting is null || !long.TryParse(setting.Value, out var influencerId)) + return Ok(new { Influencer = (object?)null }); + var player = await db.Players.FirstOrDefaultAsync(p => p.Id == influencerId); + return Ok(new { Influencer = player is null ? null : ToInfluencerWire(player) }); + } + + [HttpPost("api/influencerpartnerprogram/support")] + [Authorize] + public async Task Support([FromForm] long? influencerAccountId, [FromForm] string? username) + { + var me = this.RequireCurrentPlayerId(); + var influencer = influencerAccountId is long id + ? await db.Players.FirstOrDefaultAsync(p => p.Id == id) + : await db.Players.FirstOrDefaultAsync(p => p.Username == username); + if (influencer is null) return NotFound(); + + var setting = await db.PlayerSettings + .FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == "influencer:supported"); + if (setting is null) + { + db.PlayerSettings.Add(new PlayerSettingEntity + { + PlayerId = me, + Key = "influencer:supported", + Value = influencer.Id.ToString(), + }); + } + else + { + setting.Value = influencer.Id.ToString(); + } + await db.SaveChangesAsync(); + return Ok(new { Success = true, Influencer = ToInfluencerWire(influencer) }); + } + + [HttpPost("api/influencerpartnerprogram/remove")] + [Authorize] + public async Task Remove() + { + var me = this.RequireCurrentPlayerId(); + await db.PlayerSettings + .Where(s => s.PlayerId == me && s.Key == "influencer:supported") + .ExecuteDeleteAsync(); + return Ok(new { Success = true }); + } + + private static object ToInfluencerWire(PlayerEntity player) => new + { + AccountId = (int)player.Id, + player.Username, + DisplayName = player.DisplayName, + ProfileImage = player.ProfileImageName ?? string.Empty, + }; +} diff --git a/DorkNet.Server/Controllers/API/Inventions/InventionsController.cs b/DorkNet.Server/Controllers/API/Inventions/InventionsController.cs index f5b5aad..4d80168 100644 --- a/DorkNet.Server/Controllers/API/Inventions/InventionsController.cs +++ b/DorkNet.Server/Controllers/API/Inventions/InventionsController.cs @@ -40,6 +40,9 @@ public class InventionsController( // ── Browse ─────────────────────────────────────────────────────────── + [HttpGet("api/inventions/v1/featured")] + [HttpGet("api/inventions/v1/featureddormskins")] + [HttpGet("api/inventions/v1/toptoday")] [HttpGet("api/inventions/v3/popular")] public async Task Popular([FromQuery] int take = 50) { @@ -78,6 +81,7 @@ public class InventionsController( public Task Mine() => Saved(100); [HttpGet("api/inventions/v1/search")] + [HttpGet("api/inventions/v2/search")] public async Task Search([FromQuery] string value = "") { if (string.IsNullOrWhiteSpace(value)) @@ -127,6 +131,8 @@ public class InventionsController( public sealed record InventionBatchRequest(List InventionIds); [HttpPost("api/inventions/v1/batch")] + [HttpPost("api/inventions/v2/batch")] + [HttpPost("api/inventions/v1/dormskinsfromids")] public async Task Batch([FromBody] InventionBatchRequest req) { if (req?.InventionIds is null || req.InventionIds.Count == 0) @@ -138,6 +144,27 @@ public class InventionsController( return Ok(rows.Select(ToWire)); } + [HttpGet("api/inventions/v1/fromcreators")] + public async Task FromCreators() + { + var creatorIds = Request.Query + .SelectMany(q => q.Value) + .SelectMany(v => (v ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + .Select(v => long.TryParse(v, out var id) ? id : 0L) + .Where(id => id > 0) + .Distinct() + .Take(200) + .ToList(); + + if (creatorIds.Count == 0) return Ok(Array.Empty()); + var rows = await db.Inventions + .Where(i => !i.IsDeleted && i.IsPublished && creatorIds.Contains(i.CreatorPlayerId)) + .OrderByDescending(i => i.UpdatedAt) + .Take(200) + .ToListAsync(); + return Ok(rows.Select(ToWire)); + } + /// GET api/inventions/v1/tagfilters — drives /// RecNet.Inventions.GetTags. The watch deserialises the /// response as RecNet.GetFiltersResponse (an @@ -356,6 +383,7 @@ public class InventionsController( /// save flow. v4 wraps the new invention plus its first version in /// a Status/Invention/InventionVersion object. [HttpPost("api/inventions/v4/save")] + [HttpPost("api/inventions/v6/save")] [Authorize] public async Task SaveV4([FromBody] SaveInventionV4Request req) { @@ -426,6 +454,7 @@ public class InventionsController( int? InstantiationCost, int? LightsCost); [HttpPost("api/inventions/v3/addversion")] + [HttpPost("api/inventions/v4/addversion")] [Authorize] public async Task AddVersion([FromBody] AddVersionRequest req) { @@ -491,6 +520,22 @@ public class InventionsController( return Ok(ToWire(inv)); } + [HttpGet("api/inventions/v1/updateprice")] + [Authorize] + public async Task UpdatePrice( + [FromQuery] long inventionId, + [FromQuery] int price) + { + var pid = CurrentPlayerId; + var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted); + if (inv is null) return NotFound(); + if (inv.CreatorPlayerId != pid) return Forbid(); + inv.Price = Math.Clamp(price, 0, 1000000); + inv.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return Ok(ToWire(inv)); + } + [HttpGet("api/inventions/v1/delete")] [Authorize] public async Task Delete([FromQuery] long inventionId) @@ -685,7 +730,7 @@ public class InventionsController( i.CreatorPermission, i.GeneralPermission, IsAGInvention = i.IsAgInvention, - Price = 0, + i.Price, HideFromPlayer = false, }; diff --git a/DorkNet.Server/Controllers/API/Inventory/InventoryController.cs b/DorkNet.Server/Controllers/API/Inventory/InventoryController.cs index bc13d0b..496ddd4 100644 --- a/DorkNet.Server/Controllers/API/Inventory/InventoryController.cs +++ b/DorkNet.Server/Controllers/API/Inventory/InventoryController.cs @@ -182,6 +182,44 @@ public class InventoryController( return Ok(new { row.Id, Count = row.Quantity }); } + [HttpPost("api/consumables/v1/transfer")] + public async Task TransferConsumable( + [FromForm] long? id, + [FromForm] long? recipientPlayerId, + [FromForm] int quantity = 1) + { + var pid = Me; + var inventoryId = id ?? (long.TryParse(Request.Query["id"], out var qId) ? qId : 0); + var recipient = recipientPlayerId ?? (long.TryParse(Request.Query["recipientPlayerId"], out var qRecipient) ? qRecipient : 0); + var amount = Math.Max(1, quantity); + if (inventoryId <= 0 || recipient <= 0 || recipient == pid) + return BadRequest("invalid_transfer"); + + var source = await db.PlayerInventory.FirstOrDefaultAsync(p => p.PlayerId == pid && p.Id == inventoryId); + if (source is null || source.Quantity < amount) return NotFound(); + var target = await db.PlayerInventory + .FirstOrDefaultAsync(p => p.PlayerId == recipient && p.ItemSlug == source.ItemSlug); + if (target is null) + { + target = new PlayerInventoryEntity + { + PlayerId = recipient, + ItemSlug = source.ItemSlug, + Quantity = 0, + }; + db.PlayerInventory.Add(target); + } + + source.Quantity -= amount; + target.Quantity += amount; + if (source.Quantity == 0) db.PlayerInventory.Remove(source); + await db.SaveChangesAsync(); + await notifications.NotifyAsync(recipient, + PushNotificationId.ConsumableMappingAdded, + new { target.Id, target.ItemSlug, target.Quantity }); + return Ok(new { Success = true, SourceCount = Math.Max(0, source.Quantity), RecipientCount = target.Quantity }); + } + public sealed class ActivateConsumableRequest { public long Id { get; set; } diff --git a/DorkNet.Server/Controllers/API/ItemWishlists/ItemWishlistsController.cs b/DorkNet.Server/Controllers/API/ItemWishlists/ItemWishlistsController.cs new file mode 100644 index 0000000..cbcadc2 --- /dev/null +++ b/DorkNet.Server/Controllers/API/ItemWishlists/ItemWishlistsController.cs @@ -0,0 +1,134 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using System.Text.Json; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; + +namespace DorkNet.Server.Controllers.API.ItemWishlists; + +[ApiController] +[Route("api/itemWishlists")] +[Authorize] +public class ItemWishlistsController(DorkNetDbContext db) : ControllerBase +{ + private long Me => this.RequireCurrentPlayerId(); + + [HttpGet] + [HttpGet("v1/wishlist/me")] + public async Task Mine() + { + var rows = await db.ItemWishlists + .Where(w => w.PlayerId == Me) + .OrderByDescending(w => w.CreatedAt) + .ToListAsync(); + return Ok(rows.Select(ToWire)); + } + + [HttpGet("v1/wishlist")] + public async Task ForPlayer([FromQuery] long? playerId) + { + var pid = playerId is > 0 ? playerId.Value : Me; + var rows = await db.ItemWishlists + .Where(w => w.PlayerId == pid) + .OrderByDescending(w => w.CreatedAt) + .ToListAsync(); + return Ok(rows.Select(ToWire)); + } + + [HttpPost] + [HttpPost("v1/wishlist")] + public async Task Add() + { + var req = await ReadRequestAsync(); + var key = (req.ItemKey ?? req.ItemId ?? string.Empty).Trim(); + if (key.Length == 0) return BadRequest(new { error = "missing_item" }); + if (key.Length > 128) key = key[..128]; + var itemType = req.ItemType ?? 0; + + var exists = await db.ItemWishlists.AnyAsync(w => + w.PlayerId == Me && w.ItemKey == key && w.ItemType == itemType); + if (!exists) + { + db.ItemWishlists.Add(new ItemWishlistEntity + { + PlayerId = Me, + ItemKey = key, + ItemType = itemType, + }); + await db.SaveChangesAsync(); + } + + return await Mine(); + } + + [HttpDelete("v1/wishlist")] + [HttpPost("v1/wishlist/remove")] + public async Task Remove() + { + var req = await ReadRequestAsync(); + var key = (req.ItemKey ?? req.ItemId ?? string.Empty).Trim(); + if (key.Length == 0) return BadRequest(new { error = "missing_item" }); + var itemType = req.ItemType ?? 0; + await db.ItemWishlists + .Where(w => w.PlayerId == Me && w.ItemKey == key && w.ItemType == itemType) + .ExecuteDeleteAsync(); + return await Mine(); + } + + private async Task ReadRequestAsync() + { + if (Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(); + return new WishlistRequest + { + ItemKey = form["itemKey"].FirstOrDefault() ?? form["ItemKey"].FirstOrDefault(), + ItemId = form["itemId"].FirstOrDefault() ?? form["ItemId"].FirstOrDefault(), + ItemType = int.TryParse(form["itemType"].FirstOrDefault() ?? form["ItemType"].FirstOrDefault(), out var type) ? type : null, + }; + } + + var queryKey = Request.Query["itemKey"].FirstOrDefault() ?? Request.Query["ItemKey"].FirstOrDefault(); + var queryId = Request.Query["itemId"].FirstOrDefault() ?? Request.Query["ItemId"].FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(queryKey) || !string.IsNullOrWhiteSpace(queryId)) + { + return new WishlistRequest + { + ItemKey = queryKey, + ItemId = queryId, + ItemType = int.TryParse(Request.Query["itemType"].FirstOrDefault() ?? Request.Query["ItemType"].FirstOrDefault(), out var type) ? type : null, + }; + } + + try + { + return await JsonSerializer.DeserializeAsync( + Request.Body, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) + ?? new WishlistRequest(); + } + catch (JsonException) + { + return new WishlistRequest(); + } + } + + private static object ToWire(ItemWishlistEntity row) => new + { + row.Id, + row.PlayerId, + row.ItemKey, + ItemId = row.ItemKey, + row.ItemType, + row.CreatedAt, + }; + + public sealed class WishlistRequest + { + public string? ItemKey { get; set; } + public string? ItemId { get; set; } + public int? ItemType { get; set; } + } +} diff --git a/DorkNet.Server/Controllers/API/Keepsakes/KeepsakesController.cs b/DorkNet.Server/Controllers/API/Keepsakes/KeepsakesController.cs new file mode 100644 index 0000000..c14f9fc --- /dev/null +++ b/DorkNet.Server/Controllers/API/Keepsakes/KeepsakesController.cs @@ -0,0 +1,161 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using System.Text.Json; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.API.Keepsakes; + +[ApiController] +[Route("api/keepsakes")] +[Authorize] +public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : ControllerBase +{ + private long Me => this.RequireCurrentPlayerId(); + + [HttpGet] + public async Task Mine() + { + await EnsureLoginKeepsakeAsync(Me); + var rows = await db.Keepsakes + .Where(k => k.PlayerId == Me) + .OrderByDescending(k => k.EarnedAt) + .ToListAsync(); + return Ok(rows.Select(ToWire)); + } + + [HttpGet("categories")] + [AllowAnonymous] + public IActionResult Categories() => Ok(new[] + { + new { Category = "account", DisplayName = "Account" }, + new { Category = "event", DisplayName = "Events" }, + new { Category = "room", DisplayName = "Rooms" }, + }); + + [HttpGet("events")] + public async Task Events() + { + var rows = await db.Keepsakes + .Where(k => k.PlayerId == Me && k.Category == "event") + .OrderByDescending(k => k.EarnedAt) + .ToListAsync(); + return Ok(rows.Select(ToWire)); + } + + [HttpGet("globalconfig")] + [AllowAnonymous] + public IActionResult GlobalConfig() => Ok(new + { + Enabled = true, + Categories = new[] { "account", "event", "room" }, + CdnBaseUrl = $"https://{domain.Sub("cdn")}/", + }); + + [HttpPost] + public async Task Create() + { + var req = await ReadRequestAsync(); + var key = Trim(req.EventKey, 128); + if (key.Length == 0) key = $"manual:{Guid.NewGuid():N}"; + var category = Trim(req.Category, 64); + if (category.Length == 0) category = "event"; + + var existing = await db.Keepsakes.FirstOrDefaultAsync(k => + k.PlayerId == Me && k.EventKey == key); + if (existing is null) + { + existing = new KeepsakeEntity + { + PlayerId = Me, + EventKey = key, + }; + db.Keepsakes.Add(existing); + } + + existing.Category = category; + existing.Title = Trim(req.Title, 128); + existing.Description = Trim(req.Description, 1024); + existing.ImageName = Trim(req.ImageName, 256); + existing.EarnedAt = req.EarnedAt ?? DateTime.UtcNow; + await db.SaveChangesAsync(); + return Ok(ToWire(existing)); + } + + private async Task EnsureLoginKeepsakeAsync(long playerId) + { + const string key = "first-login"; + if (await db.Keepsakes.AnyAsync(k => k.PlayerId == playerId && k.EventKey == key)) + return; + db.Keepsakes.Add(new KeepsakeEntity + { + PlayerId = playerId, + Category = "account", + EventKey = key, + Title = "First Login", + Description = "Joined this server.", + }); + await db.SaveChangesAsync(); + } + + private async Task ReadRequestAsync() + { + if (Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(); + return new KeepsakeRequest + { + Category = form["category"].FirstOrDefault() ?? form["Category"].FirstOrDefault(), + EventKey = form["eventKey"].FirstOrDefault() ?? form["EventKey"].FirstOrDefault(), + Title = form["title"].FirstOrDefault() ?? form["Title"].FirstOrDefault(), + Description = form["description"].FirstOrDefault() ?? form["Description"].FirstOrDefault(), + ImageName = form["imageName"].FirstOrDefault() ?? form["ImageName"].FirstOrDefault(), + EarnedAt = DateTime.TryParse(form["earnedAt"].FirstOrDefault() ?? form["EarnedAt"].FirstOrDefault(), out var earned) ? earned : null, + }; + } + + try + { + return await JsonSerializer.DeserializeAsync( + Request.Body, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) + ?? new KeepsakeRequest(); + } + catch (JsonException) + { + return new KeepsakeRequest(); + } + } + + private object ToWire(KeepsakeEntity row) => new + { + row.Id, + row.PlayerId, + row.Category, + row.EventKey, + row.Title, + row.Description, + row.ImageName, + ImageUrl = string.IsNullOrWhiteSpace(row.ImageName) ? string.Empty : $"https://{domain.Sub("cdn")}/{row.ImageName}", + row.EarnedAt, + }; + + private static string Trim(string? value, int max) + { + var s = (value ?? string.Empty).Trim(); + return s.Length <= max ? s : s[..max]; + } + + public sealed class KeepsakeRequest + { + public string? Category { get; set; } + public string? EventKey { get; set; } + public string? Title { get; set; } + public string? Description { get; set; } + public string? ImageName { get; set; } + public DateTime? EarnedAt { get; set; } + } +} diff --git a/DorkNet.Server/Controllers/API/PlatformLogin/V2/PlatformLoginController.cs b/DorkNet.Server/Controllers/API/PlatformLogin/V2/PlatformLoginController.cs index dc3a8f8..bcab7fc 100644 --- a/DorkNet.Server/Controllers/API/PlatformLogin/V2/PlatformLoginController.cs +++ b/DorkNet.Server/Controllers/API/PlatformLogin/V2/PlatformLoginController.cs @@ -114,7 +114,18 @@ public class PlatformLoginController( // Login.RemoveCachedLogin → DELETE api/platformlogin/cached [HttpDelete("cached")] [HttpDelete("cachedlogins")] - public IActionResult RemoveCachedLogin() => Ok(new { }); + public async Task RemoveCachedLogin([FromQuery] long? accountId = null) + { + var targetId = accountId ?? this.CurrentPlayerId(); + if (targetId is not long playerId) return Unauthorized(); + + var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId); + if (player is null) return NotFound(); + player.LastPlatform = 0; + player.LastPlatformId = string.Empty; + await db.SaveChangesAsync(); + return Ok(new { Success = true, AccountId = (int)player.Id }); + } // Login.RefreshLogin → POST api/platformlogin/refresh // Response is Login.RefreshLoginResponse { Token } — single field, the diff --git a/DorkNet.Server/Controllers/API/PlayStationPlus/PlayStationPlusController.cs b/DorkNet.Server/Controllers/API/PlayStationPlus/PlayStationPlusController.cs new file mode 100644 index 0000000..1b94048 --- /dev/null +++ b/DorkNet.Server/Controllers/API/PlayStationPlus/PlayStationPlusController.cs @@ -0,0 +1,54 @@ +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.API.PlayStationPlus; + +[ApiController] +[Authorize] +public class PlayStationPlusController(DorkNetDbContext db) : ControllerBase +{ + [HttpGet("api/playstationplus/membership")] + public async Task Membership() + { + var me = this.RequireCurrentPlayerId(); + var row = await db.PlayerSettings + .FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == "playstationplus:expires"); + var expires = row is not null && DateTime.TryParse(row.Value, out var parsed) + ? parsed + : DateTime.MinValue; + var active = expires > DateTime.UtcNow; + return Ok(new + { + IsMember = active, + MembershipActive = active, + ExpiresAt = active ? expires : (DateTime?)null, + }); + } + + [HttpPost("api/playstationplus/expire")] + public async Task Expire() + { + var me = this.RequireCurrentPlayerId(); + var row = await db.PlayerSettings + .FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == "playstationplus:expires"); + if (row is null) + { + db.PlayerSettings.Add(new PlayerSettingEntity + { + PlayerId = me, + Key = "playstationplus:expires", + Value = DateTime.UtcNow.AddSeconds(-1).ToString("O"), + }); + } + else + { + row.Value = DateTime.UtcNow.AddSeconds(-1).ToString("O"); + } + await db.SaveChangesAsync(); + return Ok(new { Success = true, IsMember = false }); + } +} diff --git a/DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs b/DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs index 72af519..c040d4d 100644 --- a/DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs +++ b/DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using System.Text.Json; using DorkNet.Models.Notification; using DorkNet.Server.Auth; using DorkNet.Server.Data; @@ -45,6 +46,7 @@ public class PlayerEventsController( [AllowAnonymous] [HttpGet("api/playerevents/v2/{eventId:long}")] + [HttpGet("api/playerevents/v1/{eventId:long}")] public async Task GetOne(long eventId) { var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId); @@ -52,6 +54,121 @@ public class PlayerEventsController( return Ok(ToWire(ev)); } + [AllowAnonymous] + [HttpGet("api/playerevents/v1/search")] + [HttpGet("api/playerevents/v1/searchlive")] + public async Task Search( + [FromQuery] string? q = null, + [FromQuery] string? query = null, + [FromQuery] int skip = 0, + [FromQuery] int take = 50) + { + var needle = (q ?? query ?? string.Empty).Trim().ToLowerInvariant(); + skip = Math.Max(0, skip); + take = Math.Clamp(take, 1, 100); + var rows = db.PlayerEvents + .Where(e => e.EndsAt > DateTime.UtcNow); + if (needle.Length > 0) + { + rows = rows.Where(e => + e.Title.ToLower().Contains(needle) || + e.Description.ToLower().Contains(needle)); + } + + var result = await rows + .OrderBy(e => e.StartsAt) + .Skip(skip) + .Take(take) + .ToListAsync(); + return Ok(result.Select(ToWire)); + } + + [AllowAnonymous] + [HttpGet("api/playerevents/v1/room/{roomId:long}")] + public async Task ForRoom(long roomId) + { + var rows = await db.PlayerEvents + .Where(e => e.RoomId == roomId && e.EndsAt > DateTime.UtcNow) + .OrderBy(e => e.StartsAt) + .Take(100) + .ToListAsync(); + return Ok(rows.Select(ToWire)); + } + + [AllowAnonymous] + [HttpGet("api/playerevents/v1/club/{clubId:long}")] + public async Task ForClub(long clubId) + { + var memberIds = await db.ClubMemberships + .Where(m => m.ClubId == clubId) + .Select(m => m.PlayerId) + .ToListAsync(); + if (memberIds.Count == 0) return Ok(Array.Empty()); + + var roomIds = await db.Rooms + .Where(r => memberIds.Contains(r.CreatorPlayerId)) + .Select(r => r.Id) + .ToListAsync(); + if (roomIds.Count == 0) return Ok(Array.Empty()); + + var rows = await db.PlayerEvents + .Where(e => roomIds.Contains(e.RoomId) && e.EndsAt > DateTime.UtcNow) + .OrderBy(e => e.StartsAt) + .Take(100) + .ToListAsync(); + return Ok(rows.Select(ToWire)); + } + + [AllowAnonymous] + [HttpGet("api/playerevents/v1/clubs")] + public async Task ClubEvents([FromQuery] int take = 100) + { + take = Math.Clamp(take, 1, 200); + var clubPlayerIds = await db.ClubMemberships + .Select(m => m.PlayerId) + .Distinct() + .ToListAsync(); + if (clubPlayerIds.Count == 0) return Ok(Array.Empty()); + var roomIds = await db.Rooms + .Where(r => clubPlayerIds.Contains(r.CreatorPlayerId)) + .Select(r => r.Id) + .ToListAsync(); + var rows = await db.PlayerEvents + .Where(e => roomIds.Contains(e.RoomId) && e.EndsAt > DateTime.UtcNow) + .OrderBy(e => e.StartsAt) + .Take(take) + .ToListAsync(); + return Ok(rows.Select(ToWire)); + } + + public sealed class EventBulkRequest + { + public List? PlayerEventIds { get; set; } + public List? EventIds { get; set; } + public List? Ids { get; set; } + } + + [AllowAnonymous] + [HttpPost("api/playerevents/v1/bulk")] + [HttpGet("api/playerevents/v1/bulk")] + public async Task Bulk([FromBody] EventBulkRequest? body) + { + var ids = await ReadEventIdsAsync(body); + if (ids.Count == 0) return Ok(Array.Empty()); + var rows = await db.PlayerEvents + .Where(e => ids.Contains(e.Id)) + .ToListAsync(); + return Ok(rows.Select(ToWire)); + } + + [AllowAnonymous] + [HttpGet("api/playerevents/v1/tagfilters")] + public IActionResult TagFilters() => Ok(new + { + PinnedFilters = new[] { "game", "social", "competition", "class", "club" }, + PopularFilters = new[] { "game", "social", "competition", "class", "club" }, + }); + /// GET api/playerevents/v1/all — the watch's /// LocalPlayerEventInfo fetch (caller's created + RSVP'd /// events). Response keys (both required, via @@ -93,6 +210,39 @@ public class PlayerEventsController( return Ok(new { Created = created, Responses = responses }); } + [AllowAnonymous] + [HttpGet("api/playerevents/v1/all/{accountId:long}")] + public async Task AllPlayerEventsForAccount(long accountId) + { + var created = await db.PlayerEvents + .Where(e => e.CreatorPlayerId == accountId) + .OrderByDescending(e => e.StartsAt) + .Select(e => new + { + e.Id, + e.Title, + e.Description, + e.RoomId, + e.StartsAt, + e.EndsAt, + e.Capacity, + }) + .ToListAsync(); + + var responses = await db.PlayerEventResponses + .Where(r => r.PlayerId == accountId) + .Select(r => new + { + r.Id, + r.EventId, + r.Response, + r.CreatedAt, + }) + .ToListAsync(); + + return Ok(new { Created = created, Responses = responses }); + } + /// POST api/playerevents/v1/{eventId}/responses — /// alternate RSVP route the watch uses for the response-list /// flow (older / sub-room view). Body is the same RsvpRequest @@ -233,6 +383,123 @@ public class PlayerEventsController( return Ok(new { success = true, error = "" }); } + [HttpPost("api/playerevents/v2/{eventId:long}/name")] + [HttpPut("api/playerevents/v2/{eventId:long}/name")] + public async Task UpdateName(long eventId) + { + var evt = await GetOwnedEventAsync(eventId); + if (evt is null) return NotFound(); + var value = await ReadStringFieldAsync("name", "Name", "value"); + if (string.IsNullOrWhiteSpace(value)) return BadRequest("missing_name"); + evt.Title = value.Trim()[..Math.Min(value.Trim().Length, 128)]; + evt.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return Ok(ToWire(evt)); + } + + [HttpPost("api/playerevents/v2/{eventId:long}/description")] + [HttpPut("api/playerevents/v2/{eventId:long}/description")] + public async Task UpdateDescription(long eventId) + { + var evt = await GetOwnedEventAsync(eventId); + if (evt is null) return NotFound(); + var value = await ReadStringFieldAsync("description", "Description", "value") ?? string.Empty; + evt.Description = value.Trim()[..Math.Min(value.Trim().Length, 2000)]; + evt.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return Ok(ToWire(evt)); + } + + [HttpPost("api/playerevents/v2/{eventId:long}/room")] + [HttpPut("api/playerevents/v2/{eventId:long}/room")] + public async Task UpdateRoom(long eventId) + { + var evt = await GetOwnedEventAsync(eventId); + if (evt is null) return NotFound(); + var roomId = await ReadLongFieldAsync("roomId", "RoomId", "value"); + if (roomId is not long id || id <= 0) return BadRequest("missing_room"); + if (!await db.Rooms.AnyAsync(r => r.Id == id)) return NotFound("room_not_found"); + evt.RoomId = id; + evt.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return Ok(ToWire(evt)); + } + + [HttpPost("api/playerevents/v2/{eventId:long}/time")] + [HttpPut("api/playerevents/v2/{eventId:long}/time")] + public async Task UpdateTime(long eventId) + { + var evt = await GetOwnedEventAsync(eventId); + if (evt is null) return NotFound(); + var start = await ReadDateFieldAsync("startTime", "StartTime", "startsAt", "StartsAt"); + var end = await ReadDateFieldAsync("endTime", "EndTime", "endsAt", "EndsAt"); + if (start is DateTime s) evt.StartsAt = s; + if (end is DateTime e) evt.EndsAt = e > evt.StartsAt ? e : evt.StartsAt.AddHours(1); + if (start is null && end is null) return BadRequest("missing_time"); + evt.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return Ok(ToWire(evt)); + } + + [HttpPost("api/playerevents/v2/{eventId:long}/image")] + [HttpPut("api/playerevents/v2/{eventId:long}/image")] + public async Task UpdateImage(long eventId) + { + var evt = await GetOwnedEventAsync(eventId); + if (evt is null) return NotFound(); + var value = await ReadStringFieldAsync("imageName", "ImageName", "value") ?? string.Empty; + await SetEventSettingAsync(evt, "image", value.Trim()); + return Ok(ToWire(evt)); + } + + [HttpPost("api/playerevents/v2/{eventId:long}/accessibility")] + [HttpPut("api/playerevents/v2/{eventId:long}/accessibility")] + public async Task UpdateAccessibility(long eventId) + { + var evt = await GetOwnedEventAsync(eventId); + if (evt is null) return NotFound(); + var value = await ReadIntFieldAsync("accessibility", "Accessibility", "value"); + if (value is not int accessibility) return BadRequest("missing_accessibility"); + await SetEventSettingAsync(evt, "accessibility", accessibility.ToString()); + return Ok(ToWire(evt)); + } + + [HttpPost("api/playerevents/v2/{eventId:long}/tags")] + [HttpPut("api/playerevents/v2/{eventId:long}/tags")] + public async Task UpdateTags(long eventId) + { + var evt = await GetOwnedEventAsync(eventId); + if (evt is null) return NotFound(); + var value = await ReadStringFieldAsync("tags", "Tags", "value") ?? string.Empty; + await SetEventSettingAsync(evt, "tags", value.Trim()); + return Ok(ToWire(evt)); + } + + [HttpPost("api/playerevents/v2/{eventId:long}/multiinstance")] + [HttpPut("api/playerevents/v2/{eventId:long}/multiinstance")] + public async Task UpdateMultiInstance(long eventId) + { + var evt = await GetOwnedEventAsync(eventId); + if (evt is null) return NotFound(); + var value = await ReadBoolFieldAsync("multiInstance", "MultiInstance", "value"); + await SetEventSettingAsync(evt, "multiinstance", (value ?? false) ? "true" : "false"); + return Ok(ToWire(evt)); + } + + [HttpPost("api/playerevents/v2/{eventId:long}/club")] + [HttpPut("api/playerevents/v2/{eventId:long}/club")] + public async Task UpdateClub(long eventId) + { + var evt = await GetOwnedEventAsync(eventId); + if (evt is null) return NotFound(); + var clubId = await ReadLongFieldAsync("clubId", "ClubId", "value"); + if (clubId is not long id || id <= 0) return BadRequest("missing_club"); + if (!await db.ClubMemberships.AnyAsync(m => m.ClubId == id && m.PlayerId == Me)) + return Forbid(); + await SetEventSettingAsync(evt, "club", id.ToString()); + return Ok(ToWire(evt)); + } + [HttpDelete("api/playerevents/v1/{eventId:long}")] public async Task Delete(long eventId) { @@ -297,6 +564,44 @@ public class PlayerEventsController( return Ok(new { FailedInvites = failed, Result = 0 }); } + public sealed class BroadcastRequest + { + public long PlayerEventId { get; set; } + public string? Message { get; set; } + } + + [HttpPost("api/playerevents/v1/broadcast")] + public async Task Broadcast([FromBody] BroadcastRequest req) + { + var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == req.PlayerEventId); + if (ev is null) return NotFound(); + if (ev.CreatorPlayerId != Me) return Forbid(); + + var recipients = await db.PlayerEventResponses + .Where(r => r.EventId == ev.Id && r.Response != 2) + .Select(r => r.PlayerId) + .Distinct() + .ToListAsync(); + var body = string.IsNullOrWhiteSpace(req.Message) + ? $"Update for '{ev.Title}'" + : req.Message.Trim(); + foreach (var playerId in recipients.Where(id => id != Me)) + { + db.Messages.Add(new MessageEntity + { + SenderPlayerId = Me, + RecipientPlayerId = playerId, + Body = body, + }); + await notifications.NotifyAsync(playerId, + PushNotificationId.PlayerEventResponseChanged, + new { ev.Id, ev.Title, Message = body }); + } + + await db.SaveChangesAsync(); + return Ok(new { Success = true, Sent = recipients.Count }); + } + // ── Report (Phase 8) ───────────────────────────────────────────────── public sealed class PlayerEventReportRequest @@ -306,6 +611,130 @@ public class PlayerEventsController( public string? Details { get; set; } } + private async Task GetOwnedEventAsync(long eventId) + => await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId && e.CreatorPlayerId == Me); + + private async Task> ReadEventIdsAsync(EventBulkRequest? body) + { + var ids = new List(); + if (body?.PlayerEventIds is { Count: > 0 }) ids.AddRange(body.PlayerEventIds); + if (body?.EventIds is { Count: > 0 }) ids.AddRange(body.EventIds); + if (body?.Ids is { Count: > 0 }) ids.AddRange(body.Ids); + + foreach (var value in Request.Query.SelectMany(q => q.Value)) + foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + if (long.TryParse(part, out var id) && id > 0) ids.Add(id); + + if (Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(); + foreach (var key in new[] { "playerEventIds", "PlayerEventIds", "eventIds", "EventIds", "ids", "Ids" }) + foreach (var value in form[key]) + foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + if (long.TryParse(part, out var id) && id > 0) ids.Add(id); + } + + return ids.Distinct().Take(200).ToList(); + } + + private async Task ReadStringFieldAsync(params string[] names) + { + var fields = await ReadRequestFieldsAsync(); + foreach (var name in names) + if (fields.TryGetValue(name, out var value)) + return value; + return null; + } + + private async Task ReadLongFieldAsync(params string[] names) + { + var value = await ReadStringFieldAsync(names); + return long.TryParse(value, out var parsed) ? parsed : null; + } + + private async Task ReadIntFieldAsync(params string[] names) + { + var value = await ReadStringFieldAsync(names); + return int.TryParse(value, out var parsed) ? parsed : null; + } + + private async Task ReadBoolFieldAsync(params string[] names) + { + var value = await ReadStringFieldAsync(names); + return bool.TryParse(value, out var parsed) ? parsed : null; + } + + private async Task ReadDateFieldAsync(params string[] names) + { + var value = await ReadStringFieldAsync(names); + return DateTime.TryParse(value, out var parsed) ? parsed : null; + } + + private async Task> ReadRequestFieldsAsync() + { + const string itemKey = "__playerevent_fields"; + if (HttpContext.Items.TryGetValue(itemKey, out var cached) + && cached is Dictionary existing) + { + return existing; + } + + var fields = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var pair in Request.Query) + fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty; + + if (Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(); + foreach (var pair in form) + fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty; + } + else if ((Request.ContentLength ?? 0) > 0 + && Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true) + { + try + { + using var doc = await JsonDocument.ParseAsync(Request.Body); + if (doc.RootElement.ValueKind == JsonValueKind.Object) + { + foreach (var prop in doc.RootElement.EnumerateObject()) + fields[prop.Name] = prop.Value.ValueKind == JsonValueKind.String + ? prop.Value.GetString() ?? string.Empty + : prop.Value.GetRawText(); + } + } + catch (JsonException) + { + } + } + + HttpContext.Items[itemKey] = fields; + return fields; + } + + private async Task SetEventSettingAsync(PlayerEventEntity evt, string key, string value) + { + var settingKey = $"playerevent:{evt.Id}:{key}"; + var row = await db.PlayerSettings + .FirstOrDefaultAsync(s => s.PlayerId == evt.CreatorPlayerId && s.Key == settingKey); + if (row is null) + { + db.PlayerSettings.Add(new PlayerSettingEntity + { + PlayerId = evt.CreatorPlayerId, + Key = settingKey, + Value = value, + }); + } + else + { + row.Value = value; + } + + evt.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + } + /// POST api/playerevents/v1/report — file a /// moderation report against an event. Wire response is /// KLAMKCBENEA{Success, Message}; returning a bare diff --git a/DorkNet.Server/Controllers/API/PlayerWarnings/PlayerWarningsController.cs b/DorkNet.Server/Controllers/API/PlayerWarnings/PlayerWarningsController.cs new file mode 100644 index 0000000..6379b6f --- /dev/null +++ b/DorkNet.Server/Controllers/API/PlayerWarnings/PlayerWarningsController.cs @@ -0,0 +1,63 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; + +namespace DorkNet.Server.Controllers.API.PlayerWarnings; + +[ApiController] +[Authorize] +public class PlayerWarningsController(DorkNetDbContext db) : ControllerBase +{ + [HttpGet("api/playerwarnings")] + public async Task Warnings() + { + var me = this.RequireCurrentPlayerId(); + var rows = await db.PlayerSettings + .Where(s => s.PlayerId == me && s.Key.StartsWith("warning:")) + .OrderByDescending(s => s.Id) + .ToListAsync(); + return Ok(rows + .Select(ToWarningWire) + .Where(w => !w.Acknowledged) + .ToList()); + } + + [HttpPost("api/playerwarnings/acknowledge")] + public async Task Acknowledge([FromForm] string? warningId) + { + var me = this.RequireCurrentPlayerId(); + var id = warningId ?? Request.Query["warningId"].FirstOrDefault() ?? Request.Query["id"].FirstOrDefault(); + if (string.IsNullOrWhiteSpace(id)) return BadRequest("missing_warning"); + var row = await db.PlayerSettings + .FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == $"warning:{id}"); + if (row is null) return NotFound(); + var parts = row.Value.Split('|'); + row.Value = string.Join('|', parts.Take(3).Append("true")); + await db.SaveChangesAsync(); + return Ok(new { Success = true, WarningId = id }); + } + + private static WarningWire ToWarningWire(DorkNet.Server.Data.Entities.PlayerSettingEntity row) + { + var parts = row.Value.Split('|'); + return new WarningWire + { + WarningId = row.Key["warning:".Length..], + Message = parts.ElementAtOrDefault(0) ?? string.Empty, + Category = int.TryParse(parts.ElementAtOrDefault(1), out var category) ? category : 0, + CreatedAt = parts.ElementAtOrDefault(2) ?? string.Empty, + Acknowledged = bool.TryParse(parts.ElementAtOrDefault(3), out var acknowledged) && acknowledged, + }; + } + + private sealed class WarningWire + { + public string WarningId { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + public int Category { get; set; } + public string CreatedAt { get; set; } = string.Empty; + public bool Acknowledged { get; set; } + } +} diff --git a/DorkNet.Server/Controllers/API/Players/V2/PlayersController.cs b/DorkNet.Server/Controllers/API/Players/V2/PlayersController.cs index 4c6bfd1..59a23b9 100644 --- a/DorkNet.Server/Controllers/API/Players/V2/PlayersController.cs +++ b/DorkNet.Server/Controllers/API/Players/V2/PlayersController.cs @@ -21,6 +21,20 @@ public class PlayersController(PlayerService playerService, NotificationService return Ok(PlayerService.ToProfile(player)); } + [HttpGet("/api/players/v4/current/contact")] + public async Task CurrentContact() + { + var player = await playerService.GetByIdAsync(CurrentPlayerId); + if (player is null) return NotFound(); + return Ok(new + { + AccountId = (int)player.Id, + Email = player.Email ?? string.Empty, + Phone = player.Phone ?? string.Empty, + CanReceiveInvites = player.CanReceiveInvites, + }); + } + [HttpGet("{id:long}")] public async Task> GetById(long id) { diff --git a/DorkNet.Server/Controllers/API/ProgressionEvents/ProgressionEventsController.cs b/DorkNet.Server/Controllers/API/ProgressionEvents/ProgressionEventsController.cs new file mode 100644 index 0000000..df3d2c6 --- /dev/null +++ b/DorkNet.Server/Controllers/API/ProgressionEvents/ProgressionEventsController.cs @@ -0,0 +1,46 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; + +namespace DorkNet.Server.Controllers.API.ProgressionEvents; + +[ApiController] +public class ProgressionEventsController(DorkNetDbContext db) : ControllerBase +{ + [HttpGet("api/progressionEvents")] + [Authorize] + public async Task Progress() + { + var me = this.RequireCurrentPlayerId(); + var rows = await db.ObjectiveProgress + .Where(o => o.PlayerId == me && o.Key.StartsWith("progressionEvent:")) + .OrderByDescending(o => o.ClearedAt) + .Take(100) + .Select(o => new + { + EventKey = o.Key, + o.IsCompleted, + o.ClearedAt, + }) + .ToListAsync(); + return Ok(rows); + } + + [HttpGet("api/progressionEvents/active")] + [AllowAnonymous] + public IActionResult Active() + { + var start = DateTime.UtcNow.Date.AddDays(-(int)DateTime.UtcNow.DayOfWeek); + var end = start.AddDays(7); + return Ok(new + { + EventKey = $"weekly:{start:yyyyMMdd}", + Name = "Weekly Progression", + StartAt = start, + EndAt = end, + Active = true, + }); + } +} diff --git a/DorkNet.Server/Controllers/API/Relationships/V2/RelationshipsController.cs b/DorkNet.Server/Controllers/API/Relationships/V2/RelationshipsController.cs index bbb0838..9d18383 100644 --- a/DorkNet.Server/Controllers/API/Relationships/V2/RelationshipsController.cs +++ b/DorkNet.Server/Controllers/API/Relationships/V2/RelationshipsController.cs @@ -451,8 +451,76 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no return Ok(new { Imported = added }); } + [HttpGet("api/relationships/mutualfriends")] + public async Task MutualFriends([FromQuery] long playerId) + { + var me = CurrentPlayerId; + if (playerId <= 0 || playerId == me) return Ok(Array.Empty()); + var mine = await FriendIdsAsync(me); + var theirs = await FriendIdsAsync(playerId); + var mutualIds = mine.Intersect(theirs).Take(100).ToList(); + var players = await db.Players + .Where(p => mutualIds.Contains(p.Id)) + .Select(p => new + { + AccountId = (int)p.Id, + p.Username, + p.DisplayName, + ProfileImage = p.ProfileImageName ?? string.Empty, + }) + .ToListAsync(); + return Ok(players); + } + + [HttpPost("api/relationships/sendfriendintroductions")] + public async Task SendFriendIntroductions( + [FromForm] long playerId, + [FromForm] long introducedPlayerId) + { + var me = CurrentPlayerId; + if (playerId <= 0 || introducedPlayerId <= 0) + return BadRequest("missing_player"); + db.Messages.Add(new MessageEntity + { + SenderPlayerId = me, + RecipientPlayerId = playerId, + Type = 30, + Body = $"Friend introduction:{introducedPlayerId}", + }); + await db.SaveChangesAsync(); + return Ok(new { Success = true }); + } + + [HttpPost("api/relationships/v1/addfriendwithcode")] + [HttpGet("api/relationships/v1/addfriendwithcode")] + public async Task AddFriendWithCode([FromQuery] string? code, [FromForm] string? formCode) + { + var raw = (code ?? formCode ?? string.Empty).Trim(); + if (raw.Length == 0) return Ok(EmptyRelationship(0)); + long? id = long.TryParse(raw, out var parsed) ? parsed : null; + if (id is null) + { + id = await db.Players + .Where(p => p.Username == raw) + .Select(p => (long?)p.Id) + .FirstOrDefaultAsync(); + } + if (id is not long playerId) return Ok(EmptyRelationship(0)); + return await AddFriend(playerId); + } + // ── helpers ────────────────────────────────────────────────────── + private async Task> FriendIdsAsync(long playerId) + { + var rows = await db.Relationships + .Where(r => r.Status == EntityStatus.Friend + && (r.RequesterId == playerId || r.TargetId == playerId)) + .Select(r => r.RequesterId == playerId ? r.TargetId : r.RequesterId) + .ToListAsync(); + return rows.ToHashSet(); + } + /// Map a storage row to the watch's wire RelationshipType /// from 's perspective. PendingSent flips /// direction depending on which side originated. diff --git a/DorkNet.Server/Controllers/API/RoomConsumables/RoomConsumablesController.cs b/DorkNet.Server/Controllers/API/RoomConsumables/RoomConsumablesController.cs new file mode 100644 index 0000000..9557056 --- /dev/null +++ b/DorkNet.Server/Controllers/API/RoomConsumables/RoomConsumablesController.cs @@ -0,0 +1,88 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.API.RoomConsumables; + +[ApiController] +public class RoomConsumablesController(DorkNetDbContext db, LevelService level) : ControllerBase +{ + [HttpGet("api/roomconsumables")] + [HttpGet("api/roomconsumables/v1/roomConsumable")] + [AllowAnonymous] + public async Task List([FromQuery] long? id = null) + { + var query = db.StoreItems + .Where(i => i.IsActive && i.Category.ToLower() == "consumable"); + if (id is long itemId && itemId > 0) + query = query.Where(i => i.Id == itemId); + var rows = await query + .OrderBy(i => i.DisplayName) + .Take(200) + .ToListAsync(); + return Ok(rows.Select(ToWire)); + } + + [HttpPost("api/roomconsumables/v1/roomconsumable/{itemId:long}/purchase/currency")] + [Authorize] + public Task PurchaseCurrency(long itemId, [FromQuery] int? currencyType = null) => + Purchase(itemId, currencyType); + + [HttpPost("api/roomconsumables/v1/roomconsumable/{itemId:long}/purchase/tokens")] + [Authorize] + public Task PurchaseTokens(long itemId) => Purchase(itemId, 2); + + private async Task Purchase(long itemId, int? currencyType) + { + var me = this.RequireCurrentPlayerId(); + var item = await db.StoreItems + .FirstOrDefaultAsync(i => i.Id == itemId && i.IsActive && i.Category.ToLower() == "consumable"); + if (item is null) return NotFound(); + + var spendCurrency = currencyType ?? item.CurrencyType; + var balance = await level.GetBalanceAsync(me, spendCurrency); + if (balance < item.Price) + return Ok(new { Success = false, Error = "insufficient_funds", Balance = balance, CurrencyType = spendCurrency }); + + var newBalance = await level.GrantCurrencyAsync(me, spendCurrency, -item.Price, $"roomConsumable:{item.Slug}"); + var inventory = await db.PlayerInventory + .FirstOrDefaultAsync(i => i.PlayerId == me && i.ItemSlug == item.Slug); + if (inventory is null) + { + inventory = new PlayerInventoryEntity + { + PlayerId = me, + ItemSlug = item.Slug, + Quantity = 0, + }; + db.PlayerInventory.Add(inventory); + } + inventory.Quantity += 1; + await db.SaveChangesAsync(); + return Ok(new + { + Success = true, + Balance = newBalance, + CurrencyType = spendCurrency, + Item = ToWire(item), + Quantity = inventory.Quantity, + }); + } + + private static object ToWire(StoreItemEntity item) => new + { + RoomConsumableId = item.Id, + ItemId = item.Id, + item.Slug, + Name = item.DisplayName, + item.Description, + item.ImageName, + item.Price, + item.CurrencyType, + item.Category, + }; +} diff --git a/DorkNet.Server/Controllers/API/RoomCurrencies/RoomCurrenciesController.cs b/DorkNet.Server/Controllers/API/RoomCurrencies/RoomCurrenciesController.cs new file mode 100644 index 0000000..eb40e1b --- /dev/null +++ b/DorkNet.Server/Controllers/API/RoomCurrencies/RoomCurrenciesController.cs @@ -0,0 +1,405 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using System.Text.Json; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.API.RoomCurrencies; + +[ApiController] +[Authorize] +public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) : ControllerBase +{ + private long Me => this.RequireCurrentPlayerId(); + + [HttpGet("api/roomcurrencies/v1/currencies")] + [AllowAnonymous] + public async Task Currencies([FromQuery] long roomId) + { + var rows = await db.RoomCurrencies + .Where(c => !c.IsDeleted && (roomId <= 0 || c.RoomId == roomId)) + .OrderBy(c => c.Name) + .Take(100) + .ToListAsync(); + return Ok(rows.Select(ToWire)); + } + + [HttpPost("api/roomcurrencies/v1/createCurrency")] + public async Task CreateCurrency() + { + var fields = await ReadFieldsAsync(); + var roomId = ReadLong(fields, "roomId", "RoomId"); + if (roomId is not long rid || rid <= 0) return BadRequest("missing_room"); + var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == rid); + if (room is null) return NotFound("room_not_found"); + if (!await CanManageRoomAsync(room, Me)) return Forbid(); + + var name = Trim(ReadString(fields, "name", "Name") ?? "Room Currency", 64); + var currency = new RoomCurrencyEntity + { + RoomId = room.Id, + CreatorPlayerId = Me, + Name = name, + Description = Trim(ReadString(fields, "description", "Description"), 512), + ImageName = Trim(ReadString(fields, "imageName", "ImageName"), 256), + DailyLimit = Math.Max(0, ReadInt(fields, "dailyLimit", "DailyLimit") ?? 0), + }; + db.RoomCurrencies.Add(currency); + await db.SaveChangesAsync(); + return Ok(ToWire(currency)); + } + + [HttpPost("api/roomcurrencies/v1/updateCurrency")] + [HttpPut("api/roomcurrencies/v1/updateCurrency")] + public async Task UpdateCurrency() + { + var fields = await ReadFieldsAsync(); + var currency = await FindCurrencyAsync(fields); + if (currency is null) return NotFound(); + var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == currency.RoomId); + if (room is null || !await CanManageRoomAsync(room, Me)) return Forbid(); + + if (ReadString(fields, "name", "Name") is { } name) currency.Name = Trim(name, 64); + if (ReadString(fields, "description", "Description") is { } description) currency.Description = Trim(description, 512); + if (ReadString(fields, "imageName", "ImageName") is { } imageName) currency.ImageName = Trim(imageName, 256); + if (ReadInt(fields, "dailyLimit", "DailyLimit") is int dailyLimit) currency.DailyLimit = Math.Max(0, dailyLimit); + currency.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return Ok(ToWire(currency)); + } + + [HttpGet("api/roomcurrencies/v1/getBalance")] + public async Task GetBalance() + { + var fields = await ReadFieldsAsync(); + var currency = await FindCurrencyAsync(fields); + if (currency is null) return NotFound(); + var playerId = ReadLong(fields, "playerId", "PlayerId") ?? Me; + var balance = await GetBalanceAsync(playerId, currency.Id); + return Ok(new + { + PlayerId = (int)playerId, + RoomCurrencyId = currency.PublicId, + Balance = balance, + }); + } + + [HttpGet("api/roomcurrencies/v1/getAllBalances")] + public async Task GetAllBalances([FromQuery] long? roomId = null) + { + var playerId = Me; + var query = db.RoomCurrencyBalances + .Where(b => b.PlayerId == playerId) + .Join(db.RoomCurrencies, b => b.RoomCurrencyId, c => c.Id, (b, c) => new { b, c }) + .Where(x => !x.c.IsDeleted); + if (roomId is long rid && rid > 0) + query = query.Where(x => x.c.RoomId == rid); + + var rows = await query + .OrderBy(x => x.c.RoomId) + .ThenBy(x => x.c.Name) + .ToListAsync(); + return Ok(rows.Select(x => new + { + PlayerId = (int)playerId, + RoomCurrencyId = x.c.PublicId, + x.b.Balance, + })); + } + + [HttpPost("api/roomcurrencies/v1/awardCurrency/bulk")] + public async Task AwardCurrencyBulk() + { + var fields = await ReadFieldsAsync(); + var currency = await FindCurrencyAsync(fields); + if (currency is null) return NotFound(); + var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == currency.RoomId); + if (room is null || !await CanManageRoomAsync(room, Me)) return Forbid(); + + var playerIds = ReadLongList(fields, "playerIds", "PlayerIds", "accountIds", "AccountIds"); + var amount = ReadLong(fields, "amount", "Amount") ?? 0; + if (playerIds.Count == 0 || amount == 0) return BadRequest("missing_awards"); + + var results = new List(); + foreach (var playerId in playerIds) + { + var balance = await AddBalanceAsync(playerId, currency.Id, amount); + results.Add(new { PlayerId = (int)playerId, RoomCurrencyId = currency.PublicId, Balance = balance }); + } + + await db.SaveChangesAsync(); + return Ok(results); + } + + [HttpPost("api/roomcurrencies/v1/createPurchaseOffer")] + public async Task CreatePurchaseOffer() + { + var fields = await ReadFieldsAsync(); + var currency = await FindCurrencyAsync(fields); + if (currency is null) return NotFound(); + var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == currency.RoomId); + if (room is null || !await CanManageRoomAsync(room, Me)) return Forbid(); + + var offer = new RoomCurrencyPurchaseOfferEntity + { + RoomCurrencyId = currency.Id, + Name = Trim(ReadString(fields, "name", "Name") ?? currency.Name, 64), + Amount = Math.Max(1, ReadInt(fields, "amount", "Amount") ?? 1), + Price = Math.Max(0, ReadInt(fields, "price", "Price") ?? 0), + CurrencyType = Math.Max(0, ReadInt(fields, "currencyType", "CurrencyType") ?? 2), + }; + db.RoomCurrencyPurchaseOffers.Add(offer); + await db.SaveChangesAsync(); + return Ok(ToOfferWire(offer, currency)); + } + + [HttpPost("api/roomcurrencies/v1/updatePurchaseOffer")] + [HttpPut("api/roomcurrencies/v1/updatePurchaseOffer")] + public async Task UpdatePurchaseOffer() + { + var fields = await ReadFieldsAsync(); + var offer = await FindOfferAsync(fields); + if (offer is null) return NotFound(); + var currency = await db.RoomCurrencies.FirstAsync(c => c.Id == offer.RoomCurrencyId); + var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == currency.RoomId); + if (room is null || !await CanManageRoomAsync(room, Me)) return Forbid(); + + if (ReadString(fields, "name", "Name") is { } name) offer.Name = Trim(name, 64); + if (ReadInt(fields, "amount", "Amount") is int amount) offer.Amount = Math.Max(1, amount); + if (ReadInt(fields, "price", "Price") is int price) offer.Price = Math.Max(0, price); + if (ReadInt(fields, "currencyType", "CurrencyType") is int currencyType) offer.CurrencyType = Math.Max(0, currencyType); + offer.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return Ok(ToOfferWire(offer, currency)); + } + + [HttpPost("api/roomcurrencies/v1/deletePurchaseOffer")] + [HttpDelete("api/roomcurrencies/v1/deletePurchaseOffer")] + public async Task DeletePurchaseOffer() + { + var fields = await ReadFieldsAsync(); + var offer = await FindOfferAsync(fields); + if (offer is null) return NotFound(); + var currency = await db.RoomCurrencies.FirstAsync(c => c.Id == offer.RoomCurrencyId); + var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == currency.RoomId); + if (room is null || !await CanManageRoomAsync(room, Me)) return Forbid(); + + offer.IsDeleted = true; + offer.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return Ok(ToOfferWire(offer, currency)); + } + + [HttpPost("api/roomcurrencies/v1/getPurchaseOffersBatch")] + [HttpGet("api/roomcurrencies/v1/getPurchaseOffersBatch")] + [AllowAnonymous] + public async Task GetPurchaseOffersBatch() + { + var fields = await ReadFieldsAsync(); + var offerIds = ReadGuidList(fields, "purchaseOfferIds", "PurchaseOfferIds", "offerIds", "OfferIds"); + var currencyIds = ReadGuidList(fields, "roomCurrencyIds", "RoomCurrencyIds", "currencyIds", "CurrencyIds"); + + var query = db.RoomCurrencyPurchaseOffers + .Where(o => !o.IsDeleted) + .Join(db.RoomCurrencies, o => o.RoomCurrencyId, c => c.Id, (o, c) => new { o, c }) + .Where(x => !x.c.IsDeleted); + if (offerIds.Count > 0) query = query.Where(x => offerIds.Contains(x.o.PublicId)); + if (currencyIds.Count > 0) query = query.Where(x => currencyIds.Contains(x.c.PublicId)); + + var rows = await query.Take(200).ToListAsync(); + return Ok(rows.Select(x => ToOfferWire(x.o, x.c))); + } + + [HttpPost("api/roomCurrencies/v2/purchase")] + [HttpGet("api/roomCurrencies/v2/purchase")] + public async Task Purchase() + { + var fields = await ReadFieldsAsync(); + var offer = await FindOfferAsync(fields); + if (offer is null) return NotFound(); + var currency = await db.RoomCurrencies.FirstAsync(c => c.Id == offer.RoomCurrencyId); + var pid = Me; + var balance = await level.GetBalanceAsync(pid, offer.CurrencyType); + if (balance < offer.Price) + return Ok(new { Success = false, Error = "insufficient_funds", Balance = balance, offer.CurrencyType }); + var newBalance = offer.Price == 0 + ? balance + : await level.GrantCurrencyAsync(pid, offer.CurrencyType, -offer.Price, $"roomCurrency:{offer.PublicId}"); + var customBalance = await AddBalanceAsync(pid, currency.Id, offer.Amount); + await db.SaveChangesAsync(); + return Ok(new + { + Success = true, + Balance = newBalance, + offer.CurrencyType, + RoomCurrencyId = currency.PublicId, + RoomCurrencyBalance = customBalance, + }); + } + + private async Task FindCurrencyAsync(Dictionary fields) + { + var guid = ReadGuid(fields, "roomCurrencyId", "RoomCurrencyId", "currencyId", "CurrencyId", "id", "Id"); + if (guid is Guid publicId) + return await db.RoomCurrencies.FirstOrDefaultAsync(c => c.PublicId == publicId && !c.IsDeleted); + var localId = ReadLong(fields, "roomCurrencyInternalId", "internalId"); + if (localId is long id) + return await db.RoomCurrencies.FirstOrDefaultAsync(c => c.Id == id && !c.IsDeleted); + return null; + } + + private async Task FindOfferAsync(Dictionary fields) + { + var guid = ReadGuid(fields, "purchaseOfferId", "PurchaseOfferId", "offerId", "OfferId", "id", "Id"); + if (guid is Guid publicId) + return await db.RoomCurrencyPurchaseOffers.FirstOrDefaultAsync(o => o.PublicId == publicId && !o.IsDeleted); + var localId = ReadLong(fields, "purchaseOfferInternalId", "internalId"); + if (localId is long id) + return await db.RoomCurrencyPurchaseOffers.FirstOrDefaultAsync(o => o.Id == id && !o.IsDeleted); + return null; + } + + private async Task GetBalanceAsync(long playerId, long currencyId) => + await db.RoomCurrencyBalances + .Where(b => b.PlayerId == playerId && b.RoomCurrencyId == currencyId) + .Select(b => b.Balance) + .FirstOrDefaultAsync(); + + private async Task AddBalanceAsync(long playerId, long currencyId, long amount) + { + var row = await db.RoomCurrencyBalances + .FirstOrDefaultAsync(b => b.PlayerId == playerId && b.RoomCurrencyId == currencyId); + if (row is null) + { + row = new RoomCurrencyBalanceEntity { PlayerId = playerId, RoomCurrencyId = currencyId }; + db.RoomCurrencyBalances.Add(row); + } + + row.Balance = Math.Max(0, row.Balance + amount); + row.UpdatedAt = DateTime.UtcNow; + return row.Balance; + } + + private async Task CanManageRoomAsync(RoomEntity room, long playerId) + { + if (room.CreatorPlayerId == playerId) return true; + if (await db.RoomRoles.AnyAsync(r => r.RoomId == room.Id && r.PlayerId == playerId && r.Role == 0 && r.Accepted)) + return true; + return await db.Players.Where(p => p.Id == playerId).Select(p => p.IsAdmin).FirstOrDefaultAsync(); + } + + private async Task> ReadFieldsAsync() + { + var fields = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var pair in Request.Query) + fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty; + + if (Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(); + foreach (var pair in form) + fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty; + } + else if ((Request.ContentLength ?? 0) > 0 + && Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true) + { + try + { + using var doc = await JsonDocument.ParseAsync(Request.Body); + if (doc.RootElement.ValueKind == JsonValueKind.Object) + { + foreach (var prop in doc.RootElement.EnumerateObject()) + { + fields[prop.Name] = prop.Value.ValueKind switch + { + JsonValueKind.Array => string.Join(",", prop.Value.EnumerateArray().Select(v => v.ToString())), + JsonValueKind.String => prop.Value.GetString() ?? string.Empty, + _ => prop.Value.GetRawText(), + }; + } + } + } + catch (JsonException) + { + } + } + + return fields; + } + + private static string? ReadString(Dictionary fields, params string[] names) + { + foreach (var name in names) + if (fields.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value)) + return value; + return null; + } + + private static long? ReadLong(Dictionary fields, params string[] names) => + long.TryParse(ReadString(fields, names), out var value) ? value : null; + + private static int? ReadInt(Dictionary fields, params string[] names) => + int.TryParse(ReadString(fields, names), out var value) ? value : null; + + private static Guid? ReadGuid(Dictionary fields, params string[] names) => + Guid.TryParse(ReadString(fields, names), out var value) ? value : null; + + private static List ReadLongList(Dictionary fields, params string[] names) => + names.SelectMany(name => fields.TryGetValue(name, out var value) + ? value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + : Array.Empty()) + .Select(v => long.TryParse(v, out var id) ? id : 0) + .Where(id => id > 0) + .Distinct() + .Take(200) + .ToList(); + + private static List ReadGuidList(Dictionary fields, params string[] names) => + names.SelectMany(name => fields.TryGetValue(name, out var value) + ? value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + : Array.Empty()) + .Select(v => Guid.TryParse(v, out var id) ? id : Guid.Empty) + .Where(id => id != Guid.Empty) + .Distinct() + .Take(200) + .ToList(); + + private static string Trim(string? value, int max) + { + var s = (value ?? string.Empty).Trim(); + return s.Length <= max ? s : s[..max]; + } + + private static object ToWire(RoomCurrencyEntity currency) => new + { + RoomCurrencyId = currency.PublicId, + Id = currency.PublicId, + InternalId = currency.Id, + currency.RoomId, + CreatorPlayerId = (int)currency.CreatorPlayerId, + currency.Name, + currency.Description, + currency.ImageName, + currency.DailyLimit, + currency.CreatedAt, + currency.UpdatedAt, + }; + + private static object ToOfferWire(RoomCurrencyPurchaseOfferEntity offer, RoomCurrencyEntity currency) => new + { + PurchaseOfferId = offer.PublicId, + RoomCurrencyPackageId = offer.PublicId, + Id = offer.PublicId, + InternalId = offer.Id, + RoomCurrencyId = currency.PublicId, + offer.Name, + offer.Amount, + offer.Price, + offer.CurrencyType, + offer.CreatedAt, + offer.UpdatedAt, + }; +} diff --git a/DorkNet.Server/Controllers/API/RoomEarningsDistributions/RoomEarningsDistributionsController.cs b/DorkNet.Server/Controllers/API/RoomEarningsDistributions/RoomEarningsDistributionsController.cs new file mode 100644 index 0000000..4c7bac9 --- /dev/null +++ b/DorkNet.Server/Controllers/API/RoomEarningsDistributions/RoomEarningsDistributionsController.cs @@ -0,0 +1,55 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; + +namespace DorkNet.Server.Controllers.API.RoomEarningsDistributions; + +[ApiController] +[Authorize] +public class RoomEarningsDistributionsController(DorkNetDbContext db) : ControllerBase +{ + [HttpGet("api/roomEarningsDistributions")] + [HttpGet("api/roomEarningsDistributions/v1/earningsDistribution")] + public async Task EarningsDistribution([FromQuery] long? roomId = null) + { + var me = this.RequireCurrentPlayerId(); + var rooms = db.Rooms.Where(r => r.CreatorPlayerId == me); + if (roomId is long rid && rid > 0) + rooms = rooms.Where(r => r.Id == rid); + + var roomRows = await rooms + .Select(r => new { r.Id, r.Name }) + .ToListAsync(); + var roomIds = roomRows.Select(r => r.Id).ToList(); + var earnings = await db.RoomKeyPurchases + .Join(db.RoomKeys, p => p.RoomKeyId, k => k.Id, (p, k) => new { p, k }) + .Where(x => roomIds.Contains(x.k.RoomId)) + .GroupBy(x => x.k.RoomId) + .Select(g => new + { + RoomId = g.Key, + Gross = g.Sum(x => x.p.PaidPrice), + PurchaseCount = g.Count(), + }) + .ToListAsync(); + + var byRoom = earnings.ToDictionary(e => e.RoomId); + return Ok(roomRows.Select(room => + { + byRoom.TryGetValue(room.Id, out var value); + var gross = value?.Gross ?? 0; + return new + { + RoomId = room.Id, + room.Name, + GrossEarnings = gross, + NetEarnings = gross, + PurchaseCount = value?.PurchaseCount ?? 0, + CurrencyType = 2, + UpdatedAt = DateTime.UtcNow, + }; + }).ToList()); + } +} diff --git a/DorkNet.Server/Controllers/API/RoomKeys/RoomKeysController.cs b/DorkNet.Server/Controllers/API/RoomKeys/RoomKeysController.cs index 8698f62..8abbd18 100644 --- a/DorkNet.Server/Controllers/API/RoomKeys/RoomKeysController.cs +++ b/DorkNet.Server/Controllers/API/RoomKeys/RoomKeysController.cs @@ -42,6 +42,22 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase return Ok(rows.Select(ToWire)); } + [HttpGet("api/roomkeys")] + [HttpGet("api/roomkeys/v1")] + public async Task List([FromQuery] long? roomId = null) + { + var rows = db.RoomKeys.Where(k => !k.IsDeleted); + if (roomId is long id && id > 0) + rows = rows.Where(k => k.RoomId == id); + + var result = await rows + .OrderBy(k => k.RoomId) + .ThenBy(k => k.Price) + .Take(200) + .ToListAsync(); + return Ok(result.Select(ToWire)); + } + [HttpPost("api/roomkeys/v1/create")] public async Task Create() { @@ -132,6 +148,29 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase return Content(owns ? "true" : "false", "application/json"); } + [HttpGet("api/roomkeys/v1/owns")] + public async Task Owns([FromQuery] long roomKeyId) + { + var pid = Me; + var owns = await db.RoomKeyPurchases.AnyAsync(p => p.RoomKeyId == roomKeyId && p.PlayerId == pid); + return Content(owns ? "true" : "false", "application/json"); + } + + [HttpGet("api/roomkeys/v1/owns/bulk")] + [HttpPost("api/roomkeys/v1/owns/bulk")] + public async Task OwnsBulk() + { + var ids = await ReadRoomKeyIdsAsync(); + if (ids.Count == 0) return Ok(Array.Empty()); + var pid = Me; + var owned = await db.RoomKeyPurchases + .Where(p => p.PlayerId == pid && ids.Contains(p.RoomKeyId)) + .Select(p => p.RoomKeyId) + .ToListAsync(); + var ownedSet = owned.ToHashSet(); + return Ok(ids.Select(id => new { RoomKeyId = id, Owns = ownedSet.Contains(id) }).ToList()); + } + public static object RoomKeyResponse(RoomKeyStatus status, RoomKeyEntity? key) => new { Status = (int)status, @@ -173,6 +212,25 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase .FirstOrDefaultAsync(); } + private async Task> ReadRoomKeyIdsAsync() + { + var ids = new List(); + foreach (var value in Request.Query.SelectMany(q => q.Value)) + foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + if (long.TryParse(part, out var id) && id > 0) ids.Add(id); + + if (Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(); + foreach (var key in new[] { "roomKeyIds", "RoomKeyIds", "ids", "Ids" }) + foreach (var value in form[key]) + foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + if (long.TryParse(part, out var id) && id > 0) ids.Add(id); + } + + return ids.Distinct().Take(200).ToList(); + } + private async Task ReadBodyAsync() where T : new() { if (Request.HasFormContentType) diff --git a/DorkNet.Server/Controllers/API/ScreenShareReports/ScreenShareReportsController.cs b/DorkNet.Server/Controllers/API/ScreenShareReports/ScreenShareReportsController.cs new file mode 100644 index 0000000..2e898b9 --- /dev/null +++ b/DorkNet.Server/Controllers/API/ScreenShareReports/ScreenShareReportsController.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; + +namespace DorkNet.Server.Controllers.API.ScreenShareReports; + +[ApiController] +[Authorize] +public class ScreenShareReportsController(DorkNetDbContext db) : ControllerBase +{ + [HttpPost("api/screensharereports/v1/report")] + public async Task Report( + [FromForm] long? targetPlayerId, + [FromForm] long? roomId, + [FromForm] int? reportCategory, + [FromForm] string? details) + { + var reporter = this.RequireCurrentPlayerId(); + var target = targetPlayerId + ?? (long.TryParse(Request.Query["targetPlayerId"].FirstOrDefault(), out var qTarget) ? qTarget : 0); + var room = roomId + ?? (long.TryParse(Request.Query["roomId"].FirstOrDefault(), out var qRoom) ? qRoom : 0); + var category = reportCategory + ?? (int.TryParse(Request.Query["reportCategory"].FirstOrDefault(), out var qCategory) ? qCategory : 5); + var message = details ?? Request.Query["details"].FirstOrDefault() ?? string.Empty; + + db.Reports.Add(new ReportEntity + { + ReporterPlayerId = reporter, + TargetPlayerId = target, + RoomId = room, + Category = category, + Message = message[..Math.Min(1000, message.Length)], + }); + await db.SaveChangesAsync(); + return Ok(new { Success = true, Error = string.Empty }); + } +} diff --git a/DorkNet.Server/Controllers/API/Store/StorefrontsBuyController.cs b/DorkNet.Server/Controllers/API/Store/StorefrontsBuyController.cs index a362247..5aedf43 100644 --- a/DorkNet.Server/Controllers/API/Store/StorefrontsBuyController.cs +++ b/DorkNet.Server/Controllers/API/Store/StorefrontsBuyController.cs @@ -205,6 +205,7 @@ public class StorefrontsBuyController( /// persisted room key. The 2020.12 client sends roomKeyId and the /// price it displayed so we reject stale-price purchases. [HttpGet("api/storefronts/v1/buyRoomKey")] + [HttpGet("api/storefronts/v1/PurchaseRoomKeyWithCurrency")] public async Task BuyRoomKey( [FromQuery] long roomKeyId, [FromQuery] int requestedPrice) @@ -242,6 +243,95 @@ public class StorefrontsBuyController( return Ok(RoomKeyPurchaseResponse(RoomKeyStatus.Success, key, newBalance)); } + [HttpPost("api/storefronts/v2/buyInvention")] + [HttpGet("api/storefronts/v2/buyInvention")] + public async Task BuyInvention([FromQuery] long inventionId, [FromQuery] int currencyType = 2) + { + var pid = Me; + var inv = await db.Inventions.FirstOrDefaultAsync(i => i.Id == inventionId && !i.IsDeleted && i.IsPublished); + if (inv is null) return NotFound(); + var price = Math.Max(0, inv.Price); + var balance = await level.GetBalanceAsync(pid, currencyType); + if (balance < price) return Ok(BalanceUpdateResponse(balance, currencyType)); + var newBalance = price == 0 + ? balance + : await level.GrantCurrencyAsync(pid, currencyType, -price, $"buyInvention:{inv.Id}"); + var key = $"invention_purchase:{inv.Id}"; + if (!await db.ObjectiveProgress.AnyAsync(o => o.PlayerId == pid && o.Key == key)) + { + db.ObjectiveProgress.Add(new ObjectiveProgressEntity + { + PlayerId = pid, + Key = key, + IsCompleted = true, + ClearedAt = DateTime.UtcNow, + }); + await db.SaveChangesAsync(); + } + return Ok(BalanceUpdateResponse(newBalance, currencyType, null, null)); + } + + [HttpPost("api/storefronts/v1/buyForFreeGiftButton")] + [HttpGet("api/storefronts/v1/buyForFreeGiftButton")] + public async Task BuyForFreeGiftButton([FromQuery] int currencyType = 2, [FromQuery] int price = 0) + { + var pid = Me; + var balance = await level.GetBalanceAsync(pid, currencyType); + if (balance < price) return Ok(BalanceUpdateResponse(balance, currencyType)); + var newBalance = price == 0 + ? balance + : await level.GrantCurrencyAsync(pid, currencyType, -price, "freeGiftButton"); + db.GiftPackages.Add(new GiftPackageEntity + { + RecipientPlayerId = pid, + CurrencyType = 2, + Currency = 25, + Level = 1, + PackageVariant = "FreeGiftButton", + PackageMaterial = string.Empty, + IsValid = true, + SupportsCurrentPlatform = true, + }); + await db.SaveChangesAsync(); + return Ok(BalanceUpdateResponse(newBalance, currencyType)); + } + + [HttpPost("api/storefronts/v1/buyProgressionEventXpBoost")] + [HttpGet("api/storefronts/v1/buyProgressionEventXpBoost")] + public async Task BuyProgressionEventXpBoost([FromQuery] int currencyType = 2, [FromQuery] int price = 0) + { + var pid = Me; + var balance = await level.GetBalanceAsync(pid, currencyType); + if (balance < price) return Ok(BalanceUpdateResponse(balance, currencyType)); + var newBalance = price == 0 + ? balance + : await level.GrantCurrencyAsync(pid, currencyType, -price, "progressionEventXpBoost"); + db.ObjectiveProgress.Add(new ObjectiveProgressEntity + { + PlayerId = pid, + Key = $"progressionEvent:xpBoost:{Guid.NewGuid():N}", + IsCompleted = true, + ClearedAt = DateTime.UtcNow, + }); + await db.SaveChangesAsync(); + return Ok(BalanceUpdateResponse(newBalance, currencyType)); + } + + [HttpPost("api/storefronts/v1/buyPurchaseReminder")] + [HttpGet("api/storefronts/v1/buyPurchaseReminder")] + public async Task BuyPurchaseReminder() + { + db.ObjectiveProgress.Add(new ObjectiveProgressEntity + { + PlayerId = Me, + Key = $"purchaseReminder:{Guid.NewGuid():N}", + IsCompleted = true, + ClearedAt = DateTime.UtcNow, + }); + await db.SaveChangesAsync(); + return Ok(new { Success = true }); + } + // ── Helpers ────────────────────────────────────────────────────────── private async Task GrantWardrobeAsync(long pid, string guid) diff --git a/DorkNet.Server/Controllers/API/Store/StorefrontsController.cs b/DorkNet.Server/Controllers/API/Store/StorefrontsController.cs index 516ff60..6db6615 100644 --- a/DorkNet.Server/Controllers/API/Store/StorefrontsController.cs +++ b/DorkNet.Server/Controllers/API/Store/StorefrontsController.cs @@ -32,6 +32,8 @@ public class StorefrontsController( [HttpGet("api/storefronts/v1/all")] [HttpGet("api/storefronts/v2/all")] [HttpGet("api/storefronts/v3/all")] + [HttpGet("api/storefronts/v1/toptoday")] + [HttpGet("api/storefronts/v1/adcarouselitems")] [AllowAnonymous] public async Task All() { diff --git a/DorkNet.Server/Controllers/API/SubscriptionSeasons/SubscriptionSeasonsController.cs b/DorkNet.Server/Controllers/API/SubscriptionSeasons/SubscriptionSeasonsController.cs new file mode 100644 index 0000000..66a5164 --- /dev/null +++ b/DorkNet.Server/Controllers/API/SubscriptionSeasons/SubscriptionSeasonsController.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Data; + +namespace DorkNet.Server.Controllers.API.SubscriptionSeasons; + +[ApiController] +public class SubscriptionSeasonsController(DorkNetDbContext db) : ControllerBase +{ + [HttpGet("api/subscriptionseasons/v1/seasons/current")] + public async Task Current() + { + var now = DateTime.UtcNow; + var start = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc); + var end = start.AddMonths(1); + var activeSubscribers = await db.Subscriptions.CountAsync(); + var rewards = await db.StoreItems + .Where(i => i.IsActive) + .OrderBy(i => i.Id) + .Take(10) + .Select(i => new + { + RewardId = i.Id, + i.Slug, + Name = i.DisplayName, + i.Category, + i.ImageName, + }) + .ToListAsync(); + return Ok(new + { + SeasonId = $"{start:yyyyMM}", + Name = $"Season {start:yyyy.MM}", + StartAt = start, + EndAt = end, + ActiveSubscriberCount = activeSubscribers, + Rewards = rewards, + }); + } +} diff --git a/DorkNet.Server/Controllers/API/UgcPurchasables/UgcPurchasablesController.cs b/DorkNet.Server/Controllers/API/UgcPurchasables/UgcPurchasablesController.cs new file mode 100644 index 0000000..e8948ee --- /dev/null +++ b/DorkNet.Server/Controllers/API/UgcPurchasables/UgcPurchasablesController.cs @@ -0,0 +1,225 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using System.Text.Json; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; + +namespace DorkNet.Server.Controllers.API.UgcPurchasables; + +[ApiController] +public class UgcPurchasablesController(DorkNetDbContext db) : ControllerBase +{ + [HttpGet("api/ugcPurchasables")] + [AllowAnonymous] + public async Task List([FromQuery] long roomId = 0) + { + var rows = await db.UgcPurchasables + .Where(i => !i.IsDeleted && (roomId <= 0 || i.RoomId == roomId)) + .OrderBy(i => i.SortOrder) + .ThenBy(i => i.Name) + .Take(200) + .ToListAsync(); + return Ok(rows.Select(ToWire)); + } + + [HttpPost("api/ugcPurchasables")] + [Authorize] + public async Task Create() + { + var fields = await ReadFieldsAsync(); + var roomId = ReadLong(fields, "roomId", "RoomId"); + if (roomId is not long rid || rid <= 0) return BadRequest("missing_room"); + var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == rid); + if (room is null) return NotFound("room_not_found"); + var me = this.RequireCurrentPlayerId(); + if (!await CanManageRoomAsync(room, me)) return Forbid(); + + var item = new UgcPurchasableEntity + { + RoomId = room.Id, + CreatorPlayerId = me, + Name = Trim(ReadString(fields, "name", "Name") ?? "Purchasable", 128), + Description = Trim(ReadString(fields, "description", "Description"), 1024), + ImageName = Trim(ReadString(fields, "imageName", "ImageName"), 256), + Price = Math.Max(0, ReadInt(fields, "price", "Price") ?? 0), + CurrencyType = Math.Max(0, ReadInt(fields, "currencyType", "CurrencyType") ?? 2), + ItemType = Math.Max(0, ReadInt(fields, "itemType", "ItemType") ?? 0), + SortOrder = ReadInt(fields, "sortOrder", "SortOrder") ?? 0, + IsFeatured = ReadBool(fields, "isFeatured", "IsFeatured") ?? false, + }; + db.UgcPurchasables.Add(item); + await db.SaveChangesAsync(); + return Ok(ToWire(item)); + } + + [HttpPut("api/ugcPurchasables")] + [HttpPost("api/ugcPurchasables/update")] + [Authorize] + public async Task Update() + { + var fields = await ReadFieldsAsync(); + var item = await FindItemAsync(fields); + if (item is null) return NotFound(); + var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == item.RoomId); + if (room is null || !await CanManageRoomAsync(room, this.RequireCurrentPlayerId())) return Forbid(); + + if (ReadString(fields, "name", "Name") is { } name) item.Name = Trim(name, 128); + if (ReadString(fields, "description", "Description") is { } description) item.Description = Trim(description, 1024); + if (ReadString(fields, "imageName", "ImageName") is { } imageName) item.ImageName = Trim(imageName, 256); + if (ReadInt(fields, "price", "Price") is int price) item.Price = Math.Max(0, price); + if (ReadInt(fields, "currencyType", "CurrencyType") is int currencyType) item.CurrencyType = Math.Max(0, currencyType); + if (ReadInt(fields, "itemType", "ItemType") is int itemType) item.ItemType = Math.Max(0, itemType); + if (ReadInt(fields, "sortOrder", "SortOrder") is int sortOrder) item.SortOrder = sortOrder; + if (ReadBool(fields, "isFeatured", "IsFeatured") is bool featured) item.IsFeatured = featured; + item.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return Ok(ToWire(item)); + } + + [HttpDelete("api/ugcPurchasables")] + [HttpPost("api/ugcPurchasables/delete")] + [Authorize] + public async Task Delete() + { + var fields = await ReadFieldsAsync(); + var item = await FindItemAsync(fields); + if (item is null) return NotFound(); + var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == item.RoomId); + if (room is null || !await CanManageRoomAsync(room, this.RequireCurrentPlayerId())) return Forbid(); + + item.IsDeleted = true; + item.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return Ok(ToWire(item)); + } + + [HttpGet("api/ugcPurchasables/v1/items/bulk")] + [HttpPost("api/ugcPurchasables/v1/items/bulk")] + [AllowAnonymous] + public async Task Bulk() + { + var fields = await ReadFieldsAsync(); + var ids = ReadGuidList(fields, "ids", "Ids", "itemIds", "ItemIds", "ugcPurchasableIds", "UgcPurchasableIds"); + if (ids.Count == 0) return Ok(Array.Empty()); + var rows = await db.UgcPurchasables + .Where(i => !i.IsDeleted && ids.Contains(i.PublicId)) + .ToListAsync(); + return Ok(rows.Select(ToWire)); + } + + private async Task FindItemAsync(Dictionary fields) + { + var publicId = ReadGuid(fields, "ugcPurchasableId", "UgcPurchasableId", "itemId", "ItemId", "id", "Id"); + if (publicId is Guid guid) + return await db.UgcPurchasables.FirstOrDefaultAsync(i => i.PublicId == guid && !i.IsDeleted); + var localId = ReadLong(fields, "internalId", "ugcPurchasableInternalId"); + if (localId is long id) + return await db.UgcPurchasables.FirstOrDefaultAsync(i => i.Id == id && !i.IsDeleted); + return null; + } + + private async Task CanManageRoomAsync(RoomEntity room, long playerId) + { + if (room.CreatorPlayerId == playerId) return true; + if (await db.RoomRoles.AnyAsync(r => r.RoomId == room.Id && r.PlayerId == playerId && r.Role == 0 && r.Accepted)) + return true; + return await db.Players.Where(p => p.Id == playerId).Select(p => p.IsAdmin).FirstOrDefaultAsync(); + } + + private async Task> ReadFieldsAsync() + { + var fields = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var pair in Request.Query) + fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty; + + if (Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(); + foreach (var pair in form) + fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty; + } + else if ((Request.ContentLength ?? 0) > 0 + && Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true) + { + try + { + using var doc = await JsonDocument.ParseAsync(Request.Body); + if (doc.RootElement.ValueKind == JsonValueKind.Object) + { + foreach (var prop in doc.RootElement.EnumerateObject()) + { + fields[prop.Name] = prop.Value.ValueKind switch + { + JsonValueKind.Array => string.Join(",", prop.Value.EnumerateArray().Select(v => v.ToString())), + JsonValueKind.String => prop.Value.GetString() ?? string.Empty, + _ => prop.Value.GetRawText(), + }; + } + } + } + catch (JsonException) + { + } + } + + return fields; + } + + private static string? ReadString(Dictionary fields, params string[] names) + { + foreach (var name in names) + if (fields.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value)) + return value; + return null; + } + + private static long? ReadLong(Dictionary fields, params string[] names) => + long.TryParse(ReadString(fields, names), out var value) ? value : null; + + private static int? ReadInt(Dictionary fields, params string[] names) => + int.TryParse(ReadString(fields, names), out var value) ? value : null; + + private static bool? ReadBool(Dictionary fields, params string[] names) => + bool.TryParse(ReadString(fields, names), out var value) ? value : null; + + private static Guid? ReadGuid(Dictionary fields, params string[] names) => + Guid.TryParse(ReadString(fields, names), out var value) ? value : null; + + private static List ReadGuidList(Dictionary fields, params string[] names) => + names.SelectMany(name => fields.TryGetValue(name, out var value) + ? value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + : Array.Empty()) + .Select(v => Guid.TryParse(v, out var id) ? id : Guid.Empty) + .Where(id => id != Guid.Empty) + .Distinct() + .Take(200) + .ToList(); + + private static string Trim(string? value, int max) + { + var s = (value ?? string.Empty).Trim(); + return s.Length <= max ? s : s[..max]; + } + + private static object ToWire(UgcPurchasableEntity item) => new + { + UgcPurchasableId = item.PublicId, + PurchasableItemId = item.PublicId, + Id = item.PublicId, + InternalId = item.Id, + item.RoomId, + CreatorPlayerId = (int)item.CreatorPlayerId, + item.Name, + item.Description, + item.ImageName, + item.Price, + item.CurrencyType, + item.ItemType, + item.IsFeatured, + item.SortOrder, + item.CreatedAt, + item.UpdatedAt, + }; +} diff --git a/DorkNet.Server/Controllers/Accounts/AccountsController.cs b/DorkNet.Server/Controllers/Accounts/AccountsController.cs index 37512a9..d8532ee 100644 --- a/DorkNet.Server/Controllers/Accounts/AccountsController.cs +++ b/DorkNet.Server/Controllers/Accounts/AccountsController.cs @@ -73,9 +73,11 @@ public class AccountsController( private async Task GetCurrentPlayerAsync() { var auth = Request.Headers.Authorization.ToString(); - if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + var token = auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) + ? auth["Bearer ".Length..].Trim() + : Request.Cookies[AuthService.AccessCookieName]; + if (string.IsNullOrWhiteSpace(token)) return null; - var token = auth["Bearer ".Length..].Trim(); var id = authService.ValidateToken(token); if (id is not long playerId) return null; return await playerService.GetByIdAsync(playerId); @@ -170,6 +172,7 @@ public class AccountsController( // Accounts.SearchForAccounts — used by the watch's typeahead AND the // localhost public site's account search. Anonymous-safe (no JWT // required) so the front-facing site can call it directly. + [HttpGet("/account/search")] [HttpGet("/account/v1/search")] [Microsoft.AspNetCore.Authorization.AllowAnonymous] public async Task Search([FromQuery] string? query, [FromQuery] int take = 20) @@ -405,6 +408,26 @@ public class AccountsController( return age; } + private async Task SetPlayerSettingAsync(long playerId, string key, string value) + { + var row = await db.PlayerSettings.FirstOrDefaultAsync(s => s.PlayerId == playerId && s.Key == key); + if (row is null) + { + db.PlayerSettings.Add(new PlayerSettingEntity + { + PlayerId = playerId, + Key = key, + Value = value, + }); + } + else + { + row.Value = value; + } + + await db.SaveChangesAsync(); + } + // Everyone is a dev for now — exposes the in-game debug console + dev // tools. Per user request: easier debugging while iterating on the // private server. Flip back to false later if you want shipping behavior. @@ -509,6 +532,120 @@ public class AccountsController( [HttpGet("/account/v1/me/disallowiap")] public IActionResult DisallowIap() => Ok(new { Disallow = false }); + [HttpGet("/account/isactivecreator/me")] + public async Task IsActiveCreator() + { + var pid = this.CurrentPlayerId(); + if (pid is not long me) + return Content("false", "application/json"); + + var active = await db.Rooms.AnyAsync(r => r.CreatorPlayerId == me) + || await db.Inventions.AnyAsync(i => i.CreatorPlayerId == me && !i.IsDeleted) + || await db.CustomAvatarItems.AnyAsync(i => i.CreatorPlayerId == me); + return Content(active ? "true" : "false", "application/json"); + } + + [HttpPost("/account/me/createlogintoken")] + [HttpGet("/account/me/createlogintoken")] + public async Task CreateLoginToken() + { + var player = await GetCurrentPlayerAsync(); + if (player is null) return Unauthorized(); + + var token = Guid.NewGuid().ToString("N"); + var expiresAt = DateTime.UtcNow.AddMinutes(10); + await SetPlayerSettingAsync(player.Id, "remote_login_token", $"{token}|{expiresAt:O}"); + return Ok(new + { + Success = true, + Error = string.Empty, + Token = token, + LoginToken = token, + ExpiresAt = expiresAt, + }); + } + + [HttpPost("/account/me/remoteauth")] + [HttpGet("/account/me/remoteauth")] + public async Task RemoteAuth() + { + var token = Request.Query["token"].FirstOrDefault() + ?? Request.Query["loginToken"].FirstOrDefault() + ?? Request.Query["code"].FirstOrDefault(); + if (string.IsNullOrWhiteSpace(token) && Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(); + token = form["token"].FirstOrDefault() + ?? form["loginToken"].FirstOrDefault() + ?? form["code"].FirstOrDefault(); + } + + if (string.IsNullOrWhiteSpace(token)) + { + var player = await GetCurrentPlayerAsync(); + if (player is null) return Unauthorized(); + var pair = authService.GenerateTokenPair(player.Id); + return Ok(new LoginResponse + { + AccessToken = pair.AccessToken, + RefreshToken = pair.RefreshToken, + }); + } + + var prefix = token.Trim() + "|"; + var row = await db.PlayerSettings + .Where(s => s.Key == "remote_login_token" && s.Value.StartsWith(prefix)) + .OrderByDescending(s => s.Id) + .FirstOrDefaultAsync(); + if (row is null) return Unauthorized(); + + var rawExpiry = row.Value[prefix.Length..]; + if (!DateTime.TryParse(rawExpiry, out var expiresAt) || expiresAt < DateTime.UtcNow) + return Unauthorized(); + + db.PlayerSettings.Remove(row); + await db.SaveChangesAsync(); + var (access, refresh) = authService.GenerateTokenPair(row.PlayerId); + return Ok(new LoginResponse + { + AccessToken = access, + RefreshToken = refresh, + }); + } + + [HttpPost("/account/recoverpassword")] + [HttpGet("/account/recoverpassword")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public async Task RecoverPassword( + [FromForm] string? username, + [FromForm] string? email) + { + var nameOrEmail = username + ?? email + ?? Request.Query["username"].FirstOrDefault() + ?? Request.Query["email"].FirstOrDefault(); + if (string.IsNullOrWhiteSpace(nameOrEmail)) + return Ok(new RecNetResult { Success = false, Error = "missing_account" }); + + var needle = nameOrEmail.Trim().ToLowerInvariant(); + var player = await db.Players + .FirstOrDefaultAsync(p => p.Username.ToLower() == needle + || (p.Email != null && p.Email.ToLower() == needle)); + if (player is null) + return Ok(new RecNetResult { Success = false, Error = "unknown_account" }); + + var code = Random.Shared.Next(100000, 999999).ToString(); + var expiresAt = DateTime.UtcNow.AddMinutes(30); + await SetPlayerSettingAsync(player.Id, "password_recovery_code", $"{code}|{expiresAt:O}"); + return Ok(new + { + Success = true, + Error = string.Empty, + Code = code, + ExpiresAt = expiresAt, + }); + } + /// GET /account/{id}/bio — public bio lookup. Watch URL /// from Cpp2IL_ISIL/.../RecNet/Accounts.txt line 5686: /// "account/{0}/bio" (no v1 prefix). Wire shape from diff --git a/DorkNet.Server/Controllers/Auth/AuthController.cs b/DorkNet.Server/Controllers/Auth/AuthController.cs index a6c4363..3a0179c 100644 --- a/DorkNet.Server/Controllers/Auth/AuthController.cs +++ b/DorkNet.Server/Controllers/Auth/AuthController.cs @@ -31,6 +31,42 @@ public class AuthController( if (lastSeen.Date < DateTime.UtcNow.Date) await level.AwardXpAsync(playerId, LevelService.FirstLoginXp, "first_login_today"); } + + private static Dictionary BuildTokenResponse(string accessToken, string refreshToken) => new() + { + ["access_token"] = accessToken, + ["accessToken"] = accessToken, + ["AccessToken"] = accessToken, + ["token"] = accessToken, + ["Token"] = accessToken, + ["token_type"] = "Bearer", + ["tokenType"] = "Bearer", + ["TokenType"] = "Bearer", + ["expires_in"] = 43200, + ["expiresIn"] = 43200, + ["ExpiresIn"] = 43200, + ["refresh_token"] = refreshToken, + ["refreshToken"] = refreshToken, + ["RefreshToken"] = refreshToken, + ["scope"] = "openid profile", + }; + + private IActionResult TokenOk(string accessToken, string refreshToken) + { + var cookie = new CookieOptions + { + HttpOnly = true, + Secure = string.Equals(domain.Scheme, "https", StringComparison.OrdinalIgnoreCase), + SameSite = SameSiteMode.None, + Expires = DateTimeOffset.UtcNow.AddHours(12), + IsEssential = true, + }; + if (domain.Apex.Contains('.')) + cookie.Domain = "." + domain.Apex; + Response.Cookies.Append(AuthService.AccessCookieName, accessToken, cookie); + return Ok(BuildTokenResponse(accessToken, refreshToken)); + } + // OAuth token endpoint. The 2020 client uses three grant types here: // - grant_type=cached_login → user clicked a remembered account on the // account-selection screen. Body includes `account_id` (and platform @@ -90,14 +126,7 @@ public class AuthController( await TryAwardLoginXpAsync(picked.Id); await playerService.TagPlatformAsync(picked.Id, platform, effPlatformId); var (acc1, refr1) = authService.GenerateTokenPair(picked.Id); - return Ok(new - { - access_token = acc1, - token_type = "Bearer", - expires_in = 43200, - refresh_token = refr1, - scope = "openid profile", - }); + return TokenOk(acc1, refr1); } if (string.Equals(grant_type, "refresh_token", StringComparison.OrdinalIgnoreCase)) @@ -111,14 +140,7 @@ public class AuthController( if (existing is null) return Unauthorized(new { error = "invalid_grant" }); var (acc, refr) = authService.GenerateTokenPair(existing.Id); - return Ok(new - { - access_token = acc, - token_type = "Bearer", - expires_in = 43200, - refresh_token = refr, - scope = "openid profile", - }); + return TokenOk(acc, refr); } // password grant: two paths. @@ -150,14 +172,7 @@ public class AuthController( await playerService.TagPlatformAsync(byName.Id, platform, effPlatformId); await TryAwardLoginXpAsync(byName.Id); var pair = authService.GenerateTokenPair(byName.Id); - return Ok(new - { - access_token = pair.AccessToken, - token_type = "Bearer", - expires_in = 43200, - refresh_token = pair.RefreshToken, - scope = "openid profile", - }); + return TokenOk(pair.AccessToken, pair.RefreshToken); } // Device-id fallback path. Prefer the real deviceId; fall back @@ -196,14 +211,7 @@ public class AuthController( await TryAwardLoginXpAsync(player.Id); var (access, refresh) = authService.GenerateTokenPair(player.Id); - return Ok(new - { - access_token = access, - token_type = "Bearer", - expires_in = 43200, - refresh_token = refresh, - scope = "openid profile", - }); + return TokenOk(access, refresh); } [HttpGet("/connect/userinfo")] @@ -307,6 +315,62 @@ public class AuthController( return Ok(results); } + [HttpGet("/cachedlogin/current")] + public async Task CurrentCachedLogin() + { + var auth = Request.Headers.Authorization.ToString(); + if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + return Unauthorized(); + var token = auth["Bearer ".Length..].Trim(); + var id = authService.ValidateToken(token); + if (id is not long playerId) return Unauthorized(); + var player = await playerService.GetByIdAsync(playerId); + if (player is null) return Unauthorized(); + return Ok(ToCachedLogin(player, player.LastPlatform, player.LastPlatformId)); + } + + [HttpPost("/cachedlogin/migrate")] + public async Task MigrateCachedLogin( + [FromForm] int platform = 0, + [FromForm] string? platformId = null) + { + var auth = Request.Headers.Authorization.ToString(); + if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + return Unauthorized(); + var token = auth["Bearer ".Length..].Trim(); + var id = authService.ValidateToken(token); + if (id is not long playerId) return Unauthorized(); + await playerService.TagPlatformAsync(playerId, platform, platformId); + var player = await playerService.GetByIdAsync(playerId); + return player is null + ? Unauthorized() + : Ok(ToCachedLogin(player, platform, platformId ?? string.Empty)); + } + + [HttpPost("/connect/deviceauthorization")] + [HttpGet("/connect/deviceauthorization")] + public async Task DeviceAuthorization() + { + var deviceCode = Guid.NewGuid().ToString("N"); + var userCode = deviceCode[..8].ToUpperInvariant(); + db.PlayerSettings.Add(new PlayerSettingEntity + { + PlayerId = RelationshipQueries.SystemAccountId, + Key = $"deviceauthorization:{deviceCode}", + Value = $"{userCode}|{DateTime.UtcNow.AddMinutes(15):O}", + }); + await db.SaveChangesAsync(); + return Ok(new + { + device_code = deviceCode, + user_code = userCode, + verification_uri = $"https://{domain.Sub("auth")}/device", + verification_uri_complete = $"https://{domain.Sub("auth")}/device?user_code={userCode}", + expires_in = 900, + interval = 5, + }); + } + private static CachedLogin ToCachedLogin(PlayerEntity p, int platform, string platformId) => new() { Platform = platform, diff --git a/DorkNet.Server/Controllers/Auth/VersionCheckController.cs b/DorkNet.Server/Controllers/Auth/VersionCheckController.cs index d44225e..49da375 100644 --- a/DorkNet.Server/Controllers/Auth/VersionCheckController.cs +++ b/DorkNet.Server/Controllers/Auth/VersionCheckController.cs @@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Mvc; namespace DorkNet.Server.Controllers.Auth; -/// The boot-time client-version gate the 2020.12 watch hits +/// The boot-time client-version gate the 2023.03.21 client hits /// at startup as GET api/versioncheck/v4?v={build}&p={platform}. /// /// Verified against the watch's KMDHPCHFADM.VerifyGameVersion @@ -20,10 +20,9 @@ namespace DorkNet.Server.Controllers.Auth; /// front door — the watch has dedicated UX for it, no custom client /// mod work needed. /// -/// The v query param is the raw build identifier the watch -/// reads from KMJJGELKNAP.MCFMPCNNEBH — for the December build -/// that's the literal string "20201210". We map raw build IDs -/// to our internal s via the +/// The v query param is the raw build identifier supplied +/// by the client. We map raw build IDs to our internal +/// s via the /// DorkNet:BuildIdToVersionKey config map; any build ID we /// don't recognise OR that maps to a key not in /// gets @@ -49,7 +48,8 @@ public class VersionCheckController( private static readonly IReadOnlyDictionary BuiltInBuildIdToVersionKey = new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["20201210"] = "december_2020_12_18", + ["20230317"] = "march_2023_03_21", + ["20230321"] = "march_2023_03_21", }; [HttpGet("/api/versioncheck/v4")] @@ -66,8 +66,7 @@ public class VersionCheckController( // Map raw build ID → ClientVersion key via config. Format: // "DorkNet": { // "BuildIdToVersionKey": { - // "20201210": "december_2020_12_18", - // "20200310": "march_2020_03_10" + // "20230321": "march_2023_03_21" // } // } // Mapping ABSENT → unknown build → UpdateRequired. diff --git a/DorkNet.Server/Controllers/Econ/EconController.cs b/DorkNet.Server/Controllers/Econ/EconController.cs index 3e64c0c..60cf11e 100644 --- a/DorkNet.Server/Controllers/Econ/EconController.cs +++ b/DorkNet.Server/Controllers/Econ/EconController.cs @@ -71,6 +71,38 @@ public class EconController(DorkNetDbContext db, LevelService level) : Controlle .ToListAsync(); return Ok(rows); } + + [HttpGet("/econ/customAvatarItems")] + [HttpGet("/econ/customAvatarItems/v1/owned")] + public async Task GetOwnedCustomAvatarItems() + { + var pid = this.RequireCurrentPlayerId(); + var rows = await db.CustomAvatarItemOwnership + .Where(o => o.PlayerId == pid) + .Join(db.CustomAvatarItems, o => o.CustomAvatarItemId, i => i.Id, (o, i) => i) + .OrderByDescending(i => i.UpdatedAt) + .ToListAsync(); + return Ok(rows.Select(i => new + { + CustomAvatarItemId = i.PublicId, + Id = i.PublicId, + CreatorPlayerId = (int)i.CreatorPlayerId, + i.Name, + i.Description, + i.Price, + i.ItemType, + i.ImageName, + i.AssetName, + i.Color, + })); + } + + [HttpGet("/econ/customAvatarItems/v1/itemOwnershipLimit")] + public IActionResult CustomAvatarItemOwnershipLimit() => Ok(new + { + Limit = 1000, + ItemOwnershipLimit = 1000, + }); } public class TokenBalance diff --git a/DorkNet.Server/Data/DorkNetDbContext.cs b/DorkNet.Server/Data/DorkNetDbContext.cs index 0a22672..feaca38 100644 --- a/DorkNet.Server/Data/DorkNetDbContext.cs +++ b/DorkNet.Server/Data/DorkNetDbContext.cs @@ -74,6 +74,14 @@ public class DorkNetDbContext(DbContextOptions options) : DbCo public DbSet ClubSubscriptions => Set(); public DbSet SignupCodes => Set(); public DbSet PendingDevices => Set(); + public DbSet CustomAvatarItems => Set(); + public DbSet CustomAvatarItemOwnership => Set(); + public DbSet ItemWishlists => Set(); + public DbSet Keepsakes => Set(); + public DbSet RoomCurrencies => Set(); + public DbSet RoomCurrencyBalances => Set(); + public DbSet RoomCurrencyPurchaseOffers => Set(); + public DbSet UgcPurchasables => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -679,5 +687,64 @@ public class DorkNetDbContext(DbContextOptions options) : DbCo e.HasKey(c => new { c.RoomId, c.Channel }); e.HasIndex(c => c.RoomId); }); + + modelBuilder.Entity(e => + { + e.HasKey(i => i.Id); + e.HasIndex(i => i.PublicId).IsUnique(); + e.HasIndex(i => i.CreatorPlayerId); + e.HasIndex(i => i.IsPublic); + e.HasIndex(i => i.IsFeatured); + }); + + modelBuilder.Entity(e => + { + e.HasKey(o => o.Id); + e.HasIndex(o => new { o.PlayerId, o.CustomAvatarItemId }).IsUnique(); + e.HasIndex(o => o.CustomAvatarItemId); + }); + + modelBuilder.Entity(e => + { + e.HasKey(w => w.Id); + e.HasIndex(w => new { w.PlayerId, w.ItemKey, w.ItemType }).IsUnique(); + }); + + modelBuilder.Entity(e => + { + e.HasKey(k => k.Id); + e.HasIndex(k => k.PlayerId); + e.HasIndex(k => new { k.PlayerId, k.EventKey }); + }); + + modelBuilder.Entity(e => + { + e.HasKey(c => c.Id); + e.HasIndex(c => c.PublicId).IsUnique(); + e.HasIndex(c => c.RoomId); + e.HasIndex(c => c.CreatorPlayerId); + }); + + modelBuilder.Entity(e => + { + e.HasKey(b => b.Id); + e.HasIndex(b => new { b.PlayerId, b.RoomCurrencyId }).IsUnique(); + e.HasIndex(b => b.RoomCurrencyId); + }); + + modelBuilder.Entity(e => + { + e.HasKey(o => o.Id); + e.HasIndex(o => o.PublicId).IsUnique(); + e.HasIndex(o => o.RoomCurrencyId); + }); + + modelBuilder.Entity(e => + { + e.HasKey(p => p.Id); + e.HasIndex(p => p.PublicId).IsUnique(); + e.HasIndex(p => p.RoomId); + e.HasIndex(p => p.CreatorPlayerId); + }); } } diff --git a/DorkNet.Server/Data/Entities/CustomizationEntities.cs b/DorkNet.Server/Data/Entities/CustomizationEntities.cs new file mode 100644 index 0000000..382e0a2 --- /dev/null +++ b/DorkNet.Server/Data/Entities/CustomizationEntities.cs @@ -0,0 +1,94 @@ +using System.ComponentModel.DataAnnotations; + +namespace DorkNet.Server.Data.Entities; + +public class CustomAvatarItemEntity +{ + public long Id { get; set; } + + public Guid PublicId { get; set; } = Guid.NewGuid(); + + public long CreatorPlayerId { get; set; } + + [MaxLength(128)] + public string Name { get; set; } = string.Empty; + + [MaxLength(1024)] + public string Description { get; set; } = string.Empty; + + public int Price { get; set; } = 100; + + public int ItemType { get; set; } = 0; + + public int BaseAvatarItemId { get; set; } + + [MaxLength(64)] + public string Color { get; set; } = string.Empty; + + [MaxLength(256)] + public string ImageName { get; set; } = string.Empty; + + [MaxLength(256)] + public string AssetName { get; set; } = string.Empty; + + public bool IsPublic { get; set; } + + public bool IsFeatured { get; set; } + + public long CheerCount { get; set; } + + public long PurchaseCount { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} + +public class CustomAvatarItemOwnershipEntity +{ + public long Id { get; set; } + + public long PlayerId { get; set; } + + public long CustomAvatarItemId { get; set; } + + public DateTime AcquiredAt { get; set; } = DateTime.UtcNow; +} + +public class ItemWishlistEntity +{ + public long Id { get; set; } + + public long PlayerId { get; set; } + + [MaxLength(128)] + public string ItemKey { get; set; } = string.Empty; + + public int ItemType { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} + +public class KeepsakeEntity +{ + public long Id { get; set; } + + public long PlayerId { get; set; } + + [MaxLength(64)] + public string Category { get; set; } = "general"; + + [MaxLength(128)] + public string EventKey { get; set; } = string.Empty; + + [MaxLength(128)] + public string Title { get; set; } = string.Empty; + + [MaxLength(1024)] + public string Description { get; set; } = string.Empty; + + [MaxLength(256)] + public string ImageName { get; set; } = string.Empty; + + public DateTime EarnedAt { get; set; } = DateTime.UtcNow; +} diff --git a/DorkNet.Server/Data/Entities/InventionEntity.cs b/DorkNet.Server/Data/Entities/InventionEntity.cs index 8f5460c..0330951 100644 --- a/DorkNet.Server/Data/Entities/InventionEntity.cs +++ b/DorkNet.Server/Data/Entities/InventionEntity.cs @@ -85,6 +85,7 @@ public class InventionEntity public int CheerCount { get; set; } = 0; public int SpawnCount { get; set; } = 0; + public int Price { get; set; } = 0; /// Soft-delete flag. When set, the row is hidden from /// browse / search / detail endpoints but stays in the table so diff --git a/DorkNet.Server/Data/Entities/RoomEconomyEntities.cs b/DorkNet.Server/Data/Entities/RoomEconomyEntities.cs new file mode 100644 index 0000000..2ca1d00 --- /dev/null +++ b/DorkNet.Server/Data/Entities/RoomEconomyEntities.cs @@ -0,0 +1,60 @@ +using System.ComponentModel.DataAnnotations; + +namespace DorkNet.Server.Data.Entities; + +public class RoomCurrencyEntity +{ + public long Id { get; set; } + public Guid PublicId { get; set; } = Guid.NewGuid(); + public long RoomId { get; set; } + public long CreatorPlayerId { get; set; } + [MaxLength(64)] public string Name { get; set; } = string.Empty; + [MaxLength(512)] public string Description { get; set; } = string.Empty; + [MaxLength(256)] public string ImageName { get; set; } = string.Empty; + public int DailyLimit { get; set; } = 0; + public bool IsDeleted { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} + +public class RoomCurrencyBalanceEntity +{ + public long Id { get; set; } + public long PlayerId { get; set; } + public long RoomCurrencyId { get; set; } + public long Balance { get; set; } + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} + +public class RoomCurrencyPurchaseOfferEntity +{ + public long Id { get; set; } + public Guid PublicId { get; set; } = Guid.NewGuid(); + public long RoomCurrencyId { get; set; } + [MaxLength(64)] public string Name { get; set; } = string.Empty; + public int Amount { get; set; } + public int Price { get; set; } + public int CurrencyType { get; set; } = 2; + public bool IsDeleted { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} + +public class UgcPurchasableEntity +{ + public long Id { get; set; } + public Guid PublicId { get; set; } = Guid.NewGuid(); + public long RoomId { get; set; } + public long CreatorPlayerId { get; set; } + [MaxLength(128)] public string Name { get; set; } = string.Empty; + [MaxLength(1024)] public string Description { get; set; } = string.Empty; + [MaxLength(256)] public string ImageName { get; set; } = string.Empty; + public int Price { get; set; } + public int CurrencyType { get; set; } = 2; + public int ItemType { get; set; } + public bool IsFeatured { get; set; } + public int SortOrder { get; set; } + public bool IsDeleted { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/DorkNet.Server/Data/Entities/RoomEntity.cs b/DorkNet.Server/Data/Entities/RoomEntity.cs index 763685f..00b61cf 100644 --- a/DorkNet.Server/Data/Entities/RoomEntity.cs +++ b/DorkNet.Server/Data/Entities/RoomEntity.cs @@ -47,6 +47,12 @@ public class RoomEntity public bool IsAGRoom { get; set; } = true; public bool IsDormRoom { get; set; } = false; + public bool IsStudioRoom { get; set; } = false; + public bool IsRoomLinkedToRecRoomStudio { get; set; } = false; + + [MaxLength(128)] + public string StudioSessionId { get; set; } = string.Empty; + public bool CloningAllowed { get; set; } = false; public bool SupportsVRLow { get; set; } = true; public bool SupportsMobile { get; set; } = false; diff --git a/DorkNet.Server/Services/AuthService.cs b/DorkNet.Server/Services/AuthService.cs index 88232c9..8b37e3d 100644 --- a/DorkNet.Server/Services/AuthService.cs +++ b/DorkNet.Server/Services/AuthService.cs @@ -9,6 +9,8 @@ namespace DorkNet.Server.Services; public class AuthService(IConfiguration config, DorkNetDbContext db) { + public const string AccessCookieName = "dorknet_access_token"; + // Resolution order MUST match Program.cs's JwtBearer validator setup — // env var first, config key second — otherwise tokens are signed with // one secret and validated against another and every authenticated @@ -56,13 +58,22 @@ public class AuthService(IConfiguration config, DorkNetDbContext db) var claims = new List { new(ClaimTypes.NameIdentifier, playerId.ToString()), + new(JwtRegisteredClaimNames.Sub, playerId.ToString()), + new("accountId", playerId.ToString()), + new("account_id", playerId.ToString()), + new("accountid", playerId.ToString()), new(ClaimTypes.Role, "gameClient"), + new("roles", "gameClient"), new("token_type", tokenType), }; // Multiple Role claims become a JSON array on the wire — the // watch's HasRole helper handles both string and List // forms. - if (isDev) claims.Add(new Claim(ClaimTypes.Role, "developer")); + if (isDev) + { + claims.Add(new Claim(ClaimTypes.Role, "developer")); + claims.Add(new Claim("roles", "developer")); + } var descriptor = new SecurityTokenDescriptor { diff --git a/DorkNet.Server/Services/RoomService.cs b/DorkNet.Server/Services/RoomService.cs index 0dbcfee..4d0b36c 100644 --- a/DorkNet.Server/Services/RoomService.cs +++ b/DorkNet.Server/Services/RoomService.cs @@ -1016,6 +1016,9 @@ public class RoomService(DorkNetDbContext db) // showed up that the admin couldn't purge.) IsAGRoom = false, IsDormRoom = false, + IsStudioRoom = source.IsStudioRoom, + IsRoomLinkedToRecRoomStudio = source.IsRoomLinkedToRecRoomStudio, + StudioSessionId = source.StudioSessionId, CloningAllowed = source.CloningAllowed, SupportsVRLow = source.SupportsVRLow, SupportsMobile = source.SupportsMobile, @@ -1070,6 +1073,9 @@ public class RoomService(DorkNetDbContext db) // ignored by both clients. IsDorm = r.IsDormRoom, IsDormRoom = r.IsDormRoom, + IsStudioRoom = r.IsStudioRoom, + IsRoomLinkedToRecRoomStudio = r.IsRoomLinkedToRecRoomStudio, + StudioSessionId = r.StudioSessionId, CloningAllowed = r.CloningAllowed, SupportsVRLow = r.SupportsVRLow, SupportsMobile = r.SupportsMobile, diff --git a/DorkNet.Server/Startup/DatabaseBootstrap.cs b/DorkNet.Server/Startup/DatabaseBootstrap.cs index 314b5d0..5ad4027 100644 --- a/DorkNet.Server/Startup/DatabaseBootstrap.cs +++ b/DorkNet.Server/Startup/DatabaseBootstrap.cs @@ -104,6 +104,8 @@ public static class DatabaseBootstrap await EnsureInventorySchemaAsync(db); await EnsureLeaderboardSchemaAsync(db); await EnsureRoomColumnsAsync(db); + await EnsureInventionColumnsAsync(db); + await EnsureMarch2023TablesAsync(db); // Coach system account at Player.Id=1. The RR-Original room seeder // below assigns CreatorPlayerId=1 to every canonical room @@ -251,6 +253,12 @@ public static class DatabaseBootstrap { await db.Database.ExecuteSqlRawAsync( @"ALTER TABLE ""Rooms"" ADD COLUMN IF NOT EXISTS ""MaxCapacity"" integer NOT NULL DEFAULT 8;"); + await db.Database.ExecuteSqlRawAsync( + @"ALTER TABLE ""Rooms"" ADD COLUMN IF NOT EXISTS ""IsStudioRoom"" boolean NOT NULL DEFAULT false;"); + await db.Database.ExecuteSqlRawAsync( + @"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 '';"); return; } @@ -262,6 +270,45 @@ public static class DatabaseBootstrap @"ALTER TABLE ""Rooms"" ADD COLUMN ""MaxCapacity"" INTEGER NOT NULL DEFAULT 8;"); } catch { } + + try + { + await db.Database.ExecuteSqlRawAsync( + @"ALTER TABLE ""Rooms"" ADD COLUMN ""IsStudioRoom"" INTEGER NOT NULL DEFAULT 0;"); + } + catch { } + + try + { + await db.Database.ExecuteSqlRawAsync( + @"ALTER TABLE ""Rooms"" ADD COLUMN ""IsRoomLinkedToRecRoomStudio"" INTEGER NOT NULL DEFAULT 0;"); + } + catch { } + + try + { + await db.Database.ExecuteSqlRawAsync( + @"ALTER TABLE ""Rooms"" ADD COLUMN ""StudioSessionId"" TEXT NOT NULL DEFAULT '';"); + } + catch { } + } + + private static async Task EnsureInventionColumnsAsync(DorkNetDbContext db) + { + var provider = db.Database.ProviderName ?? string.Empty; + if (provider.Contains("Npgsql", StringComparison.OrdinalIgnoreCase)) + { + await db.Database.ExecuteSqlRawAsync( + @"ALTER TABLE ""Inventions"" ADD COLUMN IF NOT EXISTS ""Price"" integer NOT NULL DEFAULT 0;"); + return; + } + + try + { + await db.Database.ExecuteSqlRawAsync( + @"ALTER TABLE ""Inventions"" ADD COLUMN ""Price"" INTEGER NOT NULL DEFAULT 0;"); + } + catch { } } private static async Task EnsureServerSettingsColumnsAsync(DorkNetDbContext db) @@ -464,6 +511,280 @@ public static class DatabaseBootstrap @"CREATE INDEX IF NOT EXISTS ""IX_LeaderboardStats_RoomId_StatChannel_Value"" ON ""LeaderboardStats"" (""RoomId"", ""StatChannel"", ""Value"");"); } + private static async Task EnsureMarch2023TablesAsync(DorkNetDbContext db) + { + var provider = db.Database.ProviderName ?? string.Empty; + if (provider.Contains("Npgsql", StringComparison.OrdinalIgnoreCase)) + { + var statements = new[] + { + """ + CREATE TABLE IF NOT EXISTS "CustomAvatarItems" ( + "Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + "PublicId" uuid NOT NULL, + "CreatorPlayerId" bigint NOT NULL, + "Name" character varying(128) NOT NULL, + "Description" character varying(1024) NOT NULL, + "Price" integer NOT NULL DEFAULT 100, + "ItemType" integer NOT NULL DEFAULT 0, + "BaseAvatarItemId" integer NOT NULL DEFAULT 0, + "Color" character varying(64) NOT NULL DEFAULT '', + "ImageName" character varying(256) NOT NULL DEFAULT '', + "AssetName" character varying(256) NOT NULL DEFAULT '', + "IsPublic" boolean NOT NULL DEFAULT false, + "IsFeatured" boolean NOT NULL DEFAULT false, + "CheerCount" bigint NOT NULL DEFAULT 0, + "PurchaseCount" bigint NOT NULL DEFAULT 0, + "CreatedAt" timestamp with time zone NOT NULL DEFAULT now(), + "UpdatedAt" timestamp with time zone NOT NULL DEFAULT now() + ); + """, + @"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_CustomAvatarItems_PublicId"" ON ""CustomAvatarItems"" (""PublicId"");", + @"CREATE INDEX IF NOT EXISTS ""IX_CustomAvatarItems_CreatorPlayerId"" ON ""CustomAvatarItems"" (""CreatorPlayerId"");", + @"CREATE INDEX IF NOT EXISTS ""IX_CustomAvatarItems_IsPublic"" ON ""CustomAvatarItems"" (""IsPublic"");", + @"CREATE INDEX IF NOT EXISTS ""IX_CustomAvatarItems_IsFeatured"" ON ""CustomAvatarItems"" (""IsFeatured"");", + """ + CREATE TABLE IF NOT EXISTS "CustomAvatarItemOwnership" ( + "Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + "PlayerId" bigint NOT NULL, + "CustomAvatarItemId" bigint NOT NULL, + "AcquiredAt" timestamp with time zone NOT NULL DEFAULT now() + ); + """, + @"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_CustomAvatarItemOwnership_PlayerId_CustomAvatarItemId"" ON ""CustomAvatarItemOwnership"" (""PlayerId"", ""CustomAvatarItemId"");", + @"CREATE INDEX IF NOT EXISTS ""IX_CustomAvatarItemOwnership_CustomAvatarItemId"" ON ""CustomAvatarItemOwnership"" (""CustomAvatarItemId"");", + """ + CREATE TABLE IF NOT EXISTS "ItemWishlists" ( + "Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + "PlayerId" bigint NOT NULL, + "ItemKey" character varying(128) NOT NULL, + "ItemType" integer NOT NULL DEFAULT 0, + "CreatedAt" timestamp with time zone NOT NULL DEFAULT now() + ); + """, + @"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ItemWishlists_PlayerId_ItemKey_ItemType"" ON ""ItemWishlists"" (""PlayerId"", ""ItemKey"", ""ItemType"");", + """ + CREATE TABLE IF NOT EXISTS "Keepsakes" ( + "Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + "PlayerId" bigint NOT NULL, + "Category" character varying(64) NOT NULL DEFAULT 'general', + "EventKey" character varying(128) NOT NULL DEFAULT '', + "Title" character varying(128) NOT NULL DEFAULT '', + "Description" character varying(1024) NOT NULL DEFAULT '', + "ImageName" character varying(256) NOT NULL DEFAULT '', + "EarnedAt" timestamp with time zone NOT NULL DEFAULT now() + ); + """, + @"CREATE INDEX IF NOT EXISTS ""IX_Keepsakes_PlayerId"" ON ""Keepsakes"" (""PlayerId"");", + @"CREATE INDEX IF NOT EXISTS ""IX_Keepsakes_PlayerId_EventKey"" ON ""Keepsakes"" (""PlayerId"", ""EventKey"");", + """ + CREATE TABLE IF NOT EXISTS "RoomCurrencies" ( + "Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + "PublicId" uuid NOT NULL, + "RoomId" bigint NOT NULL, + "CreatorPlayerId" bigint NOT NULL, + "Name" character varying(64) NOT NULL DEFAULT '', + "Description" character varying(512) NOT NULL DEFAULT '', + "ImageName" character varying(256) NOT NULL DEFAULT '', + "DailyLimit" integer NOT NULL DEFAULT 0, + "IsDeleted" boolean NOT NULL DEFAULT false, + "CreatedAt" timestamp with time zone NOT NULL DEFAULT now(), + "UpdatedAt" timestamp with time zone NOT NULL DEFAULT now() + ); + """, + @"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_RoomCurrencies_PublicId"" ON ""RoomCurrencies"" (""PublicId"");", + @"CREATE INDEX IF NOT EXISTS ""IX_RoomCurrencies_RoomId"" ON ""RoomCurrencies"" (""RoomId"");", + @"CREATE INDEX IF NOT EXISTS ""IX_RoomCurrencies_CreatorPlayerId"" ON ""RoomCurrencies"" (""CreatorPlayerId"");", + """ + CREATE TABLE IF NOT EXISTS "RoomCurrencyBalances" ( + "Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + "PlayerId" bigint NOT NULL, + "RoomCurrencyId" bigint NOT NULL, + "Balance" bigint NOT NULL DEFAULT 0, + "UpdatedAt" timestamp with time zone NOT NULL DEFAULT now() + ); + """, + @"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_RoomCurrencyBalances_PlayerId_RoomCurrencyId"" ON ""RoomCurrencyBalances"" (""PlayerId"", ""RoomCurrencyId"");", + @"CREATE INDEX IF NOT EXISTS ""IX_RoomCurrencyBalances_RoomCurrencyId"" ON ""RoomCurrencyBalances"" (""RoomCurrencyId"");", + """ + CREATE TABLE IF NOT EXISTS "RoomCurrencyPurchaseOffers" ( + "Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + "PublicId" uuid NOT NULL, + "RoomCurrencyId" bigint NOT NULL, + "Name" character varying(64) NOT NULL DEFAULT '', + "Amount" integer NOT NULL DEFAULT 0, + "Price" integer NOT NULL DEFAULT 0, + "CurrencyType" integer NOT NULL DEFAULT 2, + "IsDeleted" boolean NOT NULL DEFAULT false, + "CreatedAt" timestamp with time zone NOT NULL DEFAULT now(), + "UpdatedAt" timestamp with time zone NOT NULL DEFAULT now() + ); + """, + @"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_RoomCurrencyPurchaseOffers_PublicId"" ON ""RoomCurrencyPurchaseOffers"" (""PublicId"");", + @"CREATE INDEX IF NOT EXISTS ""IX_RoomCurrencyPurchaseOffers_RoomCurrencyId"" ON ""RoomCurrencyPurchaseOffers"" (""RoomCurrencyId"");", + """ + CREATE TABLE IF NOT EXISTS "UgcPurchasables" ( + "Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + "PublicId" uuid NOT NULL, + "RoomId" bigint NOT NULL, + "CreatorPlayerId" bigint NOT NULL, + "Name" character varying(128) NOT NULL DEFAULT '', + "Description" character varying(1024) NOT NULL DEFAULT '', + "ImageName" character varying(256) NOT NULL DEFAULT '', + "Price" integer NOT NULL DEFAULT 0, + "CurrencyType" integer NOT NULL DEFAULT 2, + "ItemType" integer NOT NULL DEFAULT 0, + "IsFeatured" boolean NOT NULL DEFAULT false, + "SortOrder" integer NOT NULL DEFAULT 0, + "IsDeleted" boolean NOT NULL DEFAULT false, + "CreatedAt" timestamp with time zone NOT NULL DEFAULT now(), + "UpdatedAt" timestamp with time zone NOT NULL DEFAULT now() + ); + """, + @"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_UgcPurchasables_PublicId"" ON ""UgcPurchasables"" (""PublicId"");", + @"CREATE INDEX IF NOT EXISTS ""IX_UgcPurchasables_RoomId"" ON ""UgcPurchasables"" (""RoomId"");", + @"CREATE INDEX IF NOT EXISTS ""IX_UgcPurchasables_CreatorPlayerId"" ON ""UgcPurchasables"" (""CreatorPlayerId"");", + }; + + foreach (var sql in statements) + await db.Database.ExecuteSqlRawAsync(sql); + return; + } + + var sqliteStatements = new[] + { + """ + CREATE TABLE IF NOT EXISTS "CustomAvatarItems" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_CustomAvatarItems" PRIMARY KEY AUTOINCREMENT, + "PublicId" TEXT NOT NULL, + "CreatorPlayerId" INTEGER NOT NULL, + "Name" TEXT NOT NULL, + "Description" TEXT NOT NULL, + "Price" INTEGER NOT NULL DEFAULT 100, + "ItemType" INTEGER NOT NULL DEFAULT 0, + "BaseAvatarItemId" INTEGER NOT NULL DEFAULT 0, + "Color" TEXT NOT NULL DEFAULT '', + "ImageName" TEXT NOT NULL DEFAULT '', + "AssetName" TEXT NOT NULL DEFAULT '', + "IsPublic" INTEGER NOT NULL DEFAULT 0, + "IsFeatured" INTEGER NOT NULL DEFAULT 0, + "CheerCount" INTEGER NOT NULL DEFAULT 0, + "PurchaseCount" INTEGER NOT NULL DEFAULT 0, + "CreatedAt" TEXT NOT NULL, + "UpdatedAt" TEXT NOT NULL + ); + """, + @"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_CustomAvatarItems_PublicId"" ON ""CustomAvatarItems"" (""PublicId"");", + @"CREATE INDEX IF NOT EXISTS ""IX_CustomAvatarItems_CreatorPlayerId"" ON ""CustomAvatarItems"" (""CreatorPlayerId"");", + @"CREATE INDEX IF NOT EXISTS ""IX_CustomAvatarItems_IsPublic"" ON ""CustomAvatarItems"" (""IsPublic"");", + @"CREATE INDEX IF NOT EXISTS ""IX_CustomAvatarItems_IsFeatured"" ON ""CustomAvatarItems"" (""IsFeatured"");", + """ + CREATE TABLE IF NOT EXISTS "CustomAvatarItemOwnership" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_CustomAvatarItemOwnership" PRIMARY KEY AUTOINCREMENT, + "PlayerId" INTEGER NOT NULL, + "CustomAvatarItemId" INTEGER NOT NULL, + "AcquiredAt" TEXT NOT NULL + ); + """, + @"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_CustomAvatarItemOwnership_PlayerId_CustomAvatarItemId"" ON ""CustomAvatarItemOwnership"" (""PlayerId"", ""CustomAvatarItemId"");", + @"CREATE INDEX IF NOT EXISTS ""IX_CustomAvatarItemOwnership_CustomAvatarItemId"" ON ""CustomAvatarItemOwnership"" (""CustomAvatarItemId"");", + """ + CREATE TABLE IF NOT EXISTS "ItemWishlists" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_ItemWishlists" PRIMARY KEY AUTOINCREMENT, + "PlayerId" INTEGER NOT NULL, + "ItemKey" TEXT NOT NULL, + "ItemType" INTEGER NOT NULL DEFAULT 0, + "CreatedAt" TEXT NOT NULL + ); + """, + @"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ItemWishlists_PlayerId_ItemKey_ItemType"" ON ""ItemWishlists"" (""PlayerId"", ""ItemKey"", ""ItemType"");", + """ + CREATE TABLE IF NOT EXISTS "Keepsakes" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_Keepsakes" PRIMARY KEY AUTOINCREMENT, + "PlayerId" INTEGER NOT NULL, + "Category" TEXT NOT NULL DEFAULT 'general', + "EventKey" TEXT NOT NULL DEFAULT '', + "Title" TEXT NOT NULL DEFAULT '', + "Description" TEXT NOT NULL DEFAULT '', + "ImageName" TEXT NOT NULL DEFAULT '', + "EarnedAt" TEXT NOT NULL + ); + """, + @"CREATE INDEX IF NOT EXISTS ""IX_Keepsakes_PlayerId"" ON ""Keepsakes"" (""PlayerId"");", + @"CREATE INDEX IF NOT EXISTS ""IX_Keepsakes_PlayerId_EventKey"" ON ""Keepsakes"" (""PlayerId"", ""EventKey"");", + """ + CREATE TABLE IF NOT EXISTS "RoomCurrencies" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_RoomCurrencies" PRIMARY KEY AUTOINCREMENT, + "PublicId" TEXT NOT NULL, + "RoomId" INTEGER NOT NULL, + "CreatorPlayerId" INTEGER NOT NULL, + "Name" TEXT NOT NULL DEFAULT '', + "Description" TEXT NOT NULL DEFAULT '', + "ImageName" TEXT NOT NULL DEFAULT '', + "DailyLimit" INTEGER NOT NULL DEFAULT 0, + "IsDeleted" INTEGER NOT NULL DEFAULT 0, + "CreatedAt" TEXT NOT NULL, + "UpdatedAt" TEXT NOT NULL + ); + """, + @"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_RoomCurrencies_PublicId"" ON ""RoomCurrencies"" (""PublicId"");", + @"CREATE INDEX IF NOT EXISTS ""IX_RoomCurrencies_RoomId"" ON ""RoomCurrencies"" (""RoomId"");", + @"CREATE INDEX IF NOT EXISTS ""IX_RoomCurrencies_CreatorPlayerId"" ON ""RoomCurrencies"" (""CreatorPlayerId"");", + """ + CREATE TABLE IF NOT EXISTS "RoomCurrencyBalances" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_RoomCurrencyBalances" PRIMARY KEY AUTOINCREMENT, + "PlayerId" INTEGER NOT NULL, + "RoomCurrencyId" INTEGER NOT NULL, + "Balance" INTEGER NOT NULL DEFAULT 0, + "UpdatedAt" TEXT NOT NULL + ); + """, + @"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_RoomCurrencyBalances_PlayerId_RoomCurrencyId"" ON ""RoomCurrencyBalances"" (""PlayerId"", ""RoomCurrencyId"");", + @"CREATE INDEX IF NOT EXISTS ""IX_RoomCurrencyBalances_RoomCurrencyId"" ON ""RoomCurrencyBalances"" (""RoomCurrencyId"");", + """ + CREATE TABLE IF NOT EXISTS "RoomCurrencyPurchaseOffers" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_RoomCurrencyPurchaseOffers" PRIMARY KEY AUTOINCREMENT, + "PublicId" TEXT NOT NULL, + "RoomCurrencyId" INTEGER NOT NULL, + "Name" TEXT NOT NULL DEFAULT '', + "Amount" INTEGER NOT NULL DEFAULT 0, + "Price" INTEGER NOT NULL DEFAULT 0, + "CurrencyType" INTEGER NOT NULL DEFAULT 2, + "IsDeleted" INTEGER NOT NULL DEFAULT 0, + "CreatedAt" TEXT NOT NULL, + "UpdatedAt" TEXT NOT NULL + ); + """, + @"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_RoomCurrencyPurchaseOffers_PublicId"" ON ""RoomCurrencyPurchaseOffers"" (""PublicId"");", + @"CREATE INDEX IF NOT EXISTS ""IX_RoomCurrencyPurchaseOffers_RoomCurrencyId"" ON ""RoomCurrencyPurchaseOffers"" (""RoomCurrencyId"");", + """ + CREATE TABLE IF NOT EXISTS "UgcPurchasables" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_UgcPurchasables" PRIMARY KEY AUTOINCREMENT, + "PublicId" TEXT NOT NULL, + "RoomId" INTEGER NOT NULL, + "CreatorPlayerId" INTEGER NOT NULL, + "Name" TEXT NOT NULL DEFAULT '', + "Description" TEXT NOT NULL DEFAULT '', + "ImageName" TEXT NOT NULL DEFAULT '', + "Price" INTEGER NOT NULL DEFAULT 0, + "CurrencyType" INTEGER NOT NULL DEFAULT 2, + "ItemType" INTEGER NOT NULL DEFAULT 0, + "IsFeatured" INTEGER NOT NULL DEFAULT 0, + "SortOrder" INTEGER NOT NULL DEFAULT 0, + "IsDeleted" INTEGER NOT NULL DEFAULT 0, + "CreatedAt" TEXT NOT NULL, + "UpdatedAt" TEXT NOT NULL + ); + """, + @"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_UgcPurchasables_PublicId"" ON ""UgcPurchasables"" (""PublicId"");", + @"CREATE INDEX IF NOT EXISTS ""IX_UgcPurchasables_RoomId"" ON ""UgcPurchasables"" (""RoomId"");", + @"CREATE INDEX IF NOT EXISTS ""IX_UgcPurchasables_CreatorPlayerId"" ON ""UgcPurchasables"" (""CreatorPlayerId"");", + }; + + foreach (var sql in sqliteStatements) + await db.Database.ExecuteSqlRawAsync(sql); + } + /// SQLite migration-history reconciliation. Handles two /// cutover scenarios so legacy DBs boot cleanly without manual SQL: /// diff --git a/DorkNet.Server/Startup/RequestTracingExtensions.cs b/DorkNet.Server/Startup/RequestTracingExtensions.cs index b78c9c4..057972f 100644 --- a/DorkNet.Server/Startup/RequestTracingExtensions.cs +++ b/DorkNet.Server/Startup/RequestTracingExtensions.cs @@ -128,14 +128,15 @@ public static class RequestTracingExtensions var trimmedRequest = safeRequest is null ? null : (safeRequest.Length > 200 ? safeRequest[..200] + "..." : safeRequest); var trimmedResponse = safeResponse.Length > 300 ? safeResponse[..300] + "..." : safeResponse; + var authPresence = DescribeAuthPresence(ctx.Request); var level = thrown is not null || status >= 500 ? LogLevel.Error : status >= 400 ? LogLevel.Warning : LogLevel.Information; reqLogger.Log(level, thrown, - "[req] {Status} {Method} {Host}{Path}{Query} {ElapsedMs}ms contentType={ContentType} contentLength={ContentLength} req={ReqBody} resp={RespBody}", + "[req] {Status} {Method} {Host}{Path}{Query} {ElapsedMs}ms auth={AuthPresence} contentType={ContentType} contentLength={ContentLength} req={ReqBody} resp={RespBody}", status, ctx.Request.Method, ctx.Request.Host, ctx.Request.Path, - ctx.Request.QueryString.Value ?? "", sw.ElapsedMilliseconds, + ctx.Request.QueryString.Value ?? "", sw.ElapsedMilliseconds, authPresence, ctx.Request.ContentType ?? "", ctx.Request.ContentLength, trimmedRequest ?? "", trimmedResponse); @@ -170,6 +171,15 @@ public static class RequestTracingExtensions "password|passwd|pwd|passwordhash|token|accesstoken|refreshtoken|idtoken|" + "secret|clientsecret|apikey|api_key|authorization|deviceauth|sessionkey"; + private static string DescribeAuthPresence(HttpRequest request) + { + var hasAuthHeader = request.Headers.ContainsKey("Authorization"); + var hasBearer = request.Headers.Authorization.ToString() + .StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase); + var hasAccessCookie = request.Cookies.ContainsKey(AuthService.AccessCookieName); + return $"header={hasAuthHeader};bearer={hasBearer};cookie={hasAccessCookie}"; + } + private static readonly Regex JsonSecretRegex = new( "(\"(?:" + SecretKeys + ")\"\\s*:\\s*)\"(?:[^\"\\\\]|\\\\.)*\"", RegexOptions.IgnoreCase | RegexOptions.Compiled); diff --git a/DorkNet.Server/Startup/ServiceCollectionExtensions.cs b/DorkNet.Server/Startup/ServiceCollectionExtensions.cs index 4162d59..16c91fc 100644 --- a/DorkNet.Server/Startup/ServiceCollectionExtensions.cs +++ b/DorkNet.Server/Startup/ServiceCollectionExtensions.cs @@ -107,14 +107,22 @@ public static class ServiceCollectionExtensions ?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN") ?? "localhost"; var scheme = builder.Configuration["Domain:Scheme"] ?? "https"; + var allowedHosts = new List { apex, $"*.{apex}", "localhost", "127.0.0.1" }; + var bootstrapApex = builder.Configuration["Domain:BootstrapApex"]; + if (!string.IsNullOrWhiteSpace(bootstrapApex)) + { + allowedHosts.Add(bootstrapApex); + allowedHosts.Add($"*.{bootstrapApex}"); + } + builder.Services.AddSingleton(new DomainConfig(apex, scheme)); builder.Services.Configure(opt => { - opt.AllowedHosts = new[] { apex, $"*.{apex}", "localhost", "127.0.0.1" }; + opt.AllowedHosts = allowedHosts.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); opt.AllowEmptyHosts = true; opt.IncludeFailureMessage = true; }); - Console.WriteLine($"[domain] apex={apex}, allowedHosts=[{apex}, *.{apex}, localhost]"); + Console.WriteLine($"[domain] apex={apex}, allowedHosts=[{string.Join(", ", allowedHosts)}]"); } // ── Services ────────────────────────────────────────────────────────────── @@ -174,17 +182,16 @@ public static class ServiceCollectionExtensions var pluginInstances = new IVersionPlugin[] { new Late2020VersionPlugin(), - // Add March2020 / future generations here as their plugins land. }; foreach (var p in pluginInstances) p.RegisterStrategies(builder.Services); foreach (var p in pluginInstances) builder.Services.AddSingleton(p); var supported = (builder.Configuration.GetSection("DorkNet:SupportedVersions").Get() - ?? new[] { "december_2020_12_18" }) + ?? new[] { "march_2023_03_21" }) .ToHashSet(StringComparer.OrdinalIgnoreCase); var defaultKey = builder.Configuration["DorkNet:DefaultClientVersion"] - ?? "december_2020_12_18"; + ?? "march_2023_03_21"; builder.Services.AddSingleton(sp => new VersionRegistry( sp.GetServices(), supported, @@ -285,6 +292,12 @@ public static class ServiceCollectionExtensions var path = ctx.HttpContext.Request.Path; if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hub")) ctx.Token = accessToken; + if (string.IsNullOrEmpty(ctx.Token) && + ctx.Request.Cookies.TryGetValue(AuthService.AccessCookieName, out var cookieToken) && + !string.IsNullOrWhiteSpace(cookieToken)) + { + ctx.Token = cookieToken; + } return Task.CompletedTask; }, }; diff --git a/DorkNet.Server/Versions/Late2020/Late2020VersionPlugin.cs b/DorkNet.Server/Versions/Late2020/Late2020VersionPlugin.cs index 1fdd8dc..10448ff 100644 --- a/DorkNet.Server/Versions/Late2020/Late2020VersionPlugin.cs +++ b/DorkNet.Server/Versions/Late2020/Late2020VersionPlugin.cs @@ -2,31 +2,17 @@ using DorkNet.Server.Compat; namespace DorkNet.Server.Versions.Late2020; -/// The "Late 2020" wire-format generation: covers all Rec Room -/// builds from the autumn 2020 schema migration through the end of the -/// year. The 2020.12.18 build is the canonical member; if/when we add -/// 2020.10 or 2020.11 builds and find their wire shapes are identical, -/// we just list them in here — no new code. -/// -/// Currently this plugin is a marker: it declares which version -/// keys belong to the generation, but no strategy services are -/// registered yet. As controllers start branching on -/// 's -/// Generation, the strategies that get extracted will register -/// themselves through with a -/// keyed-DI binding so the right implementation resolves per -/// request. +/// Version marker for the 2023.03.21 client branch. This +/// branch is intentionally single-client: it does not advertise or +/// serve older 2020 build keys. /// public sealed class Late2020VersionPlugin : IVersionPlugin { - public const string GenerationKey = "Late2020"; + public const string GenerationKey = "March2023"; public IReadOnlyCollection VersionKeys { get; } = new[] { - // Verified working against this server. The canonical - // private-deployment build the codebase has been developed - // against since the move to localhost. - "december_2020_12_18", + "march_2023_03_21", }; public string Generation => GenerationKey; diff --git a/DorkNet.Server/appsettings.json b/DorkNet.Server/appsettings.json index 20ef150..f673a43 100644 --- a/DorkNet.Server/appsettings.json +++ b/DorkNet.Server/appsettings.json @@ -21,13 +21,14 @@ "PhotonRoomPrefix": "DorkNet" }, "DorkNet": { - "_comment_SupportedVersions": "Per-deployment allow-list. Requests whose X-DorkNet-Version header isn't in this set get a 426 Upgrade Required. To accept multiple client builds add their keys here.", - "SupportedVersions": [ "december_2020_12_18" ], - "_comment_DefaultClientVersion": "Fallback for requests without an X-DorkNet-Version header — the transition path while not every shipped client mod yet stamps the header. Set to '' (empty) once everyone's caught up to also reject headerless calls.", - "DefaultClientVersion": "december_2020_12_18", - "_comment_BuildIdToVersionKey": "Maps the raw build identifier the 2020 watch sends to /api/versioncheck/v4?v={buildId} (read from KMJJGELKNAP.MCFMPCNNEBH, e.g. '20201210') to our internal ClientVersion.Key. Builds not listed here get UpdateRequired and the watch shows its native dialog. Add a new build by listing its raw ID + matching version-plugin key.", + "_comment_SupportedVersions": "Per-deployment allow-list. This branch serves only the 2023.03.21 client.", + "SupportedVersions": [ "march_2023_03_21" ], + "_comment_DefaultClientVersion": "Fallback for requests without an X-DorkNet-Version header.", + "DefaultClientVersion": "march_2023_03_21", + "_comment_BuildIdToVersionKey": "Maps raw /api/versioncheck/v4?v= values to the internal client key. Add the exact 2023.03.21 build id once captured from the client log.", "BuildIdToVersionKey": { - "20201210": "december_2020_12_18" + "20230317": "march_2023_03_21", + "20230321": "march_2023_03_21" } } } diff --git a/dorknet-reupload-staging b/dorknet-reupload-staging new file mode 160000 index 0000000..570ccae --- /dev/null +++ b/dorknet-reupload-staging @@ -0,0 +1 @@ +Subproject commit 570ccae554ab6ed42d954ac9917b938055873b2c