feat: Enhance API endpoints with pagination and new subscription details for players

This commit is contained in:
Alexa
2026-06-23 00:21:58 +03:00
parent 0779aa8439
commit f7c6758114
12 changed files with 388 additions and 78 deletions
+94 -1
View File
@@ -72,6 +72,7 @@ public class Mod : MelonMod
public static string PhotonCloudRegion = "eu"; public static string PhotonCloudRegion = "eu";
// Photon Custom Auth injector parked 2026-05-28; see // Photon Custom Auth injector parked 2026-05-28; see
// attic/AuthValuesInjector.cs.attic for the code + restore notes. // attic/AuthValuesInjector.cs.attic for the code + restore notes.
public static bool BypassPhotonCustomAuth = false;
public static bool EnableTlsTrustBypass = true; public static bool EnableTlsTrustBypass = true;
// Log-only trace for the stuck first-dorm "Change Username?" // Log-only trace for the stuck first-dorm "Change Username?"
// dialog. When true, RegistrationPatches hooks the dorm // dialog. When true, RegistrationPatches hooks the dorm
@@ -200,6 +201,21 @@ public class Mod : MelonMod
// proxy uses without compile-error. // proxy uses without compile-error.
TryPatchByName("PUNNetworkManager", "OnCustomAuthenticationFailed", TryPatchByName("PUNNetworkManager", "OnCustomAuthenticationFailed",
prefix: nameof(PhotonPatches.OnCustomAuthenticationFailed_Prefix)); prefix: nameof(PhotonPatches.OnCustomAuthenticationFailed_Prefix));
TryPatchByName("DFPKLPCMNEK", "OnCustomAuthenticationFailed",
prefix: nameof(PhotonPatches.OnCustomAuthenticationFailed_Prefix),
logMiss: false);
TryPatchByName("LOPBLNLAEKB", "OnCustomAuthenticationFailed",
prefix: nameof(PhotonPatches.OnCustomAuthenticationFailed_Prefix),
logMiss: false);
TryPatchByName("LOPBLNLAEKB", "OnDisconnected",
prefix: nameof(PhotonPatches.OnDisconnected_Prefix),
logMiss: false);
TryPatchByName("OLPEMKMGEHE", "OnDisconnected",
prefix: nameof(PhotonPatches.OnDisconnected_Prefix),
logMiss: false);
TryPatchByName("DFPKLPCMNEK", "OnDisconnected",
prefix: nameof(PhotonPatches.OnDisconnected_Prefix),
logMiss: false);
TryPatchByName("RecRoom.AntiCheat.EACManager", TryPatchByName("RecRoom.AntiCheat.EACManager",
"GenerateChallengeResponse", "GenerateChallengeResponse",
args: new[] { typeof(string) }, args: new[] { typeof(string) },
@@ -713,6 +729,7 @@ public class Mod : MelonMod
if (TryGetConfigValue(r, "PhotonAppId", out v)) Cfg.PhotonAppId = v.GetString() ?? Cfg.PhotonAppId; if (TryGetConfigValue(r, "PhotonAppId", out v)) Cfg.PhotonAppId = v.GetString() ?? Cfg.PhotonAppId;
if (TryGetConfigValue(r, "PhotonVoiceAppId", out v)) Cfg.PhotonVoiceAppId = v.GetString() ?? Cfg.PhotonVoiceAppId; if (TryGetConfigValue(r, "PhotonVoiceAppId", out v)) Cfg.PhotonVoiceAppId = v.GetString() ?? Cfg.PhotonVoiceAppId;
if (TryGetConfigValue(r, "PhotonCloudRegion", out v)) Cfg.PhotonCloudRegion = v.GetString() ?? Cfg.PhotonCloudRegion; if (TryGetConfigValue(r, "PhotonCloudRegion", out v)) Cfg.PhotonCloudRegion = v.GetString() ?? Cfg.PhotonCloudRegion;
if (TryGetConfigValue(r, "BypassPhotonCustomAuth", out v)) Cfg.BypassPhotonCustomAuth = v.GetBoolean();
if (TryGetConfigValue(r, "EnableTlsTrustBypass", out v)) Cfg.EnableTlsTrustBypass = v.GetBoolean(); if (TryGetConfigValue(r, "EnableTlsTrustBypass", out v)) Cfg.EnableTlsTrustBypass = v.GetBoolean();
if (TryGetConfigValue(r, "TraceRegistrationDialog", out v)) Cfg.TraceRegistrationDialog = v.GetBoolean(); if (TryGetConfigValue(r, "TraceRegistrationDialog", out v)) Cfg.TraceRegistrationDialog = v.GetBoolean();
if (TryGetConfigValue(r, "EnableDebugConsole", out v)) Cfg.EnableDebugConsole = v.GetBoolean(); if (TryGetConfigValue(r, "EnableDebugConsole", out v)) Cfg.EnableDebugConsole = v.GetBoolean();
@@ -2531,6 +2548,10 @@ internal static class PhotonPatches
return; return;
} }
var t = __result.GetType(); var t = __result.GetType();
if (Mod.Cfg.BypassPhotonCustomAuth)
{
DisableCustomAuthValues(__result, t);
}
string Read(string name) => t.GetProperty(name)?.GetValue(__result)?.ToString() ?? "<null>"; string Read(string name) => t.GetProperty(name)?.GetValue(__result)?.ToString() ?? "<null>";
var authType = Read("AuthType"); var authType = Read("AuthType");
var userId = Read("UserId"); var userId = Read("UserId");
@@ -2546,6 +2567,56 @@ internal static class PhotonPatches
catch (Exception ex) { Mod.Log.Warning($"[photon-diag] LogAuthValues failed: {ex.Message}"); } catch (Exception ex) { Mod.Log.Warning($"[photon-diag] LogAuthValues failed: {ex.Message}"); }
} }
private static void DisableCustomAuthValues(object authValues, Type t)
{
try
{
SetMember(t, authValues, "AuthType", type => AuthTypeNoneValue(type));
SetMember(t, authValues, "AuthGetParameters", type => type == typeof(string) ? string.Empty : null);
SetMember(t, authValues, "AuthPostData", _ => null);
SetMember(t, authValues, "Token", type => type == typeof(string) ? string.Empty : null);
Mod.Log.Msg("[photon-auth] custom auth bypass enabled for this launch");
}
catch (Exception ex)
{
Mod.Log.Warning($"[photon-auth] custom auth bypass failed: {ex.Message}");
}
}
private static object? AuthTypeNoneValue(Type type)
{
if (type.IsEnum) return Enum.ToObject(type, 255);
if (type == typeof(byte)) return byte.MaxValue;
if (type == typeof(sbyte)) return (sbyte)-1;
if (type == typeof(short)) return (short)255;
if (type == typeof(ushort)) return (ushort)255;
if (type == typeof(int)) return 255;
if (type == typeof(uint)) return 255u;
if (type == typeof(long)) return 255L;
if (type == typeof(ulong)) return 255UL;
return Convert.ChangeType(255, type);
}
private static bool SetMember(Type type, object instance, string name, Func<Type, object?> valueFactory)
{
const BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly;
var prop = type.GetProperty(name, flags);
if (prop is not null && prop.CanWrite)
{
prop.SetValue(instance, valueFactory(prop.PropertyType));
return true;
}
var field = type.GetField(name, flags);
if (field is not null)
{
field.SetValue(instance, valueFactory(field.FieldType));
return true;
}
return false;
}
/// <summary>Prefix on <c>PUNNetworkManager.OnCustomAuthenticationFailed</c> /// <summary>Prefix on <c>PUNNetworkManager.OnCustomAuthenticationFailed</c>
/// — the watch's UI-error formatter only logs a truncated Unity- /// — the watch's UI-error formatter only logs a truncated Unity-
/// stringified version of the debugMessage. Capturing the raw /// stringified version of the debugMessage. Capturing the raw
@@ -2553,13 +2624,18 @@ internal static class PhotonPatches
/// Photon error including the URL Photon Cloud actually tried to /// Photon error including the URL Photon Cloud actually tried to
/// call (which is the smoking-gun answer to "is the dashboard URL /// call (which is the smoking-gun answer to "is the dashboard URL
/// saved or is Photon hitting a stale one").</summary> /// saved or is Photon hitting a stale one").</summary>
public static void OnCustomAuthenticationFailed_Prefix(object __0) public static bool OnCustomAuthenticationFailed_Prefix(object __0)
{ {
try try
{ {
var debugMessage = __0?.ToString() ?? "<null>"; var debugMessage = __0?.ToString() ?? "<null>";
Mod.Log.Error("[photon-diag] OnCustomAuthenticationFailed FULL MESSAGE:"); Mod.Log.Error("[photon-diag] OnCustomAuthenticationFailed FULL MESSAGE:");
Mod.Log.Error(debugMessage); Mod.Log.Error(debugMessage);
if (Mod.Cfg.BypassPhotonCustomAuth)
{
Mod.Log.Warning("[photon-auth] custom auth failure suppressed for this launch");
return false;
}
// Also dump current AppSettings — if Photon rejected the auth // Also dump current AppSettings — if Photon rejected the auth
// we want to know what the watch's actual final config looked // we want to know what the watch's actual final config looked
// like (post-override). // like (post-override).
@@ -2572,6 +2648,23 @@ internal static class PhotonPatches
} }
} }
catch (Exception ex) { Mod.Log.Warning($"[photon-diag] OnCustomAuthenticationFailed log failed: {ex.Message}"); } catch (Exception ex) { Mod.Log.Warning($"[photon-diag] OnCustomAuthenticationFailed log failed: {ex.Message}"); }
return true;
}
public static bool OnDisconnected_Prefix(object __0)
{
try
{
var cause = __0?.ToString() ?? "<null>";
if (Mod.Cfg.BypassPhotonCustomAuth &&
cause.IndexOf("CustomAuthenticationFailed", StringComparison.OrdinalIgnoreCase) >= 0)
{
Mod.Log.Warning("[photon-auth] CustomAuthenticationFailed disconnect suppressed for this launch");
return false;
}
}
catch (Exception ex) { Mod.Log.Warning($"[photon-auth] disconnect bypass check failed: {ex.Message}"); }
return true;
} }
/// <summary>Logs every public field + property on <paramref name="obj"/> /// <summary>Logs every public field + property on <paramref name="obj"/>
@@ -112,12 +112,19 @@ public class CustomAvatarItemsController(
[HttpGet("me")] [HttpGet("me")]
[Authorize] [Authorize]
public async Task<IActionResult> Me() public async Task<IActionResult> Me([FromQuery] int skip = 0, [FromQuery] int take = 100)
{ {
var pid = this.RequireCurrentPlayerId(); var pid = this.RequireCurrentPlayerId();
var created = await db.CustomAvatarItems skip = Math.Max(0, skip);
take = Math.Clamp(take, 1, 100);
var createdQuery = db.CustomAvatarItems
.Where(i => i.CreatorPlayerId == pid) .Where(i => i.CreatorPlayerId == pid)
.OrderByDescending(i => i.UpdatedAt) .OrderByDescending(i => i.UpdatedAt);
var total = await createdQuery.CountAsync();
var created = await createdQuery
.Skip(skip)
.Take(take)
.ToListAsync(); .ToListAsync();
var ownedIds = await db.CustomAvatarItemOwnership var ownedIds = await db.CustomAvatarItemOwnership
.Where(o => o.PlayerId == pid) .Where(o => o.PlayerId == pid)
@@ -132,6 +139,8 @@ public class CustomAvatarItemsController(
return Ok(new return Ok(new
{ {
Results = created.Select(ToWire),
TotalResults = total,
Created = created.Select(ToWire), Created = created.Select(ToWire),
Owned = owned.Select(ToWire), Owned = owned.Select(ToWire),
}); });
@@ -139,15 +148,26 @@ public class CustomAvatarItemsController(
[HttpGet("owned")] [HttpGet("owned")]
[Authorize] [Authorize]
public async Task<IActionResult> Owned() public async Task<IActionResult> Owned([FromQuery] int skip = 0, [FromQuery] int take = 100)
{ {
var pid = this.RequireCurrentPlayerId(); var pid = this.RequireCurrentPlayerId();
var rows = await db.CustomAvatarItemOwnership skip = Math.Max(0, skip);
take = Math.Clamp(take, 1, 100);
var query = db.CustomAvatarItemOwnership
.Where(o => o.PlayerId == pid) .Where(o => o.PlayerId == pid)
.Join(db.CustomAvatarItems, o => o.CustomAvatarItemId, i => i.Id, (o, i) => i) .Join(db.CustomAvatarItems, o => o.CustomAvatarItemId, i => i.Id, (o, i) => i)
.OrderByDescending(i => i.UpdatedAt) .OrderByDescending(i => i.UpdatedAt);
var total = await query.CountAsync();
var rows = await query
.Skip(skip)
.Take(take)
.ToListAsync(); .ToListAsync();
return Ok(rows.Select(ToWire)); return Ok(new
{
Results = rows.Select(ToWire),
TotalResults = total,
});
} }
[HttpGet("isCreationAllowedForAccount")] [HttpGet("isCreationAllowedForAccount")]
@@ -1,31 +1,14 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.GameSight; namespace DorkNet.Server.Controllers.API.GameSight;
[ApiController] [ApiController]
[Authorize] [AllowAnonymous]
public class GameSightController(DorkNetDbContext db) : ControllerBase public class GameSightController : ControllerBase
{ {
[HttpPost("api/gamesight/event")] [HttpPost("/api/gamesight/event")]
[AllowAnonymous] [HttpPost("/data/event")]
public async Task<IActionResult> Event([FromBody] object? body) [HttpPost("/data/heartbeat")]
{ public IActionResult Event() => Ok(new { Success = true });
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 });
}
} }
@@ -36,6 +36,7 @@ public class PlayerStateController(
// values from PlayerEntity.Level / .XP. Default 1/0 when the // values from PlayerEntity.Level / .XP. Default 1/0 when the
// account hasn't been seen yet (synthetic ids passed in queries). // account hasn't been seen yet (synthetic ids passed in queries).
[HttpGet("api/players/v1/progression/{accountId:long}")] [HttpGet("api/players/v1/progression/{accountId:long}")]
[HttpGet("api/players/v2/progression/{accountId:long}")]
public async Task<ActionResult<Progression>> GetProgression(long accountId) public async Task<ActionResult<Progression>> GetProgression(long accountId)
{ {
var p = await db.Players var p = await db.Players
@@ -51,12 +52,25 @@ public class PlayerStateController(
} }
[HttpPost("api/players/v1/progression/bulk")] [HttpPost("api/players/v1/progression/bulk")]
[HttpPost("api/players/v2/progression/bulk")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<ActionResult<List<Progression>>> GetProgressionBulk( public async Task<ActionResult<List<Progression>>> GetProgressionBulk(
[FromForm(Name = "Ids")] string? ids) [FromForm(Name = "Ids")] string? ids)
{ {
var idList = ParseIds(ids).Select(i => (long)i).ToList(); var idList = ParseIds(ids).Select(i => (long)i).ToList();
if (idList.Count == 0) return Ok(new List<Progression>()); return Ok(await BuildProgressionBulkAsync(idList));
}
[HttpGet("api/players/v2/progression/bulk")]
public async Task<ActionResult<List<Progression>>> GetProgressionBulkV2Get()
{
var idList = ParseQueryIds().ToList();
return Ok(await BuildProgressionBulkAsync(idList));
}
private async Task<List<Progression>> BuildProgressionBulkAsync(IReadOnlyCollection<long> idList)
{
if (idList.Count == 0) return new List<Progression>();
var rows = await db.Players var rows = await db.Players
.Where(p => idList.Contains(p.Id)) .Where(p => idList.Contains(p.Id))
.Select(p => new Progression .Select(p => new Progression
@@ -72,26 +86,40 @@ public class PlayerStateController(
rows.AddRange(idList rows.AddRange(idList
.Where(id => !found.Contains((int)id)) .Where(id => !found.Contains((int)id))
.Select(id => new Progression { PlayerId = (int)id })); .Select(id => new Progression { PlayerId = (int)id }));
return Ok(rows); return rows;
} }
// RecNet.Reputations.GetReputationById — aggregates CheerEntity // RecNet.Reputations.GetReputationById — aggregates CheerEntity
// by category. Each Cheer* count is the sum of distinct cheers // by category. Each Cheer* count is the sum of distinct cheers
// received from other players in that category. // received from other players in that category.
[HttpGet("api/playerReputation/v1/{accountId:long}")] [HttpGet("api/playerReputation/v1/{accountId:long}")]
[HttpGet("api/playerReputation/v2/{accountId:long}")]
public async Task<ActionResult<Reputation>> GetReputation(long accountId) => public async Task<ActionResult<Reputation>> GetReputation(long accountId) =>
Ok(await BuildReputationAsync(accountId)); Ok(await BuildReputationAsync(accountId));
[HttpPost("api/playerReputation/v1/bulk")] [HttpPost("api/playerReputation/v1/bulk")]
[HttpPost("api/playerReputation/v2/bulk")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<ActionResult<List<Reputation>>> GetReputationBulk( public async Task<ActionResult<List<Reputation>>> GetReputationBulk(
[FromForm(Name = "Ids")] string? ids) [FromForm(Name = "Ids")] string? ids)
{ {
var idList = ParseIds(ids).Select(i => (long)i).ToList(); var idList = ParseIds(ids).Select(i => (long)i).ToList();
return Ok(await BuildReputationBulkAsync(idList));
}
[HttpGet("api/playerReputation/v2/bulk")]
public async Task<ActionResult<List<Reputation>>> GetReputationBulkV2Get()
{
var idList = ParseQueryIds().ToList();
return Ok(await BuildReputationBulkAsync(idList));
}
private async Task<List<Reputation>> BuildReputationBulkAsync(IReadOnlyCollection<long> idList)
{
var result = new List<Reputation>(idList.Count); var result = new List<Reputation>(idList.Count);
foreach (var id in idList) foreach (var id in idList)
result.Add(await BuildReputationAsync(id)); result.Add(await BuildReputationAsync(id));
return Ok(result); return result;
} }
/// <summary>POST api/playerReputation/v1/cheer/{playerId} — cheer /// <summary>POST api/playerReputation/v1/cheer/{playerId} — cheer
@@ -216,6 +244,23 @@ public class PlayerStateController(
.Select(s => int.TryParse(s, out var v) ? v : 0) .Select(s => int.TryParse(s, out var v) ? v : 0)
.Where(v => v != 0); .Where(v => v != 0);
private IEnumerable<long> ParseQueryIds()
{
foreach (var (key, values) in Request.Query)
{
if (long.TryParse(key, out var keyId) && keyId > 0)
yield return keyId;
foreach (var value in values)
{
if (long.TryParse(value, out var valueId) && valueId > 0)
yield return valueId;
foreach (var part in value.ToString().Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
if (long.TryParse(part, out var partId) && partId > 0)
yield return partId;
}
}
}
// RecNet.PlayerReporting.GetModerationBlockDetails // RecNet.PlayerReporting.GetModerationBlockDetails
// IMPORTANT: returns a single object, not an array. Defaults indicate // IMPORTANT: returns a single object, not an array. Defaults indicate
// "user is not currently blocked / kicked". // "user is not currently blocked / kicked".
@@ -187,17 +187,31 @@ public class RoomsController(
} }
/// <summary> /// <summary>
/// `Rooms.GetById(roomId)` — same shape as ByName. /// `Rooms.GetById(roomId)` — same flat shape as ByName.
/// </summary> /// </summary>
[HttpGet("api/rooms/v2/{roomId:long}")] [HttpGet("api/rooms/v2/{roomId:long}")]
[HttpGet("api/rooms/v3/{roomId:long}")] [HttpGet("api/rooms/v3/{roomId:long}")]
[HttpGet("rooms/{roomId:long}")]
public async Task<IActionResult> ById(long roomId) public async Task<IActionResult> ById(long roomId)
{ {
var r = await rooms.GetByIdAsync(roomId); var r = await rooms.GetByIdAsync(roomId);
return Ok(RoomService.ToWireRoom(r ?? Synthetic($"Room_{roomId}", roomId))); return Ok(RoomService.ToWireRoom(r ?? Synthetic($"Room_{roomId}", roomId)));
} }
[HttpGet("rooms/{roomId:long}")]
public async Task<IActionResult> RoomServerById(long roomId)
{
var room = await rooms.GetByIdAsync(roomId) ?? Synthetic($"Room_{roomId}", roomId);
var sceneRows = await db.RoomScenes
.Where(s => s.RoomId == room.Id)
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var roles = await db.RoomRoles
.Where(r => r.RoomId == room.Id)
.ToListAsync();
return Ok(BuildRoomServerDetails(room, sceneRows, roles: roles));
}
// ── RoomDetails (the boot-critical one) ───────────────────────────── // ── RoomDetails (the boot-critical one) ─────────────────────────────
// //
// /v4/details/{roomId} is hit *during* the matchmaking goto flow. // /v4/details/{roomId} is hit *during* the matchmaking goto flow.
@@ -1,3 +1,4 @@
using System.Security.Cryptography;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Auth; using DorkNet.Models.Auth;
@@ -32,24 +33,33 @@ public class AuthController(
await level.AwardXpAsync(playerId, LevelService.FirstLoginXp, "first_login_today"); await level.AwardXpAsync(playerId, LevelService.FirstLoginXp, "first_login_today");
} }
private static Dictionary<string, object> BuildTokenResponse(string accessToken, string refreshToken) => new() private static Dictionary<string, object> BuildTokenResponse(string accessToken, string refreshToken)
{ {
["access_token"] = accessToken, var signingKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
["accessToken"] = accessToken,
["AccessToken"] = accessToken, return new()
["token"] = accessToken, {
["Token"] = accessToken, ["access_token"] = accessToken,
["token_type"] = "Bearer", ["Access_token"] = accessToken,
["tokenType"] = "Bearer", ["accessToken"] = accessToken,
["TokenType"] = "Bearer", ["AccessToken"] = accessToken,
["expires_in"] = 43200, ["token"] = accessToken,
["expiresIn"] = 43200, ["Token"] = accessToken,
["ExpiresIn"] = 43200, ["token_type"] = "Bearer",
["refresh_token"] = refreshToken, ["tokenType"] = "Bearer",
["refreshToken"] = refreshToken, ["TokenType"] = "Bearer",
["RefreshToken"] = refreshToken, ["expires_in"] = 43200,
["scope"] = "openid profile", ["expiresIn"] = 43200,
}; ["ExpiresIn"] = 43200,
["refresh_token"] = refreshToken,
["Refresh_token"] = refreshToken,
["refreshToken"] = refreshToken,
["RefreshToken"] = refreshToken,
["key"] = signingKey,
["Key"] = signingKey,
["scope"] = "openid profile",
};
}
private IActionResult TokenOk(string accessToken, string refreshToken) private IActionResult TokenOk(string accessToken, string refreshToken)
{ {
@@ -191,6 +191,23 @@ public class ClubsController(ClubService clubs) : ControllerBase
return Ok(playerSubs + clubSubs); return Ok(playerSubs + clubSubs);
} }
[HttpGet("/subscription/details/{accountId:long}")]
public async Task<IActionResult> SubscriptionDetails(long accountId)
{
var viewerId = this.CurrentPlayerId();
var subscriberCount = await clubs.PlayerSubscriberCountAsync(accountId);
var subscribedCount = await clubs.PlayerSubscribedCountAsync(accountId);
var isSubscribed = viewerId is long viewer
&& await clubs.IsSubscribedToPlayerAsync(viewer, accountId);
return Ok(new
{
accountId,
subscriberCount,
subscribedCount,
isSubscribed,
});
}
/// <summary> /// <summary>
/// POST endpoint the watch hits when the player taps "mark read". /// POST endpoint the watch hits when the player taps "mark read".
/// Idempotent — the upsert in the service quietly returns if a /// Idempotent — the upsert in the service quietly returns if a
@@ -74,27 +74,28 @@ public class EconController(DorkNetDbContext db, LevelService level) : Controlle
[HttpGet("/econ/customAvatarItems")] [HttpGet("/econ/customAvatarItems")]
[HttpGet("/econ/customAvatarItems/v1/owned")] [HttpGet("/econ/customAvatarItems/v1/owned")]
public async Task<IActionResult> GetOwnedCustomAvatarItems() public async Task<IActionResult> GetOwnedCustomAvatarItems([FromQuery] int skip = 0, [FromQuery] int take = 100)
{ {
var pid = this.RequireCurrentPlayerId(); var pid = this.RequireCurrentPlayerId();
var rows = await db.CustomAvatarItemOwnership skip = Math.Max(0, skip);
take = Math.Clamp(take, 1, 100);
var query = db.CustomAvatarItemOwnership
.Where(o => o.PlayerId == pid) .Where(o => o.PlayerId == pid)
.Join(db.CustomAvatarItems, o => o.CustomAvatarItemId, i => i.Id, (o, i) => i) .Join(db.CustomAvatarItems, o => o.CustomAvatarItemId, i => i.Id, (o, i) => i)
.OrderByDescending(i => i.UpdatedAt) .OrderByDescending(i => i.UpdatedAt);
var total = await query.CountAsync();
var rows = await query
.Skip(skip)
.Take(take)
.ToListAsync(); .ToListAsync();
return Ok(rows.Select(i => new
return Ok(new
{ {
CustomAvatarItemId = i.PublicId, Results = rows.Select(ToCustomAvatarItemWire),
Id = i.PublicId, TotalResults = total,
CreatorPlayerId = (int)i.CreatorPlayerId, });
i.Name,
i.Description,
i.Price,
i.ItemType,
i.ImageName,
i.AssetName,
i.Color,
}));
} }
[HttpGet("/econ/customAvatarItems/v1/itemOwnershipLimit")] [HttpGet("/econ/customAvatarItems/v1/itemOwnershipLimit")]
@@ -103,6 +104,20 @@ public class EconController(DorkNetDbContext db, LevelService level) : Controlle
Limit = 1000, Limit = 1000,
ItemOwnershipLimit = 1000, ItemOwnershipLimit = 1000,
}); });
private static object ToCustomAvatarItemWire(DorkNet.Server.Data.Entities.CustomAvatarItemEntity 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,
};
} }
public class TokenBalance public class TokenBalance
@@ -448,5 +448,5 @@ public class PresenceDto
public bool IsOnline { get; set; } = true; public bool IsOnline { get; set; } = true;
[JsonPropertyName("appVersion")] [JsonPropertyName("appVersion")]
public int AppVersion { get; set; } = 20201210; public string AppVersion { get; set; } = "20201210";
} }
@@ -179,5 +179,5 @@ public class PlayerPresenceDto
public bool IsOnline { get; set; } = true; public bool IsOnline { get; set; } = true;
[JsonPropertyName("appVersion")] [JsonPropertyName("appVersion")]
public int AppVersion { get; set; } = 20201210; public string AppVersion { get; set; } = "20201210";
} }
@@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth; using DorkNet.Server.Auth;
using DorkNet.Server.Data; using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities; using DorkNet.Server.Data.Entities;
using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace DorkNet.Server.Controllers.PlayerSettings; namespace DorkNet.Server.Controllers.PlayerSettings;
@@ -17,6 +18,30 @@ public class PlayerSettingsController(DorkNetDbContext db) : ControllerBase
{ {
private long CurrentPlayerId => this.RequireCurrentPlayerId(); private long CurrentPlayerId => this.RequireCurrentPlayerId();
[HttpGet("/playersettings")]
public async Task<ActionResult<Dictionary<string, string>>> GetMine()
{
var accountId = CurrentPlayerId;
var settings = await db.PlayerSettings
.Where(s => s.PlayerId == accountId)
.ToDictionaryAsync(s => s.Key, s => s.Value);
return Ok(settings);
}
[HttpPut("/playersettings")]
public async Task<IActionResult> SetMine()
{
var (key, value) = await ReadSettingKeyValueAsync();
if (string.IsNullOrWhiteSpace(key))
return BadRequest(new PlayerSettingsResult { Success = false, Error = "missing_key" });
key = key.Trim();
value ??= string.Empty;
await UpsertSettingAsync(CurrentPlayerId, key, value);
return Ok(new SettingDto { Key = key, Value = value });
}
[HttpGet("/settings/v1/{accountId:long}")] [HttpGet("/settings/v1/{accountId:long}")]
public async Task<ActionResult<Dictionary<string, string>>> Get(long accountId) public async Task<ActionResult<Dictionary<string, string>>> Get(long accountId)
{ {
@@ -36,14 +61,7 @@ public class PlayerSettingsController(DorkNetDbContext db) : ControllerBase
if (accountId != CurrentPlayerId) return Forbid(); if (accountId != CurrentPlayerId) return Forbid();
foreach (var (key, value) in updates) foreach (var (key, value) in updates)
{ await UpsertSettingAsync(accountId, key, value, save: false);
var existing = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == accountId && s.Key == key);
if (existing is null)
db.PlayerSettings.Add(new PlayerSettingEntity { PlayerId = accountId, Key = key, Value = value });
else
existing.Value = value;
}
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(); return Ok();
@@ -62,4 +80,90 @@ public class PlayerSettingsController(DorkNetDbContext db) : ControllerBase
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(); return Ok();
} }
private async Task UpsertSettingAsync(long accountId, string key, string value, bool save = true)
{
var existing = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == accountId && s.Key == key);
if (existing is null)
db.PlayerSettings.Add(new PlayerSettingEntity { PlayerId = accountId, Key = key, Value = value });
else
existing.Value = value;
if (save) await db.SaveChangesAsync();
}
private async Task<(string? Key, string? Value)> ReadSettingKeyValueAsync()
{
string? key = FirstValue(Request.Query, "key", "Key");
string? value = FirstValue(Request.Query, "value", "Value");
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
key ??= FirstValue(form, "key", "Key");
value ??= FirstValue(form, "value", "Value");
}
if (key is null && Request.ContentLength.GetValueOrDefault() > 0)
{
try
{
using var document = await JsonDocument.ParseAsync(Request.Body);
if (document.RootElement.ValueKind == JsonValueKind.Object)
{
key = FirstJsonString(document.RootElement, "key", "Key");
value = FirstJsonString(document.RootElement, "value", "Value");
}
}
catch (JsonException)
{
// Non-JSON bodies are handled by form/query parsing above.
}
}
return (key, value);
}
private static string? FirstValue(IQueryCollection values, params string[] names)
{
foreach (var name in names)
if (values.TryGetValue(name, out var value))
return value.ToString();
return null;
}
private static string? FirstValue(IFormCollection values, params string[] names)
{
foreach (var name in names)
if (values.TryGetValue(name, out var value))
return value.ToString();
return null;
}
private static string? FirstJsonString(JsonElement obj, params string[] names)
{
foreach (var name in names)
if (obj.TryGetProperty(name, out var prop))
return prop.ValueKind == JsonValueKind.String ? prop.GetString() : prop.ToString();
return null;
}
}
public class SettingDto
{
[JsonPropertyName("Key")]
public string Key { get; set; } = string.Empty;
[JsonPropertyName("Value")]
public string Value { get; set; } = string.Empty;
}
public class PlayerSettingsResult
{
[JsonPropertyName("Success")]
public bool Success { get; set; }
[JsonPropertyName("Error")]
public string Error { get; set; } = string.Empty;
} }
+9
View File
@@ -251,6 +251,15 @@ public class ClubService(DorkNetDbContext db)
public Task<int> PlayerSubscriberCountAsync(long playerId) => public Task<int> PlayerSubscriberCountAsync(long playerId) =>
db.Subscriptions.CountAsync(s => s.TargetPlayerId == playerId); db.Subscriptions.CountAsync(s => s.TargetPlayerId == playerId);
/// <summary>How many player/profile subscriptions this account has
/// made.</summary>
public Task<int> PlayerSubscribedCountAsync(long playerId) =>
db.Subscriptions.CountAsync(s => s.SubscriberPlayerId == playerId);
public Task<bool> IsSubscribedToPlayerAsync(long subscriberId, long targetPlayerId) =>
db.Subscriptions.AnyAsync(s => s.SubscriberPlayerId == subscriberId
&& s.TargetPlayerId == targetPlayerId);
/// <summary> /// <summary>
/// Bulk member-count lookup so the controller can project a list /// Bulk member-count lookup so the controller can project a list
/// of clubs in one round-trip. Returns a dict keyed by ClubId; any /// of clubs in one round-trip. Returns a dict keyed by ClubId; any