mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 07:28:45 -07:00
feat: implement the client routes that had no handler
Sixteen agents, one controller each, over every route the coverage test reported as missing or wrong-verb. Route+verb coverage is now 516/522 (98.9%), up from 90.8%. Each handler was written against binary evidence rather than the audit's prose: verb from the BestHTTP.HTTPMethods ordinal at the call site, response shape from the issuing method's return type, request shape from whether the client uses form fields, a raw JsonUtility body, or query params. Agents were told to skip rather than stub, and several did. New surface worth noting: - Link host: actionlink create/get/consume and datalink get/put, previously unrouted entirely. - Club roster reads: members, banned, requests, requests/search, members/search, each with the client's real sort/paging params and the wire row shape its reader expects (the banned row has no MembershipType field, so it isn't sent). - Home clubhouse set/clear, club disband (soft, via the existing State column), and DELETE mainimage as its own action rather than a verb alias — the client parses the details envelope from it, so it is not status-only. - Club chat threads, comment CRUD, keepsake collect/delete, progression-event xp boosts, image report and cheered/bulk, iOS StoreKit product reporting. The six routes still missing all need storage that does not exist yet (per-role club permissions, club additional-image slots, MinLevel, ClubChatEnabled). Those are left for a single schema change rather than faked. Also excludes DELETE /account/me from the endpoint contract probe. That route is now a real, correctly-guarded account purge, and the probe walks every route with one test player — so calling it destroyed the probe's own session and turned one real event into 49 cascading foreign-key and 401 failures.
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -240,6 +242,318 @@ public class PhotosController(
|
||||
return Ok(new { deleted = id });
|
||||
}
|
||||
|
||||
// ── 2023 home feed (Discovery host) ───────────────────────────────
|
||||
|
||||
/// <summary>The only <c>FeedItemType</c> (client enum
|
||||
/// <c>DAAEHNDDCCH</c>) the 2023 feed list can render: an image.
|
||||
/// Two independent confirmations in the binary — the list spawner
|
||||
/// builds one <c>RRUI.Data.ImageFeedItemModel</c> per returned item
|
||||
/// (<c>Assembly-CSharp/RRUI/Data/FeedListModelController_NestedType_FeedListSpawnerImpl.txt:294</c>
|
||||
/// "044 Call ImageFeedItemModel.Set"), and the share deep-link
|
||||
/// handler maps the path segment "image" + an Int64 id onto an
|
||||
/// injected feed item constructed with the enum literal 0
|
||||
/// (<c>Assembly-CSharp/RecRoom/Sharing/LinkManager.txt:5646</c>
|
||||
/// Move rdx,"image" … :5665 Move rdx,0 … :5668 Call
|
||||
/// EKLOCIPFHII..ctor). FeedItemId is therefore an image id, which on
|
||||
/// DorkNet is <see cref="PhotoEntity.Id"/> — the same id space
|
||||
/// <c>api/images/v5/bulk</c> resolves.</summary>
|
||||
private const int FeedItemTypeImage = 0;
|
||||
|
||||
/// <summary>Ranking revision stamped on the envelope and on every
|
||||
/// item. The client never interprets it: it copies the Int16 off the
|
||||
/// response into the list model and replays it in the impression
|
||||
/// telemetry it later posts back (<c>FeedListModel_NestedType_OLMJPGEMAKM.txt</c>
|
||||
/// "031 Move rax,[rdi+40] / 032 Move [rcx+280],rax" then
|
||||
/// "040 Call FeedListModel.LMDNOKJCBGE"). Bump it when the ordering
|
||||
/// below changes so the telemetry can tell the cohorts apart.</summary>
|
||||
private const short FeedAlgoVersion = 1;
|
||||
|
||||
/// <summary>POST /feed/query — the 2023 client's personalised home
|
||||
/// feed. Host is Discovery (host enum 19) and the verb is POST:
|
||||
/// <c>RecNet.Runtime/KGLCPCEGCFF.txt:166-178</c> loads
|
||||
/// r9 = "feed/query", r8 = 19, rdx = 2 (BestHTTP POST) into the
|
||||
/// shared BNDIAONDFFF request ctor. The body is a RAW JSON document
|
||||
/// (the issuing method takes an already-serialised
|
||||
/// <c>System.String</c> and hands it to BNDIAONDFFF.FJLLPHFOOJJ),
|
||||
/// NOT form fields — so it is read straight off Request.Body.
|
||||
///
|
||||
/// Request keys (reader BAEBDMPPKKE.txt:331-414, each registered in
|
||||
/// PascalCase/camelCase/lowercase): Take:Int32,
|
||||
/// ContinuationToken:String?, InjectedFeedItems:[{FeedItemType,
|
||||
/// FeedItemId}], FeedInstanceId:String?.
|
||||
///
|
||||
/// Response is the generic result envelope AECMPGPHAII<T>
|
||||
/// {Success, Error, Value} (keys at AEGADMDIKNE.txt:243-286) wrapping
|
||||
/// BDIFPHKLFKD {Items, FeedInstanceId, FeedContext,
|
||||
/// FeedAlgorithmVersion} (keys at EHKEOCKBCDH.txt:331-414). Anything
|
||||
/// else — including a 404 — rejects the promise and the home tab
|
||||
/// renders its failure state.
|
||||
///
|
||||
/// FeedContext is the continuation cursor, not a display string: the
|
||||
/// RefreshPage continuation reads field +32 (FeedContext) as the
|
||||
/// third element of its (count, items, nextToken) tuple and feeds it
|
||||
/// back as the next request's ContinuationToken
|
||||
/// (<c>FeedListModel_NestedType_OLMJPGEMAKM.txt</c> "062 Move r9,
|
||||
/// [rdi+32]"). An empty FeedContext therefore ends pagination.
|
||||
///
|
||||
/// Anonymous-safe like the other feed reads; when the caller is
|
||||
/// authenticated the ordering is personalised (friends/subscriptions
|
||||
/// first) and blocked/ignored uploaders are filtered out.</summary>
|
||||
[HttpPost("/feed/query")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult> FeedQuery()
|
||||
{
|
||||
var take = 20;
|
||||
string? continuationToken = null;
|
||||
string? requestedInstanceId = null;
|
||||
var requestedInjected = new List<long>();
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var root = doc.RootElement;
|
||||
if (TryFeedProp(root, "Take", out var takeEl) &&
|
||||
takeEl.ValueKind == JsonValueKind.Number && takeEl.TryGetInt32(out var t))
|
||||
take = t;
|
||||
if (TryFeedProp(root, "ContinuationToken", out var ctEl) &&
|
||||
ctEl.ValueKind == JsonValueKind.String)
|
||||
continuationToken = ctEl.GetString();
|
||||
if (TryFeedProp(root, "FeedInstanceId", out var fiEl) &&
|
||||
fiEl.ValueKind == JsonValueKind.String)
|
||||
requestedInstanceId = fiEl.GetString();
|
||||
if (TryFeedProp(root, "InjectedFeedItems", out var injEl) &&
|
||||
injEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var entry in injEl.EnumerateArray())
|
||||
{
|
||||
if (entry.ValueKind != JsonValueKind.Object) continue;
|
||||
// Non-image injected items can't be rendered by the
|
||||
// 2023 list, so echoing them back would produce a
|
||||
// blank card. Drop them.
|
||||
var type = TryFeedProp(entry, "FeedItemType", out var tyEl) &&
|
||||
tyEl.ValueKind == JsonValueKind.Number &&
|
||||
tyEl.TryGetInt32(out var ty)
|
||||
? ty : FeedItemTypeImage;
|
||||
if (type != FeedItemTypeImage) continue;
|
||||
if (TryFeedProp(entry, "FeedItemId", out var idEl) &&
|
||||
idEl.ValueKind == JsonValueKind.Number &&
|
||||
idEl.TryGetInt64(out var id) && id > 0)
|
||||
requestedInjected.Add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Unparseable/empty body — treat as a default first-page pull
|
||||
// rather than 400, so a malformed retry still shows content.
|
||||
}
|
||||
|
||||
take = Math.Clamp(take, 1, 50);
|
||||
var me = this.CurrentPlayerId();
|
||||
|
||||
// Uploaders the caller can't see: anyone they blocked or ignored,
|
||||
// plus anyone who blocked them.
|
||||
var hidden = new List<long>();
|
||||
var affinity = new List<long>();
|
||||
if (me is long meId)
|
||||
{
|
||||
hidden = await db.Relationships
|
||||
.Where(r => (r.RequesterId == meId &&
|
||||
(r.Status == RelationshipStatus.Blocked || r.Ignored)) ||
|
||||
(r.TargetId == meId && r.Status == RelationshipStatus.Blocked))
|
||||
.Select(r => r.RequesterId == meId ? r.TargetId : r.RequesterId)
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
|
||||
var friends = await db.Relationships
|
||||
.Where(r => r.Status == RelationshipStatus.Friend &&
|
||||
(r.RequesterId == meId || r.TargetId == meId))
|
||||
.Select(r => r.RequesterId == meId ? r.TargetId : r.RequesterId)
|
||||
.ToListAsync();
|
||||
var follows = await db.Subscriptions
|
||||
.Where(s => s.SubscriberPlayerId == meId)
|
||||
.Select(s => s.TargetPlayerId)
|
||||
.ToListAsync();
|
||||
affinity = friends.Concat(follows)
|
||||
.Where(id => id > 0 && !hidden.Contains(id))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
var resumed = TryDecodeFeedCursor(continuationToken,
|
||||
out var phase, out var cursorTicks, out var cursorId, out var carriedInjected);
|
||||
|
||||
// Injected items are pinned to the head of the FIRST page only, in
|
||||
// the order the client asked for them (they come from a share
|
||||
// deep-link the player just followed). Their ids ride along in the
|
||||
// cursor so later pages don't repeat them organically.
|
||||
var injectedIds = resumed ? carriedInjected : requestedInjected.Distinct().ToArray();
|
||||
var itemIds = new List<long>();
|
||||
if (!resumed && injectedIds.Length > 0)
|
||||
{
|
||||
var pinned = await VisibleFeedPhotos(hidden)
|
||||
.Where(p => injectedIds.Contains(p.Id))
|
||||
.Select(p => p.Id)
|
||||
.ToListAsync();
|
||||
itemIds.AddRange(injectedIds.Where(pinned.Contains));
|
||||
}
|
||||
|
||||
DateTime? cutoffAt = resumed && cursorTicks > 0 && cursorTicks <= DateTime.MaxValue.Ticks
|
||||
? new DateTime(cursorTicks)
|
||||
: (DateTime?)null;
|
||||
var cutoffId = resumed ? cursorId : 0L;
|
||||
if (phase < 0) phase = 0;
|
||||
|
||||
// Phase 0 = photos from people the caller follows or is friends
|
||||
// with; phase 1 = everyone else. Both are newest-first, and the
|
||||
// cursor is (CreatedAt, Id) so a photo posted mid-scroll can never
|
||||
// shift a row onto a page the client already consumed.
|
||||
while (itemIds.Count < take && phase <= 1)
|
||||
{
|
||||
if (phase == 0 && affinity.Count == 0)
|
||||
{
|
||||
phase = 1;
|
||||
cutoffAt = null;
|
||||
cutoffId = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
var need = take - itemIds.Count;
|
||||
var q = VisibleFeedPhotos(hidden);
|
||||
if (injectedIds.Length > 0)
|
||||
q = q.Where(p => !injectedIds.Contains(p.Id));
|
||||
q = phase == 0
|
||||
? q.Where(p => affinity.Contains(p.UploaderPlayerId))
|
||||
: q.Where(p => !affinity.Contains(p.UploaderPlayerId));
|
||||
if (cutoffAt is DateTime at)
|
||||
{
|
||||
var atId = cutoffId;
|
||||
q = q.Where(p => p.CreatedAt < at || (p.CreatedAt == at && p.Id < atId));
|
||||
}
|
||||
|
||||
var batch = await q
|
||||
.OrderByDescending(p => p.CreatedAt)
|
||||
.ThenByDescending(p => p.Id)
|
||||
.Take(need)
|
||||
.Select(p => new { p.Id, p.CreatedAt })
|
||||
.ToListAsync();
|
||||
itemIds.AddRange(batch.Select(b => b.Id));
|
||||
|
||||
if (batch.Count < need)
|
||||
{
|
||||
phase++;
|
||||
cutoffAt = null;
|
||||
cutoffId = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
cutoffAt = batch[^1].CreatedAt;
|
||||
cutoffId = batch[^1].Id;
|
||||
}
|
||||
}
|
||||
|
||||
// Empty FeedContext = end of the feed; the client stops paging.
|
||||
var nextContext = phase > 1
|
||||
? string.Empty
|
||||
: EncodeFeedCursor(phase, cutoffAt?.Ticks ?? 0, cutoffId, injectedIds);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Error = (string?)null,
|
||||
Value = new
|
||||
{
|
||||
Items = itemIds.Select(id => new
|
||||
{
|
||||
FeedItemType = FeedItemTypeImage,
|
||||
FeedItemId = id,
|
||||
FeedAlgorithmVersion = FeedAlgoVersion,
|
||||
}).ToList(),
|
||||
// The client sends the instance id it generated for this
|
||||
// scroll session and stores whatever comes back; echoing it
|
||||
// keeps its impression telemetry stitched to one session.
|
||||
FeedInstanceId = string.IsNullOrWhiteSpace(requestedInstanceId)
|
||||
? Guid.NewGuid().ToString("N")
|
||||
: requestedInstanceId,
|
||||
FeedContext = nextContext,
|
||||
FeedAlgorithmVersion = FeedAlgoVersion,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Photos eligible for the public home feed: live, public,
|
||||
/// and not from an uploader the caller blocked/ignored.</summary>
|
||||
private IQueryable<PhotoEntity> VisibleFeedPhotos(List<long> hidden)
|
||||
{
|
||||
var q = db.Photos.Where(p => p.IsPublic && p.DeletedAt == null);
|
||||
if (hidden.Count > 0)
|
||||
q = q.Where(p => !hidden.Contains(p.UploaderPlayerId));
|
||||
return q;
|
||||
}
|
||||
|
||||
/// <summary>Case-insensitive property lookup — the 2023 generated
|
||||
/// writers emit PascalCase but their readers register all three
|
||||
/// casings, so tolerate whatever the client sends.</summary>
|
||||
private static bool TryFeedProp(JsonElement obj, string name, out JsonElement value)
|
||||
{
|
||||
foreach (var prop in obj.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = prop.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Opaque base64url cursor handed to the client as
|
||||
/// FeedContext. Carries the phase, the (CreatedAt, Id) keyset
|
||||
/// position within that phase, and the pinned injected ids so they
|
||||
/// stay suppressed on later pages.</summary>
|
||||
private static string EncodeFeedCursor(int phase, long ticks, long lastId, IReadOnlyList<long> injected)
|
||||
{
|
||||
var raw = $"1|{phase}|{ticks}|{lastId}|{string.Join(',', injected)}";
|
||||
return Convert.ToBase64String(Encoding.UTF8.GetBytes(raw))
|
||||
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
}
|
||||
|
||||
private static bool TryDecodeFeedCursor(string? token, out int phase,
|
||||
out long ticks, out long lastId, out long[] injected)
|
||||
{
|
||||
phase = 0;
|
||||
ticks = 0;
|
||||
lastId = 0;
|
||||
injected = Array.Empty<long>();
|
||||
if (string.IsNullOrWhiteSpace(token)) return false;
|
||||
try
|
||||
{
|
||||
var b64 = token.Replace('-', '+').Replace('_', '/');
|
||||
b64 = b64.PadRight(b64.Length + ((4 - (b64.Length % 4)) % 4), '=');
|
||||
var parts = Encoding.UTF8.GetString(Convert.FromBase64String(b64)).Split('|');
|
||||
if (parts.Length != 5 || parts[0] != "1") return false;
|
||||
if (!int.TryParse(parts[1], out phase)) return false;
|
||||
if (!long.TryParse(parts[2], out ticks)) return false;
|
||||
if (!long.TryParse(parts[3], out lastId)) return false;
|
||||
injected = parts[4]
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(s => long.TryParse(s, out var v) ? v : 0L)
|
||||
.Where(v => v > 0)
|
||||
.ToArray();
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
// Garbage/stale cursor — restart the feed from the top instead
|
||||
// of failing the whole pull.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Trim/dedupe tagged ids and drop the uploader's own id.
|
||||
|
||||
Reference in New Issue
Block a user