mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 15:38:45 -07:00
fix: correct 2023-03-21 client wire contracts across the API and notify surfaces
Audits every route the March-2023 client can call against what this server
actually serves, and fixes the defects that were breaking gameplay. Adds
docs/recroom-2023-client-api-complete.md as the reference: ~500 route
literals across 13 subsystems, with verb, request/response shape, and a
per-subsystem diff. 315 defects catalogued; the ones fixed here are listed
in its "Already fixed" table.
Path coverage was never the problem — 465 of 506 routes already had
handlers. The failures were in shape, and they cluster into four causes:
- Wrong verb. The verb is a BestHTTP.HTTPMethods ordinal (GET=0 .. PATCH=5),
confirmed from that enum's static constructor. Routes registered POST-only
while the client PUTs returned 405, which made room consumables neither
buyable nor consumable and blocked every room-key edit.
- Bare scalar wrapped in an object. The client reads `1000`, not
`{"Limit":1000}`; the issuing method signature says which. Wrapping makes
the deserializer throw and the feature dies silently.
- [FromBody] vs form-urlencoded. The client sends nearly every write as form
data, which an [ApiController] action bound [FromBody] rejects with 415
before the handler runs. This alone broke all 13 club member-management
endpoints. Adds Binding/FormOrJsonModelBinder so this is fixed once.
- Notify dispatch keys. Both enum overloads of the client's RegisterHandler
lower the id via Int32.ToString() and register only that numeric key, and
lookup is a case-sensitive TryGetValue that returns silently on a miss. We
were sending the enum NAME for most ids, so friend-request updates, forced
logout, maintenance broadcasts, live balance and consumable updates, and
invention moderation pushes all evaporated with no error anywhere.
Notable behavioural bugs fixed:
- Editing a club announcement deleted it: PUT was a third binding on the
delete handler. Split apart, and the missing create route added.
- leaderboard/CheckAndSetStat did not exist. The 2023 client has no SetStat
literal at all, so every leaderboard score it wrote was discarded.
- Setting a club clubhouse cleared it: the handler read roomId from the query
while the client sends it in the body, then wrote the resulting null.
- Club join requests were inverted (the enum is Open=0, InviteOnly=1,
AskToJoin=2), so ask-to-join clubs rejected everyone and invite-only clubs
quietly admitted them.
- Room bans were pushed as id 24, for which the client registers no handler;
they now go out as ModerationKick with IsBan, so the ban takes effect
immediately instead of at next login.
- Room-key ownership queries answered for the wrong player, and "has anyone
bought this key" asked "did I buy it" — always false for the creator.
Also persists settings that were previously acknowledged and dropped
(playlist accessibility/restrictions/level-voting/warning, per-player status
visibility, VR movement mode and avoid-juniors, custom room codes), which is
what the new columns in the migration are for.
One test asserted the old nested PriceAndCurrency response shape; the
client's consumable formatter has no such key and reads Price and
PurchaseCurrencyId flat, so the test was corrected alongside the handler.
This commit is contained in:
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
@@ -13,6 +14,13 @@ namespace DorkNet.Server.Controllers.API.AgeVerification;
|
||||
[Authorize]
|
||||
public class AgeVerificationController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
/// <summary>The 2023-03-21 client POSTs this (verb register <c>rdx=2</c>
|
||||
/// at RecNet.Runtime/ALLHCFFLMLD.txt:216-222) and deserialises the body as
|
||||
/// a bare <c>String</c>: the issuing method is
|
||||
/// <c>FGLDKEJLAKB<IPCJLCNIBEG<System.String>> GKEBPOPAEJC()</c>
|
||||
/// (:150). Returning a JSON object here made Json.NET throw, so the
|
||||
/// promise rejected and the code was never shown in the watch.
|
||||
/// GET stays registered because it costs nothing.</summary>
|
||||
[HttpPost("generateCode")]
|
||||
[HttpGet("generateCode")]
|
||||
public async Task<IActionResult> GenerateCode()
|
||||
@@ -26,6 +34,6 @@ public class AgeVerificationController(DorkNetDbContext db) : ControllerBase
|
||||
Value = code,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Code = code, VerificationCode = code, ExpiresAt = DateTime.UtcNow.AddMinutes(15) });
|
||||
return Content(JsonSerializer.Serialize(code), "application/json");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,31 @@ namespace DorkNet.Server.Controllers.API.Comments;
|
||||
|
||||
/// <summary>
|
||||
/// Room-comment unread counts. Client contract (RecNet.Runtime
|
||||
/// <c>DJLNMANKMAA(IReadOnlyCollection<long>)</c>): the caller sends a
|
||||
/// collection of roomIds and expects a top-level JSON OBJECT
|
||||
/// <c>Dictionary<long, uint></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).
|
||||
/// <c>OPELMBNJHNO.DJLNMANKMAA(IReadOnlyCollection<long>)</c>).
|
||||
///
|
||||
/// The response is a JSON <b>ARRAY</b>, not an object. The method's return
|
||||
/// type is <c>Dictionary<long,uint></c>, but that dictionary is built
|
||||
/// client-side: OPELMBNJHNO.txt:889 constructs a
|
||||
/// <c>Func<List<UnreadRoomComments>, Dictionary<long,uint>></c>
|
||||
/// projection that runs over the deserialised payload. So the wire type is
|
||||
/// <c>List<RecNet.UnreadRoomComments></c> — each element an object with a
|
||||
/// room id (Int64) and a count (UInt32), per the two properties on
|
||||
/// RecNet/UnreadRoomComments.txt.
|
||||
///
|
||||
/// This previously returned an object map keyed by room id. Json.NET cannot
|
||||
/// read a JSON object into a <c>List<T></c>, so every call threw and the
|
||||
/// client logged "Failed to get unread room comment counts"
|
||||
/// (OPELMBNJHNO.txt:937) and fell back to an empty dictionary — the watch's
|
||||
/// room-comment unread badge never appeared.
|
||||
///
|
||||
/// The element's exact property names are attribute-driven and are not
|
||||
/// recoverable from the ISIL (see the note on key aliasing below), so each
|
||||
/// element carries the plausible spellings side by side. Json.NET matches
|
||||
/// case-insensitively and ignores unknown members, so the extra aliases are
|
||||
/// inert whichever name the DTO actually declares.
|
||||
///
|
||||
/// Counts are all zero until per-player comment read-state is tracked; the
|
||||
/// shape is what matters for the client not to throw.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
@@ -24,10 +43,14 @@ public class CommentsController : ControllerBase
|
||||
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;
|
||||
var result = roomIds
|
||||
.Select(id => new Dictionary<string, object>
|
||||
{
|
||||
["RoomId"] = id,
|
||||
["UnreadCount"] = 0u,
|
||||
["Count"] = 0u,
|
||||
})
|
||||
.ToList();
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,19 @@ namespace DorkNet.Server.Controllers.API.Compatibility;
|
||||
[ApiController]
|
||||
public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
/// <summary>The 2023-03-21 client GETs this (verb register <c>rdx=0</c>
|
||||
/// at RecNet.Runtime/FPIBGPIAOBI.txt:2984, immediately before the
|
||||
/// <c>"api/banappeal/generateCode"</c> literal on :2980) and deserialises
|
||||
/// the response as a bare <c>String</c> — the issuing method is
|
||||
/// <c>FGLDKEJLAKB<System.String> HBEPHMAKCDN()</c> (:2759) and the
|
||||
/// continuation is <c>Func<IPCJLCNIBEG<String>,
|
||||
/// FGLDKEJLAKB<String>></c> (:3019).
|
||||
///
|
||||
/// It used to be POST-only returning <c>{value: code}</c>, so the client
|
||||
/// got a 405 and, had it reached the handler, would have thrown parsing a
|
||||
/// JSON object into a string. Ban-appeal code generation never worked.
|
||||
/// The body is now the bare JSON string (<c>"BA-123456"</c>).</summary>
|
||||
[HttpGet("api/banappeal/generateCode")]
|
||||
[HttpPost("api/banappeal/generateCode")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> GenerateBanAppealCode()
|
||||
@@ -25,7 +38,7 @@ public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBas
|
||||
Value = code,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { value = code });
|
||||
return Content(JsonSerializer.Serialize(code), "application/json");
|
||||
}
|
||||
|
||||
[HttpPost("api/CampusCard/v1/UpdateAndGetSubscription")]
|
||||
@@ -110,7 +123,7 @@ public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBas
|
||||
|
||||
// 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();
|
||||
var message = $"[club {club.Id}] {req.Details ?? req.Message ?? string.Empty}".Trim();
|
||||
if (message.Length > 1000) message = message[..1000];
|
||||
db.Reports.Add(new ReportEntity
|
||||
{
|
||||
@@ -155,7 +168,13 @@ public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBas
|
||||
?? form["ClubId"].FirstOrDefault(), out var clubId) ? clubId : 0,
|
||||
ReportCategory = int.TryParse(form["reportCategory"].FirstOrDefault()
|
||||
?? form["ReportCategory"].FirstOrDefault(), out var cat) ? cat : 5,
|
||||
Message = form["message"].FirstOrDefault() ?? form["Message"].FirstOrDefault(),
|
||||
// The 2023 client names the free-text field "details"
|
||||
// (IKMMOCKDKAF.txt:25303-25322), not "message" — reading only
|
||||
// the latter stored every club report with an empty body.
|
||||
Message = form["details"].FirstOrDefault()
|
||||
?? form["Details"].FirstOrDefault()
|
||||
?? form["message"].FirstOrDefault()
|
||||
?? form["Message"].FirstOrDefault(),
|
||||
};
|
||||
}
|
||||
try
|
||||
@@ -178,5 +197,7 @@ public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBas
|
||||
public long ClubId { get; set; }
|
||||
public int ReportCategory { get; set; } = 5;
|
||||
public string? Message { get; set; }
|
||||
/// <summary>The client's spelling of <see cref="Message"/>.</summary>
|
||||
public string? Details { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,14 +121,18 @@ public class CustomAvatarItemsController(
|
||||
skip = Math.Max(0, skip);
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
var currentPlayerId = this.CurrentPlayerId();
|
||||
var rows = await db.CustomAvatarItems
|
||||
var query = db.CustomAvatarItems
|
||||
.Where(i => i.CreatorPlayerId == creatorId
|
||||
&& (i.IsPublic || i.CreatorPlayerId == currentPlayerId))
|
||||
&& (i.IsPublic || i.CreatorPlayerId == currentPlayerId));
|
||||
var total = await query.CountAsync();
|
||||
var rows = await query
|
||||
.OrderByDescending(i => i.UpdatedAt)
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
// Paged container, not a bare array — the client deserializes this into
|
||||
// CKFLFJCNEPH {Results, TotalResults}, same as Owned() below.
|
||||
return Ok(new { Results = rows.Select(ToWire), TotalResults = total });
|
||||
}
|
||||
|
||||
[HttpGet("me")]
|
||||
@@ -203,7 +207,11 @@ public class CustomAvatarItemsController(
|
||||
var allowed = player is not null
|
||||
&& !player.IsJunior
|
||||
&& (player.BannedUntil is null || player.BannedUntil <= DateTime.UtcNow);
|
||||
return Ok(new { IsCreationAllowed = allowed, Allowed = allowed });
|
||||
// Bare JSON boolean, like the two sibling gates below: the client's
|
||||
// issuing method is FGLDKEJLAKB<System.Boolean> GGMDLMLJDDK()
|
||||
// (RecNet.Runtime/MPBLNLMCEDL.txt:6048, route literal on :6205), so an
|
||||
// object body threw and the can-create gate blocked shirt creation.
|
||||
return Content(allowed ? "true" : "false", "application/json");
|
||||
}
|
||||
|
||||
[HttpGet("isCreationEnabled")]
|
||||
@@ -262,6 +270,13 @@ public class CustomAvatarItemsController(
|
||||
return Ok(ToWire(item));
|
||||
}
|
||||
|
||||
/// <summary>The 2023-03-21 client buys custom items through the
|
||||
/// <c>econ/</c> host prefix — <c>econ/customAvatarItems/v1/{id}/purchase</c>
|
||||
/// — which was never registered, so every custom-shirt purchase 404'd. The
|
||||
/// rooted route below is an alias onto the same handler. <c>requestedPrice</c>
|
||||
/// arrives as a query param and is validated against the item's real price
|
||||
/// so a tampered client can't buy at its own number.</summary>
|
||||
[HttpPost("/econ/customAvatarItems/v1/{id:guid}/purchase")]
|
||||
[HttpPost("{id:guid}/purchase")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Purchase(Guid id)
|
||||
|
||||
+26
-20
@@ -43,37 +43,43 @@ public class ExternalFriendInviteController(DorkNetDbContext db) : ControllerBas
|
||||
return Ok(new { Success = true, InviteCode = code, PhoneNumber = phoneNumber ?? string.Empty });
|
||||
}
|
||||
|
||||
/// <summary>Accounts who joined via this player's platform invites.
|
||||
///
|
||||
/// The response is a bare array of account ids — the issuing method is
|
||||
/// <c>FGLDKEJLAKB<List<System.Int32>> POJMMCCLNCB()</c>
|
||||
/// (RecNet.Runtime/OMHDDLFIPLP.txt:923). Returning objects
|
||||
/// (<c>{InviteCode, Kind, Value, CreatedAt}</c>) made Json.NET throw as soon
|
||||
/// as any referrer existed, so the referral screen broke for exactly the
|
||||
/// players who had referred someone.</summary>
|
||||
[HttpGet("api/externalfriendinvite/v1/getplatformreferrers")]
|
||||
[HttpPost("api/externalfriendinvite/v1/getplatformreferrers")]
|
||||
public async Task<IActionResult> GetPlatformReferrers()
|
||||
{
|
||||
var rows = await db.PlayerSettings
|
||||
.Where(s => s.PlayerId == Me && s.Key.StartsWith("externalinvite:platform:"))
|
||||
.OrderByDescending(s => s.Id)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToInviteWire));
|
||||
}
|
||||
=> Ok(await ReferrerIdsAsync("externalinvite:platform:"));
|
||||
|
||||
/// <summary>Same bare-id-array contract as
|
||||
/// <see cref="GetPlatformReferrers"/>, for text-message invites.</summary>
|
||||
[HttpGet("api/externalfriendinvite/v1/gettextmessagereferrers")]
|
||||
[HttpPost("api/externalfriendinvite/v1/gettextmessagereferrers")]
|
||||
public async Task<IActionResult> GetTextMessageReferrers()
|
||||
=> Ok(await ReferrerIdsAsync("externalinvite:text:"));
|
||||
|
||||
/// <summary>Account ids recorded against the caller's invites of the given
|
||||
/// kind. The redeeming account id is stored as the second pipe-delimited
|
||||
/// field once an invite is claimed; unclaimed invites contribute nothing.</summary>
|
||||
private async Task<List<int>> ReferrerIdsAsync(string keyPrefix)
|
||||
{
|
||||
var rows = await db.PlayerSettings
|
||||
.Where(s => s.PlayerId == Me && s.Key.StartsWith("externalinvite:text:"))
|
||||
.Where(s => s.PlayerId == Me && s.Key.StartsWith(keyPrefix))
|
||||
.OrderByDescending(s => s.Id)
|
||||
.Select(s => s.Value)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToInviteWire));
|
||||
|
||||
return rows
|
||||
.Select(v => v.Split('|'))
|
||||
.Select(parts => int.TryParse(parts.ElementAtOrDefault(1), out var id) ? id : 0)
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static object ToInviteWire(PlayerSettingEntity row)
|
||||
{
|
||||
var parts = row.Value.Split('|');
|
||||
return new
|
||||
{
|
||||
InviteCode = row.Key.Split(':').Last(),
|
||||
Kind = row.Key.Contains(":text:") ? "text" : "platform",
|
||||
Value = parts.FirstOrDefault() ?? string.Empty,
|
||||
CreatedAt = parts.LastOrDefault() ?? string.Empty,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -618,6 +618,16 @@ public class ImagesController(
|
||||
public long ImageId { get; set; }
|
||||
public string? ImageName { get; set; }
|
||||
public bool IsPublic { get; set; }
|
||||
|
||||
/// <summary>What the 2023-03-21 client actually sends:
|
||||
/// 0 = Private, 1 = Public, 2 = FriendsOnly. It never sends
|
||||
/// <see cref="IsPublic"/>, so reading only that flag left every photo
|
||||
/// private no matter what the player picked.</summary>
|
||||
public int? Accessibility { get; set; }
|
||||
|
||||
/// <summary>Resolved visibility: the enum when present, else the
|
||||
/// legacy boolean.</summary>
|
||||
public bool ResolvedIsPublic => Accessibility is int a ? a == 1 : IsPublic;
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/images/v1/modifyaccessibility</c> — flip
|
||||
@@ -630,7 +640,8 @@ public class ImagesController(
|
||||
[FromForm(Name = "PhotoId")] long? photoIdForm,
|
||||
[FromForm(Name = "ImageId")] long? imageIdForm,
|
||||
[FromForm(Name = "ImageName")] string? imageNameForm,
|
||||
[FromForm(Name = "IsPublic")] bool? isPublicForm)
|
||||
[FromForm(Name = "IsPublic")] bool? isPublicForm,
|
||||
[FromForm(Name = "Accessibility")] int? accessibilityForm)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var photoId = body?.PhotoId ?? body?.ImageId
|
||||
@@ -638,7 +649,12 @@ public class ImagesController(
|
||||
var imageName = body?.ImageName ?? imageNameForm;
|
||||
if (photoId <= 0 && string.IsNullOrWhiteSpace(imageName))
|
||||
return BadRequest(new { error = "missing photoId" });
|
||||
var isPublic = body?.IsPublic ?? isPublicForm ?? true;
|
||||
// Accessibility (0=Private, 1=Public, 2=FriendsOnly) is what the 2023
|
||||
// client sends; IsPublic is the older boolean.
|
||||
var accessibility = body?.Accessibility ?? accessibilityForm;
|
||||
var isPublic = accessibility is int a
|
||||
? a == 1
|
||||
: body?.IsPublic ?? isPublicForm ?? true;
|
||||
|
||||
var photo = photoId > 0
|
||||
? await db.Photos.FirstOrDefaultAsync(p => p.Id == photoId)
|
||||
|
||||
@@ -504,9 +504,25 @@ public class InventionsController(
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>The 2023-03-21 client names the blob field
|
||||
/// <c>inventionDataFilename</c> and sends four extra cost fields plus the
|
||||
/// creation room and referenced inventions. The record only had
|
||||
/// <c>BlobName</c>, which the client never sends, so the required-parameter
|
||||
/// check rejected every publish with 400 and no new invention version could
|
||||
/// ever be saved.</summary>
|
||||
public sealed record AddVersionRequest(
|
||||
long InventionId, string BlobName,
|
||||
int? InstantiationCost, int? LightsCost);
|
||||
long InventionId,
|
||||
string? BlobName,
|
||||
string? InventionDataFilename,
|
||||
int? InstantiationCost, int? LightsCost,
|
||||
int? ChipsCost, int? CloudVariablesCost, int? AiCost,
|
||||
long? CreationRoomId,
|
||||
List<long>? ReferencedInventions)
|
||||
{
|
||||
public string? Blob => !string.IsNullOrWhiteSpace(InventionDataFilename)
|
||||
? InventionDataFilename
|
||||
: BlobName;
|
||||
}
|
||||
|
||||
[HttpPost("api/inventions/v3/addversion")]
|
||||
[HttpPost("api/inventions/v4/addversion")]
|
||||
@@ -517,10 +533,11 @@ public class InventionsController(
|
||||
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted);
|
||||
if (inv is null) return NotFound();
|
||||
if (inv.CreatorPlayerId != pid) return Forbid();
|
||||
if (string.IsNullOrWhiteSpace(req.BlobName)) return BadRequest("missing BlobName");
|
||||
var blobName = req.Blob;
|
||||
if (string.IsNullOrWhiteSpace(blobName)) return BadRequest("missing inventionDataFilename");
|
||||
|
||||
var nextVer = inv.CurrentVersionNumber + 1;
|
||||
inv.CurrentBlobName = req.BlobName;
|
||||
inv.CurrentBlobName = blobName;
|
||||
inv.CurrentVersionNumber = nextVer;
|
||||
inv.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
@@ -529,7 +546,7 @@ public class InventionsController(
|
||||
InventionId = inv.Id,
|
||||
ReplicationId = Guid.NewGuid().ToString("D"),
|
||||
VersionNumber = nextVer,
|
||||
BlobName = req.BlobName,
|
||||
BlobName = blobName,
|
||||
InstantiationCost = req.InstantiationCost ?? 0,
|
||||
LightsCost = req.LightsCost ?? 0,
|
||||
});
|
||||
@@ -664,7 +681,15 @@ public class InventionsController(
|
||||
|
||||
// ── POST mutations ───────────────────────────────────────────────────
|
||||
|
||||
public sealed record SetTagsRequest(long InventionId, string? AutoTags, string? PlayerAddedTags);
|
||||
/// <summary>The client sends <c>AutoTags</c> and <c>CustomTags</c> as JSON
|
||||
/// ARRAYS of strings. Binding them to <c>string</c> made deserialization
|
||||
/// throw, so every tag edit 400'd. <c>PlayerAddedTags</c> is kept as an
|
||||
/// alias for older callers.</summary>
|
||||
public sealed record SetTagsRequest(
|
||||
long InventionId,
|
||||
List<string>? AutoTags,
|
||||
List<string>? CustomTags,
|
||||
List<string>? PlayerAddedTags);
|
||||
|
||||
[HttpPost("api/inventions/v1/settags")]
|
||||
[Authorize]
|
||||
@@ -674,8 +699,12 @@ public class InventionsController(
|
||||
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted);
|
||||
if (inv is null) return NotFound();
|
||||
if (inv.CreatorPlayerId != pid) return Forbid();
|
||||
var combined = string.Join(',', new[] { req.AutoTags, req.PlayerAddedTags }
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))).Trim(',');
|
||||
var combined = string.Join(',', new[] { req.AutoTags, req.CustomTags, req.PlayerAddedTags }
|
||||
.Where(list => list is not null)
|
||||
.SelectMany(list => list!)
|
||||
.Select(t => t?.Trim())
|
||||
.Where(t => !string.IsNullOrWhiteSpace(t))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase));
|
||||
inv.TagsCsv = combined;
|
||||
inv.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -78,8 +79,85 @@ public class InventoryController(
|
||||
/// <summary>POST <c>api/equipment/v1/update</c> — toggle the
|
||||
/// "favorited" flag on a piece of equipment (the watch lets the
|
||||
/// player pin commonly-used tools).</summary>
|
||||
/// <remarks>The 2023-03-21 client sends a JSON <b>array</b> of equipment
|
||||
/// updates here, not a single object (it serialises a
|
||||
/// <c>List<BCINJINBBHG></c>). Binding <c>[FromBody]</c> onto one
|
||||
/// object failed model binding and returned an automatic 400, so equip and
|
||||
/// favourite changes never persisted for that build. The body is now read
|
||||
/// leniently and both shapes are applied through the same per-item path.
|
||||
/// </remarks>
|
||||
[HttpPost("api/equipment/v1/update")]
|
||||
public async Task<IActionResult> UpdateEquipment([FromBody] EquipmentUpdateRequest req)
|
||||
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> UpdateEquipment()
|
||||
{
|
||||
var requests = await ReadEquipmentUpdatesAsync();
|
||||
if (requests.Count == 0) return BadRequest("missing prefab/mod identifier");
|
||||
|
||||
IActionResult last = Ok();
|
||||
foreach (var req in requests)
|
||||
{
|
||||
last = await ApplyEquipmentUpdateAsync(req);
|
||||
// Surface the first rejection rather than pretending the whole
|
||||
// batch succeeded.
|
||||
if (last is not OkObjectResult and not OkResult) return last;
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
/// <summary>Accepts a single object, an array of objects, or a form post.</summary>
|
||||
private async Task<List<EquipmentUpdateRequest>> ReadEquipmentUpdatesAsync()
|
||||
{
|
||||
var opts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||
var result = new List<EquipmentUpdateRequest>();
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
string? S(params string[] keys)
|
||||
{
|
||||
foreach (var k in keys)
|
||||
if (form.TryGetValue(k, out var v) && !string.IsNullOrEmpty(v)) return v.ToString();
|
||||
return null;
|
||||
}
|
||||
bool? B(params string[] keys)
|
||||
=> bool.TryParse(S(keys), out var b) ? b : null;
|
||||
result.Add(new EquipmentUpdateRequest
|
||||
{
|
||||
PrefabName = S("PrefabName", "prefabName"),
|
||||
ModificationGuid = S("ModificationGuid", "modificationGuid"),
|
||||
Favorited = B("Favorited", "favorited") ?? false,
|
||||
Equipped = B("Equipped", "equipped"),
|
||||
IsEquipped = B("IsEquipped", "isEquipped"),
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Request.EnableBuffering();
|
||||
Request.Body.Position = 0;
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
Request.Body.Position = 0;
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var el in root.EnumerateArray())
|
||||
{
|
||||
var one = el.Deserialize<EquipmentUpdateRequest>(opts);
|
||||
if (one is not null) result.Add(one);
|
||||
}
|
||||
}
|
||||
else if (root.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var one = root.Deserialize<EquipmentUpdateRequest>(opts);
|
||||
if (one is not null) result.Add(one);
|
||||
}
|
||||
}
|
||||
catch (JsonException) { /* unparseable body → treated as empty */ }
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<IActionResult> ApplyEquipmentUpdateAsync(EquipmentUpdateRequest req)
|
||||
{
|
||||
var pid = Me;
|
||||
// Catalog-backed skins use a compound slug so we can round-trip
|
||||
@@ -209,16 +287,28 @@ public class InventoryController(
|
||||
return Ok(new { row.Id, Count = row.Quantity });
|
||||
}
|
||||
|
||||
/// <remarks>The 2023-03-21 client serialises a <c>TransferConsumableRequest</c>
|
||||
/// with <c>JsonUtility.ToJson</c> and posts it as a raw JSON body. With
|
||||
/// <c>[ApiController]</c> plus <c>[FromForm]</c> parameters, an
|
||||
/// <c>application/json</c> request bound nothing and the transfer was
|
||||
/// rejected as invalid, so consumable gifting never worked on that build.
|
||||
/// The JSON body is now read alongside the original form/query paths.</remarks>
|
||||
[HttpPost("api/consumables/v1/transfer")]
|
||||
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> TransferConsumable(
|
||||
[FromForm] long? id,
|
||||
[FromForm] long? recipientPlayerId,
|
||||
[FromForm] int quantity = 1)
|
||||
{
|
||||
var pid = Me;
|
||||
var inventoryId = id ?? (long.TryParse(Request.Query["id"], out var qId) ? qId : 0);
|
||||
var recipient = recipientPlayerId ?? (long.TryParse(Request.Query["recipientPlayerId"], out var qRecipient) ? qRecipient : 0);
|
||||
var amount = Math.Max(1, quantity);
|
||||
var body = await ReadTransferBodyAsync();
|
||||
var inventoryId = id
|
||||
?? body.Id
|
||||
?? (long.TryParse(Request.Query["id"], out var qId) ? qId : 0);
|
||||
var recipient = recipientPlayerId
|
||||
?? body.RecipientPlayerId
|
||||
?? (long.TryParse(Request.Query["recipientPlayerId"], out var qRecipient) ? qRecipient : 0);
|
||||
var amount = Math.Max(1, body.Quantity ?? quantity);
|
||||
if (inventoryId <= 0 || recipient <= 0 || recipient == pid)
|
||||
return BadRequest("invalid_transfer");
|
||||
|
||||
@@ -247,6 +337,69 @@ public class InventoryController(
|
||||
return Ok(new { Success = true, SourceCount = Math.Max(0, source.Quantity), RecipientCount = target.Quantity });
|
||||
}
|
||||
|
||||
private readonly record struct TransferBody(long? Id, long? RecipientPlayerId, int? Quantity);
|
||||
|
||||
/// <summary>Reads the 2023 client's raw JSON transfer body. JsonUtility
|
||||
/// emits field names verbatim, and the exact casing of that DTO is not
|
||||
/// recoverable from the ISIL, so both conventions are accepted.</summary>
|
||||
private async Task<TransferBody> ReadTransferBodyAsync()
|
||||
{
|
||||
if (Request.HasFormContentType) return default;
|
||||
try
|
||||
{
|
||||
Request.EnableBuffering();
|
||||
Request.Body.Position = 0;
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
Request.Body.Position = 0;
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return default;
|
||||
|
||||
long? L(params string[] keys)
|
||||
{
|
||||
foreach (var k in keys)
|
||||
if (doc.RootElement.TryGetProperty(k, out var e) &&
|
||||
e.ValueKind == JsonValueKind.Number && e.TryGetInt64(out var v)) return v;
|
||||
return null;
|
||||
}
|
||||
int? I(params string[] keys)
|
||||
{
|
||||
foreach (var k in keys)
|
||||
if (doc.RootElement.TryGetProperty(k, out var e) &&
|
||||
e.ValueKind == JsonValueKind.Number && e.TryGetInt32(out var v)) return v;
|
||||
return null;
|
||||
}
|
||||
return new TransferBody(
|
||||
L("Id", "id", "InventoryId", "inventoryId"),
|
||||
L("RecipientPlayerId", "recipientPlayerId"),
|
||||
I("Quantity", "quantity", "Count", "count"));
|
||||
}
|
||||
catch (JsonException) { return default; }
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/consumables/v1/getTransferable/{playerId}</c> — the
|
||||
/// consumables the caller may gift to <c>playerId</c>. No handler existed,
|
||||
/// so the transfer UI 404'd and could never list anything to send.
|
||||
/// Returns the same grouped shape as <c>v2/getUnlocked</c>.</summary>
|
||||
[HttpGet("api/consumables/v1/getTransferable/{playerId:long}")]
|
||||
public async Task<IActionResult> GetTransferableConsumables(long playerId)
|
||||
{
|
||||
var pid = Me;
|
||||
var rows = await db.PlayerInventory
|
||||
.Where(p => p.PlayerId == pid && p.Quantity > 0)
|
||||
.ToListAsync();
|
||||
|
||||
// Can't gift to yourself; everything else the caller holds is fair game.
|
||||
var transferable = playerId == pid ? [] : rows;
|
||||
return Ok(transferable
|
||||
.GroupBy(r => r.ItemSlug)
|
||||
.Select(g => new
|
||||
{
|
||||
Consumable = g.Key,
|
||||
Count = g.Sum(r => r.Quantity),
|
||||
Id = g.Min(r => r.Id),
|
||||
})
|
||||
.ToList());
|
||||
}
|
||||
|
||||
public sealed class ActivateConsumableRequest
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
@@ -131,6 +131,106 @@ public class LeaderboardController(
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/leaderboard/CheckAndSetStat</c> — the compare-and-set
|
||||
/// form of <see cref="SetStat"/>, and the ONLY stat-write route the
|
||||
/// 2023-03-21 client has: that build contains no <c>SetStat</c> literal at
|
||||
/// all. Nothing registered this route, so every leaderboard score written
|
||||
/// by a 2023 client fell through to the catch-all and was lost.
|
||||
///
|
||||
/// The request is <c>RecNet.SetStatRequestDTO</c>, whose property names are
|
||||
/// NOT obfuscated in the dump: <c>StatChannel</c> (Int32), <c>RoomId</c>
|
||||
/// (Int64), <c>StatValue</c> (Int32) and the extra
|
||||
/// <c>CurrentStatValue</c> (Int32?) that distinguishes this route from
|
||||
/// plain SetStat.
|
||||
///
|
||||
/// <c>CurrentStatValue</c> is the value the client believes is currently
|
||||
/// stored. It is an optimistic-concurrency guard: the write applies only if
|
||||
/// the server agrees, which stops a stale session from clobbering a newer
|
||||
/// score. A null means "no expectation" and writes unconditionally, and a
|
||||
/// mismatch is reported rather than silently dropped so the client can
|
||||
/// re-read.
|
||||
///
|
||||
/// As with SetStat, the client has already decided the new value should win
|
||||
/// (it applies the per-channel higher/lower-is-better rule locally), so the
|
||||
/// server does not second-guess the direction.</summary>
|
||||
[HttpPost("/leaderboard/CheckAndSetStat")]
|
||||
[HttpPost("/api/leaderboard/CheckAndSetStat")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> CheckAndSetStat([FromBody] CheckAndSetStatRequest req)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var roomId = ResolveRoomId(req.RoomId, pid);
|
||||
var row = await db.LeaderboardStats.FirstOrDefaultAsync(s =>
|
||||
s.RoomId == roomId && s.PlayerId == pid && s.StatChannel == req.StatChannel);
|
||||
|
||||
if (req.CurrentStatValue is int expected && row is not null && row.Value != expected)
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
success = false,
|
||||
error = "stat_value_mismatch",
|
||||
StatValue = row.Value,
|
||||
CurrentStatValue = row.Value,
|
||||
});
|
||||
}
|
||||
|
||||
if (row is null)
|
||||
{
|
||||
row = new LeaderboardStatEntity
|
||||
{
|
||||
PlayerId = pid,
|
||||
RoomId = roomId,
|
||||
StatChannel = req.StatChannel,
|
||||
Value = req.StatValue,
|
||||
};
|
||||
db.LeaderboardStats.Add(row);
|
||||
}
|
||||
else
|
||||
{
|
||||
row.Value = req.StatValue;
|
||||
row.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
await EnsureChannelMetaAsync(roomId, req.StatChannel);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
error = "",
|
||||
StatValue = row.Value,
|
||||
CurrentStatValue = row.Value,
|
||||
});
|
||||
}
|
||||
|
||||
public sealed class CheckAndSetStatRequest
|
||||
{
|
||||
public int StatChannel { get; set; }
|
||||
public long RoomId { get; set; }
|
||||
public int StatValue { get; set; }
|
||||
|
||||
/// <summary>The value the client believes is stored. Null = write
|
||||
/// unconditionally.</summary>
|
||||
public int? CurrentStatValue { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Stamp a placeholder channel-meta row the first time a channel
|
||||
/// reports, so the admin SPA's per-room Leaderboards tab surfaces it.</summary>
|
||||
private async Task EnsureChannelMetaAsync(long roomId, int statChannel)
|
||||
{
|
||||
if (roomId <= 0) return;
|
||||
var meta = await db.LeaderboardChannelMeta
|
||||
.FirstOrDefaultAsync(c => c.RoomId == roomId && c.Channel == statChannel);
|
||||
if (meta is not null) return;
|
||||
db.LeaderboardChannelMeta.Add(new LeaderboardChannelMetaEntity
|
||||
{
|
||||
Channel = statChannel,
|
||||
RoomId = roomId,
|
||||
Name = $"Channel {statChannel}",
|
||||
LowerIsBetter = false,
|
||||
ValueFormat = "count",
|
||||
});
|
||||
}
|
||||
|
||||
public sealed class GetPlayerRankRequest
|
||||
{
|
||||
public int PlayerId { get; set; }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -105,9 +106,23 @@ public class InGameModerationController(
|
||||
|
||||
// ── /v1/instantKick — admin one-shot ─────────────────────────────
|
||||
/// <summary>
|
||||
/// Admin/dev one-shot kick. The watch posts either
|
||||
/// Admin/dev one-shot kick. The 2020.12 watch posts either
|
||||
/// <c>?playerId=N</c> for a single target (PlayerReporting.txt:3417)
|
||||
/// or a list body for bulk kicks (3590). Either form lands here.
|
||||
/// or a list body for bulk kicks (3590).
|
||||
///
|
||||
/// The 2023-03-21 client posts neither: it serialises a
|
||||
/// <c>RecNet.KickPlayerDTO</c> with <c>JsonUtility.ToJson</c> and sends it
|
||||
/// as a RAW JSON body (RecNet.Runtime/FPIBGPIAOBI.txt:5012 JsonUtility.ToJson,
|
||||
/// :5018 BNDIAONDFFF.FJLLPHFOOJJ raw-body setter, :5034 verb rdx=2).
|
||||
/// KickPlayerDTO's fields are not obfuscated — <c>public long GameSessionId</c>
|
||||
/// and <c>public List<int> PlayerIds</c> — so the body is exactly
|
||||
/// <c>{"GameSessionId":123,"PlayerIds":[456]}</c>. Unity's JsonUtility emits
|
||||
/// field names verbatim.
|
||||
///
|
||||
/// None of the query/form parameters bound against that body, so the target
|
||||
/// list came back empty and every 2023 instant-kick returned
|
||||
/// "missing_target" and kicked nobody.
|
||||
///
|
||||
/// Gated to <see cref="PlayerEntity.IsAdmin"/> /
|
||||
/// <see cref="PlayerEntity.IsDeveloper"/>.
|
||||
/// </summary>
|
||||
@@ -130,6 +145,7 @@ public class InGameModerationController(
|
||||
foreach (var part in formIds.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
if (long.TryParse(part, out var v) && v > 0) ids.Add(v);
|
||||
}
|
||||
ids.AddRange(await ReadKickPlayerDtoIdsAsync());
|
||||
ids = ids.Distinct().ToList();
|
||||
if (ids.Count == 0) return Ok(new { success = false, error = "missing_target" });
|
||||
|
||||
@@ -143,6 +159,47 @@ public class InGameModerationController(
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
/// <summary>Read the 2023 client's raw <c>KickPlayerDTO</c> JSON body.
|
||||
/// Returns an empty list for a form post or an unparseable body so the
|
||||
/// older query/form paths are unaffected.</summary>
|
||||
private async Task<List<long>> ReadKickPlayerDtoIdsAsync()
|
||||
{
|
||||
var ids = new List<long>();
|
||||
if (Request.HasFormContentType) return ids;
|
||||
|
||||
try
|
||||
{
|
||||
Request.EnableBuffering();
|
||||
Request.Body.Position = 0;
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
Request.Body.Position = 0;
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return ids;
|
||||
|
||||
foreach (var name in new[] { "PlayerIds", "playerIds" })
|
||||
{
|
||||
if (!doc.RootElement.TryGetProperty(name, out var arr) ||
|
||||
arr.ValueKind != JsonValueKind.Array) continue;
|
||||
foreach (var el in arr.EnumerateArray())
|
||||
if (el.ValueKind == JsonValueKind.Number && el.TryGetInt64(out var v) && v > 0)
|
||||
ids.Add(v);
|
||||
break;
|
||||
}
|
||||
|
||||
foreach (var name in new[] { "PlayerId", "playerId" })
|
||||
{
|
||||
if (doc.RootElement.TryGetProperty(name, out var one) &&
|
||||
one.ValueKind == JsonValueKind.Number &&
|
||||
one.TryGetInt64(out var v) && v > 0)
|
||||
{
|
||||
ids.Add(v);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException) { /* not a JSON body */ }
|
||||
return ids;
|
||||
}
|
||||
|
||||
// ── /v1/deviceId — log mismatched device id ──────────────────────
|
||||
[HttpPost("api/PlayerReporting/v1/deviceId")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
@@ -192,11 +249,21 @@ public class InGameModerationController(
|
||||
"[mod] ban by {Caller}: target={Target} reason={Reason} until={Until} display={Display}",
|
||||
caller, target, reason, p.BannedUntil, displayReason);
|
||||
|
||||
// Push a ModerationRoomBan notification so the target watch
|
||||
// shows the ban dialog and disconnects (PlayerReporting.txt:2253).
|
||||
await notifications.NotifyAsync(
|
||||
// Push the ban as a ModerationKick (22) with IsBan set, NOT as
|
||||
// ModerationRoomBan (24): the 2023-03-21 client's moderation manager
|
||||
// registers only 22, "ModerationUnkick" and 23
|
||||
// (RecNet.Runtime/FPIBGPIAOBI.txt:310, :323, :335) — there is no handler
|
||||
// for 24, so that push landed nowhere and the banned player kept playing
|
||||
// until their next login. The 22 handler reads Msg.Reason as a
|
||||
// ModerationBlockDetail, which ModerationKickPayload already produces.
|
||||
await notifications.KickPlayerAsync(
|
||||
target,
|
||||
Models.Notification.PushNotificationId.ModerationRoomBan);
|
||||
// `reason` is the numeric report category; DisplayReason is the
|
||||
// moderator's text and is what the ban dialog shows.
|
||||
string.IsNullOrWhiteSpace(displayReason)
|
||||
? "You have been banned from this room."
|
||||
: displayReason.Trim(),
|
||||
isBan: true);
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
|
||||
@@ -24,19 +24,99 @@ public class PlayerWarningsController(DorkNetDbContext db) : ControllerBase
|
||||
.ToList());
|
||||
}
|
||||
|
||||
/// <summary>Moderator issues a warning against a player. The 2023-03-21
|
||||
/// client POSTs this (verb register <c>rdx=2</c> at
|
||||
/// RecNet.Runtime/FPIBGPIAOBI.txt:3455) with the form keys
|
||||
/// <c>WarnedPlayerId</c>, <c>ReportCategory</c>, <c>DisplayReason</c> and
|
||||
/// <c>ModeratorNote</c> (:3468-3520), matching the issuing method
|
||||
/// <c>FGLDKEJLAKB<DJMHAFPGLLN> OEJIMKKDMBI(Int32, BDOGOIGCKMK,
|
||||
/// String, String)</c> (:3102).
|
||||
///
|
||||
/// Only GET was registered here, so every warning the moderator tools
|
||||
/// tried to create came back 405 and silently failed.</summary>
|
||||
[HttpPost("api/playerwarnings")]
|
||||
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> CreateWarning(
|
||||
[FromForm(Name = "WarnedPlayerId")] long? warnedPlayerId,
|
||||
[FromForm(Name = "ReportCategory")] int? reportCategory,
|
||||
[FromForm(Name = "DisplayReason")] string? displayReason,
|
||||
[FromForm(Name = "ModeratorNote")] string? moderatorNote)
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
|
||||
var moderator = await db.Players
|
||||
.Where(p => p.Id == me)
|
||||
.Select(p => (bool?)(p.IsAdmin || p.IsDeveloper || p.IsCommunityTeam))
|
||||
.FirstOrDefaultAsync();
|
||||
if (moderator != true)
|
||||
return Ok(new { Success = false, Message = "not_authorized" });
|
||||
|
||||
var target = warnedPlayerId
|
||||
?? (long.TryParse(Request.Query["WarnedPlayerId"].FirstOrDefault(), out var q) ? q : 0);
|
||||
if (target <= 0) return Ok(new { Success = false, Message = "missing_player" });
|
||||
if (!await db.Players.AnyAsync(p => p.Id == target))
|
||||
return Ok(new { Success = false, Message = "player_not_found" });
|
||||
|
||||
// Same "warning:{id}|message|category|createdAt|acknowledged" layout the
|
||||
// GET list handler parses in ToWarningWire.
|
||||
var reason = (displayReason ?? string.Empty).Trim();
|
||||
var note = (moderatorNote ?? string.Empty).Trim();
|
||||
var message = string.IsNullOrEmpty(note) ? reason : $"{reason} ({note})";
|
||||
var id = DateTime.UtcNow.ToString("yyyyMMddHHmmssfff");
|
||||
|
||||
db.PlayerSettings.Add(new DorkNet.Server.Data.Entities.PlayerSettingEntity
|
||||
{
|
||||
PlayerId = target,
|
||||
Key = $"warning:{id}",
|
||||
Value = string.Join('|',
|
||||
message.Replace('|', '/'),
|
||||
(reportCategory ?? 0).ToString(),
|
||||
DateTime.UtcNow.ToString("O"),
|
||||
"false"),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, Message = string.Empty, WarningId = id });
|
||||
}
|
||||
|
||||
/// <summary>The 2023-03-21 client's acknowledge call takes NO arguments —
|
||||
/// the issuing method is <c>FGLDKEJLAKB<DJMHAFPGLLN> DOFADJPFBMK()</c>
|
||||
/// (FPIBGPIAOBI.txt:3524) and it POSTs an empty body (:3590-3592). It means
|
||||
/// "acknowledge my pending warning".
|
||||
///
|
||||
/// Demanding a warningId made every call 400, so the modal was never marked
|
||||
/// read and re-appeared on each login. With no id supplied we now
|
||||
/// acknowledge the newest unacknowledged warning, and acknowledging nothing
|
||||
/// is a success rather than an error.</summary>
|
||||
[HttpPost("api/playerwarnings/acknowledge")]
|
||||
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> Acknowledge([FromForm] string? warningId)
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var id = warningId ?? Request.Query["warningId"].FirstOrDefault() ?? Request.Query["id"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(id)) return BadRequest("missing_warning");
|
||||
var row = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == $"warning:{id}");
|
||||
if (row is null) return NotFound();
|
||||
|
||||
DorkNet.Server.Data.Entities.PlayerSettingEntity? row;
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
// Acknowledged state lives inside the packed Value string, so the
|
||||
// filter has to run client-side after materialising.
|
||||
var rows = await db.PlayerSettings
|
||||
.Where(s => s.PlayerId == me && s.Key.StartsWith("warning:"))
|
||||
.OrderByDescending(s => s.Id)
|
||||
.ToListAsync();
|
||||
row = rows.FirstOrDefault(s => !ToWarningWire(s).Acknowledged);
|
||||
if (row is null) return Ok(new { Success = true, Message = string.Empty });
|
||||
}
|
||||
else
|
||||
{
|
||||
row = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == $"warning:{id}");
|
||||
if (row is null) return Ok(new { Success = true, Message = string.Empty });
|
||||
}
|
||||
|
||||
var parts = row.Value.Split('|');
|
||||
row.Value = string.Join('|', parts.Take(3).Append("true"));
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, WarningId = id });
|
||||
return Ok(new { Success = true, Message = string.Empty, WarningId = row.Key["warning:".Length..] });
|
||||
}
|
||||
|
||||
private static WarningWire ToWarningWire(DorkNet.Server.Data.Entities.PlayerSettingEntity row)
|
||||
|
||||
@@ -21,6 +21,9 @@ public class PlayersController(PlayerService playerService, NotificationService
|
||||
return Ok(PlayerService.ToProfile(player));
|
||||
}
|
||||
|
||||
// The 2023 client PUTs this right after setting an email; GET-only meant a
|
||||
// 405 on every contact-preferences write.
|
||||
[HttpPut("/api/players/v4/current/contact")]
|
||||
[HttpGet("/api/players/v4/current/contact")]
|
||||
public async Task<IActionResult> CurrentContact()
|
||||
{
|
||||
|
||||
@@ -73,56 +73,180 @@ public class PlaylistsController(
|
||||
public sealed class BoolFieldRequest { public bool? Value { get; set; } }
|
||||
public sealed class TagsRequest { public string? Tags { get; set; } public List<string>? TagsList { get; set; } }
|
||||
|
||||
// The 2023-03-21 client sends these as form-urlencoded PUTs. A
|
||||
// non-optional [FromBody] parameter made [ApiController] demand JSON and
|
||||
// reject every one with 415 before the action ran, so renaming, re-imaging,
|
||||
// re-tagging and publishing a playlist all failed silently. Values are now
|
||||
// read from form, query or JSON body, whichever arrived.
|
||||
|
||||
[HttpPost("/playlists/{playlistId:long}/name")]
|
||||
[HttpPut("/playlists/{playlistId:long}/name")]
|
||||
[Authorize]
|
||||
public Task<IActionResult> PlaylistName(long playlistId, [FromBody] StringFieldRequest req,
|
||||
[FromForm(Name = "Name")] string? formValue) =>
|
||||
ApplyMutation(playlistId, p => p.Name = (req?.Value ?? formValue ?? p.Name).Trim());
|
||||
public async Task<IActionResult> PlaylistName(long playlistId)
|
||||
{
|
||||
var value = await ReadFieldAsync("name", "Name", "value", "Value");
|
||||
return await ApplyMutation(playlistId, p => p.Name = (value ?? p.Name).Trim());
|
||||
}
|
||||
|
||||
[HttpPost("/playlists/{playlistId:long}/description")]
|
||||
[HttpPut("/playlists/{playlistId:long}/description")]
|
||||
[Authorize]
|
||||
public Task<IActionResult> PlaylistDescription(long playlistId, [FromBody] StringFieldRequest req,
|
||||
[FromForm(Name = "Description")] string? formValue) =>
|
||||
ApplyMutation(playlistId, p => p.Description = req?.Value ?? formValue ?? p.Description);
|
||||
public async Task<IActionResult> PlaylistDescription(long playlistId)
|
||||
{
|
||||
var value = await ReadFieldAsync("description", "Description", "value", "Value");
|
||||
return await ApplyMutation(playlistId, p => p.Description = value ?? p.Description);
|
||||
}
|
||||
|
||||
[HttpPost("/playlists/{playlistId:long}/image")]
|
||||
[HttpPut("/playlists/{playlistId:long}/image")]
|
||||
[Authorize]
|
||||
public Task<IActionResult> PlaylistImage(long playlistId, [FromBody] StringFieldRequest req,
|
||||
[FromForm(Name = "ImageName")] string? formValue) =>
|
||||
ApplyMutation(playlistId, p => p.ImageName = req?.Value ?? formValue ?? p.ImageName);
|
||||
public async Task<IActionResult> PlaylistImage(long playlistId)
|
||||
{
|
||||
var value = await ReadFieldAsync("imageName", "ImageName", "value", "Value");
|
||||
return await ApplyMutation(playlistId, p => p.ImageName = value ?? p.ImageName);
|
||||
}
|
||||
|
||||
/// <summary>The client sends REPEATED form fields <c>tag</c> and
|
||||
/// <c>autoTag</c>, not a single CSV <c>Tags</c> value.</summary>
|
||||
[HttpPost("/playlists/{playlistId:long}/tags")]
|
||||
[HttpPut("/playlists/{playlistId:long}/tags")]
|
||||
[Authorize]
|
||||
public Task<IActionResult> PlaylistTags(long playlistId, [FromBody] TagsRequest? req,
|
||||
[FromForm(Name = "Tags")] string? formValue) =>
|
||||
ApplyMutation(playlistId, p =>
|
||||
public async Task<IActionResult> PlaylistTags(long playlistId)
|
||||
{
|
||||
var tags = await ReadFieldsAsync("tag", "Tag", "autoTag", "AutoTag", "tags", "Tags");
|
||||
return await ApplyMutation(playlistId, p =>
|
||||
{
|
||||
var csv = req?.Tags
|
||||
?? (req?.TagsList is { Count: > 0 } list ? string.Join(',', list) : null)
|
||||
?? formValue
|
||||
?? p.TagsCsv;
|
||||
p.TagsCsv = csv;
|
||||
if (tags.Count > 0)
|
||||
p.TagsCsv = string.Join(',', tags.Distinct(StringComparer.OrdinalIgnoreCase));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Accessibility / visibility / level-voting / restrictions /
|
||||
/// warning are persisted on the playlist row even though the 2020
|
||||
/// PlaylistEntity doesn't have dedicated columns for each — the wire
|
||||
/// shape always reports the union-entry defaults, so for now the
|
||||
/// mutation is a no-op acknowledgement that returns the unchanged
|
||||
/// playlist. Real per-field columns can be added when we have a
|
||||
/// playlist-settings UI to drive them.</summary>
|
||||
[HttpPost("/playlists/{playlistId:long}/accessibility")]
|
||||
[HttpPut("/playlists/{playlistId:long}/accessibility")]
|
||||
[HttpPost("/playlists/{playlistId:long}/visibility")]
|
||||
[HttpPost("/playlists/{playlistId:long}/levelvoting")]
|
||||
[HttpPost("/playlists/{playlistId:long}/restrictions")]
|
||||
[HttpPost("/playlists/{playlistId:long}/warning")]
|
||||
[HttpPut("/playlists/{playlistId:long}/visibility")]
|
||||
[Authorize]
|
||||
public Task<IActionResult> PlaylistAck(long playlistId) =>
|
||||
ApplyMutation(playlistId, _ => { /* no-op: field not persisted yet */ });
|
||||
public async Task<IActionResult> PlaylistAccessibility(long playlistId)
|
||||
{
|
||||
var value = await ReadFieldAsync(
|
||||
"accessibility", "Accessibility", "visibility", "Visibility", "value", "Value");
|
||||
return await ApplyMutation(playlistId, p =>
|
||||
{
|
||||
if (int.TryParse(value, out var v)) p.Accessibility = v;
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("/playlists/{playlistId:long}/levelvoting")]
|
||||
[HttpPut("/playlists/{playlistId:long}/levelvoting")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> PlaylistLevelVoting(long playlistId)
|
||||
{
|
||||
var value = await ReadFieldAsync("supportsLevelVoting", "SupportsLevelVoting", "value", "Value");
|
||||
return await ApplyMutation(playlistId, p =>
|
||||
{
|
||||
if (bool.TryParse(value, out var v)) p.SupportsLevelVoting = v;
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("/playlists/{playlistId:long}/restrictions")]
|
||||
[HttpPut("/playlists/{playlistId:long}/restrictions")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> PlaylistRestrictions(long playlistId)
|
||||
{
|
||||
var juniors = await ReadFieldAsync("supportsJuniors", "SupportsJuniors");
|
||||
var screens = await ReadFieldAsync("supportsScreens", "SupportsScreens");
|
||||
var teleport = await ReadFieldAsync("supportsTeleportVR", "SupportsTeleportVR");
|
||||
var walk = await ReadFieldAsync("supportsWalkVR", "SupportsWalkVR");
|
||||
return await ApplyMutation(playlistId, p =>
|
||||
{
|
||||
if (bool.TryParse(juniors, out var j)) p.SupportsJuniors = j;
|
||||
if (bool.TryParse(screens, out var c)) p.SupportsScreens = c;
|
||||
if (bool.TryParse(teleport, out var t)) p.SupportsTeleportVR = t;
|
||||
if (bool.TryParse(walk, out var w)) p.SupportsWalkVR = w;
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("/playlists/{playlistId:long}/warning")]
|
||||
[HttpPut("/playlists/{playlistId:long}/warning")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> PlaylistWarning(long playlistId)
|
||||
{
|
||||
var mask = await ReadFieldAsync("warningMask", "WarningMask");
|
||||
var custom = await ReadFieldAsync("customWarning", "CustomWarning");
|
||||
return await ApplyMutation(playlistId, p =>
|
||||
{
|
||||
if (int.TryParse(mask, out var m)) p.WarningMask = m;
|
||||
if (custom is not null) p.CustomWarning = custom.Length > 512 ? custom[..512] : custom;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>First value present under any of the given names, looking at
|
||||
/// the form, the query string, then a JSON body. Null means "not sent",
|
||||
/// which every caller treats as "leave this field alone".</summary>
|
||||
private async Task<string?> ReadFieldAsync(params string[] names)
|
||||
{
|
||||
var all = await ReadFieldsAsync(names);
|
||||
return all.Count > 0 ? all[0] : null;
|
||||
}
|
||||
|
||||
/// <summary>Every value present under any of the given names — the client
|
||||
/// repeats a field name to send a list.</summary>
|
||||
private async Task<List<string>> ReadFieldsAsync(params string[] names)
|
||||
{
|
||||
var found = new List<string>();
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var n in names)
|
||||
foreach (var v in form[n])
|
||||
if (!string.IsNullOrWhiteSpace(v)) found.Add(v!);
|
||||
}
|
||||
foreach (var n in names)
|
||||
foreach (var v in Request.Query[n])
|
||||
if (!string.IsNullOrWhiteSpace(v)) found.Add(v!);
|
||||
if (found.Count > 0) return found;
|
||||
|
||||
if (!Request.HasFormContentType)
|
||||
{
|
||||
try
|
||||
{
|
||||
Request.EnableBuffering();
|
||||
Request.Body.Position = 0;
|
||||
using var doc = await System.Text.Json.JsonDocument.ParseAsync(Request.Body);
|
||||
Request.Body.Position = 0;
|
||||
var root = doc.RootElement;
|
||||
|
||||
// The tags endpoint sends a bare JSON array of strings.
|
||||
if (root.ValueKind == System.Text.Json.JsonValueKind.Array)
|
||||
{
|
||||
foreach (var el in root.EnumerateArray())
|
||||
if (el.ValueKind == System.Text.Json.JsonValueKind.String)
|
||||
found.Add(el.GetString()!);
|
||||
return found;
|
||||
}
|
||||
|
||||
if (root.ValueKind == System.Text.Json.JsonValueKind.Object)
|
||||
{
|
||||
foreach (var n in names)
|
||||
{
|
||||
if (!root.TryGetProperty(n, out var v)) continue;
|
||||
if (v.ValueKind == System.Text.Json.JsonValueKind.Array)
|
||||
{
|
||||
foreach (var el in v.EnumerateArray()) found.Add(el.ToString());
|
||||
}
|
||||
else if (v.ValueKind != System.Text.Json.JsonValueKind.Null)
|
||||
{
|
||||
found.Add(v.ToString());
|
||||
}
|
||||
if (found.Count > 0) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (System.Text.Json.JsonException) { /* no usable body */ }
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
private async Task<IActionResult> ApplyMutation(long playlistId, Action<PlaylistEntity> mutator)
|
||||
{
|
||||
|
||||
@@ -72,6 +72,30 @@ public class RoleController(DorkNetDbContext db) : ControllerBase
|
||||
return Ok(isAdmin ?? false);
|
||||
}
|
||||
|
||||
/// <summary>The March-2023 client probes this one in addition to
|
||||
/// <c>role/developer</c>. Both calls funnel through the same helper
|
||||
/// (<c>OPMAPIOEIFG.ILHILCFCNFF</c>, which formats <c>"role/{0}/{1}"</c>
|
||||
/// and issues it with HTTPMethods.GET); the two call sites differ only
|
||||
/// in the role literal they pass — <c>"developer"</c> at
|
||||
/// RecNet.Runtime/OPMAPIOEIFG.txt:9402 and <c>"moderator"</c> at
|
||||
/// :9471. The 2020 build never referenced the moderator probe, so it
|
||||
/// used to 404 for every remote player on the 2023 client.
|
||||
///
|
||||
/// DorkNet has no dedicated moderator bit, so admins and community-team
|
||||
/// members answer true — they are the accounts that actually hold
|
||||
/// moderation powers here.</summary>
|
||||
[HttpGet("/role/moderator/{accountId:long}")]
|
||||
[HttpGet("/api/role/moderator/{accountId:long}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<bool>> IsModerator(long accountId)
|
||||
{
|
||||
var isMod = await db.Players
|
||||
.Where(p => p.Id == accountId)
|
||||
.Select(p => (bool?)(p.IsAdmin || p.IsCommunityTeam))
|
||||
.FirstOrDefaultAsync();
|
||||
return Ok(isMod ?? false);
|
||||
}
|
||||
|
||||
/// <summary>Future-proofing for a hypothetical
|
||||
/// <c>role/communityteam/{id}</c> probe — not referenced by the 2020
|
||||
/// build, but mirrors the developer shape so a patched watch can
|
||||
|
||||
@@ -227,6 +227,11 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level)
|
||||
/// — buy one stack unit with tokens. Response is
|
||||
/// <c>{ OperationResult, BalanceUpdateResult, TokenBalanceResponse:
|
||||
/// { CurrencyType, Balance, Platform } }</c>.</summary>
|
||||
// The 2023-03-21 client PUTs this (verb literal 3 = PUT, at the route
|
||||
// construction in RecNet.Runtime/DCFKEFHJAGC.txt instrs 097-108), so a
|
||||
// POST-only mapping returned 405 and token-priced consumables were
|
||||
// unbuyable. Both verbs are accepted now.
|
||||
[HttpPut("api/roomconsumables/v1/roomconsumable/{publicId:guid}/purchase/tokens")]
|
||||
[HttpPost("api/roomconsumables/v1/roomconsumable/{publicId:guid}/purchase/tokens")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> PurchaseTokens(Guid publicId)
|
||||
@@ -263,6 +268,9 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level)
|
||||
/// — buy with a room currency. Response is <c>{ OperationResult,
|
||||
/// BalanceUpdateResult, CurrencyBalanceResponse: { AccountId,
|
||||
/// CurrencyId, Balance, ModifiedAt } }</c>.</summary>
|
||||
// PUT per the client (verb literal 3 at DCFKEFHJAGC.txt instrs 112-123);
|
||||
// POST-only returned 405 and currency-priced consumables were unbuyable.
|
||||
[HttpPut("api/roomconsumables/v1/roomconsumable/{publicId:guid}/purchase/currency")]
|
||||
[HttpPost("api/roomconsumables/v1/roomconsumable/{publicId:guid}/purchase/currency")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> PurchaseCurrency(Guid publicId)
|
||||
@@ -304,6 +312,10 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level)
|
||||
/// NewConcurrencyCode }</c>; response <c>{ Status, InventoryItem }</c>.
|
||||
/// On a code mismatch the current row is returned so the client can
|
||||
/// resync.</summary>
|
||||
// PUT per the client (route format "{0}/v1/roomConsumable/{1}/consume"
|
||||
// with verb literal 3 at RecNet.Runtime/LPNHMEFDAAG.txt:1449-1460);
|
||||
// POST-only returned 405, so no consumable could ever be consumed.
|
||||
[HttpPut("api/roomconsumables/v1/roomConsumable/{publicId:guid}/consume")]
|
||||
[HttpPost("api/roomconsumables/v1/roomConsumable/{publicId:guid}/consume")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Consume(Guid publicId)
|
||||
@@ -404,6 +416,18 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level)
|
||||
},
|
||||
};
|
||||
|
||||
/// <summary>Consumable descriptor as the client READS it.
|
||||
///
|
||||
/// The price is FLAT here — the client's response formatter FCIBLPCOODP
|
||||
/// reads <c>Price</c>, <c>PurchaseCurrencyId</c> and <c>ModifiedAt</c>
|
||||
/// directly off the descriptor (RecNet.Runtime/FCIBLPCOODP.txt:670-710).
|
||||
/// Emitting a nested <c>PriceAndCurrency</c> object left every item in
|
||||
/// every room shop reading back Price=0 with a null currency, so the whole
|
||||
/// catalogue looked token-priced and free.
|
||||
///
|
||||
/// Note the asymmetry: REQUEST bodies really do nest PriceAndCurrency, and
|
||||
/// ReadBodyAsync below still flattens that on the way in. Only the response
|
||||
/// shape changed.</summary>
|
||||
private static object ToDescWire(RoomConsumableEntity c) => new
|
||||
{
|
||||
RoomConsumableId = c.PublicId,
|
||||
@@ -411,7 +435,9 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level)
|
||||
c.Name,
|
||||
c.Description,
|
||||
c.ImageName,
|
||||
PriceAndCurrency = new { c.Price, c.CurrencyId },
|
||||
c.Price,
|
||||
PurchaseCurrencyId = c.CurrencyId,
|
||||
ModifiedAt = DateTime.SpecifyKind(c.UpdatedAt, DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
private static object ToInventoryWire(RoomConsumableOwnershipEntity own, RoomConsumableEntity item) => new
|
||||
|
||||
@@ -45,7 +45,7 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) :
|
||||
Name = name,
|
||||
Description = Trim(ReadString(fields, "description", "Description"), 512),
|
||||
ImageName = Trim(ReadString(fields, "imageName", "ImageName"), 256),
|
||||
DailyLimit = Math.Max(0, ReadInt(fields, "dailyLimit", "DailyLimit") ?? 0),
|
||||
DailyLimit = Math.Max(0, ReadInt(fields, "limit", "Limit", "dailyLimit", "DailyLimit") ?? 0),
|
||||
};
|
||||
db.RoomCurrencies.Add(currency);
|
||||
await db.SaveChangesAsync();
|
||||
@@ -65,7 +65,7 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) :
|
||||
if (ReadString(fields, "name", "Name") is { } name) currency.Name = Trim(name, 64);
|
||||
if (ReadString(fields, "description", "Description") is { } description) currency.Description = Trim(description, 512);
|
||||
if (ReadString(fields, "imageName", "ImageName") is { } imageName) currency.ImageName = Trim(imageName, 256);
|
||||
if (ReadInt(fields, "dailyLimit", "DailyLimit") is int dailyLimit) currency.DailyLimit = Math.Max(0, dailyLimit);
|
||||
if (ReadInt(fields, "limit", "Limit", "dailyLimit", "DailyLimit") is int dailyLimit) currency.DailyLimit = Math.Max(0, dailyLimit);
|
||||
currency.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(currency));
|
||||
@@ -77,13 +77,20 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) :
|
||||
var fields = await ReadFieldsAsync();
|
||||
var currency = await FindCurrencyAsync(fields);
|
||||
if (currency is null) return NotFound();
|
||||
var playerId = ReadLong(fields, "playerId", "PlayerId") ?? Me;
|
||||
// The client sends "accountId" and can ask about ANOTHER player
|
||||
// (circuits read other players' balances); reading only playerId made
|
||||
// every such query silently answer for the caller instead.
|
||||
var playerId = ReadLong(fields, "accountId", "AccountId", "playerId", "PlayerId") ?? Me;
|
||||
var balance = await GetBalanceAsync(playerId, currency.Id);
|
||||
return Ok(new
|
||||
{
|
||||
AccountId = (int)playerId,
|
||||
CurrencyId = currency.PublicId,
|
||||
Balance = balance,
|
||||
ModifiedAt = DateTime.UtcNow,
|
||||
// legacy aliases
|
||||
PlayerId = (int)playerId,
|
||||
RoomCurrencyId = currency.PublicId,
|
||||
Balance = balance,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -104,9 +111,13 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) :
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(x => new
|
||||
{
|
||||
AccountId = (int)playerId,
|
||||
CurrencyId = x.c.PublicId,
|
||||
x.b.Balance,
|
||||
ModifiedAt = DateTime.SpecifyKind(x.b.UpdatedAt, DateTimeKind.Utc),
|
||||
// legacy aliases
|
||||
PlayerId = (int)playerId,
|
||||
RoomCurrencyId = x.c.PublicId,
|
||||
x.b.Balance,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -373,33 +384,62 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) :
|
||||
return s.Length <= max ? s : s[..max];
|
||||
}
|
||||
|
||||
/// <summary>Room currency as the client READS it (formatter PEOAOMGOMGC):
|
||||
/// <c>CurrencyId</c>, <c>RoomId</c>, <c>Name</c>, <c>Description</c>,
|
||||
/// <c>CurrencyType</c>, <c>Limit</c>, <c>ImageName</c>, <c>CreatedAt</c>,
|
||||
/// <c>ModifiedAt</c>.
|
||||
///
|
||||
/// The previous names were server inventions (<c>RoomCurrencyId</c>,
|
||||
/// <c>DailyLimit</c>, <c>UpdatedAt</c>) that shared almost no keys with
|
||||
/// what the client reads, so every currency came back with an empty
|
||||
/// CurrencyId and a zero limit — the room currency HUD could not match a
|
||||
/// balance to a currency. The legacy aliases are kept alongside the correct
|
||||
/// names because unknown members are ignored client-side and other DorkNet
|
||||
/// surfaces (admin UI) still read them.</summary>
|
||||
private static object ToWire(RoomCurrencyEntity currency) => new
|
||||
{
|
||||
CurrencyId = currency.PublicId,
|
||||
currency.RoomId,
|
||||
currency.Name,
|
||||
currency.Description,
|
||||
CurrencyType = 300,
|
||||
Limit = (long)currency.DailyLimit,
|
||||
currency.ImageName,
|
||||
CreatedAt = DateTime.SpecifyKind(currency.CreatedAt, DateTimeKind.Utc),
|
||||
ModifiedAt = DateTime.SpecifyKind(currency.UpdatedAt, DateTimeKind.Utc),
|
||||
|
||||
// legacy aliases
|
||||
RoomCurrencyId = currency.PublicId,
|
||||
Id = currency.PublicId,
|
||||
InternalId = currency.Id,
|
||||
currency.RoomId,
|
||||
CreatorPlayerId = (int)currency.CreatorPlayerId,
|
||||
currency.Name,
|
||||
currency.Description,
|
||||
currency.ImageName,
|
||||
currency.DailyLimit,
|
||||
currency.CreatedAt,
|
||||
currency.UpdatedAt,
|
||||
};
|
||||
|
||||
/// <summary>Purchase offer as the client READS it (formatter AALDCAANEMM):
|
||||
/// <c>CurrencyPurchaseOfferId</c>, <c>CurrencyId</c>, <c>Name</c>,
|
||||
/// <c>CurrencyAmount</c>, <c>Price</c>, <c>CurrencyType</c>,
|
||||
/// <c>CreatedAt</c>, <c>ModifiedAt</c>. Legacy aliases retained as in
|
||||
/// <see cref="ToWire"/>.</summary>
|
||||
private static object ToOfferWire(RoomCurrencyPurchaseOfferEntity offer, RoomCurrencyEntity currency) => new
|
||||
{
|
||||
CurrencyPurchaseOfferId = offer.PublicId,
|
||||
CurrencyId = currency.PublicId,
|
||||
offer.Name,
|
||||
CurrencyAmount = offer.Amount,
|
||||
offer.Price,
|
||||
offer.CurrencyType,
|
||||
CreatedAt = DateTime.SpecifyKind(offer.CreatedAt, DateTimeKind.Utc),
|
||||
ModifiedAt = DateTime.SpecifyKind(offer.UpdatedAt, DateTimeKind.Utc),
|
||||
|
||||
// legacy aliases
|
||||
PurchaseOfferId = offer.PublicId,
|
||||
RoomCurrencyPackageId = offer.PublicId,
|
||||
Id = offer.PublicId,
|
||||
InternalId = offer.Id,
|
||||
RoomCurrencyId = currency.PublicId,
|
||||
offer.Name,
|
||||
offer.Amount,
|
||||
offer.Price,
|
||||
offer.CurrencyType,
|
||||
offer.CreatedAt,
|
||||
offer.UpdatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -31,12 +31,21 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
/// <summary>The watch key ring — keys the caller HOLDS. This used to return
|
||||
/// keys the caller CREATED, which is a different set: a creator never buys
|
||||
/// their own key, so players saw none of the keys they had actually
|
||||
/// purchased. Keys the caller created are unioned in so room owners still
|
||||
/// see their own.</summary>
|
||||
[HttpGet("api/roomkeys/v1/mine")]
|
||||
public async Task<IActionResult> Mine()
|
||||
{
|
||||
var pid = Me;
|
||||
var purchasedIds = await db.RoomKeyPurchases
|
||||
.Where(p => p.PlayerId == pid)
|
||||
.Select(p => p.RoomKeyId)
|
||||
.ToListAsync();
|
||||
var rows = await db.RoomKeys
|
||||
.Where(k => k.CreatorPlayerId == pid && !k.IsDeleted)
|
||||
.Where(k => !k.IsDeleted && (purchasedIds.Contains(k.Id) || k.CreatorPlayerId == pid))
|
||||
.OrderByDescending(k => k.UpdatedAt)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
@@ -93,6 +102,25 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase
|
||||
return Ok(RoomKeyResponse(RoomKeyStatus.Success, key));
|
||||
}
|
||||
|
||||
/// <summary>The 2023-03-21 client does not call <c>update</c>. It builds
|
||||
/// <c>"api/roomkeys/v1/" + action</c> with verb PUT and a <c>RoomKeyId</c>
|
||||
/// field, where action is one of <c>updateAll</c>, <c>updateName</c>,
|
||||
/// <c>updateDescription</c> or <c>updatePrice</c>
|
||||
/// (RecNet.Runtime/AHMBBJNANBP.txt: prefix concat at instr 058, verb 3 at
|
||||
/// 067, <c>"RoomKeyId"</c> at 079; action literals at :1345, :1559, :1772,
|
||||
/// :2043). None were registered, so every room-key edit 405'd.
|
||||
///
|
||||
/// All four share this handler: the request DTO's fields are already
|
||||
/// optional, so a partial update naturally leaves the others untouched.</summary>
|
||||
[HttpPut("api/roomkeys/v1/updateAll")]
|
||||
[HttpPost("api/roomkeys/v1/updateAll")]
|
||||
[HttpPut("api/roomkeys/v1/updateName")]
|
||||
[HttpPost("api/roomkeys/v1/updateName")]
|
||||
[HttpPut("api/roomkeys/v1/updateDescription")]
|
||||
[HttpPost("api/roomkeys/v1/updateDescription")]
|
||||
[HttpPut("api/roomkeys/v1/updatePrice")]
|
||||
[HttpPost("api/roomkeys/v1/updatePrice")]
|
||||
[HttpPut("api/roomkeys/v1/update")]
|
||||
[HttpPost("api/roomkeys/v1/update")]
|
||||
public async Task<IActionResult> Update()
|
||||
{
|
||||
@@ -140,35 +168,104 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase
|
||||
return Content(((int)RoomKeyStatus.Success).ToString(), "application/json");
|
||||
}
|
||||
|
||||
/// <summary>"Has anyone bought this key?" — the creator calls this before a
|
||||
/// destructive edit. It previously answered "has the CALLER bought it",
|
||||
/// which is always false for the creator, so the guard never fired and
|
||||
/// creators could silently invalidate keys players had paid for.</summary>
|
||||
[HttpGet("api/roomkeys/v1/purchased/{roomKeyId:long}")]
|
||||
public async Task<IActionResult> Purchased(long roomKeyId)
|
||||
{
|
||||
var pid = Me;
|
||||
var owns = await db.RoomKeyPurchases.AnyAsync(p => p.RoomKeyId == roomKeyId && p.PlayerId == pid);
|
||||
return Content(owns ? "true" : "false", "application/json");
|
||||
var anyPurchase = await db.RoomKeyPurchases.AnyAsync(p => p.RoomKeyId == roomKeyId);
|
||||
return Content(anyPurchase ? "true" : "false", "application/json");
|
||||
}
|
||||
|
||||
/// <summary>The client sends both <c>roomKeyId</c> and <c>playerId</c> — key
|
||||
/// doors evaluate OTHER players' ownership, not just the caller's. Ignoring
|
||||
/// playerId meant a door asked about someone else and got the caller's
|
||||
/// answer.</summary>
|
||||
[HttpGet("api/roomkeys/v1/owns")]
|
||||
public async Task<IActionResult> Owns([FromQuery] long roomKeyId)
|
||||
public async Task<IActionResult> Owns([FromQuery] long roomKeyId, [FromQuery] long? playerId = null)
|
||||
{
|
||||
var pid = Me;
|
||||
var owns = await db.RoomKeyPurchases.AnyAsync(p => p.RoomKeyId == roomKeyId && p.PlayerId == pid);
|
||||
var subject = playerId is long p && p > 0 ? p : Me;
|
||||
var owns = await db.RoomKeyPurchases.AnyAsync(x => x.RoomKeyId == roomKeyId && x.PlayerId == subject);
|
||||
return Content(owns ? "true" : "false", "application/json");
|
||||
}
|
||||
|
||||
/// <summary>Bulk key-ownership probe, used by key doors to gate a whole
|
||||
/// room of players at once.
|
||||
///
|
||||
/// The 2023-03-21 client POSTs a JSON <b>array</b> of
|
||||
/// <c>{RoomKeyId, AccountId}</c> pairs as a raw body
|
||||
/// (RecNet.Runtime/AHMBBJNANBP_NestedType_JIPDBBEFAPN.txt instrs 044-065),
|
||||
/// so each element names the player it is asking about. The old
|
||||
/// query/form-only reader saw an empty id list and answered for the caller,
|
||||
/// meaning key doors evaluated the wrong player — or nobody at all.</summary>
|
||||
[HttpGet("api/roomkeys/v1/owns/bulk")]
|
||||
[HttpPost("api/roomkeys/v1/owns/bulk")]
|
||||
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> OwnsBulk()
|
||||
{
|
||||
var ids = await ReadRoomKeyIdsAsync();
|
||||
if (ids.Count == 0) return Ok(Array.Empty<object>());
|
||||
var pid = Me;
|
||||
var owned = await db.RoomKeyPurchases
|
||||
.Where(p => p.PlayerId == pid && ids.Contains(p.RoomKeyId))
|
||||
.Select(p => p.RoomKeyId)
|
||||
.ToListAsync();
|
||||
var ownedSet = owned.ToHashSet();
|
||||
return Ok(ids.Select(id => new { RoomKeyId = id, Owns = ownedSet.Contains(id) }).ToList());
|
||||
var pairs = await ReadOwnershipPairsAsync();
|
||||
if (pairs.Count == 0)
|
||||
{
|
||||
// Legacy query/form path: ids only, always about the caller.
|
||||
var ids = await ReadRoomKeyIdsAsync();
|
||||
if (ids.Count == 0) return Ok(Array.Empty<object>());
|
||||
pairs = ids.Select(id => (RoomKeyId: id, AccountId: Me)).ToList();
|
||||
}
|
||||
|
||||
var keyIds = pairs.Select(p => p.RoomKeyId).Distinct().ToList();
|
||||
var accountIds = pairs.Select(p => p.AccountId).Distinct().ToList();
|
||||
var owned = (await db.RoomKeyPurchases
|
||||
.Where(p => keyIds.Contains(p.RoomKeyId) && accountIds.Contains(p.PlayerId))
|
||||
.Select(p => new { p.RoomKeyId, p.PlayerId })
|
||||
.ToListAsync())
|
||||
.Select(p => (p.RoomKeyId, p.PlayerId))
|
||||
.ToHashSet();
|
||||
|
||||
return Ok(pairs.Select(p => new
|
||||
{
|
||||
p.RoomKeyId,
|
||||
AccountId = (int)p.AccountId,
|
||||
DoesPlayerOwnRoomKey = owned.Contains((p.RoomKeyId, p.AccountId)),
|
||||
// legacy alias
|
||||
Owns = owned.Contains((p.RoomKeyId, p.AccountId)),
|
||||
}).ToList());
|
||||
}
|
||||
|
||||
/// <summary>Parse the 2023 client's JSON array of
|
||||
/// <c>{RoomKeyId, AccountId}</c> pairs. Returns empty for a form post or a
|
||||
/// body that isn't an array, so the legacy path still applies.</summary>
|
||||
private async Task<List<(long RoomKeyId, long AccountId)>> ReadOwnershipPairsAsync()
|
||||
{
|
||||
var pairs = new List<(long, long)>();
|
||||
if (Request.HasFormContentType) return pairs;
|
||||
try
|
||||
{
|
||||
Request.EnableBuffering();
|
||||
Request.Body.Position = 0;
|
||||
using var doc = await System.Text.Json.JsonDocument.ParseAsync(Request.Body);
|
||||
Request.Body.Position = 0;
|
||||
if (doc.RootElement.ValueKind != System.Text.Json.JsonValueKind.Array) return pairs;
|
||||
|
||||
foreach (var el in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
if (el.ValueKind != System.Text.Json.JsonValueKind.Object) continue;
|
||||
long? Read(params string[] names)
|
||||
{
|
||||
foreach (var n in names)
|
||||
if (el.TryGetProperty(n, out var v) &&
|
||||
v.ValueKind == System.Text.Json.JsonValueKind.Number &&
|
||||
v.TryGetInt64(out var x)) return x;
|
||||
return null;
|
||||
}
|
||||
var keyId = Read("RoomKeyId", "roomKeyId");
|
||||
if (keyId is not long k || k <= 0) continue;
|
||||
pairs.Add((k, Read("AccountId", "accountId", "PlayerId", "playerId") ?? Me));
|
||||
}
|
||||
}
|
||||
catch (System.Text.Json.JsonException) { /* not a JSON array body */ }
|
||||
return pairs;
|
||||
}
|
||||
|
||||
public static object RoomKeyResponse(RoomKeyStatus status, RoomKeyEntity? key) => new
|
||||
@@ -300,6 +397,8 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase
|
||||
|
||||
public sealed class UpdateRoomKeyRequest
|
||||
{
|
||||
// The 2023 client spells this "RoomKeyId"; System.Text.Json is
|
||||
// configured case-insensitively so both land on this property.
|
||||
[JsonPropertyName("roomKeyId")] public long RoomKeyId { get; set; }
|
||||
[JsonPropertyName("name")] public string? Name { get; set; }
|
||||
[JsonPropertyName("description")] public string? Description { get; set; }
|
||||
|
||||
@@ -304,7 +304,11 @@ public class RoomsController(
|
||||
/// </summary>
|
||||
[HttpGet("api/rooms/v2/baserooms")]
|
||||
[HttpGet("api/rooms/v3/baserooms")]
|
||||
// The 2023 client issues this from NLDBPDCNNCF, whose requests all carry
|
||||
// the roomserver/ prefix in this deployment — without the twin the base/AG
|
||||
// room list 404s and the room browser's base section is empty.
|
||||
[HttpGet("rooms/base")]
|
||||
[HttpGet("roomserver/rooms/base")]
|
||||
public async Task<IActionResult> BaseRooms()
|
||||
{
|
||||
var names = await serverSettings.GetBaseRoomNamesAsync();
|
||||
@@ -2532,7 +2536,9 @@ public class RoomsController(
|
||||
/// <c>api/rooms/v3/featured</c>). Returning the
|
||||
/// <see cref="Featured"/> shape here throws KeyNotFoundException on
|
||||
/// the watch's group-id lookup.</summary>
|
||||
// Same roomserver/ prefix requirement as rooms/base.
|
||||
[HttpGet("featuredrooms/current")]
|
||||
[HttpGet("roomserver/featuredrooms/current")]
|
||||
public async Task<IActionResult> FeaturedRoomsCurrent()
|
||||
{
|
||||
var ids = await rooms.FeaturedAgRoomIdsAsync(12);
|
||||
@@ -2540,10 +2546,22 @@ public class RoomsController(
|
||||
.Where(r => ids.Contains(r.Id))
|
||||
.ToListAsync();
|
||||
var byId = featuredRooms.ToDictionary(r => r.Id);
|
||||
// The featured-tile reader looks for RoomName, which ToWireRoom does not
|
||||
// emit — every tile's caption came back null. Re-project the wire object
|
||||
// into a dictionary so the extra key sits alongside the real ones rather
|
||||
// than nesting them.
|
||||
var wireRooms = ids
|
||||
.Select(id => byId.TryGetValue(id, out var room) ? RoomService.ToWireRoom(room) : null)
|
||||
.Where(room => room is not null)
|
||||
.Cast<object>()
|
||||
.Where(byId.ContainsKey)
|
||||
.Select(id =>
|
||||
{
|
||||
var room = byId[id];
|
||||
var wire = RoomService.ToWireRoom(room);
|
||||
var map = new Dictionary<string, object?>();
|
||||
foreach (var prop in wire.GetType().GetProperties())
|
||||
map[prop.Name] = prop.GetValue(wire);
|
||||
map["RoomName"] = room.Name;
|
||||
return (object)map;
|
||||
})
|
||||
.ToList();
|
||||
return Ok(new
|
||||
{
|
||||
|
||||
@@ -10,22 +10,50 @@ namespace DorkNet.Server.Controllers.API.ScreenShareReports;
|
||||
[Authorize]
|
||||
public class ScreenShareReportsController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
/// <summary>Screen-share (photo) report. The 2023-03-21 client posts the
|
||||
/// form keys <c>ImageName</c>, <c>ReportedPlayerId</c>, <c>RoomId</c>,
|
||||
/// <c>RoomInstanceId</c>, <c>RoomInstanceType</c> and <c>Details</c>
|
||||
/// (RecNet.Runtime/KAEAEIODGBG.txt:281-360). It sends no
|
||||
/// <c>reportCategory</c> at all.
|
||||
///
|
||||
/// The old signature bound <c>targetPlayerId</c>, so the reported player
|
||||
/// was stored as 0 on every screen-share report and the reported image was
|
||||
/// dropped entirely — the report was unactionable. Form binding is
|
||||
/// case-insensitive, so <c>RoomId</c>/<c>Details</c> did land; the names
|
||||
/// that differ are the ones spelled out explicitly below.</summary>
|
||||
[HttpPost("api/screensharereports/v1/report")]
|
||||
public async Task<IActionResult> Report(
|
||||
[FromForm(Name = "ReportedPlayerId")] long? reportedPlayerId,
|
||||
[FromForm] long? targetPlayerId,
|
||||
[FromForm] long? roomId,
|
||||
[FromForm(Name = "RoomInstanceId")] long? roomInstanceId,
|
||||
[FromForm(Name = "RoomInstanceType")] int? roomInstanceType,
|
||||
[FromForm(Name = "ImageName")] string? imageName,
|
||||
[FromForm] int? reportCategory,
|
||||
[FromForm] string? details)
|
||||
{
|
||||
var reporter = this.RequireCurrentPlayerId();
|
||||
var target = targetPlayerId
|
||||
?? (long.TryParse(Request.Query["targetPlayerId"].FirstOrDefault(), out var qTarget) ? qTarget : 0);
|
||||
var target = reportedPlayerId
|
||||
?? targetPlayerId
|
||||
?? (long.TryParse(Request.Query["ReportedPlayerId"].FirstOrDefault()
|
||||
?? Request.Query["targetPlayerId"].FirstOrDefault(), out var qTarget) ? qTarget : 0);
|
||||
var room = roomId
|
||||
?? (long.TryParse(Request.Query["roomId"].FirstOrDefault(), out var qRoom) ? qRoom : 0);
|
||||
// The client never sends a category for screen-share reports; 5 is the
|
||||
// server's existing "other" bucket.
|
||||
var category = reportCategory
|
||||
?? (int.TryParse(Request.Query["reportCategory"].FirstOrDefault(), out var qCategory) ? qCategory : 5);
|
||||
var message = details ?? Request.Query["details"].FirstOrDefault() ?? string.Empty;
|
||||
|
||||
// Keep the evidence (image + instance) in the stored text: ReportEntity
|
||||
// has no dedicated columns for them and a report without the offending
|
||||
// image is not actionable by a moderator.
|
||||
var context = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(imageName)) context.Add($"image={imageName}");
|
||||
if (roomInstanceId is > 0) context.Add($"instance={roomInstanceId}");
|
||||
if (roomInstanceType is not null) context.Add($"instanceType={roomInstanceType}");
|
||||
if (context.Count > 0) message = $"[screenshare {string.Join(' ', context)}] {message}".Trim();
|
||||
|
||||
db.Reports.Add(new ReportEntity
|
||||
{
|
||||
ReporterPlayerId = reporter,
|
||||
|
||||
@@ -11,9 +11,17 @@ namespace DorkNet.Server.Controllers.API.UgcPurchasables;
|
||||
[ApiController]
|
||||
public class UgcPurchasablesController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
/// <summary>The 2023-03-21 client asks for a room's in-room store with the
|
||||
/// path form <c>api/ugcPurchasables/v1/items/room/{roomId}</c>, not the
|
||||
/// <c>?roomId=</c> query this handler was originally written for. Without
|
||||
/// the path route it 404'd and the in-room UGC store never populated on
|
||||
/// room join.</summary>
|
||||
[HttpGet("api/ugcPurchasables/v1/items/room/{roomId:long}")]
|
||||
[HttpGet("api/ugcPurchasables")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> List([FromQuery] long roomId = 0)
|
||||
// No explicit binding source: roomId comes from the route on the path form
|
||||
// above and from the query string on the bare route.
|
||||
public async Task<IActionResult> List(long roomId = 0)
|
||||
{
|
||||
var rows = await db.UgcPurchasables
|
||||
.Where(i => !i.IsDeleted && (roomId <= 0 || i.RoomId == roomId))
|
||||
|
||||
Reference in New Issue
Block a user