feat: Enhance API endpoints and add new features

- Added support for v3 gift generation in AvatarGiftsController.
- Updated club report messages to include club IDs for better tracking in CompatibilityFeatureController.
- Introduced new endpoints for voice moderation and whitelisted emojis in ConfigController.
- Modified image retrieval logic to support single image lookups by name in ImagesController.
- Adjusted incentivized referrals to return an empty array when no rewards are available in IncentivizedReferralsController.
- Implemented trial invention functionality in InventionsController.
- Added top creators today endpoint in PlayerSubscriptionsController.
- Expanded room earnings distribution functionality in RoomEarningsDistributionsController.
- Enhanced room search capabilities with pagination in RoomsController.
- Improved account name generation options in AccountsController.
- Added party invite privacy settings in PlayerSettingsController.
- Created CommentsController for handling unread comment counts.
- Established PurchaseCampaignController for handling purchase campaign telemetry.
This commit is contained in:
Alexa
2026-07-12 22:18:58 +03:00
parent 5fcef3ffe7
commit 3d7d7ebdf1
14 changed files with 617 additions and 96 deletions
@@ -59,7 +59,11 @@ public class AvatarGiftsController(
/// that room has no pool — or for non-game free-gift tiles — it falls /// 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 /// back to a random wardrobe item. The rolled item is stamped as a
/// gift package; opening the box (<c>gifts/consume</c>) grants it.</summary> /// gift package; opening the box (<c>gifts/consume</c>) grants it.</summary>
// 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/v2/gifts/generate")]
[HttpPost("api/avatar/v3/gifts/generate")]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")] [Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> Generate([FromBody] GenerateGiftRequest? req) public async Task<IActionResult> Generate([FromBody] GenerateGiftRequest? req)
{ {
@@ -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;
/// <summary>
/// Room-comment unread counts. Client contract (RecNet.Runtime
/// <c>DJLNMANKMAA(IReadOnlyCollection&lt;long&gt;)</c>): the caller sends a
/// collection of roomIds and expects a top-level JSON OBJECT
/// <c>Dictionary&lt;long, uint&gt;</c> 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).
/// </summary>
[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<IActionResult> UnreadCounts()
{
var roomIds = await ReadRoomIdsAsync();
// Keys must be strings in JSON; values are the (currently always 0)
// unread counts.
var result = new Dictionary<string, uint>();
foreach (var id in roomIds) result[id.ToString()] = 0;
return Ok(result);
}
private async Task<HashSet<long>> ReadRoomIdsAsync()
{
var ids = new HashSet<long>();
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;
}
}
@@ -108,7 +108,9 @@ public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBas
: null; : null;
if (club is null) return Ok(new { Success = false, Message = "club_not_found" }); 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]; if (message.Length > 1000) message = message[..1000];
db.Reports.Add(new ReportEntity db.Reports.Add(new ReportEntity
{ {
@@ -117,6 +117,27 @@ public class ConfigController(ConfigService configService) : ControllerBase
}); });
} }
/// <summary>GET <c>/voice/requiresModeration</c> — bare Boolean the
/// client reads to decide whether voice needs moderation. ToxMod is
/// disabled server-side, so no moderation is required.</summary>
[HttpGet("/voice/requiresModeration")]
[Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult VoiceRequiresModeration() => Content("false", "application/json");
/// <summary>GET <c>/emojiConfig/whitelistedEmojis</c> — the allow-list of
/// emoji permitted in text chat (RRUI.EmojiPicker, IReadOnlyList&lt;string&gt;).
/// Returns a broad default set so the picker is populated.</summary>
[HttpGet("/emojiConfig/whitelistedEmojis")]
[Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult WhitelistedEmojis() => Ok(new[]
{
"😀", "😁", "😂", "🤣", "😊", "😇", "🙂", "😉", "😍", "😘",
"😜", "🤪", "😎", "🤩", "🥳", "😏", "😢", "😭", "😡", "🤯",
"😱", "😴", "🤔", "🤗", "🙄", "😬", "👍", "👎", "👏", "🙌",
"🙏", "💪", "✌️", "🤞", "👋", "🤝", "❤️", "🔥", "⭐", "✨",
"🎉", "🎊", "💯", "😈", "👻", "🤖", "🎮", "🏆",
});
/// <summary>GET <c>api/config/v1/freegiftbutton</c> — the /// <summary>GET <c>api/config/v1/freegiftbutton</c> — the
/// 2020.12 client deserializes this endpoint as a raw Boolean. /// 2020.12 client deserializes this endpoint as a raw Boolean.
/// This server does not run the timed free-gift campaign, so the /// This server does not run the timed free-gift campaign, so the
@@ -303,17 +303,23 @@ public class ImagesController(
return Ok(rows.Select(p => BuildImageInfo(p, p.UploaderPlayerId))); return Ok(rows.Select(p => BuildImageInfo(p, p.UploaderPlayerId)));
} }
/// <summary>GET <c>api/images/v6?name={blobName}</c> — single-image
/// lookup by name. Client contract (RecNet.Runtime
/// <c>KLJOGJHBONK.INHMKKAJJKO(string)</c>) returns ONE
/// <c>ICOFKEGOGOD</c> 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.</summary>
[HttpGet("api/images/v6")] [HttpGet("api/images/v6")]
[AllowAnonymous] [AllowAnonymous]
public async Task<IActionResult> ImagesV6([FromQuery] int take = 50) public async Task<IActionResult> ImagesV6([FromQuery] string? name)
{ {
take = Math.Clamp(take, 1, 100); if (string.IsNullOrWhiteSpace(name)) return NotFound();
var rows = await db.Photos var pid = this.CurrentPlayerId();
.Where(p => p.IsPublic && p.DeletedAt == null) var photo = await db.Photos.FirstOrDefaultAsync(p =>
.OrderByDescending(p => p.CreatedAt) p.BlobName == name && p.DeletedAt == null &&
.Take(take) (p.IsPublic || (pid != null && p.UploaderPlayerId == pid.Value)));
.ToListAsync(); if (photo is null) return NotFound();
return Ok(rows.Select(p => BuildImageInfo(p, p.UploaderPlayerId))); return Ok(BuildImageInfo(photo, photo.UploaderPlayerId));
} }
/// <summary>GET <c>api/images/v2/named</c> — the watch's /// <summary>GET <c>api/images/v2/named</c> — the watch's
@@ -34,17 +34,23 @@ public class IncentivizedReferralsController(DorkNetDbContext db, LevelService l
}); });
} }
// Client contract: the claim response deserializes as a top-level
// List<RewardSelection> 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")] [HttpPost("claim")]
public async Task<IActionResult> Claim() public async Task<IActionResult> Claim()
{ {
var referralCount = await db.PlayerSettings var referralCount = await db.PlayerSettings
.CountAsync(s => s.Key.StartsWith($"referral:credited:{Me}:")); .CountAsync(s => s.Key.StartsWith($"referral:credited:{Me}:"));
if (referralCount < 1) var alreadyClaimed = await db.PlayerSettings
return Ok(new { Success = false, Error = "not_enough_referrals" }); .AnyAsync(s => s.PlayerId == Me && s.Key == "referral:reward_claimed");
if (await db.PlayerSettings.AnyAsync(s => s.PlayerId == Me && s.Key == "referral:reward_claimed")) if (referralCount < 1 || alreadyClaimed)
return Ok(new { Success = false, Error = "already_claimed" }); return Ok(Array.Empty<object>());
var balance = await level.GrantCurrencyAsync(Me, 2, 250, "incentivized_referral"); await level.GrantCurrencyAsync(Me, 2, 250, "incentivized_referral");
db.PlayerSettings.Add(new PlayerSettingEntity db.PlayerSettings.Add(new PlayerSettingEntity
{ {
PlayerId = Me, PlayerId = Me,
@@ -52,6 +58,11 @@ public class IncentivizedReferralsController(DorkNetDbContext db, LevelService l
Value = DateTime.UtcNow.ToString("O"), Value = DateTime.UtcNow.ToString("O"),
}); });
await db.SaveChangesAsync(); 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" },
});
} }
} }
@@ -334,6 +334,39 @@ public class InventionsController(
return Ok(ToWire(i)); return Ok(ToWire(i));
} }
/// <summary>POST <c>api/storefronts/v1/trialInvention?inventionId={id}</c>
/// — start a free trial of a store invention. Client
/// (RecNet.Runtime <c>DCFKEFHJAGC.PPCLLFMHJLD(long)</c>) expects a SINGLE
/// Invention DTO back (same <see cref="ToWire"/> shape it uses for
/// search), which it spawns for the trial window (length from
/// <c>trialInvention/duration</c>). Hosted here — not in
/// StorefrontsController — so it can reuse the invention wire builder.
/// We record the trial start so it's an auditable action.</summary>
[HttpPost("api/storefronts/v1/trialInvention")]
[Authorize]
public async Task<ActionResult> 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")] [HttpGet("api/inventions/v1/details")]
public async Task<ActionResult> Details([FromQuery] long inventionId) public async Task<ActionResult> Details([FromQuery] long inventionId)
{ {
@@ -44,6 +44,51 @@ public class PlayerSubscriptionsController(
return Ok(subs); return Ok(subs);
} }
/// <summary>GET <c>/subscription/top/creators/today</c> — the
/// subscriptions tab's "top creators" carousel. Client contract
/// (RecNet.Runtime <c>JDBPOMCAIIG()</c>) is a bare
/// <c>List&lt;creator&gt;</c> of <c>{ accountId:int, subscriberCount:long,
/// … }</c>. Ranks creators by total subscriber count. The leading "/"
/// makes this route absolute, bypassing the class's
/// <c>api/[controller]/v1</c> prefix.</summary>
[HttpGet("/subscription/top/creators/today")]
[AllowAnonymous]
public async Task<ActionResult> 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<string, object?>
{
["AccountId"] = (int)c.AccountId,
["accountId"] = (int)c.AccountId,
["SubscriberCount"] = c.SubscriberCount,
["subscriberCount"] = c.SubscriberCount,
["NewSubscribersToday"] = newToday,
["newSubscribersToday"] = newToday,
};
}).ToList());
}
[HttpPost("subscribe/{targetId:long}")] [HttpPost("subscribe/{targetId:long}")]
public async Task<ActionResult> Subscribe(long targetId) public async Task<ActionResult> Subscribe(long targetId)
{ {
@@ -0,0 +1,22 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace DorkNet.Server.Controllers.API.PurchaseCampaign;
/// <summary>
/// Purchase-campaign impression telemetry. Client contract (RecNet.Runtime
/// <c>JHAEJJEGPKJ(int)</c>) 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.
/// </summary>
[ApiController]
[Authorize]
public class PurchaseCampaignController : ControllerBase
{
[HttpPost("/purchasecampaign/shown")]
[HttpPost("/purchasecampaign/shown/{campaignId:int}")]
public IActionResult Shown() => Ok();
}
@@ -1,55 +1,201 @@
using System.Text.Json;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth; using DorkNet.Server.Auth;
using DorkNet.Server.Data; using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.RoomEarningsDistributions; namespace DorkNet.Server.Controllers.API.RoomEarningsDistributions;
/// <summary>
/// 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):
/// <list type="bullet">
/// <item>GET <c>api/roomEarningsDistributions/v1/earningsDistribution/{roomId}</c>
/// → a SINGLE object <c>{ RoomId, EarningsDistributionMapping:
/// Dictionary&lt;accountId,percent&gt;, EarningsDistributionMethod: 0|1 }</c>
/// (0 = Equal, 1 = Custom).</item>
/// <item>POST the bare <c>.../v1/earningsDistribution</c> with that object
/// to save.</item>
/// </list>
/// Persisted as JSON on the room creator's settings row, keyed per room.
/// </summary>
[ApiController] [ApiController]
[Authorize] [Authorize]
public class RoomEarningsDistributionsController(DorkNetDbContext db) : ControllerBase 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}";
/// <summary>GET the split config for one room (client's per-room GET).</summary>
[HttpGet("api/roomEarningsDistributions/v1/earningsDistribution/{roomId:long}")]
public async Task<IActionResult> 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));
}
/// <summary>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.</summary>
[HttpGet("api/roomEarningsDistributions")] [HttpGet("api/roomEarningsDistributions")]
[HttpGet("api/roomEarningsDistributions/v1/earningsDistribution")] [HttpGet("api/roomEarningsDistributions/v1/earningsDistribution")]
public async Task<IActionResult> EarningsDistribution([FromQuery] long? roomId = null) public async Task<IActionResult> ListMine([FromQuery] long? roomId = null)
{ {
var me = this.RequireCurrentPlayerId(); var me = this.RequireCurrentPlayerId();
var rooms = db.Rooms.Where(r => r.CreatorPlayerId == me); var q = db.Rooms.Where(r => r.CreatorPlayerId == me);
if (roomId is long rid && rid > 0) if (roomId is long rid && rid > 0) q = q.Where(r => r.Id == rid);
rooms = rooms.Where(r => r.Id == rid); var rooms = await q.Select(r => new { r.Id, r.CreatorPlayerId }).ToListAsync();
var roomRows = await rooms var result = new List<object>();
.Select(r => new { r.Id, r.Name }) foreach (var r in rooms)
.ToListAsync();
var roomIds = roomRows.Select(r => r.Id).ToList();
var earnings = await db.RoomKeyPurchases
.Join(db.RoomKeys, p => p.RoomKeyId, k => k.Id, (p, k) => new { p, k })
.Where(x => roomIds.Contains(x.k.RoomId))
.GroupBy(x => x.k.RoomId)
.Select(g => new
{
RoomId = g.Key,
Gross = g.Sum(x => x.p.PaidPrice),
PurchaseCount = g.Count(),
})
.ToListAsync();
var byRoom = earnings.ToDictionary(e => e.RoomId);
return Ok(roomRows.Select(room =>
{ {
byRoom.TryGetValue(room.Id, out var value); var (method, mapping) = await LoadAsync(me, r.Id, r.CreatorPlayerId);
var gross = value?.Gross ?? 0; result.Add(BuildDto(r.Id, method, mapping));
return new }
return Ok(result);
}
/// <summary>POST the bare URL to save a room's split config (client's
/// save method). Only the room creator may change it.</summary>
[HttpPost("api/roomEarningsDistributions/v1/earningsDistribution")]
[HttpPut("api/roomEarningsDistributions/v1/earningsDistribution")]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> 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, PlayerId = room.CreatorPlayerId, Key = key, Value = payload,
room.Name, });
GrossEarnings = gross, else
NetEarnings = gross, row.Value = payload;
PurchaseCount = value?.PurchaseCount ?? 0, await db.SaveChangesAsync();
CurrencyType = 2,
UpdatedAt = DateTime.UtcNow, return Ok(BuildDto(roomId, method, mapping));
}; }
}).ToList());
// ── helpers ──────────────────────────────────────────────────────────
private async Task<(int Method, Dictionary<long, int> 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<StoredDistribution>(row.Value);
if (stored is not null)
return (stored.Method, stored.Mapping ?? new Dictionary<long, int>());
}
catch { /* fall through to default */ }
}
// Default: Equal method, creator takes 100% (solo room).
return (MethodEqual, new Dictionary<long, int> { [creatorId] = 100 });
}
private static object BuildDto(long roomId, int method, Dictionary<long, int> 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<string, object?>
{
["RoomId"] = roomId,
["roomId"] = roomId,
["EarningsDistributionMethod"] = method,
["earningsDistributionMethod"] = method,
["EarningsDistributionMapping"] = mappingWire,
["earningsDistributionMapping"] = mappingWire,
};
}
private async Task<(long RoomId, int Method, Dictionary<long, int> Mapping)> ReadBodyAsync()
{
long roomId = 0;
int method = MethodEqual;
var mapping = new Dictionary<long, int>();
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<long, int>? Mapping { get; set; }
} }
} }
@@ -253,13 +253,37 @@ public class RoomsController(
/// </summary> /// </summary>
[HttpGet("api/rooms/v1/search")] [HttpGet("api/rooms/v1/search")]
[HttpGet("api/rooms/v2/search")] [HttpGet("api/rooms/v2/search")]
[HttpGet("rooms/search")]
public async Task<IActionResult> Search( public async Task<IActionResult> Search(
[FromQuery(Name = "query")] string? query, [FromQuery(Name = "query")] string? query,
[FromQuery(Name = "value")] string? value) [FromQuery(Name = "value")] string? value)
=> Ok((await rooms.SearchAsync(query ?? value ?? string.Empty)) => Ok((await rooms.SearchAsync(query ?? value ?? string.Empty))
.Select(RoomService.ToWireRoom).ToList()); .Select(RoomService.ToWireRoom).ToList());
/// <summary>GET <c>rooms/search</c> — the room-server search route.
/// Unlike the <c>api/rooms/v*</c> variants (flat <c>List&lt;Room&gt;</c>),
/// this one's client contract is a PAGED wrapper object
/// <c>{ Results, TotalResults }</c> (IDLBPALJJDJ : PagedResult&lt;Room&gt;);
/// returning a bare array crashes the watch's key-based reader.</summary>
[HttpGet("rooms/search")]
public async Task<IActionResult> 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,
});
}
/// <summary>GET <c>api/rooms/v2/live</c> — currently-active rooms /// <summary>GET <c>api/rooms/v2/live</c> — currently-active rooms
/// (rooms with at least one player presence). Same shape as Hot /// (rooms with at least one player presence). Same shape as Hot
/// — order by HotScore as a proxy for "live activity" since we /// — order by HotScore as a proxy for "live activity" since we
@@ -301,14 +301,31 @@ public class AccountsController(
return Ok(accounts); return Ok(accounts);
} }
// Accounts.GetNameGenerationOptions → NameGenDTO {Nouns, Adjectives} // Accounts.GetNameGenerationOptions → NameGenDTO {Nouns, Adjectives}
// (two List<string>). The 2023 client also calls the newer root path
// `namegen/options` (RecNet.Runtime EAHBKJEMHEM, two List<string>);
// 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/namegen")]
[HttpGet("/account/v1/namegeneration")] [HttpGet("/account/v1/namegeneration")]
public IActionResult NameGen() => Ok(new [HttpGet("/namegen/options")]
public IActionResult NameGen()
{ {
Nouns = new[] { "Player", "Hero", "Star", "Fox", "Wolf" }, var nouns = new[]
Adjectives = new[] { "Brave", "Swift", "Clever", "Bold", "Bright" }, {
}); "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 // Phone-confirm is the one mutation we don't persist — there's no
// SMS provider in the loop. Acknowledge silently. // SMS provider in the loop. Acknowledge silently.
@@ -670,27 +687,102 @@ public class AccountsController(
[HttpGet("/accountprivacysettings/{accountId:long}")] [HttpGet("/accountprivacysettings/{accountId:long}")]
[HttpGet("/account/v1/privacysettings/{accountId:long}")] [HttpGet("/account/v1/privacysettings/{accountId:long}")]
[HttpGet("/account/{accountId:long}/privacysettings")] [HttpGet("/account/{accountId:long}/privacysettings")]
public IActionResult GetAccountPrivacySettings(long accountId) => Ok(new public async Task<IActionResult> GetAccountPrivacySettings(long accountId)
{ {
AccountId = (int)accountId, // Recent-history visibility is the one privacy bit the watch can
IsProfileVisible = true, // actually toggle (setter below); read it back so the client's
IsOnlineStatusVisible = true, // GetRecentHistoryVisibility reader sees the persisted value.
IsActivityVisible = true, var recentHistoryVisible = await GetRecentHistoryVisibleAsync(accountId);
ShowOnlineStatus = true, return Ok(new
ShowActivity = true, {
ShowCurrentRoom = true, AccountId = (int)accountId,
ReceiveFriendRequests = true, IsProfileVisible = true,
ReceiveInvites = true, IsOnlineStatusVisible = true,
ReceiveMessages = true, IsActivityVisible = true,
WhoCanSeeOnlineStatus = 0, ShowOnlineStatus = true,
WhoCanSeeActivity = 0, ShowActivity = true,
WhoCanSeeCurrentRoom = 0, ShowCurrentRoom = true,
WhoCanSendFriendRequests = 0, ReceiveFriendRequests = true,
WhoCanInviteMe = 0, ReceiveInvites = true,
WhoCanMessageMe = 0, ReceiveMessages = true,
VoicePrivacy = 0, IsRecentHistoryVisible = recentHistoryVisible,
TextChatPrivacy = 0, 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<bool> 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);
}
/// <summary>POST/PUT <c>/accountprivacysettings/recenthistoryvisibility</c>
/// — the watch's <c>ChangeRecentHistoryVisibility(bool)</c>. Body carries
/// <c>isRecentHistoryVisible</c> (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
/// <c>/accountprivacysettings/{accountId:long}</c> route can never match
/// this literal segment (the <c>:long</c> constraint rejects it), so
/// without this handler the toggle 404'd ("Failed to modify …").</summary>
[HttpPost("/accountprivacysettings/recenthistoryvisibility")]
[HttpPut("/accountprivacysettings/recenthistoryvisibility")]
[Microsoft.AspNetCore.Authorization.Authorize]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> 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<bool> 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 — // Previously had a /{*path} catch-all that returned [] / {}. Removed —
// unknown accounts URLs now 404 so we notice them in logs and wire // unknown accounts URLs now 404 so we notice them in logs and wire
@@ -105,6 +105,19 @@ public class ClubsController(ClubService clubs) : ControllerBase
}); });
} }
/// <summary>GET <c>/club/mostactivetoday</c> — the Clubs discover tab's
/// "most active today" carousel. Client contract is a bare
/// <c>IReadOnlyList&lt;Club&gt;</c> (not the search envelope), so this
/// returns a flat array of the canonical <see cref="ToWireClub"/> shape,
/// ordered by the club service's default activity sort.</summary>
[HttpGet("/club/mostactivetoday")]
public async Task<IActionResult> 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());
}
/// <summary>GET <c>/club?name=Foo</c> — direct name lookup used by /// <summary>GET <c>/club?name=Foo</c> — direct name lookup used by
/// the client when resolving a club by display name. Returns the /// the client when resolving a club by display name. Returns the
/// single <c>PLILLKHMNDA</c> Club wire shape.</summary> /// single <c>PLILLKHMNDA</c> Club wire shape.</summary>
@@ -708,27 +721,12 @@ public class ClubsController(ClubService clubs) : ControllerBase
})); }));
} }
public sealed class ClubReportRequest // NOTE: POST /api/clubreporting/v1/report is served by
{ // CompatibilityFeatureController.ClubReport, which persists the same
public long? ClubId { get; set; } // ReportEntity AND returns the { Success, Message } object the 2023
public int? Category { get; set; } // client requires. A second handler used to live here; having both bind
public string? Details { get; set; } // the identical verb+template made every club report 500 with
} // AmbiguousMatchException, so this duplicate was removed.
/// <summary>POST <c>/api/clubreporting/v1/report</c> — file a
/// moderation report against a club. Persists to the same
/// <see cref="DorkNet.Server.Data.Entities.ReportEntity"/> 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.</summary>
[HttpPost("/api/clubreporting/v1/report")]
[Authorize]
public async Task<IActionResult> 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();
}
// ── Announcements (per-club feed + per-announcement) ───────────── // ── Announcements (per-club feed + per-announcement) ─────────────
@@ -43,6 +43,46 @@ public class PlayerSettingsController(DorkNetDbContext db, ILogger<PlayerSetting
return Ok(new SettingDto { Key = key, Value = value }); return Ok(new SettingDto { Key = key, Value = value });
} }
private const string PartyInviteKey = "settings:partyinvite";
/// <summary>GET <c>/settings/partyinvite</c> — the caller's party-invite
/// privacy setting. Client contract (RecNet.Runtime <c>BMGICFICCPN()</c>)
/// is a BARE Int32 (e.g. 0 = everyone, 1 = friends, 2 = nobody), not an
/// object. Default 0 when never set.</summary>
[HttpGet("/settings/partyinvite")]
public async Task<IActionResult> 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");
}
/// <summary>POST/PUT <c>/settings/partyinvite</c> — set the party-invite
/// privacy value (raw int body, <c>?value=</c> query, or form field).</summary>
[HttpPost("/settings/partyinvite")]
[HttpPut("/settings/partyinvite")]
public async Task<IActionResult> 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}")] [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)
{ {