mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 15:08:47 -07:00
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.
This commit is contained in:
+542
-10
@@ -24,11 +24,14 @@
|
|||||||
using HarmonyLib;
|
using HarmonyLib;
|
||||||
using MelonLoader;
|
using MelonLoader;
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
[assembly: MelonInfo(typeof(DorkNet.ClientMod.Mod), "DorkNet ClientMod", "1.0.0", "Dork Squad")]
|
[assembly: MelonInfo(typeof(DorkNet.ClientMod.Mod), "DorkNet ClientMod", "1.0.0", "Dork Squad")]
|
||||||
[assembly: MelonGame(null, null)]
|
[assembly: MelonGame(null, null)]
|
||||||
@@ -197,6 +200,13 @@ public class Mod : MelonMod
|
|||||||
// proxy uses without compile-error.
|
// proxy uses without compile-error.
|
||||||
TryPatchByName("PUNNetworkManager", "OnCustomAuthenticationFailed",
|
TryPatchByName("PUNNetworkManager", "OnCustomAuthenticationFailed",
|
||||||
prefix: nameof(PhotonPatches.OnCustomAuthenticationFailed_Prefix));
|
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.
|
// TLS cert-validation bypass — gated behind Cfg.EnableTlsTrustBypass.
|
||||||
// Even when the prefix's return value is "let original run", the
|
// Even when the prefix's return value is "let original run", the
|
||||||
// detour itself still has to be JIT-installed when BouncyCastle
|
// 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",
|
complete &= TryPatch("Uri ctor (string, UriKind)", typeof(Uri), "ctor",
|
||||||
new[] { typeof(string), typeof(UriKind) },
|
new[] { typeof(string), typeof(UriKind) },
|
||||||
prefix: nameof(UriPatches.UriStringCtor_Prefix));
|
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",
|
complete &= TryPatchByName("BestHTTP.HTTPRequest", "Send",
|
||||||
prefix: nameof(UriPatches.HttpRequestSend_Prefix));
|
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 &= TryPatchHttpManagerStringSendRequestOverloads();
|
||||||
complete &= TryPatchByName("BestHTTP.HTTPManager", "SendRequest",
|
complete &= TryPatchByName("BestHTTP.HTTPManager", "SendRequest",
|
||||||
args: new[] { ResolveType("BestHTTP.HTTPRequest") ?? typeof(object) },
|
args: new[] { ResolveType("BestHTTP.HTTPRequest") ?? typeof(object) },
|
||||||
prefix: nameof(UriPatches.HttpManagerSendRequestObject_Prefix));
|
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;
|
_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()
|
private bool TryPatchHttpManagerStringSendRequestOverloads()
|
||||||
{
|
{
|
||||||
var type = ResolveType("BestHTTP.HTTPManager");
|
var type = ResolveType("BestHTTP.HTTPManager");
|
||||||
@@ -582,6 +657,19 @@ public class Mod : MelonMod
|
|||||||
return true;
|
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<string>();
|
||||||
|
foreach (var parameter in parameters)
|
||||||
|
names.Add(parameter.ParameterType.Name);
|
||||||
|
return string.Join(",", names);
|
||||||
|
}
|
||||||
|
|
||||||
public override void OnUpdate()
|
public override void OnUpdate()
|
||||||
{
|
{
|
||||||
if (!_firstUpdateLogged)
|
if (!_firstUpdateLogged)
|
||||||
@@ -903,7 +991,7 @@ public class Mod : MelonMod
|
|||||||
{
|
{
|
||||||
// Look in all three patch holder classes — small enough that a
|
// Look in all three patch holder classes — small enough that a
|
||||||
// linear scan is cheaper than per-class lookups.
|
// 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);
|
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
|
||||||
if (m is not null) return m;
|
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() ?? "<null>")} " +
|
||||||
|
$"service={SanitizeInline(__1?.ToString() ?? "<null>")} path={SanitizeInline(__2?.ToString() ?? "<null>")}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void HeaderSet_Prefix(object __instance, object? __0, object? __1, MethodBase __originalMethod)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var name = __0?.ToString() ?? "<null>";
|
||||||
|
var value = RedactHeaderValue(name, __1?.ToString());
|
||||||
|
var uri = TryReadRequestUri(__instance)?.ToString() ?? "<uri unread>";
|
||||||
|
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() ?? "<uri unread>";
|
||||||
|
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("<Uri>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<object>()), 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() ?? "<null>");
|
||||||
|
}
|
||||||
|
|
||||||
|
return "<method unread>";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string TryReadHeaders(object request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dump = AccessTools.Method(request.GetType(), "DumpHeaders", Type.EmptyTypes);
|
||||||
|
if (dump?.Invoke(request, Array.Empty<object>()) is object rawDump)
|
||||||
|
return SanitizeInline(rawDump.ToString() ?? "<empty>");
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
var known = new List<string>();
|
||||||
|
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 ? "<headers unread>" : 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<object>());
|
||||||
|
var bytes = TryCopyBytes(body, BodyPreviewBytes + 1);
|
||||||
|
if (bytes is null) continue;
|
||||||
|
if (bytes.Length == 0) return "<empty>";
|
||||||
|
|
||||||
|
var shown = Math.Min(bytes.Length, BodyPreviewBytes);
|
||||||
|
var text = Encoding.UTF8.GetString(bytes, 0, shown);
|
||||||
|
var suffix = bytes.Length > BodyPreviewBytes ? "...<truncated>" : "";
|
||||||
|
return $"{bytes.Length} bytes \"{SanitizeInline(text)}{suffix}\"";
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
return "<body unread>";
|
||||||
|
}
|
||||||
|
|
||||||
|
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<byte>();
|
||||||
|
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<object>());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RedactHeaderValue(string name, string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value)) return "<empty>";
|
||||||
|
if (name.Equals("Authorization", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
name.Equals("Cookie", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
name.Equals("Set-Cookie", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
name.Equals("X-RNSIG", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return "<redacted>";
|
||||||
|
}
|
||||||
|
|
||||||
|
return SanitizeInline(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SanitizeInline(string value)
|
||||||
|
{
|
||||||
|
var redacted = SensitivePairRegex.Replace(value, "$1=<redacted>");
|
||||||
|
redacted = BearerRegex.Replace(redacted, "Bearer <redacted>");
|
||||||
|
redacted = redacted.Replace("\r", "\\r").Replace("\n", "\\n").Replace("\t", "\\t");
|
||||||
|
return redacted.Length <= 1200 ? redacted : redacted[..1200] + "...<truncated>";
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
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)
|
public static void HttpManagerSendRequestString_Prefix(ref string url)
|
||||||
{
|
{
|
||||||
RewriteString(ref url, "httpmanager-rewrite");
|
RewriteString(ref url, "httpmanager-rewrite");
|
||||||
|
HttpTracePatches.TraceUrl(url, "BestHTTP.HTTPManager.SendRequest(string)");
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void HttpManagerSendRequestObject_Prefix(object request)
|
public static void HttpManagerSendRequestObject_Prefix(object request)
|
||||||
{
|
{
|
||||||
RewriteRequestUri(request, "httpmanager-request-rewrite");
|
RewriteRequestUri(request, "httpmanager-request-rewrite");
|
||||||
|
HttpTracePatches.TraceRequestObject(request, "BestHTTP.HTTPManager.SendRequest(HTTPRequest)");
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void UriStringCtor_Prefix(ref string uriString)
|
public static void UriStringCtor_Prefix(ref string uriString)
|
||||||
{
|
{
|
||||||
RewriteString(ref uriString, "uri-rewrite");
|
RewriteString(ref uriString, "uri-rewrite");
|
||||||
|
HttpTracePatches.TraceUrl(uriString, "System.Uri..ctor(string)");
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void HttpRequestSend_Prefix(object __instance)
|
public static void HttpRequestSend_Prefix(object __instance)
|
||||||
{
|
{
|
||||||
RewriteRequestUri(__instance, "http-rewrite");
|
RewriteRequestUri(__instance, "http-rewrite");
|
||||||
|
HttpTracePatches.TraceRequestObject(__instance, "BestHTTP.HTTPRequest.Send");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void RewriteRequestUri(object request, string label)
|
private static void RewriteRequestUri(object request, string label)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var uriProp = request.GetType().GetProperty("Uri", BindingFlags.Public | BindingFlags.Instance);
|
if (request is null) return;
|
||||||
if (uriProp?.GetValue(request) is not Uri uri) return;
|
var type = request.GetType();
|
||||||
|
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||||
|
|
||||||
var rewritten = RewriteUri(uri);
|
if (TryRewriteUriProperty(request, type.GetProperty("Uri", flags), label)) return;
|
||||||
if (rewritten is null) return;
|
if (TryRewriteUriField(request, type.GetField("<Uri>k__BackingField", flags), label)) return;
|
||||||
|
|
||||||
uriProp.SetValue(request, rewritten);
|
foreach (var prop in type.GetProperties(flags))
|
||||||
Mod.Log.Msg($"[{label}] {uri} → {rewritten}");
|
{
|
||||||
|
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)
|
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)
|
private static void RewriteString(ref string uriString, string label)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(uriString)) return;
|
if (string.IsNullOrEmpty(uriString)) return;
|
||||||
var host = Mod.Cfg.ServerHost;
|
|
||||||
if (string.IsNullOrEmpty(host)) return;
|
|
||||||
if (uriString.IndexOf(".rec.net", StringComparison.OrdinalIgnoreCase) < 0) 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;
|
if (rewritten == uriString) return;
|
||||||
Mod.Log.Msg($"[{label}] {uriString} → {rewritten}");
|
Mod.Log.Msg($"[{label}] {uriString} → {rewritten}");
|
||||||
uriString = rewritten;
|
uriString = rewritten;
|
||||||
@@ -2364,6 +2886,16 @@ internal static class SavePatches
|
|||||||
private static Type? FindGameType(string name) => Mod.ResolveType(name);
|
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
|
internal static class TlsPatches
|
||||||
{
|
{
|
||||||
// Both BouncyCastle TLS authenticators have the same `void
|
// Both BouncyCastle TLS authenticators have the same `void
|
||||||
|
|||||||
@@ -106,9 +106,11 @@ public class AccountApiController(
|
|||||||
private async Task<PlayerEntity?> GetCurrentPlayerAsync()
|
private async Task<PlayerEntity?> GetCurrentPlayerAsync()
|
||||||
{
|
{
|
||||||
var auth = Request.Headers.Authorization.ToString();
|
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;
|
return null;
|
||||||
var token = auth["Bearer ".Length..].Trim();
|
|
||||||
var id = authService.ValidateToken(token);
|
var id = authService.ValidateToken(token);
|
||||||
if (id is not long playerId) return null;
|
if (id is not long playerId) return null;
|
||||||
return await playerService.GetByIdAsync(playerId);
|
return await playerService.GetByIdAsync(playerId);
|
||||||
|
|||||||
@@ -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<IActionResult> 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) });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<IActionResult> 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<IActionResult> 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<IActionResult> 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -186,6 +186,22 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
|
|||||||
return Ok(result);
|
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<ActionResult<List<UnlockedAvatarItemDto>>> DefaultUnlocked() => Get();
|
||||||
|
|
||||||
/// <summary>The set of GUIDs the bundled
|
/// <summary>The set of GUIDs the bundled
|
||||||
/// <c>AvatarItemWardrobeRuntimeConfig.avatarItemDataLookup</c>
|
/// <c>AvatarItemWardrobeRuntimeConfig.avatarItemDataLookup</c>
|
||||||
/// contains. Exposed so other controllers (StoreService seed,
|
/// contains. Exposed so other controllers (StoreService seed,
|
||||||
|
|||||||
@@ -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")]
|
[HttpPost("api/clubreporting/v1/report")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
|
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using DorkNet.Server.Services;
|
|||||||
namespace DorkNet.Server.Controllers.API.Config.V2;
|
namespace DorkNet.Server.Controllers.API.Config.V2;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
|
[Route("api/config")]
|
||||||
[Route("api/[controller]/v2")]
|
[Route("api/[controller]/v2")]
|
||||||
public class ConfigController(ConfigService configService) : ControllerBase
|
public class ConfigController(ConfigService configService) : ControllerBase
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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<IActionResult> 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<CustomAvatarItemEntity> 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<IActionResult> 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<IActionResult> Bulk([FromBody] BulkRequest? body)
|
||||||
|
{
|
||||||
|
var ids = await ReadIdsAsync(body);
|
||||||
|
if (ids.Count == 0) return Ok(Array.Empty<object>());
|
||||||
|
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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<CustomAvatarItemEntity>()
|
||||||
|
: 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<List<Guid>> ReadIdsAsync(BulkRequest? body)
|
||||||
|
{
|
||||||
|
var result = new List<Guid>();
|
||||||
|
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<DesignRequest> 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<DesignRequest>(
|
||||||
|
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<Guid>? Ids { get; set; }
|
||||||
|
public List<Guid>? 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,34 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using DorkNet.Server.Data;
|
||||||
|
|
||||||
namespace DorkNet.Server.Controllers.API.Events.V3;
|
namespace DorkNet.Server.Controllers.API.Events.V3;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]/v3")]
|
[Route("api/[controller]/v3")]
|
||||||
public class EventsController : ControllerBase
|
public class EventsController(DorkNetDbContext db) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("list")]
|
[HttpGet("list")]
|
||||||
public ActionResult<List<object>> GetActiveEvents() => Ok(new List<object>());
|
public async Task<ActionResult<List<object>>> 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<object>().ToList());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<IActionResult> 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<Dictionary<string, string>> ReadFieldsAsync()
|
||||||
|
{
|
||||||
|
var fields = new Dictionary<string, string>(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<string, string> 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<string, string> fields, params string[] names) =>
|
||||||
|
int.TryParse(ReadString(fields, names), out var value) ? value : null;
|
||||||
|
|
||||||
|
private static List<long> ReadLongList(Dictionary<string, string> fields, params string[] names) =>
|
||||||
|
names.SelectMany(name => fields.TryGetValue(name, out var value)
|
||||||
|
? value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
: Array.Empty<string>())
|
||||||
|
.Select(v => long.TryParse(v, out var id) ? id : 0)
|
||||||
|
.Where(id => id > 0)
|
||||||
|
.Distinct()
|
||||||
|
.Take(100)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
@@ -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<IActionResult> 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -274,6 +274,48 @@ public class ImagesController(
|
|||||||
return Ok(rows.Select(p => BuildImageInfo(p, pid)));
|
return Ok(rows.Select(p => BuildImageInfo(p, pid)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class ImageBulkRequest
|
||||||
|
{
|
||||||
|
public List<long>? ImageIds { get; set; }
|
||||||
|
public List<long>? SavedImageIds { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("api/images/v5/bulk")]
|
||||||
|
[HttpPost("api/images/v5/bulk")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> BulkImages([FromBody] ImageBulkRequest? body)
|
||||||
|
{
|
||||||
|
var pid = this.RequireCurrentPlayerId();
|
||||||
|
var ids = (body?.ImageIds ?? body?.SavedImageIds ?? new List<long>())
|
||||||
|
.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<object>());
|
||||||
|
|
||||||
|
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<IActionResult> 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)));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>GET <c>api/images/v2/named</c> — the watch's
|
/// <summary>GET <c>api/images/v2/named</c> — the watch's
|
||||||
/// <c>Images.DownloadNamedImageMappings</c> endpoint. Returns a
|
/// <c>Images.DownloadNamedImageMappings</c> endpoint. Returns a
|
||||||
/// list of <c>NamedImageDTO</c> entries; an empty list is a valid
|
/// list of <c>NamedImageDTO</c> entries; an empty list is a valid
|
||||||
@@ -424,6 +466,7 @@ public class ImagesController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("api/images/v4/cheered/bulk")]
|
[HttpPost("api/images/v4/cheered/bulk")]
|
||||||
|
[HttpPost("api/images/v5/cheered/bulk")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<IActionResult> CheeredBulk([FromBody] BulkCheeredRequest? body)
|
public async Task<IActionResult> CheeredBulk([FromBody] BulkCheeredRequest? body)
|
||||||
{
|
{
|
||||||
|
|||||||
+57
@@ -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<IActionResult> 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<IActionResult> 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+102
@@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -40,6 +40,9 @@ public class InventionsController(
|
|||||||
|
|
||||||
// ── Browse ───────────────────────────────────────────────────────────
|
// ── Browse ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[HttpGet("api/inventions/v1/featured")]
|
||||||
|
[HttpGet("api/inventions/v1/featureddormskins")]
|
||||||
|
[HttpGet("api/inventions/v1/toptoday")]
|
||||||
[HttpGet("api/inventions/v3/popular")]
|
[HttpGet("api/inventions/v3/popular")]
|
||||||
public async Task<ActionResult> Popular([FromQuery] int take = 50)
|
public async Task<ActionResult> Popular([FromQuery] int take = 50)
|
||||||
{
|
{
|
||||||
@@ -78,6 +81,7 @@ public class InventionsController(
|
|||||||
public Task<ActionResult> Mine() => Saved(100);
|
public Task<ActionResult> Mine() => Saved(100);
|
||||||
|
|
||||||
[HttpGet("api/inventions/v1/search")]
|
[HttpGet("api/inventions/v1/search")]
|
||||||
|
[HttpGet("api/inventions/v2/search")]
|
||||||
public async Task<ActionResult> Search([FromQuery] string value = "")
|
public async Task<ActionResult> Search([FromQuery] string value = "")
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(value))
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
@@ -127,6 +131,8 @@ public class InventionsController(
|
|||||||
public sealed record InventionBatchRequest(List<long> InventionIds);
|
public sealed record InventionBatchRequest(List<long> InventionIds);
|
||||||
|
|
||||||
[HttpPost("api/inventions/v1/batch")]
|
[HttpPost("api/inventions/v1/batch")]
|
||||||
|
[HttpPost("api/inventions/v2/batch")]
|
||||||
|
[HttpPost("api/inventions/v1/dormskinsfromids")]
|
||||||
public async Task<ActionResult> Batch([FromBody] InventionBatchRequest req)
|
public async Task<ActionResult> Batch([FromBody] InventionBatchRequest req)
|
||||||
{
|
{
|
||||||
if (req?.InventionIds is null || req.InventionIds.Count == 0)
|
if (req?.InventionIds is null || req.InventionIds.Count == 0)
|
||||||
@@ -138,6 +144,27 @@ public class InventionsController(
|
|||||||
return Ok(rows.Select(ToWire));
|
return Ok(rows.Select(ToWire));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("api/inventions/v1/fromcreators")]
|
||||||
|
public async Task<ActionResult> 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<object>());
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>GET <c>api/inventions/v1/tagfilters</c> — drives
|
/// <summary>GET <c>api/inventions/v1/tagfilters</c> — drives
|
||||||
/// <c>RecNet.Inventions.GetTags</c>. The watch deserialises the
|
/// <c>RecNet.Inventions.GetTags</c>. The watch deserialises the
|
||||||
/// response as <c>RecNet.GetFiltersResponse</c> (an
|
/// response as <c>RecNet.GetFiltersResponse</c> (an
|
||||||
@@ -356,6 +383,7 @@ public class InventionsController(
|
|||||||
/// save flow. v4 wraps the new invention plus its first version in
|
/// save flow. v4 wraps the new invention plus its first version in
|
||||||
/// a Status/Invention/InventionVersion object.</summary>
|
/// a Status/Invention/InventionVersion object.</summary>
|
||||||
[HttpPost("api/inventions/v4/save")]
|
[HttpPost("api/inventions/v4/save")]
|
||||||
|
[HttpPost("api/inventions/v6/save")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult> SaveV4([FromBody] SaveInventionV4Request req)
|
public async Task<ActionResult> SaveV4([FromBody] SaveInventionV4Request req)
|
||||||
{
|
{
|
||||||
@@ -426,6 +454,7 @@ public class InventionsController(
|
|||||||
int? InstantiationCost, int? LightsCost);
|
int? InstantiationCost, int? LightsCost);
|
||||||
|
|
||||||
[HttpPost("api/inventions/v3/addversion")]
|
[HttpPost("api/inventions/v3/addversion")]
|
||||||
|
[HttpPost("api/inventions/v4/addversion")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult> AddVersion([FromBody] AddVersionRequest req)
|
public async Task<ActionResult> AddVersion([FromBody] AddVersionRequest req)
|
||||||
{
|
{
|
||||||
@@ -491,6 +520,22 @@ public class InventionsController(
|
|||||||
return Ok(ToWire(inv));
|
return Ok(ToWire(inv));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("api/inventions/v1/updateprice")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult> 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")]
|
[HttpGet("api/inventions/v1/delete")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult> Delete([FromQuery] long inventionId)
|
public async Task<ActionResult> Delete([FromQuery] long inventionId)
|
||||||
@@ -685,7 +730,7 @@ public class InventionsController(
|
|||||||
i.CreatorPermission,
|
i.CreatorPermission,
|
||||||
i.GeneralPermission,
|
i.GeneralPermission,
|
||||||
IsAGInvention = i.IsAgInvention,
|
IsAGInvention = i.IsAgInvention,
|
||||||
Price = 0,
|
i.Price,
|
||||||
HideFromPlayer = false,
|
HideFromPlayer = false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -182,6 +182,44 @@ public class InventoryController(
|
|||||||
return Ok(new { row.Id, Count = row.Quantity });
|
return Ok(new { row.Id, Count = row.Quantity });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("api/consumables/v1/transfer")]
|
||||||
|
public async Task<IActionResult> 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 sealed class ActivateConsumableRequest
|
||||||
{
|
{
|
||||||
public long Id { get; set; }
|
public long Id { get; set; }
|
||||||
|
|||||||
@@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<WishlistRequest> 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<WishlistRequest>(
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<KeepsakeRequest> 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<KeepsakeRequest>(
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -114,7 +114,18 @@ public class PlatformLoginController(
|
|||||||
// Login.RemoveCachedLogin → DELETE api/platformlogin/cached
|
// Login.RemoveCachedLogin → DELETE api/platformlogin/cached
|
||||||
[HttpDelete("cached")]
|
[HttpDelete("cached")]
|
||||||
[HttpDelete("cachedlogins")]
|
[HttpDelete("cachedlogins")]
|
||||||
public IActionResult RemoveCachedLogin() => Ok(new { });
|
public async Task<IActionResult> 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
|
// Login.RefreshLogin → POST api/platformlogin/refresh
|
||||||
// Response is Login.RefreshLoginResponse { Token } — single field, the
|
// Response is Login.RefreshLoginResponse { Token } — single field, the
|
||||||
|
|||||||
@@ -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<IActionResult> 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<IActionResult> 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Text.Json;
|
||||||
using DorkNet.Models.Notification;
|
using DorkNet.Models.Notification;
|
||||||
using DorkNet.Server.Auth;
|
using DorkNet.Server.Auth;
|
||||||
using DorkNet.Server.Data;
|
using DorkNet.Server.Data;
|
||||||
@@ -45,6 +46,7 @@ public class PlayerEventsController(
|
|||||||
|
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[HttpGet("api/playerevents/v2/{eventId:long}")]
|
[HttpGet("api/playerevents/v2/{eventId:long}")]
|
||||||
|
[HttpGet("api/playerevents/v1/{eventId:long}")]
|
||||||
public async Task<ActionResult> GetOne(long eventId)
|
public async Task<ActionResult> GetOne(long eventId)
|
||||||
{
|
{
|
||||||
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId);
|
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId);
|
||||||
@@ -52,6 +54,121 @@ public class PlayerEventsController(
|
|||||||
return Ok(ToWire(ev));
|
return Ok(ToWire(ev));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpGet("api/playerevents/v1/search")]
|
||||||
|
[HttpGet("api/playerevents/v1/searchlive")]
|
||||||
|
public async Task<ActionResult> 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<ActionResult> 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<ActionResult> 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<object>());
|
||||||
|
|
||||||
|
var roomIds = await db.Rooms
|
||||||
|
.Where(r => memberIds.Contains(r.CreatorPlayerId))
|
||||||
|
.Select(r => r.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
if (roomIds.Count == 0) return Ok(Array.Empty<object>());
|
||||||
|
|
||||||
|
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<ActionResult> 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<object>());
|
||||||
|
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<long>? PlayerEventIds { get; set; }
|
||||||
|
public List<long>? EventIds { get; set; }
|
||||||
|
public List<long>? Ids { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpPost("api/playerevents/v1/bulk")]
|
||||||
|
[HttpGet("api/playerevents/v1/bulk")]
|
||||||
|
public async Task<ActionResult> Bulk([FromBody] EventBulkRequest? body)
|
||||||
|
{
|
||||||
|
var ids = await ReadEventIdsAsync(body);
|
||||||
|
if (ids.Count == 0) return Ok(Array.Empty<object>());
|
||||||
|
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" },
|
||||||
|
});
|
||||||
|
|
||||||
/// <summary>GET <c>api/playerevents/v1/all</c> — the watch's
|
/// <summary>GET <c>api/playerevents/v1/all</c> — the watch's
|
||||||
/// <c>LocalPlayerEventInfo</c> fetch (caller's created + RSVP'd
|
/// <c>LocalPlayerEventInfo</c> fetch (caller's created + RSVP'd
|
||||||
/// events). Response keys (both required, via
|
/// events). Response keys (both required, via
|
||||||
@@ -93,6 +210,39 @@ public class PlayerEventsController(
|
|||||||
return Ok(new { Created = created, Responses = responses });
|
return Ok(new { Created = created, Responses = responses });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpGet("api/playerevents/v1/all/{accountId:long}")]
|
||||||
|
public async Task<ActionResult> 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 });
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>POST <c>api/playerevents/v1/{eventId}/responses</c> —
|
/// <summary>POST <c>api/playerevents/v1/{eventId}/responses</c> —
|
||||||
/// alternate RSVP route the watch uses for the response-list
|
/// alternate RSVP route the watch uses for the response-list
|
||||||
/// flow (older / sub-room view). Body is the same RsvpRequest
|
/// flow (older / sub-room view). Body is the same RsvpRequest
|
||||||
@@ -233,6 +383,123 @@ public class PlayerEventsController(
|
|||||||
return Ok(new { success = true, error = "" });
|
return Ok(new { success = true, error = "" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("api/playerevents/v2/{eventId:long}/name")]
|
||||||
|
[HttpPut("api/playerevents/v2/{eventId:long}/name")]
|
||||||
|
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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}")]
|
[HttpDelete("api/playerevents/v1/{eventId:long}")]
|
||||||
public async Task<ActionResult> Delete(long eventId)
|
public async Task<ActionResult> Delete(long eventId)
|
||||||
{
|
{
|
||||||
@@ -297,6 +564,44 @@ public class PlayerEventsController(
|
|||||||
return Ok(new { FailedInvites = failed, Result = 0 });
|
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<IActionResult> 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) ─────────────────────────────────────────────────
|
// ── Report (Phase 8) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
public sealed class PlayerEventReportRequest
|
public sealed class PlayerEventReportRequest
|
||||||
@@ -306,6 +611,130 @@ public class PlayerEventsController(
|
|||||||
public string? Details { get; set; }
|
public string? Details { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<PlayerEventEntity?> GetOwnedEventAsync(long eventId)
|
||||||
|
=> await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId && e.CreatorPlayerId == Me);
|
||||||
|
|
||||||
|
private async Task<List<long>> ReadEventIdsAsync(EventBulkRequest? body)
|
||||||
|
{
|
||||||
|
var ids = new List<long>();
|
||||||
|
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<string?> 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<long?> ReadLongFieldAsync(params string[] names)
|
||||||
|
{
|
||||||
|
var value = await ReadStringFieldAsync(names);
|
||||||
|
return long.TryParse(value, out var parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int?> ReadIntFieldAsync(params string[] names)
|
||||||
|
{
|
||||||
|
var value = await ReadStringFieldAsync(names);
|
||||||
|
return int.TryParse(value, out var parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool?> ReadBoolFieldAsync(params string[] names)
|
||||||
|
{
|
||||||
|
var value = await ReadStringFieldAsync(names);
|
||||||
|
return bool.TryParse(value, out var parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<DateTime?> ReadDateFieldAsync(params string[] names)
|
||||||
|
{
|
||||||
|
var value = await ReadStringFieldAsync(names);
|
||||||
|
return DateTime.TryParse(value, out var parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Dictionary<string, string>> ReadRequestFieldsAsync()
|
||||||
|
{
|
||||||
|
const string itemKey = "__playerevent_fields";
|
||||||
|
if (HttpContext.Items.TryGetValue(itemKey, out var cached)
|
||||||
|
&& cached is Dictionary<string, string> existing)
|
||||||
|
{
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fields = new Dictionary<string, string>(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();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>POST <c>api/playerevents/v1/report</c> — file a
|
/// <summary>POST <c>api/playerevents/v1/report</c> — file a
|
||||||
/// moderation report against an event. Wire response is
|
/// moderation report against an event. Wire response is
|
||||||
/// <c>KLAMKCBENEA{Success, Message}</c>; returning a bare
|
/// <c>KLAMKCBENEA{Success, Message}</c>; returning a bare
|
||||||
|
|||||||
@@ -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<IActionResult> 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<IActionResult> 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,20 @@ public class PlayersController(PlayerService playerService, NotificationService
|
|||||||
return Ok(PlayerService.ToProfile(player));
|
return Ok(PlayerService.ToProfile(player));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("/api/players/v4/current/contact")]
|
||||||
|
public async Task<IActionResult> 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}")]
|
[HttpGet("{id:long}")]
|
||||||
public async Task<ActionResult<PlayerProfile>> GetById(long id)
|
public async Task<ActionResult<PlayerProfile>> GetById(long id)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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<IActionResult> 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -451,8 +451,76 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
|
|||||||
return Ok(new { Imported = added });
|
return Ok(new { Imported = added });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("api/relationships/mutualfriends")]
|
||||||
|
public async Task<IActionResult> MutualFriends([FromQuery] long playerId)
|
||||||
|
{
|
||||||
|
var me = CurrentPlayerId;
|
||||||
|
if (playerId <= 0 || playerId == me) return Ok(Array.Empty<object>());
|
||||||
|
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<IActionResult> 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<ActionResult> 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 ──────────────────────────────────────────────────────
|
// ── helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async Task<HashSet<long>> 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();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Map a storage row to the watch's wire RelationshipType
|
/// <summary>Map a storage row to the watch's wire RelationshipType
|
||||||
/// from <paramref name="me"/>'s perspective. PendingSent flips
|
/// from <paramref name="me"/>'s perspective. PendingSent flips
|
||||||
/// direction depending on which side originated.</summary>
|
/// direction depending on which side originated.</summary>
|
||||||
|
|||||||
@@ -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<IActionResult> 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<IActionResult> PurchaseCurrency(long itemId, [FromQuery] int? currencyType = null) =>
|
||||||
|
Purchase(itemId, currencyType);
|
||||||
|
|
||||||
|
[HttpPost("api/roomconsumables/v1/roomconsumable/{itemId:long}/purchase/tokens")]
|
||||||
|
[Authorize]
|
||||||
|
public Task<IActionResult> PurchaseTokens(long itemId) => Purchase(itemId, 2);
|
||||||
|
|
||||||
|
private async Task<IActionResult> 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<object>();
|
||||||
|
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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<RoomCurrencyEntity?> FindCurrencyAsync(Dictionary<string, string> 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<RoomCurrencyPurchaseOfferEntity?> FindOfferAsync(Dictionary<string, string> 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<long> GetBalanceAsync(long playerId, long currencyId) =>
|
||||||
|
await db.RoomCurrencyBalances
|
||||||
|
.Where(b => b.PlayerId == playerId && b.RoomCurrencyId == currencyId)
|
||||||
|
.Select(b => b.Balance)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
private async Task<long> 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<bool> 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<Dictionary<string, string>> ReadFieldsAsync()
|
||||||
|
{
|
||||||
|
var fields = new Dictionary<string, string>(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<string, string> 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<string, string> fields, params string[] names) =>
|
||||||
|
long.TryParse(ReadString(fields, names), out var value) ? value : null;
|
||||||
|
|
||||||
|
private static int? ReadInt(Dictionary<string, string> fields, params string[] names) =>
|
||||||
|
int.TryParse(ReadString(fields, names), out var value) ? value : null;
|
||||||
|
|
||||||
|
private static Guid? ReadGuid(Dictionary<string, string> fields, params string[] names) =>
|
||||||
|
Guid.TryParse(ReadString(fields, names), out var value) ? value : null;
|
||||||
|
|
||||||
|
private static List<long> ReadLongList(Dictionary<string, string> fields, params string[] names) =>
|
||||||
|
names.SelectMany(name => fields.TryGetValue(name, out var value)
|
||||||
|
? value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
: Array.Empty<string>())
|
||||||
|
.Select(v => long.TryParse(v, out var id) ? id : 0)
|
||||||
|
.Where(id => id > 0)
|
||||||
|
.Distinct()
|
||||||
|
.Take(200)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
private static List<Guid> ReadGuidList(Dictionary<string, string> fields, params string[] names) =>
|
||||||
|
names.SelectMany(name => fields.TryGetValue(name, out var value)
|
||||||
|
? value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
: Array.Empty<string>())
|
||||||
|
.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,
|
||||||
|
};
|
||||||
|
}
|
||||||
+55
@@ -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<IActionResult> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,22 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase
|
|||||||
return Ok(rows.Select(ToWire));
|
return Ok(rows.Select(ToWire));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("api/roomkeys")]
|
||||||
|
[HttpGet("api/roomkeys/v1")]
|
||||||
|
public async Task<IActionResult> 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")]
|
[HttpPost("api/roomkeys/v1/create")]
|
||||||
public async Task<IActionResult> Create()
|
public async Task<IActionResult> Create()
|
||||||
{
|
{
|
||||||
@@ -132,6 +148,29 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase
|
|||||||
return Content(owns ? "true" : "false", "application/json");
|
return Content(owns ? "true" : "false", "application/json");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("api/roomkeys/v1/owns")]
|
||||||
|
public async Task<IActionResult> 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<IActionResult> OwnsBulk()
|
||||||
|
{
|
||||||
|
var ids = await ReadRoomKeyIdsAsync();
|
||||||
|
if (ids.Count == 0) return Ok(Array.Empty<object>());
|
||||||
|
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
|
public static object RoomKeyResponse(RoomKeyStatus status, RoomKeyEntity? key) => new
|
||||||
{
|
{
|
||||||
Status = (int)status,
|
Status = (int)status,
|
||||||
@@ -173,6 +212,25 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase
|
|||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<List<long>> ReadRoomKeyIdsAsync()
|
||||||
|
{
|
||||||
|
var ids = new List<long>();
|
||||||
|
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<T> ReadBodyAsync<T>() where T : new()
|
private async Task<T> ReadBodyAsync<T>() where T : new()
|
||||||
{
|
{
|
||||||
if (Request.HasFormContentType)
|
if (Request.HasFormContentType)
|
||||||
|
|||||||
@@ -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<IActionResult> 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -205,6 +205,7 @@ public class StorefrontsBuyController(
|
|||||||
/// persisted room key. The 2020.12 client sends roomKeyId and the
|
/// persisted room key. The 2020.12 client sends roomKeyId and the
|
||||||
/// price it displayed so we reject stale-price purchases.</summary>
|
/// price it displayed so we reject stale-price purchases.</summary>
|
||||||
[HttpGet("api/storefronts/v1/buyRoomKey")]
|
[HttpGet("api/storefronts/v1/buyRoomKey")]
|
||||||
|
[HttpGet("api/storefronts/v1/PurchaseRoomKeyWithCurrency")]
|
||||||
public async Task<IActionResult> BuyRoomKey(
|
public async Task<IActionResult> BuyRoomKey(
|
||||||
[FromQuery] long roomKeyId,
|
[FromQuery] long roomKeyId,
|
||||||
[FromQuery] int requestedPrice)
|
[FromQuery] int requestedPrice)
|
||||||
@@ -242,6 +243,95 @@ public class StorefrontsBuyController(
|
|||||||
return Ok(RoomKeyPurchaseResponse(RoomKeyStatus.Success, key, newBalance));
|
return Ok(RoomKeyPurchaseResponse(RoomKeyStatus.Success, key, newBalance));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("api/storefronts/v2/buyInvention")]
|
||||||
|
[HttpGet("api/storefronts/v2/buyInvention")]
|
||||||
|
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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 ──────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private async Task GrantWardrobeAsync(long pid, string guid)
|
private async Task GrantWardrobeAsync(long pid, string guid)
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ public class StorefrontsController(
|
|||||||
[HttpGet("api/storefronts/v1/all")]
|
[HttpGet("api/storefronts/v1/all")]
|
||||||
[HttpGet("api/storefronts/v2/all")]
|
[HttpGet("api/storefronts/v2/all")]
|
||||||
[HttpGet("api/storefronts/v3/all")]
|
[HttpGet("api/storefronts/v3/all")]
|
||||||
|
[HttpGet("api/storefronts/v1/toptoday")]
|
||||||
|
[HttpGet("api/storefronts/v1/adcarouselitems")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> All()
|
public async Task<IActionResult> All()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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<IActionResult> 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> Bulk()
|
||||||
|
{
|
||||||
|
var fields = await ReadFieldsAsync();
|
||||||
|
var ids = ReadGuidList(fields, "ids", "Ids", "itemIds", "ItemIds", "ugcPurchasableIds", "UgcPurchasableIds");
|
||||||
|
if (ids.Count == 0) return Ok(Array.Empty<object>());
|
||||||
|
var rows = await db.UgcPurchasables
|
||||||
|
.Where(i => !i.IsDeleted && ids.Contains(i.PublicId))
|
||||||
|
.ToListAsync();
|
||||||
|
return Ok(rows.Select(ToWire));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<UgcPurchasableEntity?> FindItemAsync(Dictionary<string, string> 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<bool> 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<Dictionary<string, string>> ReadFieldsAsync()
|
||||||
|
{
|
||||||
|
var fields = new Dictionary<string, string>(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<string, string> 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<string, string> fields, params string[] names) =>
|
||||||
|
long.TryParse(ReadString(fields, names), out var value) ? value : null;
|
||||||
|
|
||||||
|
private static int? ReadInt(Dictionary<string, string> fields, params string[] names) =>
|
||||||
|
int.TryParse(ReadString(fields, names), out var value) ? value : null;
|
||||||
|
|
||||||
|
private static bool? ReadBool(Dictionary<string, string> fields, params string[] names) =>
|
||||||
|
bool.TryParse(ReadString(fields, names), out var value) ? value : null;
|
||||||
|
|
||||||
|
private static Guid? ReadGuid(Dictionary<string, string> fields, params string[] names) =>
|
||||||
|
Guid.TryParse(ReadString(fields, names), out var value) ? value : null;
|
||||||
|
|
||||||
|
private static List<Guid> ReadGuidList(Dictionary<string, string> fields, params string[] names) =>
|
||||||
|
names.SelectMany(name => fields.TryGetValue(name, out var value)
|
||||||
|
? value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
: Array.Empty<string>())
|
||||||
|
.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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -73,9 +73,11 @@ public class AccountsController(
|
|||||||
private async Task<Data.Entities.PlayerEntity?> GetCurrentPlayerAsync()
|
private async Task<Data.Entities.PlayerEntity?> GetCurrentPlayerAsync()
|
||||||
{
|
{
|
||||||
var auth = Request.Headers.Authorization.ToString();
|
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;
|
return null;
|
||||||
var token = auth["Bearer ".Length..].Trim();
|
|
||||||
var id = authService.ValidateToken(token);
|
var id = authService.ValidateToken(token);
|
||||||
if (id is not long playerId) return null;
|
if (id is not long playerId) return null;
|
||||||
return await playerService.GetByIdAsync(playerId);
|
return await playerService.GetByIdAsync(playerId);
|
||||||
@@ -170,6 +172,7 @@ public class AccountsController(
|
|||||||
// Accounts.SearchForAccounts — used by the watch's typeahead AND the
|
// Accounts.SearchForAccounts — used by the watch's typeahead AND the
|
||||||
// localhost public site's account search. Anonymous-safe (no JWT
|
// localhost public site's account search. Anonymous-safe (no JWT
|
||||||
// required) so the front-facing site can call it directly.
|
// required) so the front-facing site can call it directly.
|
||||||
|
[HttpGet("/account/search")]
|
||||||
[HttpGet("/account/v1/search")]
|
[HttpGet("/account/v1/search")]
|
||||||
[Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
[Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||||
public async Task<IActionResult> Search([FromQuery] string? query, [FromQuery] int take = 20)
|
public async Task<IActionResult> Search([FromQuery] string? query, [FromQuery] int take = 20)
|
||||||
@@ -405,6 +408,26 @@ public class AccountsController(
|
|||||||
return age;
|
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
|
// Everyone is a dev for now — exposes the in-game debug console + dev
|
||||||
// tools. Per user request: easier debugging while iterating on the
|
// tools. Per user request: easier debugging while iterating on the
|
||||||
// private server. Flip back to false later if you want shipping behavior.
|
// private server. Flip back to false later if you want shipping behavior.
|
||||||
@@ -509,6 +532,120 @@ public class AccountsController(
|
|||||||
[HttpGet("/account/v1/me/disallowiap")]
|
[HttpGet("/account/v1/me/disallowiap")]
|
||||||
public IActionResult DisallowIap() => Ok(new { Disallow = false });
|
public IActionResult DisallowIap() => Ok(new { Disallow = false });
|
||||||
|
|
||||||
|
[HttpGet("/account/isactivecreator/me")]
|
||||||
|
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>GET /account/{id}/bio — public bio lookup. Watch URL
|
/// <summary>GET /account/{id}/bio — public bio lookup. Watch URL
|
||||||
/// from <c>Cpp2IL_ISIL/.../RecNet/Accounts.txt</c> line 5686:
|
/// from <c>Cpp2IL_ISIL/.../RecNet/Accounts.txt</c> line 5686:
|
||||||
/// <c>"account/{0}/bio"</c> (no v1 prefix). Wire shape from
|
/// <c>"account/{0}/bio"</c> (no v1 prefix). Wire shape from
|
||||||
|
|||||||
@@ -31,6 +31,42 @@ public class AuthController(
|
|||||||
if (lastSeen.Date < DateTime.UtcNow.Date)
|
if (lastSeen.Date < DateTime.UtcNow.Date)
|
||||||
await level.AwardXpAsync(playerId, LevelService.FirstLoginXp, "first_login_today");
|
await level.AwardXpAsync(playerId, LevelService.FirstLoginXp, "first_login_today");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, object> 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:
|
// OAuth token endpoint. The 2020 client uses three grant types here:
|
||||||
// - grant_type=cached_login → user clicked a remembered account on the
|
// - grant_type=cached_login → user clicked a remembered account on the
|
||||||
// account-selection screen. Body includes `account_id` (and platform
|
// account-selection screen. Body includes `account_id` (and platform
|
||||||
@@ -90,14 +126,7 @@ public class AuthController(
|
|||||||
await TryAwardLoginXpAsync(picked.Id);
|
await TryAwardLoginXpAsync(picked.Id);
|
||||||
await playerService.TagPlatformAsync(picked.Id, platform, effPlatformId);
|
await playerService.TagPlatformAsync(picked.Id, platform, effPlatformId);
|
||||||
var (acc1, refr1) = authService.GenerateTokenPair(picked.Id);
|
var (acc1, refr1) = authService.GenerateTokenPair(picked.Id);
|
||||||
return Ok(new
|
return TokenOk(acc1, refr1);
|
||||||
{
|
|
||||||
access_token = acc1,
|
|
||||||
token_type = "Bearer",
|
|
||||||
expires_in = 43200,
|
|
||||||
refresh_token = refr1,
|
|
||||||
scope = "openid profile",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.Equals(grant_type, "refresh_token", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(grant_type, "refresh_token", StringComparison.OrdinalIgnoreCase))
|
||||||
@@ -111,14 +140,7 @@ public class AuthController(
|
|||||||
if (existing is null)
|
if (existing is null)
|
||||||
return Unauthorized(new { error = "invalid_grant" });
|
return Unauthorized(new { error = "invalid_grant" });
|
||||||
var (acc, refr) = authService.GenerateTokenPair(existing.Id);
|
var (acc, refr) = authService.GenerateTokenPair(existing.Id);
|
||||||
return Ok(new
|
return TokenOk(acc, refr);
|
||||||
{
|
|
||||||
access_token = acc,
|
|
||||||
token_type = "Bearer",
|
|
||||||
expires_in = 43200,
|
|
||||||
refresh_token = refr,
|
|
||||||
scope = "openid profile",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// password grant: two paths.
|
// password grant: two paths.
|
||||||
@@ -150,14 +172,7 @@ public class AuthController(
|
|||||||
await playerService.TagPlatformAsync(byName.Id, platform, effPlatformId);
|
await playerService.TagPlatformAsync(byName.Id, platform, effPlatformId);
|
||||||
await TryAwardLoginXpAsync(byName.Id);
|
await TryAwardLoginXpAsync(byName.Id);
|
||||||
var pair = authService.GenerateTokenPair(byName.Id);
|
var pair = authService.GenerateTokenPair(byName.Id);
|
||||||
return Ok(new
|
return TokenOk(pair.AccessToken, pair.RefreshToken);
|
||||||
{
|
|
||||||
access_token = pair.AccessToken,
|
|
||||||
token_type = "Bearer",
|
|
||||||
expires_in = 43200,
|
|
||||||
refresh_token = pair.RefreshToken,
|
|
||||||
scope = "openid profile",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Device-id fallback path. Prefer the real deviceId; fall back
|
// Device-id fallback path. Prefer the real deviceId; fall back
|
||||||
@@ -196,14 +211,7 @@ public class AuthController(
|
|||||||
await TryAwardLoginXpAsync(player.Id);
|
await TryAwardLoginXpAsync(player.Id);
|
||||||
var (access, refresh) = authService.GenerateTokenPair(player.Id);
|
var (access, refresh) = authService.GenerateTokenPair(player.Id);
|
||||||
|
|
||||||
return Ok(new
|
return TokenOk(access, refresh);
|
||||||
{
|
|
||||||
access_token = access,
|
|
||||||
token_type = "Bearer",
|
|
||||||
expires_in = 43200,
|
|
||||||
refresh_token = refresh,
|
|
||||||
scope = "openid profile",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("/connect/userinfo")]
|
[HttpGet("/connect/userinfo")]
|
||||||
@@ -307,6 +315,62 @@ public class AuthController(
|
|||||||
return Ok(results);
|
return Ok(results);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("/cachedlogin/current")]
|
||||||
|
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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()
|
private static CachedLogin ToCachedLogin(PlayerEntity p, int platform, string platformId) => new()
|
||||||
{
|
{
|
||||||
Platform = platform,
|
Platform = platform,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
|
|
||||||
namespace DorkNet.Server.Controllers.Auth;
|
namespace DorkNet.Server.Controllers.Auth;
|
||||||
|
|
||||||
/// <summary>The boot-time client-version gate the 2020.12 watch hits
|
/// <summary>The boot-time client-version gate the 2023.03.21 client hits
|
||||||
/// at startup as <c>GET api/versioncheck/v4?v={build}&p={platform}</c>.
|
/// at startup as <c>GET api/versioncheck/v4?v={build}&p={platform}</c>.
|
||||||
///
|
///
|
||||||
/// <para>Verified against the watch's <c>KMDHPCHFADM.VerifyGameVersion</c>
|
/// <para>Verified against the watch's <c>KMDHPCHFADM.VerifyGameVersion</c>
|
||||||
@@ -20,10 +20,9 @@ namespace DorkNet.Server.Controllers.Auth;
|
|||||||
/// front door — the watch has dedicated UX for it, no custom client
|
/// front door — the watch has dedicated UX for it, no custom client
|
||||||
/// mod work needed.</para>
|
/// mod work needed.</para>
|
||||||
///
|
///
|
||||||
/// <para>The <c>v</c> query param is the raw build identifier the watch
|
/// <para>The <c>v</c> query param is the raw build identifier supplied
|
||||||
/// reads from <c>KMJJGELKNAP.MCFMPCNNEBH</c> — for the December build
|
/// by the client. We map raw build IDs to our internal
|
||||||
/// that's the literal string <c>"20201210"</c>. We map raw build IDs
|
/// <see cref="ClientVersion.Key"/>s via the
|
||||||
/// to our internal <see cref="ClientVersion.Key"/>s via the
|
|
||||||
/// <c>DorkNet:BuildIdToVersionKey</c> config map; any build ID we
|
/// <c>DorkNet:BuildIdToVersionKey</c> config map; any build ID we
|
||||||
/// don't recognise OR that maps to a key not in
|
/// don't recognise OR that maps to a key not in
|
||||||
/// <see cref="VersionRegistry.SupportedVersionKeys"/> gets
|
/// <see cref="VersionRegistry.SupportedVersionKeys"/> gets
|
||||||
@@ -49,7 +48,8 @@ public class VersionCheckController(
|
|||||||
private static readonly IReadOnlyDictionary<string, string> BuiltInBuildIdToVersionKey =
|
private static readonly IReadOnlyDictionary<string, string> BuiltInBuildIdToVersionKey =
|
||||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
["20201210"] = "december_2020_12_18",
|
["20230317"] = "march_2023_03_21",
|
||||||
|
["20230321"] = "march_2023_03_21",
|
||||||
};
|
};
|
||||||
|
|
||||||
[HttpGet("/api/versioncheck/v4")]
|
[HttpGet("/api/versioncheck/v4")]
|
||||||
@@ -66,8 +66,7 @@ public class VersionCheckController(
|
|||||||
// Map raw build ID → ClientVersion key via config. Format:
|
// Map raw build ID → ClientVersion key via config. Format:
|
||||||
// "DorkNet": {
|
// "DorkNet": {
|
||||||
// "BuildIdToVersionKey": {
|
// "BuildIdToVersionKey": {
|
||||||
// "20201210": "december_2020_12_18",
|
// "20230321": "march_2023_03_21"
|
||||||
// "20200310": "march_2020_03_10"
|
|
||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
// Mapping ABSENT → unknown build → UpdateRequired.
|
// Mapping ABSENT → unknown build → UpdateRequired.
|
||||||
|
|||||||
@@ -71,6 +71,38 @@ public class EconController(DorkNetDbContext db, LevelService level) : Controlle
|
|||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
return Ok(rows);
|
return Ok(rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("/econ/customAvatarItems")]
|
||||||
|
[HttpGet("/econ/customAvatarItems/v1/owned")]
|
||||||
|
public async Task<IActionResult> 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
|
public class TokenBalance
|
||||||
|
|||||||
@@ -74,6 +74,14 @@ public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbCo
|
|||||||
public DbSet<ClubSubscriptionEntity> ClubSubscriptions => Set<ClubSubscriptionEntity>();
|
public DbSet<ClubSubscriptionEntity> ClubSubscriptions => Set<ClubSubscriptionEntity>();
|
||||||
public DbSet<SignupCodeEntity> SignupCodes => Set<SignupCodeEntity>();
|
public DbSet<SignupCodeEntity> SignupCodes => Set<SignupCodeEntity>();
|
||||||
public DbSet<PendingDeviceEntity> PendingDevices => Set<PendingDeviceEntity>();
|
public DbSet<PendingDeviceEntity> PendingDevices => Set<PendingDeviceEntity>();
|
||||||
|
public DbSet<CustomAvatarItemEntity> CustomAvatarItems => Set<CustomAvatarItemEntity>();
|
||||||
|
public DbSet<CustomAvatarItemOwnershipEntity> CustomAvatarItemOwnership => Set<CustomAvatarItemOwnershipEntity>();
|
||||||
|
public DbSet<ItemWishlistEntity> ItemWishlists => Set<ItemWishlistEntity>();
|
||||||
|
public DbSet<KeepsakeEntity> Keepsakes => Set<KeepsakeEntity>();
|
||||||
|
public DbSet<RoomCurrencyEntity> RoomCurrencies => Set<RoomCurrencyEntity>();
|
||||||
|
public DbSet<RoomCurrencyBalanceEntity> RoomCurrencyBalances => Set<RoomCurrencyBalanceEntity>();
|
||||||
|
public DbSet<RoomCurrencyPurchaseOfferEntity> RoomCurrencyPurchaseOffers => Set<RoomCurrencyPurchaseOfferEntity>();
|
||||||
|
public DbSet<UgcPurchasableEntity> UgcPurchasables => Set<UgcPurchasableEntity>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -679,5 +687,64 @@ public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbCo
|
|||||||
e.HasKey(c => new { c.RoomId, c.Channel });
|
e.HasKey(c => new { c.RoomId, c.Channel });
|
||||||
e.HasIndex(c => c.RoomId);
|
e.HasIndex(c => c.RoomId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<CustomAvatarItemEntity>(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<CustomAvatarItemOwnershipEntity>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(o => o.Id);
|
||||||
|
e.HasIndex(o => new { o.PlayerId, o.CustomAvatarItemId }).IsUnique();
|
||||||
|
e.HasIndex(o => o.CustomAvatarItemId);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<ItemWishlistEntity>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(w => w.Id);
|
||||||
|
e.HasIndex(w => new { w.PlayerId, w.ItemKey, w.ItemType }).IsUnique();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<KeepsakeEntity>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(k => k.Id);
|
||||||
|
e.HasIndex(k => k.PlayerId);
|
||||||
|
e.HasIndex(k => new { k.PlayerId, k.EventKey });
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<RoomCurrencyEntity>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(c => c.Id);
|
||||||
|
e.HasIndex(c => c.PublicId).IsUnique();
|
||||||
|
e.HasIndex(c => c.RoomId);
|
||||||
|
e.HasIndex(c => c.CreatorPlayerId);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<RoomCurrencyBalanceEntity>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(b => b.Id);
|
||||||
|
e.HasIndex(b => new { b.PlayerId, b.RoomCurrencyId }).IsUnique();
|
||||||
|
e.HasIndex(b => b.RoomCurrencyId);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<RoomCurrencyPurchaseOfferEntity>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(o => o.Id);
|
||||||
|
e.HasIndex(o => o.PublicId).IsUnique();
|
||||||
|
e.HasIndex(o => o.RoomCurrencyId);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<UgcPurchasableEntity>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(p => p.Id);
|
||||||
|
e.HasIndex(p => p.PublicId).IsUnique();
|
||||||
|
e.HasIndex(p => p.RoomId);
|
||||||
|
e.HasIndex(p => p.CreatorPlayerId);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -85,6 +85,7 @@ public class InventionEntity
|
|||||||
|
|
||||||
public int CheerCount { get; set; } = 0;
|
public int CheerCount { get; set; } = 0;
|
||||||
public int SpawnCount { get; set; } = 0;
|
public int SpawnCount { get; set; } = 0;
|
||||||
|
public int Price { get; set; } = 0;
|
||||||
|
|
||||||
/// <summary>Soft-delete flag. When set, the row is hidden from
|
/// <summary>Soft-delete flag. When set, the row is hidden from
|
||||||
/// browse / search / detail endpoints but stays in the table so
|
/// browse / search / detail endpoints but stays in the table so
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -47,6 +47,12 @@ public class RoomEntity
|
|||||||
public bool IsAGRoom { get; set; } = true;
|
public bool IsAGRoom { get; set; } = true;
|
||||||
|
|
||||||
public bool IsDormRoom { get; set; } = false;
|
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 CloningAllowed { get; set; } = false;
|
||||||
public bool SupportsVRLow { get; set; } = true;
|
public bool SupportsVRLow { get; set; } = true;
|
||||||
public bool SupportsMobile { get; set; } = false;
|
public bool SupportsMobile { get; set; } = false;
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ namespace DorkNet.Server.Services;
|
|||||||
|
|
||||||
public class AuthService(IConfiguration config, DorkNetDbContext db)
|
public class AuthService(IConfiguration config, DorkNetDbContext db)
|
||||||
{
|
{
|
||||||
|
public const string AccessCookieName = "dorknet_access_token";
|
||||||
|
|
||||||
// Resolution order MUST match Program.cs's JwtBearer validator setup —
|
// Resolution order MUST match Program.cs's JwtBearer validator setup —
|
||||||
// env var first, config key second — otherwise tokens are signed with
|
// env var first, config key second — otherwise tokens are signed with
|
||||||
// one secret and validated against another and every authenticated
|
// one secret and validated against another and every authenticated
|
||||||
@@ -56,13 +58,22 @@ public class AuthService(IConfiguration config, DorkNetDbContext db)
|
|||||||
var claims = new List<Claim>
|
var claims = new List<Claim>
|
||||||
{
|
{
|
||||||
new(ClaimTypes.NameIdentifier, playerId.ToString()),
|
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(ClaimTypes.Role, "gameClient"),
|
||||||
|
new("roles", "gameClient"),
|
||||||
new("token_type", tokenType),
|
new("token_type", tokenType),
|
||||||
};
|
};
|
||||||
// Multiple Role claims become a JSON array on the wire — the
|
// Multiple Role claims become a JSON array on the wire — the
|
||||||
// watch's HasRole helper handles both string and List<string>
|
// watch's HasRole helper handles both string and List<string>
|
||||||
// forms.
|
// 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
|
var descriptor = new SecurityTokenDescriptor
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1016,6 +1016,9 @@ public class RoomService(DorkNetDbContext db)
|
|||||||
// showed up that the admin couldn't purge.)
|
// showed up that the admin couldn't purge.)
|
||||||
IsAGRoom = false,
|
IsAGRoom = false,
|
||||||
IsDormRoom = false,
|
IsDormRoom = false,
|
||||||
|
IsStudioRoom = source.IsStudioRoom,
|
||||||
|
IsRoomLinkedToRecRoomStudio = source.IsRoomLinkedToRecRoomStudio,
|
||||||
|
StudioSessionId = source.StudioSessionId,
|
||||||
CloningAllowed = source.CloningAllowed,
|
CloningAllowed = source.CloningAllowed,
|
||||||
SupportsVRLow = source.SupportsVRLow,
|
SupportsVRLow = source.SupportsVRLow,
|
||||||
SupportsMobile = source.SupportsMobile,
|
SupportsMobile = source.SupportsMobile,
|
||||||
@@ -1070,6 +1073,9 @@ public class RoomService(DorkNetDbContext db)
|
|||||||
// ignored by both clients.
|
// ignored by both clients.
|
||||||
IsDorm = r.IsDormRoom,
|
IsDorm = r.IsDormRoom,
|
||||||
IsDormRoom = r.IsDormRoom,
|
IsDormRoom = r.IsDormRoom,
|
||||||
|
IsStudioRoom = r.IsStudioRoom,
|
||||||
|
IsRoomLinkedToRecRoomStudio = r.IsRoomLinkedToRecRoomStudio,
|
||||||
|
StudioSessionId = r.StudioSessionId,
|
||||||
CloningAllowed = r.CloningAllowed,
|
CloningAllowed = r.CloningAllowed,
|
||||||
SupportsVRLow = r.SupportsVRLow,
|
SupportsVRLow = r.SupportsVRLow,
|
||||||
SupportsMobile = r.SupportsMobile,
|
SupportsMobile = r.SupportsMobile,
|
||||||
|
|||||||
@@ -104,6 +104,8 @@ public static class DatabaseBootstrap
|
|||||||
await EnsureInventorySchemaAsync(db);
|
await EnsureInventorySchemaAsync(db);
|
||||||
await EnsureLeaderboardSchemaAsync(db);
|
await EnsureLeaderboardSchemaAsync(db);
|
||||||
await EnsureRoomColumnsAsync(db);
|
await EnsureRoomColumnsAsync(db);
|
||||||
|
await EnsureInventionColumnsAsync(db);
|
||||||
|
await EnsureMarch2023TablesAsync(db);
|
||||||
|
|
||||||
// Coach system account at Player.Id=1. The RR-Original room seeder
|
// Coach system account at Player.Id=1. The RR-Original room seeder
|
||||||
// below assigns CreatorPlayerId=1 to every canonical room
|
// below assigns CreatorPlayerId=1 to every canonical room
|
||||||
@@ -251,6 +253,12 @@ public static class DatabaseBootstrap
|
|||||||
{
|
{
|
||||||
await db.Database.ExecuteSqlRawAsync(
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
@"ALTER TABLE ""Rooms"" ADD COLUMN IF NOT EXISTS ""MaxCapacity"" integer NOT NULL DEFAULT 8;");
|
@"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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,6 +270,45 @@ public static class DatabaseBootstrap
|
|||||||
@"ALTER TABLE ""Rooms"" ADD COLUMN ""MaxCapacity"" INTEGER NOT NULL DEFAULT 8;");
|
@"ALTER TABLE ""Rooms"" ADD COLUMN ""MaxCapacity"" INTEGER NOT NULL DEFAULT 8;");
|
||||||
}
|
}
|
||||||
catch { }
|
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)
|
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"");");
|
@"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);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>SQLite migration-history reconciliation. Handles two
|
/// <summary>SQLite migration-history reconciliation. Handles two
|
||||||
/// cutover scenarios so legacy DBs boot cleanly without manual SQL:
|
/// cutover scenarios so legacy DBs boot cleanly without manual SQL:
|
||||||
/// <list type="number">
|
/// <list type="number">
|
||||||
|
|||||||
@@ -128,14 +128,15 @@ public static class RequestTracingExtensions
|
|||||||
var trimmedRequest = safeRequest is null ? null
|
var trimmedRequest = safeRequest is null ? null
|
||||||
: (safeRequest.Length > 200 ? safeRequest[..200] + "..." : safeRequest);
|
: (safeRequest.Length > 200 ? safeRequest[..200] + "..." : safeRequest);
|
||||||
var trimmedResponse = safeResponse.Length > 300 ? safeResponse[..300] + "..." : safeResponse;
|
var trimmedResponse = safeResponse.Length > 300 ? safeResponse[..300] + "..." : safeResponse;
|
||||||
|
var authPresence = DescribeAuthPresence(ctx.Request);
|
||||||
var level = thrown is not null || status >= 500 ? LogLevel.Error
|
var level = thrown is not null || status >= 500 ? LogLevel.Error
|
||||||
: status >= 400 ? LogLevel.Warning
|
: status >= 400 ? LogLevel.Warning
|
||||||
: LogLevel.Information;
|
: LogLevel.Information;
|
||||||
|
|
||||||
reqLogger.Log(level, thrown,
|
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,
|
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,
|
ctx.Request.ContentType ?? "", ctx.Request.ContentLength,
|
||||||
trimmedRequest ?? "", trimmedResponse);
|
trimmedRequest ?? "", trimmedResponse);
|
||||||
|
|
||||||
@@ -170,6 +171,15 @@ public static class RequestTracingExtensions
|
|||||||
"password|passwd|pwd|passwordhash|token|accesstoken|refreshtoken|idtoken|" +
|
"password|passwd|pwd|passwordhash|token|accesstoken|refreshtoken|idtoken|" +
|
||||||
"secret|clientsecret|apikey|api_key|authorization|deviceauth|sessionkey";
|
"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(
|
private static readonly Regex JsonSecretRegex = new(
|
||||||
"(\"(?:" + SecretKeys + ")\"\\s*:\\s*)\"(?:[^\"\\\\]|\\\\.)*\"",
|
"(\"(?:" + SecretKeys + ")\"\\s*:\\s*)\"(?:[^\"\\\\]|\\\\.)*\"",
|
||||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
|||||||
@@ -107,14 +107,22 @@ public static class ServiceCollectionExtensions
|
|||||||
?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN")
|
?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN")
|
||||||
?? "localhost";
|
?? "localhost";
|
||||||
var scheme = builder.Configuration["Domain:Scheme"] ?? "https";
|
var scheme = builder.Configuration["Domain:Scheme"] ?? "https";
|
||||||
|
var allowedHosts = new List<string> { 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.AddSingleton(new DomainConfig(apex, scheme));
|
||||||
builder.Services.Configure<Microsoft.AspNetCore.HostFiltering.HostFilteringOptions>(opt =>
|
builder.Services.Configure<Microsoft.AspNetCore.HostFiltering.HostFilteringOptions>(opt =>
|
||||||
{
|
{
|
||||||
opt.AllowedHosts = new[] { apex, $"*.{apex}", "localhost", "127.0.0.1" };
|
opt.AllowedHosts = allowedHosts.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||||
opt.AllowEmptyHosts = true;
|
opt.AllowEmptyHosts = true;
|
||||||
opt.IncludeFailureMessage = true;
|
opt.IncludeFailureMessage = true;
|
||||||
});
|
});
|
||||||
Console.WriteLine($"[domain] apex={apex}, allowedHosts=[{apex}, *.{apex}, localhost]");
|
Console.WriteLine($"[domain] apex={apex}, allowedHosts=[{string.Join(", ", allowedHosts)}]");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Services ──────────────────────────────────────────────────────────────
|
// ── Services ──────────────────────────────────────────────────────────────
|
||||||
@@ -174,17 +182,16 @@ public static class ServiceCollectionExtensions
|
|||||||
var pluginInstances = new IVersionPlugin[]
|
var pluginInstances = new IVersionPlugin[]
|
||||||
{
|
{
|
||||||
new Late2020VersionPlugin(),
|
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) p.RegisterStrategies(builder.Services);
|
||||||
foreach (var p in pluginInstances)
|
foreach (var p in pluginInstances)
|
||||||
builder.Services.AddSingleton<IVersionPlugin>(p);
|
builder.Services.AddSingleton<IVersionPlugin>(p);
|
||||||
|
|
||||||
var supported = (builder.Configuration.GetSection("DorkNet:SupportedVersions").Get<string[]>()
|
var supported = (builder.Configuration.GetSection("DorkNet:SupportedVersions").Get<string[]>()
|
||||||
?? new[] { "december_2020_12_18" })
|
?? new[] { "march_2023_03_21" })
|
||||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
var defaultKey = builder.Configuration["DorkNet:DefaultClientVersion"]
|
var defaultKey = builder.Configuration["DorkNet:DefaultClientVersion"]
|
||||||
?? "december_2020_12_18";
|
?? "march_2023_03_21";
|
||||||
builder.Services.AddSingleton(sp => new VersionRegistry(
|
builder.Services.AddSingleton(sp => new VersionRegistry(
|
||||||
sp.GetServices<IVersionPlugin>(),
|
sp.GetServices<IVersionPlugin>(),
|
||||||
supported,
|
supported,
|
||||||
@@ -285,6 +292,12 @@ public static class ServiceCollectionExtensions
|
|||||||
var path = ctx.HttpContext.Request.Path;
|
var path = ctx.HttpContext.Request.Path;
|
||||||
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hub"))
|
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hub"))
|
||||||
ctx.Token = accessToken;
|
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;
|
return Task.CompletedTask;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,31 +2,17 @@ using DorkNet.Server.Compat;
|
|||||||
|
|
||||||
namespace DorkNet.Server.Versions.Late2020;
|
namespace DorkNet.Server.Versions.Late2020;
|
||||||
|
|
||||||
/// <summary>The "Late 2020" wire-format generation: covers all Rec Room
|
/// <summary>Version marker for the 2023.03.21 client branch. This
|
||||||
/// builds from the autumn 2020 schema migration through the end of the
|
/// branch is intentionally single-client: it does not advertise or
|
||||||
/// year. The 2020.12.18 build is the canonical member; if/when we add
|
/// serve older 2020 build keys.
|
||||||
/// 2020.10 or 2020.11 builds and find their wire shapes are identical,
|
|
||||||
/// we just list them in <see cref="VersionKeys"/> here — no new code.
|
|
||||||
///
|
|
||||||
/// <para>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
|
|
||||||
/// <see cref="HttpContextExtensions.ClientVersion"/>'s
|
|
||||||
/// <c>Generation</c>, the strategies that get extracted will register
|
|
||||||
/// themselves through <see cref="RegisterStrategies"/> with a
|
|
||||||
/// keyed-DI binding so the right implementation resolves per
|
|
||||||
/// request.</para>
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class Late2020VersionPlugin : IVersionPlugin
|
public sealed class Late2020VersionPlugin : IVersionPlugin
|
||||||
{
|
{
|
||||||
public const string GenerationKey = "Late2020";
|
public const string GenerationKey = "March2023";
|
||||||
|
|
||||||
public IReadOnlyCollection<string> VersionKeys { get; } = new[]
|
public IReadOnlyCollection<string> VersionKeys { get; } = new[]
|
||||||
{
|
{
|
||||||
// Verified working against this server. The canonical
|
"march_2023_03_21",
|
||||||
// private-deployment build the codebase has been developed
|
|
||||||
// against since the move to localhost.
|
|
||||||
"december_2020_12_18",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
public string Generation => GenerationKey;
|
public string Generation => GenerationKey;
|
||||||
|
|||||||
@@ -21,13 +21,14 @@
|
|||||||
"PhotonRoomPrefix": "DorkNet"
|
"PhotonRoomPrefix": "DorkNet"
|
||||||
},
|
},
|
||||||
"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.",
|
"_comment_SupportedVersions": "Per-deployment allow-list. This branch serves only the 2023.03.21 client.",
|
||||||
"SupportedVersions": [ "december_2020_12_18" ],
|
"SupportedVersions": [ "march_2023_03_21" ],
|
||||||
"_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.",
|
"_comment_DefaultClientVersion": "Fallback for requests without an X-DorkNet-Version header.",
|
||||||
"DefaultClientVersion": "december_2020_12_18",
|
"DefaultClientVersion": "march_2023_03_21",
|
||||||
"_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_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": {
|
"BuildIdToVersionKey": {
|
||||||
"20201210": "december_2020_12_18"
|
"20230317": "march_2023_03_21",
|
||||||
|
"20230321": "march_2023_03_21"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Submodule
+1
Submodule dorknet-reupload-staging added at 570ccae554
Reference in New Issue
Block a user