From df73507ee9a2498be44961984ff60b1889fe6d84 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Tue, 23 Jun 2026 00:21:58 +0300 Subject: [PATCH] feat: Enhance API endpoints with pagination and new subscription details for players --- DorkNet.ClientMod/Mod.cs | 95 +++++++++++++- .../CustomAvatarItemsController.cs | 34 ++++- .../API/GameSight/GameSightController.cs | 29 +---- .../API/PlayerState/PlayerStateController.cs | 51 +++++++- .../API/Rooms/V2/RoomsController.cs | 18 ++- .../Controllers/Auth/AuthController.cs | 44 ++++--- .../Controllers/Clubs/ClubsController.cs | 17 +++ .../Controllers/Econ/EconController.cs | 45 ++++--- .../Match/MatchPlayerController.cs | 2 +- .../Match/PlayerPresenceController.cs | 2 +- .../PlayerSettingsController.cs | 120 ++++++++++++++++-- DorkNet.Server/Services/ClubService.cs | 9 ++ 12 files changed, 388 insertions(+), 78 deletions(-) diff --git a/DorkNet.ClientMod/Mod.cs b/DorkNet.ClientMod/Mod.cs index 65d5027..c119ab0 100644 --- a/DorkNet.ClientMod/Mod.cs +++ b/DorkNet.ClientMod/Mod.cs @@ -72,6 +72,7 @@ public class Mod : MelonMod public static string PhotonCloudRegion = "eu"; // Photon Custom Auth injector parked 2026-05-28; see // attic/AuthValuesInjector.cs.attic for the code + restore notes. + public static bool BypassPhotonCustomAuth = false; public static bool EnableTlsTrustBypass = true; // Log-only trace for the stuck first-dorm "Change Username?" // dialog. When true, RegistrationPatches hooks the dorm @@ -200,6 +201,21 @@ public class Mod : MelonMod // proxy uses without compile-error. TryPatchByName("PUNNetworkManager", "OnCustomAuthenticationFailed", 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", "GenerateChallengeResponse", 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, "PhotonVoiceAppId", out v)) Cfg.PhotonVoiceAppId = v.GetString() ?? Cfg.PhotonVoiceAppId; 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, "TraceRegistrationDialog", out v)) Cfg.TraceRegistrationDialog = v.GetBoolean(); if (TryGetConfigValue(r, "EnableDebugConsole", out v)) Cfg.EnableDebugConsole = v.GetBoolean(); @@ -2531,6 +2548,10 @@ internal static class PhotonPatches return; } var t = __result.GetType(); + if (Mod.Cfg.BypassPhotonCustomAuth) + { + DisableCustomAuthValues(__result, t); + } string Read(string name) => t.GetProperty(name)?.GetValue(__result)?.ToString() ?? ""; var authType = Read("AuthType"); var userId = Read("UserId"); @@ -2546,6 +2567,56 @@ internal static class PhotonPatches 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 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; + } + /// Prefix on PUNNetworkManager.OnCustomAuthenticationFailed /// — the watch's UI-error formatter only logs a truncated Unity- /// 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 /// call (which is the smoking-gun answer to "is the dashboard URL /// saved or is Photon hitting a stale one"). - public static void OnCustomAuthenticationFailed_Prefix(object __0) + public static bool OnCustomAuthenticationFailed_Prefix(object __0) { try { var debugMessage = __0?.ToString() ?? ""; Mod.Log.Error("[photon-diag] OnCustomAuthenticationFailed FULL MESSAGE:"); 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 // we want to know what the watch's actual final config looked // like (post-override). @@ -2572,6 +2648,23 @@ internal static class PhotonPatches } } 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() ?? ""; + 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; } /// Logs every public field + property on diff --git a/DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs b/DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs index 8021412..4efa969 100644 --- a/DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs +++ b/DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs @@ -112,12 +112,19 @@ public class CustomAvatarItemsController( [HttpGet("me")] [Authorize] - public async Task Me() + public async Task Me([FromQuery] int skip = 0, [FromQuery] int take = 100) { 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) - .OrderByDescending(i => i.UpdatedAt) + .OrderByDescending(i => i.UpdatedAt); + var total = await createdQuery.CountAsync(); + var created = await createdQuery + .Skip(skip) + .Take(take) .ToListAsync(); var ownedIds = await db.CustomAvatarItemOwnership .Where(o => o.PlayerId == pid) @@ -132,6 +139,8 @@ public class CustomAvatarItemsController( return Ok(new { + Results = created.Select(ToWire), + TotalResults = total, Created = created.Select(ToWire), Owned = owned.Select(ToWire), }); @@ -139,15 +148,26 @@ public class CustomAvatarItemsController( [HttpGet("owned")] [Authorize] - public async Task Owned() + public async Task Owned([FromQuery] int skip = 0, [FromQuery] int take = 100) { 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) .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(); - return Ok(rows.Select(ToWire)); + return Ok(new + { + Results = rows.Select(ToWire), + TotalResults = total, + }); } [HttpGet("isCreationAllowedForAccount")] diff --git a/DorkNet.Server/Controllers/API/GameSight/GameSightController.cs b/DorkNet.Server/Controllers/API/GameSight/GameSightController.cs index dc784a1..a1d1635 100644 --- a/DorkNet.Server/Controllers/API/GameSight/GameSightController.cs +++ b/DorkNet.Server/Controllers/API/GameSight/GameSightController.cs @@ -1,31 +1,14 @@ 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 +[AllowAnonymous] +public class GameSightController : ControllerBase { - [HttpPost("api/gamesight/event")] - [AllowAnonymous] - public async Task Event([FromBody] object? body) - { - var playerId = this.CurrentPlayerId(); - if (playerId is null) - return Ok(new { Success = true }); - - db.ObjectiveProgress.Add(new ObjectiveProgressEntity - { - PlayerId = playerId.Value, - Key = $"gamesight:{Guid.NewGuid():N}", - IsCompleted = true, - ClearedAt = DateTime.UtcNow, - }); - await db.SaveChangesAsync(); - return Ok(new { Success = true }); - } + [HttpPost("/api/gamesight/event")] + [HttpPost("/data/event")] + [HttpPost("/data/heartbeat")] + public IActionResult Event() => Ok(new { Success = true }); } diff --git a/DorkNet.Server/Controllers/API/PlayerState/PlayerStateController.cs b/DorkNet.Server/Controllers/API/PlayerState/PlayerStateController.cs index 44e7cc3..5d07502 100644 --- a/DorkNet.Server/Controllers/API/PlayerState/PlayerStateController.cs +++ b/DorkNet.Server/Controllers/API/PlayerState/PlayerStateController.cs @@ -36,6 +36,7 @@ public class PlayerStateController( // values from PlayerEntity.Level / .XP. Default 1/0 when the // account hasn't been seen yet (synthetic ids passed in queries). [HttpGet("api/players/v1/progression/{accountId:long}")] + [HttpGet("api/players/v2/progression/{accountId:long}")] public async Task> GetProgression(long accountId) { var p = await db.Players @@ -51,12 +52,25 @@ public class PlayerStateController( } [HttpPost("api/players/v1/progression/bulk")] + [HttpPost("api/players/v2/progression/bulk")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] public async Task>> GetProgressionBulk( [FromForm(Name = "Ids")] string? ids) { var idList = ParseIds(ids).Select(i => (long)i).ToList(); - if (idList.Count == 0) return Ok(new List()); + return Ok(await BuildProgressionBulkAsync(idList)); + } + + [HttpGet("api/players/v2/progression/bulk")] + public async Task>> GetProgressionBulkV2Get() + { + var idList = ParseQueryIds().ToList(); + return Ok(await BuildProgressionBulkAsync(idList)); + } + + private async Task> BuildProgressionBulkAsync(IReadOnlyCollection idList) + { + if (idList.Count == 0) return new List(); var rows = await db.Players .Where(p => idList.Contains(p.Id)) .Select(p => new Progression @@ -72,26 +86,40 @@ public class PlayerStateController( rows.AddRange(idList .Where(id => !found.Contains((int)id)) .Select(id => new Progression { PlayerId = (int)id })); - return Ok(rows); + return rows; } // RecNet.Reputations.GetReputationById — aggregates CheerEntity // by category. Each Cheer* count is the sum of distinct cheers // received from other players in that category. [HttpGet("api/playerReputation/v1/{accountId:long}")] + [HttpGet("api/playerReputation/v2/{accountId:long}")] public async Task> GetReputation(long accountId) => Ok(await BuildReputationAsync(accountId)); [HttpPost("api/playerReputation/v1/bulk")] + [HttpPost("api/playerReputation/v2/bulk")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] public async Task>> GetReputationBulk( [FromForm(Name = "Ids")] string? ids) { var idList = ParseIds(ids).Select(i => (long)i).ToList(); + return Ok(await BuildReputationBulkAsync(idList)); + } + + [HttpGet("api/playerReputation/v2/bulk")] + public async Task>> GetReputationBulkV2Get() + { + var idList = ParseQueryIds().ToList(); + return Ok(await BuildReputationBulkAsync(idList)); + } + + private async Task> BuildReputationBulkAsync(IReadOnlyCollection idList) + { var result = new List(idList.Count); foreach (var id in idList) result.Add(await BuildReputationAsync(id)); - return Ok(result); + return result; } /// POST api/playerReputation/v1/cheer/{playerId} — cheer @@ -216,6 +244,23 @@ public class PlayerStateController( .Select(s => int.TryParse(s, out var v) ? v : 0) .Where(v => v != 0); + private IEnumerable 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 // IMPORTANT: returns a single object, not an array. Defaults indicate // "user is not currently blocked / kicked". diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs index 53b99f2..57218a8 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs @@ -187,17 +187,31 @@ public class RoomsController( } /// - /// `Rooms.GetById(roomId)` — same shape as ByName. + /// `Rooms.GetById(roomId)` — same flat shape as ByName. /// [HttpGet("api/rooms/v2/{roomId:long}")] [HttpGet("api/rooms/v3/{roomId:long}")] - [HttpGet("rooms/{roomId:long}")] public async Task ById(long roomId) { var r = await rooms.GetByIdAsync(roomId); return Ok(RoomService.ToWireRoom(r ?? Synthetic($"Room_{roomId}", roomId))); } + [HttpGet("rooms/{roomId:long}")] + public async Task 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) ───────────────────────────── // // /v4/details/{roomId} is hit *during* the matchmaking goto flow. diff --git a/DorkNet.Server/Controllers/Auth/AuthController.cs b/DorkNet.Server/Controllers/Auth/AuthController.cs index 3a0179c..cda2592 100644 --- a/DorkNet.Server/Controllers/Auth/AuthController.cs +++ b/DorkNet.Server/Controllers/Auth/AuthController.cs @@ -1,3 +1,4 @@ +using System.Security.Cryptography; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using DorkNet.Models.Auth; @@ -32,24 +33,33 @@ public class AuthController( await level.AwardXpAsync(playerId, LevelService.FirstLoginXp, "first_login_today"); } - private static Dictionary BuildTokenResponse(string accessToken, string refreshToken) => new() + private static Dictionary BuildTokenResponse(string accessToken, string refreshToken) { - ["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", - }; + var signingKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); + + return new() + { + ["access_token"] = accessToken, + ["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, + ["Refresh_token"] = refreshToken, + ["refreshToken"] = refreshToken, + ["RefreshToken"] = refreshToken, + ["key"] = signingKey, + ["Key"] = signingKey, + ["scope"] = "openid profile", + }; + } private IActionResult TokenOk(string accessToken, string refreshToken) { diff --git a/DorkNet.Server/Controllers/Clubs/ClubsController.cs b/DorkNet.Server/Controllers/Clubs/ClubsController.cs index bc93560..e9da4d4 100644 --- a/DorkNet.Server/Controllers/Clubs/ClubsController.cs +++ b/DorkNet.Server/Controllers/Clubs/ClubsController.cs @@ -191,6 +191,23 @@ public class ClubsController(ClubService clubs) : ControllerBase return Ok(playerSubs + clubSubs); } + [HttpGet("/subscription/details/{accountId:long}")] + public async Task 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, + }); + } + /// /// POST endpoint the watch hits when the player taps "mark read". /// Idempotent — the upsert in the service quietly returns if a diff --git a/DorkNet.Server/Controllers/Econ/EconController.cs b/DorkNet.Server/Controllers/Econ/EconController.cs index 60cf11e..dedf6ab 100644 --- a/DorkNet.Server/Controllers/Econ/EconController.cs +++ b/DorkNet.Server/Controllers/Econ/EconController.cs @@ -74,27 +74,28 @@ public class EconController(DorkNetDbContext db, LevelService level) : Controlle [HttpGet("/econ/customAvatarItems")] [HttpGet("/econ/customAvatarItems/v1/owned")] - public async Task GetOwnedCustomAvatarItems() + public async Task GetOwnedCustomAvatarItems([FromQuery] int skip = 0, [FromQuery] int take = 100) { 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) .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(); - return Ok(rows.Select(i => new + + return Ok(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, - })); + Results = rows.Select(ToCustomAvatarItemWire), + TotalResults = total, + }); } [HttpGet("/econ/customAvatarItems/v1/itemOwnershipLimit")] @@ -103,6 +104,20 @@ public class EconController(DorkNetDbContext db, LevelService level) : Controlle Limit = 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 diff --git a/DorkNet.Server/Controllers/Match/MatchPlayerController.cs b/DorkNet.Server/Controllers/Match/MatchPlayerController.cs index 883de03..04e70fb 100644 --- a/DorkNet.Server/Controllers/Match/MatchPlayerController.cs +++ b/DorkNet.Server/Controllers/Match/MatchPlayerController.cs @@ -448,5 +448,5 @@ public class PresenceDto public bool IsOnline { get; set; } = true; [JsonPropertyName("appVersion")] - public int AppVersion { get; set; } = 20201210; + public string AppVersion { get; set; } = "20201210"; } diff --git a/DorkNet.Server/Controllers/Match/PlayerPresenceController.cs b/DorkNet.Server/Controllers/Match/PlayerPresenceController.cs index 74acfc2..9811e00 100644 --- a/DorkNet.Server/Controllers/Match/PlayerPresenceController.cs +++ b/DorkNet.Server/Controllers/Match/PlayerPresenceController.cs @@ -179,5 +179,5 @@ public class PlayerPresenceDto public bool IsOnline { get; set; } = true; [JsonPropertyName("appVersion")] - public int AppVersion { get; set; } = 20201210; + public string AppVersion { get; set; } = "20201210"; } diff --git a/DorkNet.Server/Controllers/PlayerSettings/PlayerSettingsController.cs b/DorkNet.Server/Controllers/PlayerSettings/PlayerSettingsController.cs index 7af4c3a..06b8e29 100644 --- a/DorkNet.Server/Controllers/PlayerSettings/PlayerSettingsController.cs +++ b/DorkNet.Server/Controllers/PlayerSettings/PlayerSettingsController.cs @@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore; using DorkNet.Server.Auth; using DorkNet.Server.Data; using DorkNet.Server.Data.Entities; +using System.Text.Json; using System.Text.Json.Serialization; namespace DorkNet.Server.Controllers.PlayerSettings; @@ -17,6 +18,30 @@ public class PlayerSettingsController(DorkNetDbContext db) : ControllerBase { private long CurrentPlayerId => this.RequireCurrentPlayerId(); + [HttpGet("/playersettings")] + public async Task>> 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 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}")] public async Task>> Get(long accountId) { @@ -36,14 +61,7 @@ public class PlayerSettingsController(DorkNetDbContext db) : ControllerBase if (accountId != CurrentPlayerId) return Forbid(); foreach (var (key, value) in updates) - { - 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 UpsertSettingAsync(accountId, key, value, save: false); await db.SaveChangesAsync(); return Ok(); @@ -62,4 +80,90 @@ public class PlayerSettingsController(DorkNetDbContext db) : ControllerBase await db.SaveChangesAsync(); 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; } diff --git a/DorkNet.Server/Services/ClubService.cs b/DorkNet.Server/Services/ClubService.cs index d73aa28..4292f31 100644 --- a/DorkNet.Server/Services/ClubService.cs +++ b/DorkNet.Server/Services/ClubService.cs @@ -251,6 +251,15 @@ public class ClubService(DorkNetDbContext db) public Task PlayerSubscriberCountAsync(long playerId) => db.Subscriptions.CountAsync(s => s.TargetPlayerId == playerId); + /// How many player/profile subscriptions this account has + /// made. + public Task PlayerSubscribedCountAsync(long playerId) => + db.Subscriptions.CountAsync(s => s.SubscriberPlayerId == playerId); + + public Task IsSubscribedToPlayerAsync(long subscriberId, long targetPlayerId) => + db.Subscriptions.AnyAsync(s => s.SubscriberPlayerId == subscriberId + && s.TargetPlayerId == targetPlayerId); + /// /// Bulk member-count lookup so the controller can project a list /// of clubs in one round-trip. Returns a dict keyed by ClubId; any