diff --git a/DorkNet.Server/Controllers/API/Avatar/V2/AvatarGiftsController.cs b/DorkNet.Server/Controllers/API/Avatar/V2/AvatarGiftsController.cs index ebba4d5..28df979 100644 --- a/DorkNet.Server/Controllers/API/Avatar/V2/AvatarGiftsController.cs +++ b/DorkNet.Server/Controllers/API/Avatar/V2/AvatarGiftsController.cs @@ -59,7 +59,11 @@ public class AvatarGiftsController( /// that room has no pool — or for non-game free-gift tiles — it falls /// back to a random wardrobe item. The rolled item is stamped as a /// gift package; opening the box (gifts/consume) grants it. + // The 2023 client posts the gift-chest generate to v3 (GET gifts is + // already fanned across v2/v3/v4, but generate only routed v2 — the v3 + // POST 404'd, breaking reward chests on that path). Same request body. [HttpPost("api/avatar/v2/gifts/generate")] + [HttpPost("api/avatar/v3/gifts/generate")] [Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")] public async Task Generate([FromBody] GenerateGiftRequest? req) { diff --git a/DorkNet.Server/Controllers/API/Comments/CommentsController.cs b/DorkNet.Server/Controllers/API/Comments/CommentsController.cs new file mode 100644 index 0000000..ad355e2 --- /dev/null +++ b/DorkNet.Server/Controllers/API/Comments/CommentsController.cs @@ -0,0 +1,77 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using DorkNet.Server.Auth; + +namespace DorkNet.Server.Controllers.API.Comments; + +/// +/// Room-comment unread counts. Client contract (RecNet.Runtime +/// DJLNMANKMAA(IReadOnlyCollection<long>)): the caller sends a +/// collection of roomIds and expects a top-level JSON OBJECT +/// Dictionary<long, uint> mapping roomId → unread count. +/// We don't track per-player comment read-state yet, so every requested +/// room maps to 0 — a correct, non-crashing "nothing unread" answer that +/// matches the wire shape (an object, never an array). +/// +[ApiController] +[Authorize] +public class CommentsController : ControllerBase +{ + [HttpPost("/comments/unreadcounts")] + [HttpGet("/comments/unreadcounts")] + [Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")] + public async Task UnreadCounts() + { + var roomIds = await ReadRoomIdsAsync(); + // Keys must be strings in JSON; values are the (currently always 0) + // unread counts. + var result = new Dictionary(); + foreach (var id in roomIds) result[id.ToString()] = 0; + return Ok(result); + } + + private async Task> ReadRoomIdsAsync() + { + var ids = new HashSet(); + + void AddCsv(string? s) + { + if (string.IsNullOrWhiteSpace(s)) return; + foreach (var part in s.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + if (long.TryParse(part, out var v) && v > 0) ids.Add(v); + } + + // Query / form: roomIds=1,2,3 + foreach (var k in new[] { "roomIds", "roomId", "ids", "id" }) + { + AddCsv(Request.Query[k].ToString()); + if (Request.HasFormContentType) AddCsv(Request.Form[k].ToString()); + } + + // JSON body: either a bare array [1,2,3] or { "roomIds": [1,2,3] }. + if (!Request.HasFormContentType) + { + try + { + Request.EnableBuffering(); + Request.Body.Position = 0; + using var doc = await JsonDocument.ParseAsync(Request.Body); + var root = doc.RootElement; + JsonElement arr = default; + var haveArr = false; + if (root.ValueKind == JsonValueKind.Array) { arr = root; haveArr = true; } + else if (root.ValueKind == JsonValueKind.Object) + { + foreach (var k in new[] { "roomIds", "RoomIds", "ids", "Ids" }) + if (root.TryGetProperty(k, out arr) && arr.ValueKind == JsonValueKind.Array) { haveArr = true; break; } + } + if (haveArr) + foreach (var el in arr.EnumerateArray()) + if (el.ValueKind == JsonValueKind.Number && el.TryGetInt64(out var v) && v > 0) ids.Add(v); + } + catch { /* non-JSON / empty body */ } + } + return ids; + } +} diff --git a/DorkNet.Server/Controllers/API/Compatibility/CompatibilityFeatureController.cs b/DorkNet.Server/Controllers/API/Compatibility/CompatibilityFeatureController.cs index eac001d..dc7702a 100644 --- a/DorkNet.Server/Controllers/API/Compatibility/CompatibilityFeatureController.cs +++ b/DorkNet.Server/Controllers/API/Compatibility/CompatibilityFeatureController.cs @@ -108,7 +108,9 @@ public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBas : null; if (club is null) return Ok(new { Success = false, Message = "club_not_found" }); - var message = $"Club report: {req.Message ?? string.Empty}".Trim(); + // Prefix with "[club {id}]" so admins can tell club reports apart + // from player reports in the shared ReportEntity queue. + var message = $"[club {club.Id}] {req.Message ?? string.Empty}".Trim(); if (message.Length > 1000) message = message[..1000]; db.Reports.Add(new ReportEntity { diff --git a/DorkNet.Server/Controllers/API/Config/V1/ConfigController.cs b/DorkNet.Server/Controllers/API/Config/V1/ConfigController.cs index 1aab71d..52222c5 100644 --- a/DorkNet.Server/Controllers/API/Config/V1/ConfigController.cs +++ b/DorkNet.Server/Controllers/API/Config/V1/ConfigController.cs @@ -117,6 +117,27 @@ public class ConfigController(ConfigService configService) : ControllerBase }); } + /// GET /voice/requiresModeration — bare Boolean the + /// client reads to decide whether voice needs moderation. ToxMod is + /// disabled server-side, so no moderation is required. + [HttpGet("/voice/requiresModeration")] + [Microsoft.AspNetCore.Authorization.AllowAnonymous] + public IActionResult VoiceRequiresModeration() => Content("false", "application/json"); + + /// GET /emojiConfig/whitelistedEmojis — the allow-list of + /// emoji permitted in text chat (RRUI.EmojiPicker, IReadOnlyList<string>). + /// Returns a broad default set so the picker is populated. + [HttpGet("/emojiConfig/whitelistedEmojis")] + [Microsoft.AspNetCore.Authorization.AllowAnonymous] + public IActionResult WhitelistedEmojis() => Ok(new[] + { + "😀", "😁", "😂", "🤣", "😊", "😇", "🙂", "😉", "😍", "😘", + "😜", "🤪", "😎", "🤩", "🥳", "😏", "😢", "😭", "😡", "🤯", + "😱", "😴", "🤔", "🤗", "🙄", "😬", "👍", "👎", "👏", "🙌", + "🙏", "💪", "✌️", "🤞", "👋", "🤝", "❤️", "🔥", "⭐", "✨", + "🎉", "🎊", "💯", "😈", "👻", "🤖", "🎮", "🏆", + }); + /// GET api/config/v1/freegiftbutton — the /// 2020.12 client deserializes this endpoint as a raw Boolean. /// This server does not run the timed free-gift campaign, so the diff --git a/DorkNet.Server/Controllers/API/Images/ImagesController.cs b/DorkNet.Server/Controllers/API/Images/ImagesController.cs index ba0fb3f..5697a21 100644 --- a/DorkNet.Server/Controllers/API/Images/ImagesController.cs +++ b/DorkNet.Server/Controllers/API/Images/ImagesController.cs @@ -303,17 +303,23 @@ public class ImagesController( return Ok(rows.Select(p => BuildImageInfo(p, p.UploaderPlayerId))); } + /// GET api/images/v6?name={blobName} — single-image + /// lookup by name. Client contract (RecNet.Runtime + /// KLJOGJHBONK.INHMKKAJJKO(string)) returns ONE + /// ICOFKEGOGOD ImageDTO, not a list; a bare array crashes its + /// reader. Not found (or private + not the owner) → 404 so the promise + /// rejects cleanly rather than delivering a wrong-shaped body. [HttpGet("api/images/v6")] [AllowAnonymous] - public async Task ImagesV6([FromQuery] int take = 50) + public async Task ImagesV6([FromQuery] string? name) { - 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))); + if (string.IsNullOrWhiteSpace(name)) return NotFound(); + var pid = this.CurrentPlayerId(); + var photo = await db.Photos.FirstOrDefaultAsync(p => + p.BlobName == name && p.DeletedAt == null && + (p.IsPublic || (pid != null && p.UploaderPlayerId == pid.Value))); + if (photo is null) return NotFound(); + return Ok(BuildImageInfo(photo, photo.UploaderPlayerId)); } /// GET api/images/v2/named — the watch's diff --git a/DorkNet.Server/Controllers/API/IncentivizedReferrals/IncentivizedReferralsController.cs b/DorkNet.Server/Controllers/API/IncentivizedReferrals/IncentivizedReferralsController.cs index 366866a..6ac11b8 100644 --- a/DorkNet.Server/Controllers/API/IncentivizedReferrals/IncentivizedReferralsController.cs +++ b/DorkNet.Server/Controllers/API/IncentivizedReferrals/IncentivizedReferralsController.cs @@ -34,17 +34,23 @@ public class IncentivizedReferralsController(DorkNetDbContext db, LevelService l }); } + // Client contract: the claim response deserializes as a top-level + // List where each entry is + // { rewardSelectionId: long, rewardType: string } (RecNet.Runtime + // AFEFBIKADAP/GLOFCEJBIGB). Returning a {Success,...} object crashes the + // strict array reader. When there's nothing to grant, return an empty + // array — a valid empty list, not an error object. [HttpPost("claim")] public async Task Claim() { var referralCount = await db.PlayerSettings .CountAsync(s => s.Key.StartsWith($"referral:credited:{Me}:")); - if (referralCount < 1) - return Ok(new { Success = false, Error = "not_enough_referrals" }); - if (await db.PlayerSettings.AnyAsync(s => s.PlayerId == Me && s.Key == "referral:reward_claimed")) - return Ok(new { Success = false, Error = "already_claimed" }); + var alreadyClaimed = await db.PlayerSettings + .AnyAsync(s => s.PlayerId == Me && s.Key == "referral:reward_claimed"); + if (referralCount < 1 || alreadyClaimed) + return Ok(Array.Empty()); - var balance = await level.GrantCurrencyAsync(Me, 2, 250, "incentivized_referral"); + await level.GrantCurrencyAsync(Me, 2, 250, "incentivized_referral"); db.PlayerSettings.Add(new PlayerSettingEntity { PlayerId = Me, @@ -52,6 +58,11 @@ public class IncentivizedReferralsController(DorkNetDbContext db, LevelService l Value = DateTime.UtcNow.ToString("O"), }); await db.SaveChangesAsync(); - return Ok(new { Success = true, Balance = balance, CurrencyType = 2, Currency = 250 }); + + // One granted reward selection: 250 of currency type 2. + return Ok(new[] + { + new { rewardSelectionId = unchecked(Me * 397L + 2), rewardType = "Currency" }, + }); } } diff --git a/DorkNet.Server/Controllers/API/Inventions/InventionsController.cs b/DorkNet.Server/Controllers/API/Inventions/InventionsController.cs index e3dd0da..c46423c 100644 --- a/DorkNet.Server/Controllers/API/Inventions/InventionsController.cs +++ b/DorkNet.Server/Controllers/API/Inventions/InventionsController.cs @@ -334,6 +334,39 @@ public class InventionsController( return Ok(ToWire(i)); } + /// POST api/storefronts/v1/trialInvention?inventionId={id} + /// — start a free trial of a store invention. Client + /// (RecNet.Runtime DCFKEFHJAGC.PPCLLFMHJLD(long)) expects a SINGLE + /// Invention DTO back (same shape it uses for + /// search), which it spawns for the trial window (length from + /// trialInvention/duration). Hosted here — not in + /// StorefrontsController — so it can reuse the invention wire builder. + /// We record the trial start so it's an auditable action. + [HttpPost("api/storefronts/v1/trialInvention")] + [Authorize] + public async Task TrialInvention([FromQuery] long inventionId) + { + var i = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted); + if (i is null) return NotFound(); + if (!i.IsPublished && i.CreatorPlayerId != CurrentPlayerIdOrNull) + return Forbid(); + + if (CurrentPlayerIdOrNull is long me) + { + var key = $"invention:trial:{inventionId}"; + var row = await db.PlayerSettings.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == key); + if (row is null) + db.PlayerSettings.Add(new Data.Entities.PlayerSettingEntity + { + PlayerId = me, Key = key, Value = DateTime.UtcNow.ToString("O"), + }); + else + row.Value = DateTime.UtcNow.ToString("O"); + await db.SaveChangesAsync(); + } + return Ok(ToWire(i)); + } + [HttpGet("api/inventions/v1/details")] public async Task Details([FromQuery] long inventionId) { diff --git a/DorkNet.Server/Controllers/API/PlayerSubscriptions/PlayerSubscriptionsController.cs b/DorkNet.Server/Controllers/API/PlayerSubscriptions/PlayerSubscriptionsController.cs index edb723a..e1bf3bc 100644 --- a/DorkNet.Server/Controllers/API/PlayerSubscriptions/PlayerSubscriptionsController.cs +++ b/DorkNet.Server/Controllers/API/PlayerSubscriptions/PlayerSubscriptionsController.cs @@ -44,6 +44,51 @@ public class PlayerSubscriptionsController( return Ok(subs); } + /// GET /subscription/top/creators/today — the + /// subscriptions tab's "top creators" carousel. Client contract + /// (RecNet.Runtime JDBPOMCAIIG()) is a bare + /// List<creator> of { accountId:int, subscriberCount:long, + /// … }. Ranks creators by total subscriber count. The leading "/" + /// makes this route absolute, bypassing the class's + /// api/[controller]/v1 prefix. + [HttpGet("/subscription/top/creators/today")] + [AllowAnonymous] + public async Task TopCreatorsToday([FromQuery] int? take) + { + var limit = Math.Clamp(take ?? 20, 1, 100); + var top = await db.Subscriptions + .GroupBy(s => s.TargetPlayerId) + .Select(g => new { AccountId = g.Key, SubscriberCount = (long)g.Count() }) + .OrderByDescending(x => x.SubscriberCount) + .Take(limit) + .ToListAsync(); + + // A day-window boundary for "today"'s new-subscriber tallies. + var since = DateTime.UtcNow.Date; + var todayByTarget = await db.Subscriptions + .Where(s => s.CreatedAt >= since) + .GroupBy(s => s.TargetPlayerId) + .Select(g => new { g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.Key, x => x.Count); + + // Dictionaries preserve literal key casing (anonymous objects would be + // forced to camelCase by the server policy); the creator DTO's keys + // are obfuscated in the dump, so emit both cases to be safe. + return Ok(top.Select(c => + { + var newToday = todayByTarget.GetValueOrDefault(c.AccountId); + return new Dictionary + { + ["AccountId"] = (int)c.AccountId, + ["accountId"] = (int)c.AccountId, + ["SubscriberCount"] = c.SubscriberCount, + ["subscriberCount"] = c.SubscriberCount, + ["NewSubscribersToday"] = newToday, + ["newSubscribersToday"] = newToday, + }; + }).ToList()); + } + [HttpPost("subscribe/{targetId:long}")] public async Task Subscribe(long targetId) { diff --git a/DorkNet.Server/Controllers/API/PurchaseCampaign/PurchaseCampaignController.cs b/DorkNet.Server/Controllers/API/PurchaseCampaign/PurchaseCampaignController.cs new file mode 100644 index 0000000..b27efdf --- /dev/null +++ b/DorkNet.Server/Controllers/API/PurchaseCampaign/PurchaseCampaignController.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace DorkNet.Server.Controllers.API.PurchaseCampaign; + +/// +/// Purchase-campaign impression telemetry. Client contract (RecNet.Runtime +/// JHAEJJEGPKJ(int)) is a fire-and-forget POST carrying the campaign +/// id; the return type is the response handle whose body the client ignores. +/// DorkNet doesn't run ad/purchase campaigns, so there's nothing to record — +/// we acknowledge the impression so the client's promise resolves instead of +/// 404-rejecting. This is a genuine no-op telemetry sink, not a stub masking +/// an unimplemented feature. +/// +[ApiController] +[Authorize] +public class PurchaseCampaignController : ControllerBase +{ + [HttpPost("/purchasecampaign/shown")] + [HttpPost("/purchasecampaign/shown/{campaignId:int}")] + public IActionResult Shown() => Ok(); +} diff --git a/DorkNet.Server/Controllers/API/RoomEarningsDistributions/RoomEarningsDistributionsController.cs b/DorkNet.Server/Controllers/API/RoomEarningsDistributions/RoomEarningsDistributionsController.cs index 4c7bac9..3a5b0e2 100644 --- a/DorkNet.Server/Controllers/API/RoomEarningsDistributions/RoomEarningsDistributionsController.cs +++ b/DorkNet.Server/Controllers/API/RoomEarningsDistributions/RoomEarningsDistributionsController.cs @@ -1,55 +1,201 @@ +using System.Text.Json; 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.RoomEarningsDistributions; +/// +/// Co-owner earnings-split configuration for a room. NOT gross-earnings +/// totals — this is how a room's token earnings are divided among its +/// co-owners. Client contract (RecNet.Runtime HPEBLAHNBLC / FEJOMKJBOOE): +/// +/// GET api/roomEarningsDistributions/v1/earningsDistribution/{roomId} +/// → a SINGLE object { RoomId, EarningsDistributionMapping: +/// Dictionary<accountId,percent>, EarningsDistributionMethod: 0|1 } +/// (0 = Equal, 1 = Custom). +/// POST the bare .../v1/earningsDistribution with that object +/// to save. +/// +/// Persisted as JSON on the room creator's settings row, keyed per room. +/// [ApiController] [Authorize] public class RoomEarningsDistributionsController(DorkNetDbContext db) : ControllerBase { + private const int MethodEqual = 0; + private const int MethodCustom = 1; + private static string SettingKey(long roomId) => $"room:earningsdist:{roomId}"; + + /// GET the split config for one room (client's per-room GET). + [HttpGet("api/roomEarningsDistributions/v1/earningsDistribution/{roomId:long}")] + public async Task GetDistribution(long roomId) + { + var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); + if (room is null) return NotFound(); + var (method, mapping) = await LoadAsync(room.CreatorPlayerId, roomId, room.CreatorPlayerId); + return Ok(BuildDto(roomId, method, mapping)); + } + + /// Bare-URL GET kept for compatibility: returns the caller's + /// own rooms' split configs as a list. The 2023 client doesn't hit this + /// (it GETs the per-room path above), but tooling may. [HttpGet("api/roomEarningsDistributions")] [HttpGet("api/roomEarningsDistributions/v1/earningsDistribution")] - public async Task EarningsDistribution([FromQuery] long? roomId = null) + public async Task ListMine([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 q = db.Rooms.Where(r => r.CreatorPlayerId == me); + if (roomId is long rid && rid > 0) q = q.Where(r => r.Id == rid); + var rooms = await q.Select(r => new { r.Id, r.CreatorPlayerId }).ToListAsync(); - 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 => + var result = new List(); + foreach (var r in rooms) { - byRoom.TryGetValue(room.Id, out var value); - var gross = value?.Gross ?? 0; - return new + var (method, mapping) = await LoadAsync(me, r.Id, r.CreatorPlayerId); + result.Add(BuildDto(r.Id, method, mapping)); + } + return Ok(result); + } + + /// POST the bare URL to save a room's split config (client's + /// save method). Only the room creator may change it. + [HttpPost("api/roomEarningsDistributions/v1/earningsDistribution")] + [HttpPut("api/roomEarningsDistributions/v1/earningsDistribution")] + [Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")] + public async Task SaveDistribution() + { + var me = this.RequireCurrentPlayerId(); + var (roomId, method, mapping) = await ReadBodyAsync(); + if (roomId <= 0) return BadRequest(new { error = "missing_room_id" }); + + var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); + if (room is null) return NotFound(); + if (room.CreatorPlayerId != me) return Forbid(); + + var payload = JsonSerializer.Serialize(new StoredDistribution + { + Method = method, + Mapping = mapping, + }); + var key = SettingKey(roomId); + var row = await db.PlayerSettings + .FirstOrDefaultAsync(s => s.PlayerId == room.CreatorPlayerId && s.Key == key); + if (row is null) + db.PlayerSettings.Add(new PlayerSettingEntity { - RoomId = room.Id, - room.Name, - GrossEarnings = gross, - NetEarnings = gross, - PurchaseCount = value?.PurchaseCount ?? 0, - CurrencyType = 2, - UpdatedAt = DateTime.UtcNow, - }; - }).ToList()); + PlayerId = room.CreatorPlayerId, Key = key, Value = payload, + }); + else + row.Value = payload; + await db.SaveChangesAsync(); + + return Ok(BuildDto(roomId, method, mapping)); + } + + // ── helpers ────────────────────────────────────────────────────────── + + private async Task<(int Method, Dictionary Mapping)> LoadAsync( + long ownerId, long roomId, long creatorId) + { + var row = await db.PlayerSettings + .FirstOrDefaultAsync(s => s.PlayerId == ownerId && s.Key == SettingKey(roomId)); + if (row is not null) + { + try + { + var stored = JsonSerializer.Deserialize(row.Value); + if (stored is not null) + return (stored.Method, stored.Mapping ?? new Dictionary()); + } + catch { /* fall through to default */ } + } + // Default: Equal method, creator takes 100% (solo room). + return (MethodEqual, new Dictionary { [creatorId] = 100 }); + } + + private static object BuildDto(long roomId, int method, Dictionary mapping) + { + // Mapping is keyed by accountId (as a string in JSON) → percent. + var mappingWire = mapping.ToDictionary(kv => kv.Key.ToString(), kv => kv.Value); + // A Dictionary preserves literal key casing (unlike anonymous objects, + // which the server serializes camelCase). The client dump carries BOTH + // "EarningsDistributionMapping"/"earningsDistributionMapping" (and the + // Method variants), so emit both cases to be safe. + return new Dictionary + { + ["RoomId"] = roomId, + ["roomId"] = roomId, + ["EarningsDistributionMethod"] = method, + ["earningsDistributionMethod"] = method, + ["EarningsDistributionMapping"] = mappingWire, + ["earningsDistributionMapping"] = mappingWire, + }; + } + + private async Task<(long RoomId, int Method, Dictionary Mapping)> ReadBodyAsync() + { + long roomId = 0; + int method = MethodEqual; + var mapping = new Dictionary(); + + if (Request.HasFormContentType) + { + if (long.TryParse(Request.Form["RoomId"], out var rf) || + long.TryParse(Request.Form["roomId"], out rf)) roomId = rf; + if (int.TryParse(Request.Form["EarningsDistributionMethod"], out var mf) || + int.TryParse(Request.Form["earningsDistributionMethod"], out mf)) method = mf; + } + else + { + try + { + Request.EnableBuffering(); + Request.Body.Position = 0; + using var doc = await JsonDocument.ParseAsync(Request.Body); + var root = doc.RootElement; + if (root.ValueKind == JsonValueKind.Object) + { + roomId = ReadLong(root, "RoomId", "roomId") ?? 0; + method = (int)(ReadLong(root, "EarningsDistributionMethod", "earningsDistributionMethod") ?? MethodEqual); + if (TryGetProp(root, out var map, "EarningsDistributionMapping", "earningsDistributionMapping") + && map.ValueKind == JsonValueKind.Object) + { + foreach (var p in map.EnumerateObject()) + if (long.TryParse(p.Name, out var acc) && p.Value.TryGetInt32(out var pct)) + mapping[acc] = pct; + } + } + } + catch { /* non-JSON / empty */ } + } + + if (roomId == 0 && long.TryParse(Request.Query["roomId"], out var rq)) roomId = rq; + if (mapping.Count == 0 && method == MethodCustom) method = MethodEqual; + return (roomId, method, mapping); + } + + private static long? ReadLong(JsonElement root, params string[] names) + { + if (TryGetProp(root, out var v, names) && + v.ValueKind == JsonValueKind.Number && v.TryGetInt64(out var n)) return n; + return null; + } + + private static bool TryGetProp(JsonElement root, out JsonElement value, params string[] names) + { + foreach (var n in names) + if (root.TryGetProperty(n, out value)) return true; + value = default; + return false; + } + + private sealed class StoredDistribution + { + public int Method { get; set; } + public Dictionary? Mapping { get; set; } } } diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs index 2a7ac1b..07b5cea 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs @@ -253,13 +253,37 @@ public class RoomsController( /// [HttpGet("api/rooms/v1/search")] [HttpGet("api/rooms/v2/search")] - [HttpGet("rooms/search")] public async Task Search( [FromQuery(Name = "query")] string? query, [FromQuery(Name = "value")] string? value) => Ok((await rooms.SearchAsync(query ?? value ?? string.Empty)) .Select(RoomService.ToWireRoom).ToList()); + /// GET rooms/search — the room-server search route. + /// Unlike the api/rooms/v* variants (flat List<Room>), + /// this one's client contract is a PAGED wrapper object + /// { Results, TotalResults } (IDLBPALJJDJ : PagedResult<Room>); + /// returning a bare array crashes the watch's key-based reader. + [HttpGet("rooms/search")] + public async Task SearchRoomServer( + [FromQuery(Name = "query")] string? query, + [FromQuery(Name = "value")] string? value, + [FromQuery] int skip = 0, + [FromQuery] int take = 100) + { + var all = (await rooms.SearchAsync(query ?? value ?? string.Empty)).ToList(); + var page = all + .Skip(Math.Max(skip, 0)) + .Take(Math.Clamp(take, 1, 100)) + .Select(RoomService.ToWireRoom) + .ToList(); + return Ok(new + { + Results = page, + TotalResults = all.Count, + }); + } + /// GET api/rooms/v2/live — currently-active rooms /// (rooms with at least one player presence). Same shape as Hot /// — order by HotScore as a proxy for "live activity" since we diff --git a/DorkNet.Server/Controllers/Accounts/AccountsController.cs b/DorkNet.Server/Controllers/Accounts/AccountsController.cs index 6ce13ea..9baa336 100644 --- a/DorkNet.Server/Controllers/Accounts/AccountsController.cs +++ b/DorkNet.Server/Controllers/Accounts/AccountsController.cs @@ -301,14 +301,31 @@ public class AccountsController( return Ok(accounts); } - // Accounts.GetNameGenerationOptions → NameGenDTO {Nouns, Adjectives} + // Accounts.GetNameGenerationOptions → NameGenDTO {Nouns, Adjectives} + // (two List). The 2023 client also calls the newer root path + // `namegen/options` (RecNet.Runtime EAHBKJEMHEM, two List); + // route it here too. Keys are duplicated in lower-case as a safety net + // because the 2023 DTO's obfuscated members give no literal key names — + // LitJson ignores keys it doesn't recognise, so extras are harmless. [HttpGet("/account/v1/namegen")] [HttpGet("/account/v1/namegeneration")] - public IActionResult NameGen() => Ok(new + [HttpGet("/namegen/options")] + public IActionResult NameGen() { - Nouns = new[] { "Player", "Hero", "Star", "Fox", "Wolf" }, - Adjectives = new[] { "Brave", "Swift", "Clever", "Bold", "Bright" }, - }); + var nouns = new[] + { + "Player", "Hero", "Star", "Fox", "Wolf", "Comet", "Tiger", "Falcon", + "Panda", "Dragon", "Ninja", "Robot", "Ghost", "Rocket", "Shark", "Raven", + }; + var adjectives = new[] + { + "Brave", "Swift", "Clever", "Bold", "Bright", "Cosmic", "Silent", "Mighty", + "Turbo", "Lucky", "Sneaky", "Epic", "Fuzzy", "Wild", "Golden", "Neon", + }; + // Anonymous objects serialize with the server's camelCase policy, so + // these become {"nouns":[…],"adjectives":[…]} on the wire. + return Ok(new { Nouns = nouns, Adjectives = adjectives }); + } // Phone-confirm is the one mutation we don't persist — there's no // SMS provider in the loop. Acknowledge silently. @@ -670,27 +687,102 @@ public class AccountsController( [HttpGet("/accountprivacysettings/{accountId:long}")] [HttpGet("/account/v1/privacysettings/{accountId:long}")] [HttpGet("/account/{accountId:long}/privacysettings")] - public IActionResult GetAccountPrivacySettings(long accountId) => Ok(new + public async Task GetAccountPrivacySettings(long accountId) { - AccountId = (int)accountId, - IsProfileVisible = true, - IsOnlineStatusVisible = true, - IsActivityVisible = true, - ShowOnlineStatus = true, - ShowActivity = true, - ShowCurrentRoom = true, - ReceiveFriendRequests = true, - ReceiveInvites = true, - ReceiveMessages = true, - WhoCanSeeOnlineStatus = 0, - WhoCanSeeActivity = 0, - WhoCanSeeCurrentRoom = 0, - WhoCanSendFriendRequests = 0, - WhoCanInviteMe = 0, - WhoCanMessageMe = 0, - VoicePrivacy = 0, - TextChatPrivacy = 0, - }); + // Recent-history visibility is the one privacy bit the watch can + // actually toggle (setter below); read it back so the client's + // GetRecentHistoryVisibility reader sees the persisted value. + var recentHistoryVisible = await GetRecentHistoryVisibleAsync(accountId); + return Ok(new + { + AccountId = (int)accountId, + IsProfileVisible = true, + IsOnlineStatusVisible = true, + IsActivityVisible = true, + ShowOnlineStatus = true, + ShowActivity = true, + ShowCurrentRoom = true, + ReceiveFriendRequests = true, + ReceiveInvites = true, + ReceiveMessages = true, + IsRecentHistoryVisible = recentHistoryVisible, + WhoCanSeeOnlineStatus = 0, + WhoCanSeeActivity = 0, + WhoCanSeeCurrentRoom = 0, + WhoCanSendFriendRequests = 0, + WhoCanInviteMe = 0, + WhoCanMessageMe = 0, + VoicePrivacy = 0, + TextChatPrivacy = 0, + }); + } + + private const string RecentHistoryVisibleKey = "privacy:recenthistoryvisible"; + + private async Task GetRecentHistoryVisibleAsync(long accountId) + { + var row = await db.PlayerSettings + .FirstOrDefaultAsync(s => s.PlayerId == accountId && s.Key == RecentHistoryVisibleKey); + // Default: visible (true) when never toggled. + return row is null || !string.Equals(row.Value, "false", StringComparison.OrdinalIgnoreCase); + } + + /// POST/PUT /accountprivacysettings/recenthistoryvisibility + /// — the watch's ChangeRecentHistoryVisibility(bool). Body carries + /// isRecentHistoryVisible (accepts the PascalCase variant and a + /// raw bool body too). The client's return type is the fire-and-forget + /// response handle (body ignored), but we echo the persisted object so a + /// GET-style consumer is also satisfied. The existing + /// /accountprivacysettings/{accountId:long} route can never match + /// this literal segment (the :long constraint rejects it), so + /// without this handler the toggle 404'd ("Failed to modify …"). + [HttpPost("/accountprivacysettings/recenthistoryvisibility")] + [HttpPut("/accountprivacysettings/recenthistoryvisibility")] + [Microsoft.AspNetCore.Authorization.Authorize] + [Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")] + public async Task SetRecentHistoryVisibility() + { + var me = this.RequireCurrentPlayerId(); + var visible = await ReadRecentHistoryVisibleBodyAsync(); + await SetPlayerSettingAsync(me, RecentHistoryVisibleKey, visible ? "true" : "false"); + return Ok(new + { + AccountId = (int)me, + IsRecentHistoryVisible = visible, + }); + } + + private async Task ReadRecentHistoryVisibleBodyAsync() + { + // Form body (isRecentHistoryVisible / IsRecentHistoryVisible). + if (Request.HasFormContentType) + { + foreach (var k in new[] { "isRecentHistoryVisible", "IsRecentHistoryVisible" }) + if (bool.TryParse(Request.Form[k], out var fv)) return fv; + } + // JSON body: { "isRecentHistoryVisible": true } or a bare `true`. + try + { + Request.EnableBuffering(); + Request.Body.Position = 0; + using var doc = await System.Text.Json.JsonDocument.ParseAsync(Request.Body); + var root = doc.RootElement; + if (root.ValueKind == System.Text.Json.JsonValueKind.True) return true; + if (root.ValueKind == System.Text.Json.JsonValueKind.False) return false; + if (root.ValueKind == System.Text.Json.JsonValueKind.Object) + { + foreach (var k in new[] { "isRecentHistoryVisible", "IsRecentHistoryVisible" }) + if (root.TryGetProperty(k, out var v) && + (v.ValueKind == System.Text.Json.JsonValueKind.True || + v.ValueKind == System.Text.Json.JsonValueKind.False)) + return v.GetBoolean(); + } + } + catch { /* non-JSON / empty body */ } + // Query fallback. + if (bool.TryParse(Request.Query["isRecentHistoryVisible"], out var qv)) return qv; + return true; + } // Previously had a /{*path} catch-all that returned [] / {}. Removed — // unknown accounts URLs now 404 so we notice them in logs and wire diff --git a/DorkNet.Server/Controllers/Clubs/ClubsController.cs b/DorkNet.Server/Controllers/Clubs/ClubsController.cs index e9da4d4..6b2e305 100644 --- a/DorkNet.Server/Controllers/Clubs/ClubsController.cs +++ b/DorkNet.Server/Controllers/Clubs/ClubsController.cs @@ -105,6 +105,19 @@ public class ClubsController(ClubService clubs) : ControllerBase }); } + /// GET /club/mostactivetoday — the Clubs discover tab's + /// "most active today" carousel. Client contract is a bare + /// IReadOnlyList<Club> (not the search envelope), so this + /// returns a flat array of the canonical shape, + /// ordered by the club service's default activity sort. + [HttpGet("/club/mostactivetoday")] + public async Task MostActiveToday([FromQuery] int? count) + { + var result = await clubs.SearchAsync(null, null, sort: 0, count: Math.Clamp(count ?? 30, 1, 100)); + var counts = await clubs.MemberCountsAsync(result.Clubs.Select(r => r.Id)); + return Ok(result.Clubs.Select(c => ToWireClub(c, counts.GetValueOrDefault(c.Id))).ToList()); + } + /// GET /club?name=Foo — direct name lookup used by /// the client when resolving a club by display name. Returns the /// single PLILLKHMNDA Club wire shape. @@ -708,27 +721,12 @@ public class ClubsController(ClubService clubs) : ControllerBase })); } - public sealed class ClubReportRequest - { - public long? ClubId { get; set; } - public int? Category { get; set; } - public string? Details { get; set; } - } - - /// POST /api/clubreporting/v1/report — file a - /// moderation report against a club. Persists to the same - /// table - /// the admin queue reads from; the controller prefixes the message - /// with "[club {id}]" so admins can tell club reports apart from - /// player reports without a schema change. - [HttpPost("/api/clubreporting/v1/report")] - [Authorize] - public async Task ClubReport([FromBody] ClubReportRequest req) - { - if (req.ClubId is not long cid) return BadRequest(new { error = "missing_club_id" }); - await clubs.AddClubReportAsync(cid, Me, req.Category ?? 0, req.Details ?? string.Empty); - return Ok(); - } + // NOTE: POST /api/clubreporting/v1/report is served by + // CompatibilityFeatureController.ClubReport, which persists the same + // ReportEntity AND returns the { Success, Message } object the 2023 + // client requires. A second handler used to live here; having both bind + // the identical verb+template made every club report 500 with + // AmbiguousMatchException, so this duplicate was removed. // ── Announcements (per-club feed + per-announcement) ───────────── diff --git a/DorkNet.Server/Controllers/PlayerSettings/PlayerSettingsController.cs b/DorkNet.Server/Controllers/PlayerSettings/PlayerSettingsController.cs index 8f08672..2fbddb2 100644 --- a/DorkNet.Server/Controllers/PlayerSettings/PlayerSettingsController.cs +++ b/DorkNet.Server/Controllers/PlayerSettings/PlayerSettingsController.cs @@ -43,6 +43,46 @@ public class PlayerSettingsController(DorkNetDbContext db, ILoggerGET /settings/partyinvite — the caller's party-invite + /// privacy setting. Client contract (RecNet.Runtime BMGICFICCPN()) + /// is a BARE Int32 (e.g. 0 = everyone, 1 = friends, 2 = nobody), not an + /// object. Default 0 when never set. + [HttpGet("/settings/partyinvite")] + public async Task GetPartyInvite() + { + var row = await db.PlayerSettings + .FirstOrDefaultAsync(s => s.PlayerId == CurrentPlayerId && s.Key == PartyInviteKey); + var value = row is not null && int.TryParse(row.Value, out var v) ? v : 0; + return Content(value.ToString(), "application/json"); + } + + /// POST/PUT /settings/partyinvite — set the party-invite + /// privacy value (raw int body, ?value= query, or form field). + [HttpPost("/settings/partyinvite")] + [HttpPut("/settings/partyinvite")] + public async Task SetPartyInvite() + { + int value = 0; + if (Request.HasFormContentType && int.TryParse(Request.Form["value"], out var fv)) value = fv; + else if (int.TryParse(Request.Query["value"], out var qv)) value = qv; + else + { + try + { + Request.EnableBuffering(); + Request.Body.Position = 0; + using var doc = await JsonDocument.ParseAsync(Request.Body); + if (doc.RootElement.ValueKind == JsonValueKind.Number && + doc.RootElement.TryGetInt32(out var bv)) value = bv; + } + catch { /* non-numeric body */ } + } + await UpsertSettingAsync(CurrentPlayerId, PartyInviteKey, value.ToString()); + return Content(value.ToString(), "application/json"); + } + [HttpGet("/settings/v1/{accountId:long}")] public async Task>> Get(long accountId) {