mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -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 MelonLoader;
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
[assembly: MelonInfo(typeof(DorkNet.ClientMod.Mod), "DorkNet ClientMod", "1.0.0", "Dork Squad")]
|
||||
[assembly: MelonGame(null, null)]
|
||||
@@ -197,6 +200,13 @@ public class Mod : MelonMod
|
||||
// proxy uses without compile-error.
|
||||
TryPatchByName("PUNNetworkManager", "OnCustomAuthenticationFailed",
|
||||
prefix: nameof(PhotonPatches.OnCustomAuthenticationFailed_Prefix));
|
||||
TryPatchByName("RecRoom.AntiCheat.EACManager",
|
||||
"GenerateChallengeResponse",
|
||||
args: new[] { typeof(string) },
|
||||
prefix: nameof(AntiCheatPatches.GenerateChallengeResponse_Prefix));
|
||||
TryPatchByName("LBNJFPOLCDL", "JHLKGBGJKKK",
|
||||
args: new[] { typeof(string), typeof(string), typeof(string) },
|
||||
prefix: nameof(AuthPatches.CaptureTokenArgs_Prefix));
|
||||
// TLS cert-validation bypass — gated behind Cfg.EnableTlsTrustBypass.
|
||||
// Even when the prefix's return value is "let original run", the
|
||||
// detour itself still has to be JIT-installed when BouncyCastle
|
||||
@@ -542,16 +552,81 @@ public class Mod : MelonMod
|
||||
complete &= TryPatch("Uri ctor (string, UriKind)", typeof(Uri), "ctor",
|
||||
new[] { typeof(string), typeof(UriKind) },
|
||||
prefix: nameof(UriPatches.UriStringCtor_Prefix));
|
||||
complete &= TryPatchHttpRequestUriConstructors();
|
||||
TryPatchByName("BestHTTP.HTTPRequest", "set_Uri",
|
||||
args: new[] { typeof(Uri) },
|
||||
prefix: nameof(UriPatches.HttpRequestSetUri_Prefix));
|
||||
TryPatchByName("BestHTTP.HTTPRequest", "PrepareUri",
|
||||
args: new[] { typeof(Uri) },
|
||||
prefix: nameof(UriPatches.HttpRequestPrepareUri_Prefix));
|
||||
complete &= TryPatchByName("BestHTTP.HTTPRequest", "Send",
|
||||
prefix: nameof(UriPatches.HttpRequestSend_Prefix));
|
||||
TryPatchByName("BestHTTP.HTTPRequest", "SetHeader",
|
||||
args: new[] { typeof(string), typeof(string) },
|
||||
prefix: nameof(HttpTracePatches.HeaderSet_Prefix));
|
||||
TryPatchByName("BestHTTP.HTTPRequest", "AddHeader",
|
||||
args: new[] { typeof(string), typeof(string) },
|
||||
prefix: nameof(HttpTracePatches.HeaderSet_Prefix));
|
||||
complete &= TryPatchHttpManagerStringSendRequestOverloads();
|
||||
complete &= TryPatchByName("BestHTTP.HTTPManager", "SendRequest",
|
||||
args: new[] { ResolveType("BestHTTP.HTTPRequest") ?? typeof(object) },
|
||||
prefix: nameof(UriPatches.HttpManagerSendRequestObject_Prefix));
|
||||
TryPatchByName("GFOGGHLDJFF", "JBFGGIJPOLC",
|
||||
prefix: nameof(HttpTracePatches.RecNetRequestSend_Prefix));
|
||||
TryPatchByName("DNKAJOCIFHA", "JBFGGIJPOLC",
|
||||
prefix: nameof(HttpTracePatches.RecNetRequestSend_Prefix));
|
||||
TryPatchByName("GFOGGHLDJFF", "GMFJNBANJJA",
|
||||
args: new[] { typeof(string), typeof(string) },
|
||||
prefix: nameof(HttpTracePatches.HeaderSet_Prefix));
|
||||
TryPatchByName("DNKAJOCIFHA", "GMFJNBANJJA",
|
||||
args: new[] { typeof(string), typeof(string) },
|
||||
prefix: nameof(HttpTracePatches.HeaderSet_Prefix));
|
||||
var httpMethodsType = ResolveType("BestHTTP.HTTPMethods") ?? typeof(object);
|
||||
var recNetServiceType = ResolveType("GJDLNNLKDIJ") ?? typeof(object);
|
||||
TryPatchByName("BNDIAONDFFF", "ctor",
|
||||
args: new[] { httpMethodsType, recNetServiceType, typeof(string) },
|
||||
prefix: nameof(HttpTracePatches.RecNetRequestBuilderCtor_Prefix));
|
||||
|
||||
_networkPatchesRegistered = complete;
|
||||
}
|
||||
|
||||
private bool TryPatchHttpRequestUriConstructors()
|
||||
{
|
||||
var type = ResolveType("BestHTTP.HTTPRequest");
|
||||
if (type is null)
|
||||
{
|
||||
Log.Warning("[patch-miss] BestHTTP.HTTPRequest..ctor(Uri): type not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
var prefix = GetPatchMethod(nameof(UriPatches.HttpRequestCtorUri_Prefix));
|
||||
var patched = 0;
|
||||
foreach (var ctor in type.GetConstructors(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||
{
|
||||
var parameters = ctor.GetParameters();
|
||||
if (parameters.Length == 0 || !IsUriType(parameters[0].ParameterType)) continue;
|
||||
|
||||
try
|
||||
{
|
||||
HarmonyInstance.Patch(ctor, prefix: new HarmonyMethod(prefix));
|
||||
patched++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[patch-fail] BestHTTP.HTTPRequest..ctor({FormatParameterTypes(parameters)}): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (patched == 0)
|
||||
{
|
||||
Log.Warning("[patch-miss] BestHTTP.HTTPRequest..ctor(Uri)");
|
||||
return false;
|
||||
}
|
||||
|
||||
Log.Msg($"[patch-ok] BestHTTP.HTTPRequest..ctor(Uri first) overloads x{patched}");
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryPatchHttpManagerStringSendRequestOverloads()
|
||||
{
|
||||
var type = ResolveType("BestHTTP.HTTPManager");
|
||||
@@ -582,6 +657,19 @@ public class Mod : MelonMod
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsUriType(Type type)
|
||||
{
|
||||
return type == typeof(Uri) || type.FullName == typeof(Uri).FullName || type.Name == nameof(Uri);
|
||||
}
|
||||
|
||||
private static string FormatParameterTypes(ParameterInfo[] parameters)
|
||||
{
|
||||
var names = new List<string>();
|
||||
foreach (var parameter in parameters)
|
||||
names.Add(parameter.ParameterType.Name);
|
||||
return string.Join(",", names);
|
||||
}
|
||||
|
||||
public override void OnUpdate()
|
||||
{
|
||||
if (!_firstUpdateLogged)
|
||||
@@ -903,7 +991,7 @@ public class Mod : MelonMod
|
||||
{
|
||||
// Look in all three patch holder classes — small enough that a
|
||||
// linear scan is cheaper than per-class lookups.
|
||||
foreach (var holder in new[] { typeof(UriPatches), typeof(PhotonPatches), typeof(TlsPatches), typeof(DiagnosticPatches), typeof(SavePatches), typeof(ChatPatches), typeof(JoinPatches), typeof(RegistrationPatches), typeof(DebugConsolePatches), typeof(ScreenSharePatches), typeof(MakerPenGiftPreviewPatches), typeof(QuestTeamSize) })
|
||||
foreach (var holder in new[] { typeof(UriPatches), typeof(HttpTracePatches), typeof(AuthPatches), typeof(PhotonPatches), typeof(AntiCheatPatches), typeof(TlsPatches), typeof(DiagnosticPatches), typeof(SavePatches), typeof(ChatPatches), typeof(JoinPatches), typeof(RegistrationPatches), typeof(DebugConsolePatches), typeof(ScreenSharePatches), typeof(MakerPenGiftPreviewPatches), typeof(QuestTeamSize) })
|
||||
{
|
||||
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
|
||||
if (m is not null) return m;
|
||||
@@ -1720,40 +1808,434 @@ internal static class MakerPenGiftPreviewPatches
|
||||
}
|
||||
}
|
||||
|
||||
internal static class AuthPatches
|
||||
{
|
||||
private static string? _latestAccessToken;
|
||||
private static int _tokenLogCount;
|
||||
|
||||
public static void CaptureTokenArgs_Prefix(object? __0, object? __1, object? __2)
|
||||
{
|
||||
try
|
||||
{
|
||||
var accessToken = __0?.ToString();
|
||||
if (LooksLikeJwt(accessToken))
|
||||
_latestAccessToken = accessToken;
|
||||
|
||||
if (_tokenLogCount++ < 3)
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
"[auth-trace] token setter " +
|
||||
$"access={Presence(__0)} refresh={Presence(__1)} key={Presence(__2)}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Warning($"[auth-trace] token capture failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool LooksLikeJwt(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return false;
|
||||
|
||||
var dots = 0;
|
||||
foreach (var ch in value)
|
||||
if (ch == '.') dots++;
|
||||
return dots == 2;
|
||||
}
|
||||
|
||||
private static string Presence(object? value) =>
|
||||
string.IsNullOrWhiteSpace(value?.ToString()) ? "missing" : "present";
|
||||
}
|
||||
|
||||
internal static class HttpTracePatches
|
||||
{
|
||||
private const int BodyPreviewBytes = 700;
|
||||
private static readonly Regex SensitivePairRegex = new(
|
||||
@"(?i)(password|client_secret|access_token|accessToken|refresh_token|refreshToken|authorization|token|key)=([^&\s]+)",
|
||||
RegexOptions.Compiled);
|
||||
private static readonly Regex BearerRegex = new(
|
||||
@"(?i)Bearer\s+[-._~+/=A-Za-z0-9]+",
|
||||
RegexOptions.Compiled);
|
||||
private static int _requestId;
|
||||
private static int _failureLogCount;
|
||||
|
||||
public static void RecNetRequestSend_Prefix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
TraceRequestObject(__instance, $"{__originalMethod.DeclaringType?.Name}.{__originalMethod.Name}");
|
||||
}
|
||||
|
||||
public static void RecNetRequestBuilderCtor_Prefix(object? __0, object? __1, object? __2)
|
||||
{
|
||||
var id = NextRequestId();
|
||||
DiagnosticPatches.Write(
|
||||
$"[http-trace] #{id} BNDIAONDFFF.ctor method={SanitizeInline(__0?.ToString() ?? "<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
|
||||
{
|
||||
public static void HttpRequestCtorUri_Prefix(object __0)
|
||||
{
|
||||
HttpTracePatches.TraceUrl(__0?.ToString(), "BestHTTP.HTTPRequest..ctor(Uri)");
|
||||
}
|
||||
|
||||
public static void HttpRequestSetUri_Prefix(ref Uri __0)
|
||||
{
|
||||
RewriteUriArg(ref __0, "httprequest-seturi-rewrite");
|
||||
}
|
||||
|
||||
public static void HttpRequestPrepareUri_Prefix(ref Uri __0)
|
||||
{
|
||||
RewriteUriArg(ref __0, "httprequest-prepareuri-rewrite");
|
||||
}
|
||||
|
||||
public static void HttpManagerSendRequestString_Prefix(ref string url)
|
||||
{
|
||||
RewriteString(ref url, "httpmanager-rewrite");
|
||||
HttpTracePatches.TraceUrl(url, "BestHTTP.HTTPManager.SendRequest(string)");
|
||||
}
|
||||
|
||||
public static void HttpManagerSendRequestObject_Prefix(object request)
|
||||
{
|
||||
RewriteRequestUri(request, "httpmanager-request-rewrite");
|
||||
HttpTracePatches.TraceRequestObject(request, "BestHTTP.HTTPManager.SendRequest(HTTPRequest)");
|
||||
}
|
||||
|
||||
public static void UriStringCtor_Prefix(ref string uriString)
|
||||
{
|
||||
RewriteString(ref uriString, "uri-rewrite");
|
||||
HttpTracePatches.TraceUrl(uriString, "System.Uri..ctor(string)");
|
||||
}
|
||||
|
||||
public static void HttpRequestSend_Prefix(object __instance)
|
||||
{
|
||||
RewriteRequestUri(__instance, "http-rewrite");
|
||||
HttpTracePatches.TraceRequestObject(__instance, "BestHTTP.HTTPRequest.Send");
|
||||
}
|
||||
|
||||
private static void RewriteRequestUri(object request, string label)
|
||||
{
|
||||
try
|
||||
{
|
||||
var uriProp = request.GetType().GetProperty("Uri", BindingFlags.Public | BindingFlags.Instance);
|
||||
if (uriProp?.GetValue(request) is not Uri uri) return;
|
||||
if (request is null) return;
|
||||
var type = request.GetType();
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
|
||||
var rewritten = RewriteUri(uri);
|
||||
if (rewritten is null) return;
|
||||
if (TryRewriteUriProperty(request, type.GetProperty("Uri", flags), label)) return;
|
||||
if (TryRewriteUriField(request, type.GetField("<Uri>k__BackingField", flags), label)) return;
|
||||
|
||||
uriProp.SetValue(request, rewritten);
|
||||
Mod.Log.Msg($"[{label}] {uri} → {rewritten}");
|
||||
foreach (var prop in type.GetProperties(flags))
|
||||
{
|
||||
if (prop.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||
if (TryRewriteUriProperty(request, prop, label)) return;
|
||||
}
|
||||
|
||||
foreach (var field in type.GetFields(flags))
|
||||
{
|
||||
if (field.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||
if (TryRewriteUriField(request, field, label)) return;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1761,13 +2243,53 @@ internal static class UriPatches
|
||||
}
|
||||
}
|
||||
|
||||
private static void RewriteUriArg(ref Uri uri, string label)
|
||||
{
|
||||
if (uri is null) return;
|
||||
var rewritten = RewriteUri(uri);
|
||||
if (rewritten is null) return;
|
||||
|
||||
Mod.Log.Msg($"[{label}] {uri} -> {rewritten}");
|
||||
uri = rewritten;
|
||||
}
|
||||
|
||||
private static bool TryRewriteUriProperty(object request, PropertyInfo? prop, string label)
|
||||
{
|
||||
if (prop is null || !prop.CanRead || !prop.CanWrite) return false;
|
||||
if (prop.GetIndexParameters().Length != 0) return false;
|
||||
if (prop.GetValue(request) is not Uri uri) return false;
|
||||
|
||||
var rewritten = RewriteUri(uri);
|
||||
if (rewritten is null) return false;
|
||||
|
||||
prop.SetValue(request, rewritten);
|
||||
Mod.Log.Msg($"[{label}] {uri} -> {rewritten}");
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryRewriteUriField(object request, FieldInfo? field, string label)
|
||||
{
|
||||
if (field is null || field.IsInitOnly) return false;
|
||||
if (field.GetValue(request) is not Uri uri) return false;
|
||||
|
||||
var rewritten = RewriteUri(uri);
|
||||
if (rewritten is null) return false;
|
||||
|
||||
field.SetValue(request, rewritten);
|
||||
Mod.Log.Msg($"[{label}] {uri} -> {rewritten}");
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void RewriteString(ref string uriString, string label)
|
||||
{
|
||||
if (string.IsNullOrEmpty(uriString)) return;
|
||||
var host = Mod.Cfg.ServerHost;
|
||||
if (string.IsNullOrEmpty(host)) return;
|
||||
if (uriString.IndexOf(".rec.net", StringComparison.OrdinalIgnoreCase) < 0) return;
|
||||
var rewritten = uriString.Replace(".rec.net", "." + host);
|
||||
if (!Uri.TryCreate(uriString, UriKind.Absolute, out var uri)) return;
|
||||
|
||||
var rewrittenUri = RewriteUri(uri);
|
||||
if (rewrittenUri is null) return;
|
||||
|
||||
var rewritten = rewrittenUri.ToString();
|
||||
if (rewritten == uriString) return;
|
||||
Mod.Log.Msg($"[{label}] {uriString} → {rewritten}");
|
||||
uriString = rewritten;
|
||||
@@ -2364,6 +2886,16 @@ internal static class SavePatches
|
||||
private static Type? FindGameType(string name) => Mod.ResolveType(name);
|
||||
}
|
||||
|
||||
internal static class AntiCheatPatches
|
||||
{
|
||||
public static bool GenerateChallengeResponse_Prefix(string FIMBPCACMKJ, ref string __result)
|
||||
{
|
||||
__result = "AAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
Mod.Log.Msg("[eac] using local challenge response for copied test client");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class TlsPatches
|
||||
{
|
||||
// Both BouncyCastle TLS authenticators have the same `void
|
||||
|
||||
@@ -106,9 +106,11 @@ public class AccountApiController(
|
||||
private async Task<PlayerEntity?> GetCurrentPlayerAsync()
|
||||
{
|
||||
var auth = Request.Headers.Authorization.ToString();
|
||||
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
var token = auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
|
||||
? auth["Bearer ".Length..].Trim()
|
||||
: Request.Cookies[AuthService.AccessCookieName];
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
return null;
|
||||
var token = auth["Bearer ".Length..].Trim();
|
||||
var id = authService.ValidateToken(token);
|
||||
if (id is not long playerId) return null;
|
||||
return await playerService.GetByIdAsync(playerId);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
[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
|
||||
/// <c>AvatarItemWardrobeRuntimeConfig.avatarItemDataLookup</c>
|
||||
/// 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")]
|
||||
[Authorize]
|
||||
[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;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/config")]
|
||||
[Route("api/[controller]/v2")]
|
||||
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.EntityFrameworkCore;
|
||||
using DorkNet.Server.Data;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Events.V3;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]/v3")]
|
||||
public class EventsController : ControllerBase
|
||||
public class EventsController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpGet("list")]
|
||||
public ActionResult<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)));
|
||||
}
|
||||
|
||||
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
|
||||
/// <c>Images.DownloadNamedImageMappings</c> endpoint. Returns a
|
||||
/// 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/v5/cheered/bulk")]
|
||||
[Authorize]
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
|
||||
[HttpGet("api/inventions/v1/featured")]
|
||||
[HttpGet("api/inventions/v1/featureddormskins")]
|
||||
[HttpGet("api/inventions/v1/toptoday")]
|
||||
[HttpGet("api/inventions/v3/popular")]
|
||||
public async Task<ActionResult> Popular([FromQuery] int take = 50)
|
||||
{
|
||||
@@ -78,6 +81,7 @@ public class InventionsController(
|
||||
public Task<ActionResult> Mine() => Saved(100);
|
||||
|
||||
[HttpGet("api/inventions/v1/search")]
|
||||
[HttpGet("api/inventions/v2/search")]
|
||||
public async Task<ActionResult> Search([FromQuery] string value = "")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
@@ -127,6 +131,8 @@ public class InventionsController(
|
||||
public sealed record InventionBatchRequest(List<long> InventionIds);
|
||||
|
||||
[HttpPost("api/inventions/v1/batch")]
|
||||
[HttpPost("api/inventions/v2/batch")]
|
||||
[HttpPost("api/inventions/v1/dormskinsfromids")]
|
||||
public async Task<ActionResult> Batch([FromBody] InventionBatchRequest req)
|
||||
{
|
||||
if (req?.InventionIds is null || req.InventionIds.Count == 0)
|
||||
@@ -138,6 +144,27 @@ public class InventionsController(
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpGet("api/inventions/v1/fromcreators")]
|
||||
public async Task<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
|
||||
/// <c>RecNet.Inventions.GetTags</c>. The watch deserialises the
|
||||
/// 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
|
||||
/// a Status/Invention/InventionVersion object.</summary>
|
||||
[HttpPost("api/inventions/v4/save")]
|
||||
[HttpPost("api/inventions/v6/save")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> SaveV4([FromBody] SaveInventionV4Request req)
|
||||
{
|
||||
@@ -426,6 +454,7 @@ public class InventionsController(
|
||||
int? InstantiationCost, int? LightsCost);
|
||||
|
||||
[HttpPost("api/inventions/v3/addversion")]
|
||||
[HttpPost("api/inventions/v4/addversion")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> AddVersion([FromBody] AddVersionRequest req)
|
||||
{
|
||||
@@ -491,6 +520,22 @@ public class InventionsController(
|
||||
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")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> Delete([FromQuery] long inventionId)
|
||||
@@ -685,7 +730,7 @@ public class InventionsController(
|
||||
i.CreatorPermission,
|
||||
i.GeneralPermission,
|
||||
IsAGInvention = i.IsAgInvention,
|
||||
Price = 0,
|
||||
i.Price,
|
||||
HideFromPlayer = false,
|
||||
};
|
||||
|
||||
|
||||
@@ -182,6 +182,44 @@ public class InventoryController(
|
||||
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 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
|
||||
[HttpDelete("cached")]
|
||||
[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
|
||||
// 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.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Models.Notification;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
@@ -45,6 +46,7 @@ public class PlayerEventsController(
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v2/{eventId:long}")]
|
||||
[HttpGet("api/playerevents/v1/{eventId:long}")]
|
||||
public async Task<ActionResult> GetOne(long eventId)
|
||||
{
|
||||
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId);
|
||||
@@ -52,6 +54,121 @@ public class PlayerEventsController(
|
||||
return Ok(ToWire(ev));
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v1/search")]
|
||||
[HttpGet("api/playerevents/v1/searchlive")]
|
||||
public async Task<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
|
||||
/// <c>LocalPlayerEventInfo</c> fetch (caller's created + RSVP'd
|
||||
/// events). Response keys (both required, via
|
||||
@@ -93,6 +210,39 @@ public class PlayerEventsController(
|
||||
return Ok(new { Created = created, Responses = responses });
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v1/all/{accountId:long}")]
|
||||
public async Task<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> —
|
||||
/// alternate RSVP route the watch uses for the response-list
|
||||
/// flow (older / sub-room view). Body is the same RsvpRequest
|
||||
@@ -233,6 +383,123 @@ public class PlayerEventsController(
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/name")]
|
||||
[HttpPut("api/playerevents/v2/{eventId:long}/name")]
|
||||
public async Task<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}")]
|
||||
public async Task<ActionResult> Delete(long eventId)
|
||||
{
|
||||
@@ -297,6 +564,44 @@ public class PlayerEventsController(
|
||||
return Ok(new { FailedInvites = failed, Result = 0 });
|
||||
}
|
||||
|
||||
public sealed class BroadcastRequest
|
||||
{
|
||||
public long PlayerEventId { get; set; }
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("api/playerevents/v1/broadcast")]
|
||||
public async Task<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) ─────────────────────────────────────────────────
|
||||
|
||||
public sealed class PlayerEventReportRequest
|
||||
@@ -306,6 +611,130 @@ public class PlayerEventsController(
|
||||
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
|
||||
/// moderation report against an event. Wire response is
|
||||
/// <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));
|
||||
}
|
||||
|
||||
[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}")]
|
||||
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 });
|
||||
}
|
||||
|
||||
[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 ──────────────────────────────────────────────────────
|
||||
|
||||
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
|
||||
/// from <paramref name="me"/>'s perspective. PendingSent flips
|
||||
/// 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));
|
||||
}
|
||||
|
||||
[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")]
|
||||
public async Task<IActionResult> Create()
|
||||
{
|
||||
@@ -132,6 +148,29 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase
|
||||
return Content(owns ? "true" : "false", "application/json");
|
||||
}
|
||||
|
||||
[HttpGet("api/roomkeys/v1/owns")]
|
||||
public async Task<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
|
||||
{
|
||||
Status = (int)status,
|
||||
@@ -173,6 +212,25 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase
|
||||
.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()
|
||||
{
|
||||
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
|
||||
/// price it displayed so we reject stale-price purchases.</summary>
|
||||
[HttpGet("api/storefronts/v1/buyRoomKey")]
|
||||
[HttpGet("api/storefronts/v1/PurchaseRoomKeyWithCurrency")]
|
||||
public async Task<IActionResult> BuyRoomKey(
|
||||
[FromQuery] long roomKeyId,
|
||||
[FromQuery] int requestedPrice)
|
||||
@@ -242,6 +243,95 @@ public class StorefrontsBuyController(
|
||||
return Ok(RoomKeyPurchaseResponse(RoomKeyStatus.Success, key, newBalance));
|
||||
}
|
||||
|
||||
[HttpPost("api/storefronts/v2/buyInvention")]
|
||||
[HttpGet("api/storefronts/v2/buyInvention")]
|
||||
public async Task<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 ──────────────────────────────────────────────────────────
|
||||
|
||||
private async Task GrantWardrobeAsync(long pid, string guid)
|
||||
|
||||
@@ -32,6 +32,8 @@ public class StorefrontsController(
|
||||
[HttpGet("api/storefronts/v1/all")]
|
||||
[HttpGet("api/storefronts/v2/all")]
|
||||
[HttpGet("api/storefronts/v3/all")]
|
||||
[HttpGet("api/storefronts/v1/toptoday")]
|
||||
[HttpGet("api/storefronts/v1/adcarouselitems")]
|
||||
[AllowAnonymous]
|
||||
public async Task<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()
|
||||
{
|
||||
var auth = Request.Headers.Authorization.ToString();
|
||||
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
var token = auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
|
||||
? auth["Bearer ".Length..].Trim()
|
||||
: Request.Cookies[AuthService.AccessCookieName];
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
return null;
|
||||
var token = auth["Bearer ".Length..].Trim();
|
||||
var id = authService.ValidateToken(token);
|
||||
if (id is not long playerId) return null;
|
||||
return await playerService.GetByIdAsync(playerId);
|
||||
@@ -170,6 +172,7 @@ public class AccountsController(
|
||||
// Accounts.SearchForAccounts — used by the watch's typeahead AND the
|
||||
// localhost public site's account search. Anonymous-safe (no JWT
|
||||
// required) so the front-facing site can call it directly.
|
||||
[HttpGet("/account/search")]
|
||||
[HttpGet("/account/v1/search")]
|
||||
[Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> Search([FromQuery] string? query, [FromQuery] int take = 20)
|
||||
@@ -405,6 +408,26 @@ public class AccountsController(
|
||||
return age;
|
||||
}
|
||||
|
||||
private async Task SetPlayerSettingAsync(long playerId, string key, string value)
|
||||
{
|
||||
var row = await db.PlayerSettings.FirstOrDefaultAsync(s => s.PlayerId == playerId && s.Key == key);
|
||||
if (row is null)
|
||||
{
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = playerId,
|
||||
Key = key,
|
||||
Value = value,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
row.Value = value;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Everyone is a dev for now — exposes the in-game debug console + dev
|
||||
// tools. Per user request: easier debugging while iterating on the
|
||||
// private server. Flip back to false later if you want shipping behavior.
|
||||
@@ -509,6 +532,120 @@ public class AccountsController(
|
||||
[HttpGet("/account/v1/me/disallowiap")]
|
||||
public IActionResult DisallowIap() => Ok(new { Disallow = false });
|
||||
|
||||
[HttpGet("/account/isactivecreator/me")]
|
||||
public async Task<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
|
||||
/// from <c>Cpp2IL_ISIL/.../RecNet/Accounts.txt</c> line 5686:
|
||||
/// <c>"account/{0}/bio"</c> (no v1 prefix). Wire shape from
|
||||
|
||||
@@ -31,6 +31,42 @@ public class AuthController(
|
||||
if (lastSeen.Date < DateTime.UtcNow.Date)
|
||||
await level.AwardXpAsync(playerId, LevelService.FirstLoginXp, "first_login_today");
|
||||
}
|
||||
|
||||
private static Dictionary<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:
|
||||
// - grant_type=cached_login → user clicked a remembered account on the
|
||||
// account-selection screen. Body includes `account_id` (and platform
|
||||
@@ -90,14 +126,7 @@ public class AuthController(
|
||||
await TryAwardLoginXpAsync(picked.Id);
|
||||
await playerService.TagPlatformAsync(picked.Id, platform, effPlatformId);
|
||||
var (acc1, refr1) = authService.GenerateTokenPair(picked.Id);
|
||||
return Ok(new
|
||||
{
|
||||
access_token = acc1,
|
||||
token_type = "Bearer",
|
||||
expires_in = 43200,
|
||||
refresh_token = refr1,
|
||||
scope = "openid profile",
|
||||
});
|
||||
return TokenOk(acc1, refr1);
|
||||
}
|
||||
|
||||
if (string.Equals(grant_type, "refresh_token", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -111,14 +140,7 @@ public class AuthController(
|
||||
if (existing is null)
|
||||
return Unauthorized(new { error = "invalid_grant" });
|
||||
var (acc, refr) = authService.GenerateTokenPair(existing.Id);
|
||||
return Ok(new
|
||||
{
|
||||
access_token = acc,
|
||||
token_type = "Bearer",
|
||||
expires_in = 43200,
|
||||
refresh_token = refr,
|
||||
scope = "openid profile",
|
||||
});
|
||||
return TokenOk(acc, refr);
|
||||
}
|
||||
|
||||
// password grant: two paths.
|
||||
@@ -150,14 +172,7 @@ public class AuthController(
|
||||
await playerService.TagPlatformAsync(byName.Id, platform, effPlatformId);
|
||||
await TryAwardLoginXpAsync(byName.Id);
|
||||
var pair = authService.GenerateTokenPair(byName.Id);
|
||||
return Ok(new
|
||||
{
|
||||
access_token = pair.AccessToken,
|
||||
token_type = "Bearer",
|
||||
expires_in = 43200,
|
||||
refresh_token = pair.RefreshToken,
|
||||
scope = "openid profile",
|
||||
});
|
||||
return TokenOk(pair.AccessToken, pair.RefreshToken);
|
||||
}
|
||||
|
||||
// Device-id fallback path. Prefer the real deviceId; fall back
|
||||
@@ -196,14 +211,7 @@ public class AuthController(
|
||||
await TryAwardLoginXpAsync(player.Id);
|
||||
var (access, refresh) = authService.GenerateTokenPair(player.Id);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
access_token = access,
|
||||
token_type = "Bearer",
|
||||
expires_in = 43200,
|
||||
refresh_token = refresh,
|
||||
scope = "openid profile",
|
||||
});
|
||||
return TokenOk(access, refresh);
|
||||
}
|
||||
|
||||
[HttpGet("/connect/userinfo")]
|
||||
@@ -307,6 +315,62 @@ public class AuthController(
|
||||
return Ok(results);
|
||||
}
|
||||
|
||||
[HttpGet("/cachedlogin/current")]
|
||||
public async Task<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()
|
||||
{
|
||||
Platform = platform,
|
||||
|
||||
@@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
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>.
|
||||
///
|
||||
/// <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
|
||||
/// mod work needed.</para>
|
||||
///
|
||||
/// <para>The <c>v</c> query param is the raw build identifier the watch
|
||||
/// reads from <c>KMJJGELKNAP.MCFMPCNNEBH</c> — for the December build
|
||||
/// that's the literal string <c>"20201210"</c>. We map raw build IDs
|
||||
/// to our internal <see cref="ClientVersion.Key"/>s via the
|
||||
/// <para>The <c>v</c> query param is the raw build identifier supplied
|
||||
/// by the client. We map raw build IDs to our internal
|
||||
/// <see cref="ClientVersion.Key"/>s via the
|
||||
/// <c>DorkNet:BuildIdToVersionKey</c> config map; any build ID we
|
||||
/// don't recognise OR that maps to a key not in
|
||||
/// <see cref="VersionRegistry.SupportedVersionKeys"/> gets
|
||||
@@ -49,7 +48,8 @@ public class VersionCheckController(
|
||||
private static readonly IReadOnlyDictionary<string, string> BuiltInBuildIdToVersionKey =
|
||||
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")]
|
||||
@@ -66,8 +66,7 @@ public class VersionCheckController(
|
||||
// Map raw build ID → ClientVersion key via config. Format:
|
||||
// "DorkNet": {
|
||||
// "BuildIdToVersionKey": {
|
||||
// "20201210": "december_2020_12_18",
|
||||
// "20200310": "march_2020_03_10"
|
||||
// "20230321": "march_2023_03_21"
|
||||
// }
|
||||
// }
|
||||
// Mapping ABSENT → unknown build → UpdateRequired.
|
||||
|
||||
@@ -71,6 +71,38 @@ public class EconController(DorkNetDbContext db, LevelService level) : Controlle
|
||||
.ToListAsync();
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
[HttpGet("/econ/customAvatarItems")]
|
||||
[HttpGet("/econ/customAvatarItems/v1/owned")]
|
||||
public async Task<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
|
||||
|
||||
@@ -74,6 +74,14 @@ public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbCo
|
||||
public DbSet<ClubSubscriptionEntity> ClubSubscriptions => Set<ClubSubscriptionEntity>();
|
||||
public DbSet<SignupCodeEntity> SignupCodes => Set<SignupCodeEntity>();
|
||||
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)
|
||||
{
|
||||
@@ -679,5 +687,64 @@ public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbCo
|
||||
e.HasKey(c => new { c.RoomId, c.Channel });
|
||||
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 SpawnCount { get; set; } = 0;
|
||||
public int Price { get; set; } = 0;
|
||||
|
||||
/// <summary>Soft-delete flag. When set, the row is hidden from
|
||||
/// 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 IsDormRoom { get; set; } = false;
|
||||
public bool IsStudioRoom { get; set; } = false;
|
||||
public bool IsRoomLinkedToRecRoomStudio { get; set; } = false;
|
||||
|
||||
[MaxLength(128)]
|
||||
public string StudioSessionId { get; set; } = string.Empty;
|
||||
|
||||
public bool CloningAllowed { get; set; } = false;
|
||||
public bool SupportsVRLow { get; set; } = true;
|
||||
public bool SupportsMobile { get; set; } = false;
|
||||
|
||||
@@ -9,6 +9,8 @@ namespace DorkNet.Server.Services;
|
||||
|
||||
public class AuthService(IConfiguration config, DorkNetDbContext db)
|
||||
{
|
||||
public const string AccessCookieName = "dorknet_access_token";
|
||||
|
||||
// Resolution order MUST match Program.cs's JwtBearer validator setup —
|
||||
// env var first, config key second — otherwise tokens are signed with
|
||||
// one secret and validated against another and every authenticated
|
||||
@@ -56,13 +58,22 @@ public class AuthService(IConfiguration config, DorkNetDbContext db)
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, playerId.ToString()),
|
||||
new(JwtRegisteredClaimNames.Sub, playerId.ToString()),
|
||||
new("accountId", playerId.ToString()),
|
||||
new("account_id", playerId.ToString()),
|
||||
new("accountid", playerId.ToString()),
|
||||
new(ClaimTypes.Role, "gameClient"),
|
||||
new("roles", "gameClient"),
|
||||
new("token_type", tokenType),
|
||||
};
|
||||
// Multiple Role claims become a JSON array on the wire — the
|
||||
// watch's HasRole helper handles both string and List<string>
|
||||
// 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
|
||||
{
|
||||
|
||||
@@ -1016,6 +1016,9 @@ public class RoomService(DorkNetDbContext db)
|
||||
// showed up that the admin couldn't purge.)
|
||||
IsAGRoom = false,
|
||||
IsDormRoom = false,
|
||||
IsStudioRoom = source.IsStudioRoom,
|
||||
IsRoomLinkedToRecRoomStudio = source.IsRoomLinkedToRecRoomStudio,
|
||||
StudioSessionId = source.StudioSessionId,
|
||||
CloningAllowed = source.CloningAllowed,
|
||||
SupportsVRLow = source.SupportsVRLow,
|
||||
SupportsMobile = source.SupportsMobile,
|
||||
@@ -1070,6 +1073,9 @@ public class RoomService(DorkNetDbContext db)
|
||||
// ignored by both clients.
|
||||
IsDorm = r.IsDormRoom,
|
||||
IsDormRoom = r.IsDormRoom,
|
||||
IsStudioRoom = r.IsStudioRoom,
|
||||
IsRoomLinkedToRecRoomStudio = r.IsRoomLinkedToRecRoomStudio,
|
||||
StudioSessionId = r.StudioSessionId,
|
||||
CloningAllowed = r.CloningAllowed,
|
||||
SupportsVRLow = r.SupportsVRLow,
|
||||
SupportsMobile = r.SupportsMobile,
|
||||
|
||||
@@ -104,6 +104,8 @@ public static class DatabaseBootstrap
|
||||
await EnsureInventorySchemaAsync(db);
|
||||
await EnsureLeaderboardSchemaAsync(db);
|
||||
await EnsureRoomColumnsAsync(db);
|
||||
await EnsureInventionColumnsAsync(db);
|
||||
await EnsureMarch2023TablesAsync(db);
|
||||
|
||||
// Coach system account at Player.Id=1. The RR-Original room seeder
|
||||
// below assigns CreatorPlayerId=1 to every canonical room
|
||||
@@ -251,6 +253,12 @@ public static class DatabaseBootstrap
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
@"ALTER TABLE ""Rooms"" ADD COLUMN IF NOT EXISTS ""MaxCapacity"" integer NOT NULL DEFAULT 8;");
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
@"ALTER TABLE ""Rooms"" ADD COLUMN IF NOT EXISTS ""IsStudioRoom"" boolean NOT NULL DEFAULT false;");
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
@"ALTER TABLE ""Rooms"" ADD COLUMN IF NOT EXISTS ""IsRoomLinkedToRecRoomStudio"" boolean NOT NULL DEFAULT false;");
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
@"ALTER TABLE ""Rooms"" ADD COLUMN IF NOT EXISTS ""StudioSessionId"" character varying(128) NOT NULL DEFAULT '';");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -262,6 +270,45 @@ public static class DatabaseBootstrap
|
||||
@"ALTER TABLE ""Rooms"" ADD COLUMN ""MaxCapacity"" INTEGER NOT NULL DEFAULT 8;");
|
||||
}
|
||||
catch { }
|
||||
|
||||
try
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
@"ALTER TABLE ""Rooms"" ADD COLUMN ""IsStudioRoom"" INTEGER NOT NULL DEFAULT 0;");
|
||||
}
|
||||
catch { }
|
||||
|
||||
try
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
@"ALTER TABLE ""Rooms"" ADD COLUMN ""IsRoomLinkedToRecRoomStudio"" INTEGER NOT NULL DEFAULT 0;");
|
||||
}
|
||||
catch { }
|
||||
|
||||
try
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
@"ALTER TABLE ""Rooms"" ADD COLUMN ""StudioSessionId"" TEXT NOT NULL DEFAULT '';");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private static async Task EnsureInventionColumnsAsync(DorkNetDbContext db)
|
||||
{
|
||||
var provider = db.Database.ProviderName ?? string.Empty;
|
||||
if (provider.Contains("Npgsql", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
@"ALTER TABLE ""Inventions"" ADD COLUMN IF NOT EXISTS ""Price"" integer NOT NULL DEFAULT 0;");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
@"ALTER TABLE ""Inventions"" ADD COLUMN ""Price"" INTEGER NOT NULL DEFAULT 0;");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private static async Task EnsureServerSettingsColumnsAsync(DorkNetDbContext db)
|
||||
@@ -464,6 +511,280 @@ public static class DatabaseBootstrap
|
||||
@"CREATE INDEX IF NOT EXISTS ""IX_LeaderboardStats_RoomId_StatChannel_Value"" ON ""LeaderboardStats"" (""RoomId"", ""StatChannel"", ""Value"");");
|
||||
}
|
||||
|
||||
private static async Task EnsureMarch2023TablesAsync(DorkNetDbContext db)
|
||||
{
|
||||
var provider = db.Database.ProviderName ?? string.Empty;
|
||||
if (provider.Contains("Npgsql", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var statements = new[]
|
||||
{
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS "CustomAvatarItems" (
|
||||
"Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
"PublicId" uuid NOT NULL,
|
||||
"CreatorPlayerId" bigint NOT NULL,
|
||||
"Name" character varying(128) NOT NULL,
|
||||
"Description" character varying(1024) NOT NULL,
|
||||
"Price" integer NOT NULL DEFAULT 100,
|
||||
"ItemType" integer NOT NULL DEFAULT 0,
|
||||
"BaseAvatarItemId" integer NOT NULL DEFAULT 0,
|
||||
"Color" character varying(64) NOT NULL DEFAULT '',
|
||||
"ImageName" character varying(256) NOT NULL DEFAULT '',
|
||||
"AssetName" character varying(256) NOT NULL DEFAULT '',
|
||||
"IsPublic" boolean NOT NULL DEFAULT false,
|
||||
"IsFeatured" boolean NOT NULL DEFAULT false,
|
||||
"CheerCount" bigint NOT NULL DEFAULT 0,
|
||||
"PurchaseCount" bigint NOT NULL DEFAULT 0,
|
||||
"CreatedAt" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
"UpdatedAt" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);
|
||||
""",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_CustomAvatarItems_PublicId"" ON ""CustomAvatarItems"" (""PublicId"");",
|
||||
@"CREATE INDEX IF NOT EXISTS ""IX_CustomAvatarItems_CreatorPlayerId"" ON ""CustomAvatarItems"" (""CreatorPlayerId"");",
|
||||
@"CREATE INDEX IF NOT EXISTS ""IX_CustomAvatarItems_IsPublic"" ON ""CustomAvatarItems"" (""IsPublic"");",
|
||||
@"CREATE INDEX IF NOT EXISTS ""IX_CustomAvatarItems_IsFeatured"" ON ""CustomAvatarItems"" (""IsFeatured"");",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS "CustomAvatarItemOwnership" (
|
||||
"Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
"PlayerId" bigint NOT NULL,
|
||||
"CustomAvatarItemId" bigint NOT NULL,
|
||||
"AcquiredAt" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);
|
||||
""",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_CustomAvatarItemOwnership_PlayerId_CustomAvatarItemId"" ON ""CustomAvatarItemOwnership"" (""PlayerId"", ""CustomAvatarItemId"");",
|
||||
@"CREATE INDEX IF NOT EXISTS ""IX_CustomAvatarItemOwnership_CustomAvatarItemId"" ON ""CustomAvatarItemOwnership"" (""CustomAvatarItemId"");",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS "ItemWishlists" (
|
||||
"Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
"PlayerId" bigint NOT NULL,
|
||||
"ItemKey" character varying(128) NOT NULL,
|
||||
"ItemType" integer NOT NULL DEFAULT 0,
|
||||
"CreatedAt" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);
|
||||
""",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ItemWishlists_PlayerId_ItemKey_ItemType"" ON ""ItemWishlists"" (""PlayerId"", ""ItemKey"", ""ItemType"");",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS "Keepsakes" (
|
||||
"Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
"PlayerId" bigint NOT NULL,
|
||||
"Category" character varying(64) NOT NULL DEFAULT 'general',
|
||||
"EventKey" character varying(128) NOT NULL DEFAULT '',
|
||||
"Title" character varying(128) NOT NULL DEFAULT '',
|
||||
"Description" character varying(1024) NOT NULL DEFAULT '',
|
||||
"ImageName" character varying(256) NOT NULL DEFAULT '',
|
||||
"EarnedAt" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);
|
||||
""",
|
||||
@"CREATE INDEX IF NOT EXISTS ""IX_Keepsakes_PlayerId"" ON ""Keepsakes"" (""PlayerId"");",
|
||||
@"CREATE INDEX IF NOT EXISTS ""IX_Keepsakes_PlayerId_EventKey"" ON ""Keepsakes"" (""PlayerId"", ""EventKey"");",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS "RoomCurrencies" (
|
||||
"Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
"PublicId" uuid NOT NULL,
|
||||
"RoomId" bigint NOT NULL,
|
||||
"CreatorPlayerId" bigint NOT NULL,
|
||||
"Name" character varying(64) NOT NULL DEFAULT '',
|
||||
"Description" character varying(512) NOT NULL DEFAULT '',
|
||||
"ImageName" character varying(256) NOT NULL DEFAULT '',
|
||||
"DailyLimit" integer NOT NULL DEFAULT 0,
|
||||
"IsDeleted" boolean NOT NULL DEFAULT false,
|
||||
"CreatedAt" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
"UpdatedAt" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);
|
||||
""",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_RoomCurrencies_PublicId"" ON ""RoomCurrencies"" (""PublicId"");",
|
||||
@"CREATE INDEX IF NOT EXISTS ""IX_RoomCurrencies_RoomId"" ON ""RoomCurrencies"" (""RoomId"");",
|
||||
@"CREATE INDEX IF NOT EXISTS ""IX_RoomCurrencies_CreatorPlayerId"" ON ""RoomCurrencies"" (""CreatorPlayerId"");",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS "RoomCurrencyBalances" (
|
||||
"Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
"PlayerId" bigint NOT NULL,
|
||||
"RoomCurrencyId" bigint NOT NULL,
|
||||
"Balance" bigint NOT NULL DEFAULT 0,
|
||||
"UpdatedAt" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);
|
||||
""",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_RoomCurrencyBalances_PlayerId_RoomCurrencyId"" ON ""RoomCurrencyBalances"" (""PlayerId"", ""RoomCurrencyId"");",
|
||||
@"CREATE INDEX IF NOT EXISTS ""IX_RoomCurrencyBalances_RoomCurrencyId"" ON ""RoomCurrencyBalances"" (""RoomCurrencyId"");",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS "RoomCurrencyPurchaseOffers" (
|
||||
"Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
"PublicId" uuid NOT NULL,
|
||||
"RoomCurrencyId" bigint NOT NULL,
|
||||
"Name" character varying(64) NOT NULL DEFAULT '',
|
||||
"Amount" integer NOT NULL DEFAULT 0,
|
||||
"Price" integer NOT NULL DEFAULT 0,
|
||||
"CurrencyType" integer NOT NULL DEFAULT 2,
|
||||
"IsDeleted" boolean NOT NULL DEFAULT false,
|
||||
"CreatedAt" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
"UpdatedAt" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);
|
||||
""",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_RoomCurrencyPurchaseOffers_PublicId"" ON ""RoomCurrencyPurchaseOffers"" (""PublicId"");",
|
||||
@"CREATE INDEX IF NOT EXISTS ""IX_RoomCurrencyPurchaseOffers_RoomCurrencyId"" ON ""RoomCurrencyPurchaseOffers"" (""RoomCurrencyId"");",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS "UgcPurchasables" (
|
||||
"Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
"PublicId" uuid NOT NULL,
|
||||
"RoomId" bigint NOT NULL,
|
||||
"CreatorPlayerId" bigint NOT NULL,
|
||||
"Name" character varying(128) NOT NULL DEFAULT '',
|
||||
"Description" character varying(1024) NOT NULL DEFAULT '',
|
||||
"ImageName" character varying(256) NOT NULL DEFAULT '',
|
||||
"Price" integer NOT NULL DEFAULT 0,
|
||||
"CurrencyType" integer NOT NULL DEFAULT 2,
|
||||
"ItemType" integer NOT NULL DEFAULT 0,
|
||||
"IsFeatured" boolean NOT NULL DEFAULT false,
|
||||
"SortOrder" integer NOT NULL DEFAULT 0,
|
||||
"IsDeleted" boolean NOT NULL DEFAULT false,
|
||||
"CreatedAt" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
"UpdatedAt" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);
|
||||
""",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_UgcPurchasables_PublicId"" ON ""UgcPurchasables"" (""PublicId"");",
|
||||
@"CREATE INDEX IF NOT EXISTS ""IX_UgcPurchasables_RoomId"" ON ""UgcPurchasables"" (""RoomId"");",
|
||||
@"CREATE INDEX IF NOT EXISTS ""IX_UgcPurchasables_CreatorPlayerId"" ON ""UgcPurchasables"" (""CreatorPlayerId"");",
|
||||
};
|
||||
|
||||
foreach (var sql in statements)
|
||||
await db.Database.ExecuteSqlRawAsync(sql);
|
||||
return;
|
||||
}
|
||||
|
||||
var sqliteStatements = new[]
|
||||
{
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS "CustomAvatarItems" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_CustomAvatarItems" PRIMARY KEY AUTOINCREMENT,
|
||||
"PublicId" TEXT NOT NULL,
|
||||
"CreatorPlayerId" INTEGER NOT NULL,
|
||||
"Name" TEXT NOT NULL,
|
||||
"Description" TEXT NOT NULL,
|
||||
"Price" INTEGER NOT NULL DEFAULT 100,
|
||||
"ItemType" INTEGER NOT NULL DEFAULT 0,
|
||||
"BaseAvatarItemId" INTEGER NOT NULL DEFAULT 0,
|
||||
"Color" TEXT NOT NULL DEFAULT '',
|
||||
"ImageName" TEXT NOT NULL DEFAULT '',
|
||||
"AssetName" TEXT NOT NULL DEFAULT '',
|
||||
"IsPublic" INTEGER NOT NULL DEFAULT 0,
|
||||
"IsFeatured" INTEGER NOT NULL DEFAULT 0,
|
||||
"CheerCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"PurchaseCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"CreatedAt" TEXT NOT NULL,
|
||||
"UpdatedAt" TEXT NOT NULL
|
||||
);
|
||||
""",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_CustomAvatarItems_PublicId"" ON ""CustomAvatarItems"" (""PublicId"");",
|
||||
@"CREATE INDEX IF NOT EXISTS ""IX_CustomAvatarItems_CreatorPlayerId"" ON ""CustomAvatarItems"" (""CreatorPlayerId"");",
|
||||
@"CREATE INDEX IF NOT EXISTS ""IX_CustomAvatarItems_IsPublic"" ON ""CustomAvatarItems"" (""IsPublic"");",
|
||||
@"CREATE INDEX IF NOT EXISTS ""IX_CustomAvatarItems_IsFeatured"" ON ""CustomAvatarItems"" (""IsFeatured"");",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS "CustomAvatarItemOwnership" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_CustomAvatarItemOwnership" PRIMARY KEY AUTOINCREMENT,
|
||||
"PlayerId" INTEGER NOT NULL,
|
||||
"CustomAvatarItemId" INTEGER NOT NULL,
|
||||
"AcquiredAt" TEXT NOT NULL
|
||||
);
|
||||
""",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_CustomAvatarItemOwnership_PlayerId_CustomAvatarItemId"" ON ""CustomAvatarItemOwnership"" (""PlayerId"", ""CustomAvatarItemId"");",
|
||||
@"CREATE INDEX IF NOT EXISTS ""IX_CustomAvatarItemOwnership_CustomAvatarItemId"" ON ""CustomAvatarItemOwnership"" (""CustomAvatarItemId"");",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS "ItemWishlists" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_ItemWishlists" PRIMARY KEY AUTOINCREMENT,
|
||||
"PlayerId" INTEGER NOT NULL,
|
||||
"ItemKey" TEXT NOT NULL,
|
||||
"ItemType" INTEGER NOT NULL DEFAULT 0,
|
||||
"CreatedAt" TEXT NOT NULL
|
||||
);
|
||||
""",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ItemWishlists_PlayerId_ItemKey_ItemType"" ON ""ItemWishlists"" (""PlayerId"", ""ItemKey"", ""ItemType"");",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS "Keepsakes" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_Keepsakes" PRIMARY KEY AUTOINCREMENT,
|
||||
"PlayerId" INTEGER NOT NULL,
|
||||
"Category" TEXT NOT NULL DEFAULT 'general',
|
||||
"EventKey" TEXT NOT NULL DEFAULT '',
|
||||
"Title" TEXT NOT NULL DEFAULT '',
|
||||
"Description" TEXT NOT NULL DEFAULT '',
|
||||
"ImageName" TEXT NOT NULL DEFAULT '',
|
||||
"EarnedAt" TEXT NOT NULL
|
||||
);
|
||||
""",
|
||||
@"CREATE INDEX IF NOT EXISTS ""IX_Keepsakes_PlayerId"" ON ""Keepsakes"" (""PlayerId"");",
|
||||
@"CREATE INDEX IF NOT EXISTS ""IX_Keepsakes_PlayerId_EventKey"" ON ""Keepsakes"" (""PlayerId"", ""EventKey"");",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS "RoomCurrencies" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_RoomCurrencies" PRIMARY KEY AUTOINCREMENT,
|
||||
"PublicId" TEXT NOT NULL,
|
||||
"RoomId" INTEGER NOT NULL,
|
||||
"CreatorPlayerId" INTEGER NOT NULL,
|
||||
"Name" TEXT NOT NULL DEFAULT '',
|
||||
"Description" TEXT NOT NULL DEFAULT '',
|
||||
"ImageName" TEXT NOT NULL DEFAULT '',
|
||||
"DailyLimit" INTEGER NOT NULL DEFAULT 0,
|
||||
"IsDeleted" INTEGER NOT NULL DEFAULT 0,
|
||||
"CreatedAt" TEXT NOT NULL,
|
||||
"UpdatedAt" TEXT NOT NULL
|
||||
);
|
||||
""",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_RoomCurrencies_PublicId"" ON ""RoomCurrencies"" (""PublicId"");",
|
||||
@"CREATE INDEX IF NOT EXISTS ""IX_RoomCurrencies_RoomId"" ON ""RoomCurrencies"" (""RoomId"");",
|
||||
@"CREATE INDEX IF NOT EXISTS ""IX_RoomCurrencies_CreatorPlayerId"" ON ""RoomCurrencies"" (""CreatorPlayerId"");",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS "RoomCurrencyBalances" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_RoomCurrencyBalances" PRIMARY KEY AUTOINCREMENT,
|
||||
"PlayerId" INTEGER NOT NULL,
|
||||
"RoomCurrencyId" INTEGER NOT NULL,
|
||||
"Balance" INTEGER NOT NULL DEFAULT 0,
|
||||
"UpdatedAt" TEXT NOT NULL
|
||||
);
|
||||
""",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_RoomCurrencyBalances_PlayerId_RoomCurrencyId"" ON ""RoomCurrencyBalances"" (""PlayerId"", ""RoomCurrencyId"");",
|
||||
@"CREATE INDEX IF NOT EXISTS ""IX_RoomCurrencyBalances_RoomCurrencyId"" ON ""RoomCurrencyBalances"" (""RoomCurrencyId"");",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS "RoomCurrencyPurchaseOffers" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_RoomCurrencyPurchaseOffers" PRIMARY KEY AUTOINCREMENT,
|
||||
"PublicId" TEXT NOT NULL,
|
||||
"RoomCurrencyId" INTEGER NOT NULL,
|
||||
"Name" TEXT NOT NULL DEFAULT '',
|
||||
"Amount" INTEGER NOT NULL DEFAULT 0,
|
||||
"Price" INTEGER NOT NULL DEFAULT 0,
|
||||
"CurrencyType" INTEGER NOT NULL DEFAULT 2,
|
||||
"IsDeleted" INTEGER NOT NULL DEFAULT 0,
|
||||
"CreatedAt" TEXT NOT NULL,
|
||||
"UpdatedAt" TEXT NOT NULL
|
||||
);
|
||||
""",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_RoomCurrencyPurchaseOffers_PublicId"" ON ""RoomCurrencyPurchaseOffers"" (""PublicId"");",
|
||||
@"CREATE INDEX IF NOT EXISTS ""IX_RoomCurrencyPurchaseOffers_RoomCurrencyId"" ON ""RoomCurrencyPurchaseOffers"" (""RoomCurrencyId"");",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS "UgcPurchasables" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_UgcPurchasables" PRIMARY KEY AUTOINCREMENT,
|
||||
"PublicId" TEXT NOT NULL,
|
||||
"RoomId" INTEGER NOT NULL,
|
||||
"CreatorPlayerId" INTEGER NOT NULL,
|
||||
"Name" TEXT NOT NULL DEFAULT '',
|
||||
"Description" TEXT NOT NULL DEFAULT '',
|
||||
"ImageName" TEXT NOT NULL DEFAULT '',
|
||||
"Price" INTEGER NOT NULL DEFAULT 0,
|
||||
"CurrencyType" INTEGER NOT NULL DEFAULT 2,
|
||||
"ItemType" INTEGER NOT NULL DEFAULT 0,
|
||||
"IsFeatured" INTEGER NOT NULL DEFAULT 0,
|
||||
"SortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"IsDeleted" INTEGER NOT NULL DEFAULT 0,
|
||||
"CreatedAt" TEXT NOT NULL,
|
||||
"UpdatedAt" TEXT NOT NULL
|
||||
);
|
||||
""",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_UgcPurchasables_PublicId"" ON ""UgcPurchasables"" (""PublicId"");",
|
||||
@"CREATE INDEX IF NOT EXISTS ""IX_UgcPurchasables_RoomId"" ON ""UgcPurchasables"" (""RoomId"");",
|
||||
@"CREATE INDEX IF NOT EXISTS ""IX_UgcPurchasables_CreatorPlayerId"" ON ""UgcPurchasables"" (""CreatorPlayerId"");",
|
||||
};
|
||||
|
||||
foreach (var sql in sqliteStatements)
|
||||
await db.Database.ExecuteSqlRawAsync(sql);
|
||||
}
|
||||
|
||||
/// <summary>SQLite migration-history reconciliation. Handles two
|
||||
/// cutover scenarios so legacy DBs boot cleanly without manual SQL:
|
||||
/// <list type="number">
|
||||
|
||||
@@ -128,14 +128,15 @@ public static class RequestTracingExtensions
|
||||
var trimmedRequest = safeRequest is null ? null
|
||||
: (safeRequest.Length > 200 ? safeRequest[..200] + "..." : safeRequest);
|
||||
var trimmedResponse = safeResponse.Length > 300 ? safeResponse[..300] + "..." : safeResponse;
|
||||
var authPresence = DescribeAuthPresence(ctx.Request);
|
||||
var level = thrown is not null || status >= 500 ? LogLevel.Error
|
||||
: status >= 400 ? LogLevel.Warning
|
||||
: LogLevel.Information;
|
||||
|
||||
reqLogger.Log(level, thrown,
|
||||
"[req] {Status} {Method} {Host}{Path}{Query} {ElapsedMs}ms contentType={ContentType} contentLength={ContentLength} req={ReqBody} resp={RespBody}",
|
||||
"[req] {Status} {Method} {Host}{Path}{Query} {ElapsedMs}ms auth={AuthPresence} contentType={ContentType} contentLength={ContentLength} req={ReqBody} resp={RespBody}",
|
||||
status, ctx.Request.Method, ctx.Request.Host, ctx.Request.Path,
|
||||
ctx.Request.QueryString.Value ?? "", sw.ElapsedMilliseconds,
|
||||
ctx.Request.QueryString.Value ?? "", sw.ElapsedMilliseconds, authPresence,
|
||||
ctx.Request.ContentType ?? "", ctx.Request.ContentLength,
|
||||
trimmedRequest ?? "", trimmedResponse);
|
||||
|
||||
@@ -170,6 +171,15 @@ public static class RequestTracingExtensions
|
||||
"password|passwd|pwd|passwordhash|token|accesstoken|refreshtoken|idtoken|" +
|
||||
"secret|clientsecret|apikey|api_key|authorization|deviceauth|sessionkey";
|
||||
|
||||
private static string DescribeAuthPresence(HttpRequest request)
|
||||
{
|
||||
var hasAuthHeader = request.Headers.ContainsKey("Authorization");
|
||||
var hasBearer = request.Headers.Authorization.ToString()
|
||||
.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase);
|
||||
var hasAccessCookie = request.Cookies.ContainsKey(AuthService.AccessCookieName);
|
||||
return $"header={hasAuthHeader};bearer={hasBearer};cookie={hasAccessCookie}";
|
||||
}
|
||||
|
||||
private static readonly Regex JsonSecretRegex = new(
|
||||
"(\"(?:" + SecretKeys + ")\"\\s*:\\s*)\"(?:[^\"\\\\]|\\\\.)*\"",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
@@ -107,14 +107,22 @@ public static class ServiceCollectionExtensions
|
||||
?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN")
|
||||
?? "localhost";
|
||||
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.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.IncludeFailureMessage = true;
|
||||
});
|
||||
Console.WriteLine($"[domain] apex={apex}, allowedHosts=[{apex}, *.{apex}, localhost]");
|
||||
Console.WriteLine($"[domain] apex={apex}, allowedHosts=[{string.Join(", ", allowedHosts)}]");
|
||||
}
|
||||
|
||||
// ── Services ──────────────────────────────────────────────────────────────
|
||||
@@ -174,17 +182,16 @@ public static class ServiceCollectionExtensions
|
||||
var pluginInstances = new IVersionPlugin[]
|
||||
{
|
||||
new Late2020VersionPlugin(),
|
||||
// Add March2020 / future generations here as their plugins land.
|
||||
};
|
||||
foreach (var p in pluginInstances) p.RegisterStrategies(builder.Services);
|
||||
foreach (var p in pluginInstances)
|
||||
builder.Services.AddSingleton<IVersionPlugin>(p);
|
||||
|
||||
var supported = (builder.Configuration.GetSection("DorkNet:SupportedVersions").Get<string[]>()
|
||||
?? new[] { "december_2020_12_18" })
|
||||
?? new[] { "march_2023_03_21" })
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var defaultKey = builder.Configuration["DorkNet:DefaultClientVersion"]
|
||||
?? "december_2020_12_18";
|
||||
?? "march_2023_03_21";
|
||||
builder.Services.AddSingleton(sp => new VersionRegistry(
|
||||
sp.GetServices<IVersionPlugin>(),
|
||||
supported,
|
||||
@@ -285,6 +292,12 @@ public static class ServiceCollectionExtensions
|
||||
var path = ctx.HttpContext.Request.Path;
|
||||
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hub"))
|
||||
ctx.Token = accessToken;
|
||||
if (string.IsNullOrEmpty(ctx.Token) &&
|
||||
ctx.Request.Cookies.TryGetValue(AuthService.AccessCookieName, out var cookieToken) &&
|
||||
!string.IsNullOrWhiteSpace(cookieToken))
|
||||
{
|
||||
ctx.Token = cookieToken;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,31 +2,17 @@ using DorkNet.Server.Compat;
|
||||
|
||||
namespace DorkNet.Server.Versions.Late2020;
|
||||
|
||||
/// <summary>The "Late 2020" wire-format generation: covers all Rec Room
|
||||
/// builds from the autumn 2020 schema migration through the end of the
|
||||
/// year. The 2020.12.18 build is the canonical member; if/when we add
|
||||
/// 2020.10 or 2020.11 builds and find their wire shapes are identical,
|
||||
/// we just list them in <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>Version marker for the 2023.03.21 client branch. This
|
||||
/// branch is intentionally single-client: it does not advertise or
|
||||
/// serve older 2020 build keys.
|
||||
/// </summary>
|
||||
public sealed class Late2020VersionPlugin : IVersionPlugin
|
||||
{
|
||||
public const string GenerationKey = "Late2020";
|
||||
public const string GenerationKey = "March2023";
|
||||
|
||||
public IReadOnlyCollection<string> VersionKeys { get; } = new[]
|
||||
{
|
||||
// Verified working against this server. The canonical
|
||||
// private-deployment build the codebase has been developed
|
||||
// against since the move to localhost.
|
||||
"december_2020_12_18",
|
||||
"march_2023_03_21",
|
||||
};
|
||||
|
||||
public string Generation => GenerationKey;
|
||||
|
||||
@@ -21,13 +21,14 @@
|
||||
"PhotonRoomPrefix": "DorkNet"
|
||||
},
|
||||
"DorkNet": {
|
||||
"_comment_SupportedVersions": "Per-deployment allow-list. Requests whose X-DorkNet-Version header isn't in this set get a 426 Upgrade Required. To accept multiple client builds add their keys here.",
|
||||
"SupportedVersions": [ "december_2020_12_18" ],
|
||||
"_comment_DefaultClientVersion": "Fallback for requests without an X-DorkNet-Version header — the transition path while not every shipped client mod yet stamps the header. Set to '' (empty) once everyone's caught up to also reject headerless calls.",
|
||||
"DefaultClientVersion": "december_2020_12_18",
|
||||
"_comment_BuildIdToVersionKey": "Maps the raw build identifier the 2020 watch sends to /api/versioncheck/v4?v={buildId} (read from KMJJGELKNAP.MCFMPCNNEBH, e.g. '20201210') to our internal ClientVersion.Key. Builds not listed here get UpdateRequired and the watch shows its native dialog. Add a new build by listing its raw ID + matching version-plugin key.",
|
||||
"_comment_SupportedVersions": "Per-deployment allow-list. This branch serves only the 2023.03.21 client.",
|
||||
"SupportedVersions": [ "march_2023_03_21" ],
|
||||
"_comment_DefaultClientVersion": "Fallback for requests without an X-DorkNet-Version header.",
|
||||
"DefaultClientVersion": "march_2023_03_21",
|
||||
"_comment_BuildIdToVersionKey": "Maps raw /api/versioncheck/v4?v= values to the internal client key. Add the exact 2023.03.21 build id once captured from the client log.",
|
||||
"BuildIdToVersionKey": {
|
||||
"20201210": "december_2020_12_18"
|
||||
"20230317": "march_2023_03_21",
|
||||
"20230321": "march_2023_03_21"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Submodule
+1
Submodule dorknet-reupload-staging added at 570ccae554
Reference in New Issue
Block a user