using System.Security.Cryptography; using System.Text.Json; using System.Text.Json.Serialization; using DorkNet.Server.Auth; using DorkNet.Server.Binding; using DorkNet.Server.Data; using DorkNet.Server.Data.Entities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace DorkNet.Server.Controllers.Link; /// /// The Link host (GJDLNNLKDIJ.Link = host ordinal 11) share-code /// service — RecNet.Runtime/CKBKHENHCAN. Every "share this" surface in /// the 2023 client funnels through here: /// RecRoom.Sharing.ActionCode subclasses (FriendCode, ReferralCode, /// MeetupCode, ClubCode, PlayerEventCode, RoomCode, InfluencerCode, PhotoCode) /// create an action link — a short code the player reads out or pastes, /// which the receiving client redeems — while LinkManager stores the /// larger opaque payload behind a share URL as a data link. /// /// The five routes and their verbs, read off the dispatch call in /// CKBKHENHCAN.txt (the shape is /// (this, verb=rdx, host=r8, path=r9, body=stack), verb being a /// BestHTTP.HTTPMethods ordinal — GET=0, POST=2, PUT=3): /// /// GET actionlink/{code} — CGIEJDIHJBD (:635), verb /// 045 Move rdx, 0 (:793), host 050 LoadAddress r8, [rdx+11] /// (:798), literal "actionlink/" (:786). /// POST actionlink — PCNPNDPAFLK (:860), verb /// 106 Move rdx, 2 (:1226), host 105 Move r8, 11 (:1225), /// literal "actionlink" (:1224). /// POST actionlink/{code}/consume — MKAKECJOKDG (:1380), /// verb 077 Move rdx, 2 (:1675), path built by /// String.Concat("actionlink/", WebUtility.UrlEncode(code), "/consume") /// (:1665-1669). /// PUT datalink — ONPLPBLKACA (:419), verb /// 033 Move rdx, 3 (:560), literal "datalink" (:558). /// GET datalink/{code} — KEHDDPCHPGK (:194), verb /// 045 Move rdx, 0 (:352), literal "datalink/" (:345). /// /// All five were unrouted, so every share/redeem flow 404'd. /// /// Storage. Codes live in PlayerSettings — the server's /// existing general-purpose per-player key/value table, already used for /// ban-appeal codes (CompatibilityFeatureController) and external /// friend invites (ExternalFriendInviteController) — under /// actionlink:{CODE} / datalink:{CODE} keys on the creator's /// row. That keeps codes durable across restarts without a schema change; the /// cost is the 1024-character cap, /// which is enforced explicitly below rather than left to trip a Postgres /// varchar(1024) constraint at SaveChanges. /// [ApiController] public class LinkController(DorkNetDbContext db, ILogger logger) : ControllerBase { private const string ActionPrefix = "actionlink:"; private const string DataPrefix = "datalink:"; /// Unambiguous uppercase alphabet — no O/0/I/1 — because action /// codes are read aloud and re-typed into /// ActionCodeConsumptionModel's input field. private const string Alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; /// PlayerSettingEntity.Value is [MaxLength(1024)]. private const int ValueLimit = 1024; /// RecRoom.Sharing.CBKKGBIJHAL (2023.06.21 dump.cs:1248232): /// Unknown=-1, Friend=0, Referral=1, Meetup=2, Club=3, PlayerEvent=4, /// Room=5, Influencer=6, Photo=7. Confirmed live at the consume call sites /// — RoomCode passes 030 Move r8, 5 /// (RoomCode_NestedType_IBLIEDEHMPI.txt:175) and ClubCode passes /// 029 Move r8, 3 (ClubCode_NestedType_EAGCMJOEANN.txt:85). private const int CodeTypeReferral = 1; // ── actionlink ──────────────────────────────────────────────────────────── /// POST actionlink — mint a share code. /// /// Form fields are added one at a time by /// BNDIAONDFFF.AFGEDDANEKP: data (:1234), validHours /// (:1249), maxCount (:1261, boxed Nullable<Int32> — /// omitted when null), codeType (:1273) and extraDataId /// (:1287, boxed Nullable<Int64>). The explicit /// [ModelBinder] is mandatory: [ApiController] would /// otherwise re-infer [FromBody] and 415 the form post. /// /// The issuing method is /// FGLDKEJLAKB<System.String> PCNPNDPAFLK(...) (:860), so the /// body is a bare JSON string — the code itself. Ok(string) /// would go out as unquoted text/plain via /// StringOutputFormatter, which the client's String formatter /// cannot read, hence the explicit Content(JsonSerializer.Serialize(…)) /// (same pattern as the ban-appeal code handler). [HttpPost("/actionlink")] [Authorize] public async Task ActionLinkCreate( [ModelBinder(typeof(FormOrJsonModelBinder))] ActionLinkCreateRequest req) { var me = this.RequireCurrentPlayerId(); var data = req.Data ?? string.Empty; var payload = new ActionLinkPayload { Data = data, ExpiresAt = req.ValidHours > 0 ? (DateTime?)DateTime.UtcNow.AddHours(req.ValidHours) : null, MaxCount = req.MaxCount is > 0 ? req.MaxCount : (int?)null, Uses = 0, CodeType = req.CodeType, ExtraDataId = req.ExtraDataId, }; var encoded = JsonSerializer.Serialize(payload); if (encoded.Length > ValueLimit) { logger.LogWarning( "[link] actionlink payload too large player={PlayerId} codeType={CodeType} bytes={Length} limit={Limit}", me, req.CodeType, encoded.Length, ValueLimit); return BadRequest(new { Error = "Action link data is too large to store." }); } var code = await ReserveCodeAsync(ActionPrefix, 8); if (code is null) return StatusCode(StatusCodes.Status503ServiceUnavailable); db.PlayerSettings.Add(new PlayerSettingEntity { PlayerId = me, Key = ActionPrefix + code, Value = encoded, }); await db.SaveChangesAsync(); logger.LogInformation( "[link] actionlink created code={Code} player={PlayerId} codeType={CodeType} validHours={ValidHours} maxCount={MaxCount}", code, me, req.CodeType, req.ValidHours, req.MaxCount); return Content(JsonSerializer.Serialize(code), "application/json"); } /// GET actionlink/{code} — peek at a code without spending /// a use. /// /// Anonymous on purpose: ActionCode.ModifyPreAuthLaunchTarget /// resolves an inbound deep link during BootSequence, i.e. before /// the player has a token. /// /// Return type FGLDKEJLAKB<NBAFGFFIOLK> (:635); the /// generated formatter DPIECEMDKPL registers exactly three members — /// CreatorPlayerId (:267, Int32), Data (:294, String), /// IsValid (:310, Boolean) — each also in camelCase (:278/:302/:318) /// and lowercase (:286/:326), so the server's camelCase anonymous-object /// serialization binds. An unknown code answers 200 with /// IsValid:false rather than 404 so the client always gets a body it /// can parse instead of faulting the promise. [HttpGet("/actionlink/{code}")] [AllowAnonymous] public async Task ActionLinkGet(string code) { var row = await FindAsync(ActionPrefix, code); var payload = Decode(row); if (row is null || payload is null) return Ok(ActionLinkResult(0, string.Empty, false)); var valid = IsUsable(payload); return Ok(ActionLinkResult((int)row.PlayerId, valid ? payload.Data : string.Empty, valid)); } /// POST actionlink/{code}/consume — redeem a code. /// /// Form fields: id (:1684 — the code again), validHours /// (:1692, Nullable<Int32>), codeType (:1720), /// newPlayer (:1732) and newInstall (:1745). Response shape /// is the same NBAFGFFIOLK as the GET (:1380). /// /// validHours here is the creating ActionCode's /// Configuration.AutoRenewHours — the call sites load it immediately /// before dispatch (043 Call Configuration.get_AutoRenewHours, /// RoomCode_NestedType_IBLIEDEHMPI.txt:168; ClubCode_NestedType_EAGCMJOEANN.txt:78) /// and it is null for codes whose AutoRenew is off. So a /// present value means "this code renews on use": the expiry is pushed out /// that many hours instead of the code burning down. /// /// codeType is checked against the stored one — a Room code /// (5) redeemed through the Club (3) flow would otherwise hand the club /// handler a room payload to parse. /// /// Referral codes are the one type with no other server touchpoint: /// ReferralCode has no HTTP routes of its own, and /// IncentivizedReferralsController counts /// referral:credited:{inviterId}:{inviteeId} rows that nothing else /// writes. Redeeming one here as a new player is what credits the /// inviter. /// /// Anonymous like the GET — the consume can fire from the pre-auth /// launch-target path; the caller's id is only needed for referral /// crediting. [HttpPost("/actionlink/{code}/consume")] [AllowAnonymous] public async Task ActionLinkConsume( string code, [ModelBinder(typeof(FormOrJsonModelBinder))] ActionLinkConsumeRequest req) { // The client sends the code in the path AND as the `id` field; prefer // the path, fall back to the field. var wanted = string.IsNullOrWhiteSpace(code) ? req.Id : code; var row = await FindAsync(ActionPrefix, wanted); var payload = Decode(row); if (row is null || payload is null) { logger.LogInformation("[link] actionlink consume miss code={Code}", Normalize(wanted)); return Ok(ActionLinkResult(0, string.Empty, false)); } if (!IsUsable(payload) || payload.CodeType != req.CodeType) { logger.LogInformation( "[link] actionlink consume rejected code={Code} storedType={StoredType} askedType={AskedType} uses={Uses}/{MaxCount} expires={Expires}", Normalize(wanted), payload.CodeType, req.CodeType, payload.Uses, payload.MaxCount, payload.ExpiresAt); return Ok(ActionLinkResult((int)row.PlayerId, string.Empty, false)); } payload.Uses++; // AutoRenewHours present ⇒ the code renews rather than ages out. if (req.ValidHours is > 0) payload.ExpiresAt = DateTime.UtcNow.AddHours(req.ValidHours.Value); var encoded = JsonSerializer.Serialize(payload); if (encoded.Length <= ValueLimit) row.Value = encoded; var consumer = this.CurrentPlayerId(); await CreditReferralAsync(payload, row.PlayerId, consumer, req.NewPlayer); await db.SaveChangesAsync(); logger.LogInformation( "[link] actionlink consumed code={Code} creator={Creator} consumer={Consumer} type={CodeType} uses={Uses} newPlayer={NewPlayer} newInstall={NewInstall}", Normalize(wanted), row.PlayerId, consumer ?? 0, payload.CodeType, payload.Uses, req.NewPlayer, req.NewInstall); return Ok(ActionLinkResult((int)row.PlayerId, payload.Data, true)); } // ── datalink ────────────────────────────────────────────────────────────── /// PUT datalink — stash the payload behind a share URL. /// /// Single form field data (:569, added right after the /// 033 Move rdx, 3 PUT dispatch at :560). Return type is /// FGLDKEJLAKB<System.String> (:419) — a bare JSON string /// holding the generated code, so the same explicit /// Content(JsonSerializer.Serialize(…), "application/json") as the /// action-link create. /// /// Codes are 10 characters here rather than 8: nobody types a /// datalink code, it only ever travels inside a URL, so the extra entropy /// is free. [HttpPut("/datalink")] [Authorize] public async Task DataLinkCreate( [ModelBinder(typeof(FormOrJsonModelBinder))] DataLinkCreateRequest req) { var me = this.RequireCurrentPlayerId(); var data = req.Data ?? string.Empty; if (data.Length > ValueLimit) { logger.LogWarning( "[link] datalink payload too large player={PlayerId} bytes={Length} limit={Limit}", me, data.Length, ValueLimit); return BadRequest(new { Error = "Data link payload is too large to store." }); } var code = await ReserveCodeAsync(DataPrefix, 10); if (code is null) return StatusCode(StatusCodes.Status503ServiceUnavailable); db.PlayerSettings.Add(new PlayerSettingEntity { PlayerId = me, Key = DataPrefix + code, Value = data, }); await db.SaveChangesAsync(); logger.LogInformation("[link] datalink created code={Code} player={PlayerId} bytes={Length}", code, me, data.Length); return Content(JsonSerializer.Serialize(code), "application/json"); } /// GET datalink/{code} — resolve a share URL back to its /// payload. /// /// Return type FGLDKEJLAKB<LBBKBMPAEIO> (:194) whose /// generated formatter IKBPJACLIHH registers a single member, /// Data (:139) — plus the data (:150) alias the server's /// camelCase serializer emits. Anonymous, like the action-link read: this /// is resolved from a cold-boot deep link. An unknown code returns an empty /// Data rather than 404 so the strict reader still gets an /// object. [HttpGet("/datalink/{code}")] [AllowAnonymous] public async Task DataLinkGet(string code) { var row = await FindAsync(DataPrefix, code); return Ok(new { Data = row?.Value ?? string.Empty }); } // ── helpers ─────────────────────────────────────────────────────────────── /// Codes are stored uppercase so a player who types their share /// code in lowercase still redeems it. private static string Normalize(string? code) => (code ?? string.Empty).Trim().ToUpperInvariant(); private Task FindAsync(string prefix, string? code) { var normalized = Normalize(code); if (normalized.Length == 0) return Task.FromResult(null); var key = prefix + normalized; return db.PlayerSettings.FirstOrDefaultAsync(s => s.Key == key); } /// Generates a code that is free across every player's rows (the /// unique index is on (PlayerId, Key), but a code has to be globally /// unique because lookups are by key alone). Retries a handful of times /// before giving up rather than colliding on insert. private async Task ReserveCodeAsync(string prefix, int length) { for (var attempt = 0; attempt < 6; attempt++) { var code = NewCode(length); var key = prefix + code; if (!await db.PlayerSettings.AnyAsync(s => s.Key == key)) return code; } logger.LogError("[link] exhausted code-generation attempts for prefix={Prefix}", prefix); return null; } private static string NewCode(int length) { var chars = new char[length]; for (var i = 0; i < length; i++) chars[i] = Alphabet[RandomNumberGenerator.GetInt32(Alphabet.Length)]; return new string(chars); } private ActionLinkPayload? Decode(PlayerSettingEntity? row) { if (row is null) return null; try { return JsonSerializer.Deserialize(row.Value); } catch (JsonException ex) { logger.LogWarning(ex, "[link] unreadable action-link row id={RowId} key={Key}", row.Id, row.Key); return null; } } private static bool IsUsable(ActionLinkPayload payload) { if (payload.ExpiresAt is { } expiry && expiry <= DateTime.UtcNow) return false; if (payload.MaxCount is { } max && payload.Uses >= max) return false; return true; } /// Records "consumer was referred by creator" in the /// shape IncentivizedReferralsController already reads /// (referral:credited:{inviterId}:{inviteeId}, counted by key prefix /// regardless of which row owns it — stored on the invitee, matching the /// externalinvite:redeemed:* convention). Only fires for referral /// codes redeemed by a signed-in, brand-new player who is not the /// creator. private async Task CreditReferralAsync( ActionLinkPayload payload, long creatorId, long? consumerId, bool newPlayer) { if (payload.CodeType != CodeTypeReferral) return; if (!newPlayer) return; if (consumerId is not { } consumer || consumer == creatorId) return; var key = $"referral:credited:{creatorId}:{consumer}"; if (await db.PlayerSettings.AnyAsync(s => s.PlayerId == consumer && s.Key == key)) return; db.PlayerSettings.Add(new PlayerSettingEntity { PlayerId = consumer, Key = key, Value = DateTime.UtcNow.ToString("O"), }); logger.LogInformation("[link] referral credited inviter={Inviter} invitee={Invitee}", creatorId, consumer); } private static object ActionLinkResult(int creatorPlayerId, string data, bool isValid) => new { CreatorPlayerId = creatorPlayerId, Data = data, IsValid = isValid, }; /// Everything the client told us at create time, packed into the /// single PlayerSettings.Value string. Keys are one character each /// to leave as much of the 1024-character budget as possible for /// . private sealed class ActionLinkPayload { [JsonPropertyName("d")] public string Data { get; set; } = string.Empty; [JsonPropertyName("e")] public DateTime? ExpiresAt { get; set; } [JsonPropertyName("m")] public int? MaxCount { get; set; } [JsonPropertyName("u")] public int Uses { get; set; } [JsonPropertyName("t")] public int CodeType { get; set; } [JsonPropertyName("x")] public long? ExtraDataId { get; set; } } } /// Form fields of POST actionlink — CKBKHENHCAN.txt:1234 /// (data), :1249 (validHours), :1261 (maxCount), :1273 /// (codeType), :1287 (extraDataId). public sealed class ActionLinkCreateRequest { public string? Data { get; set; } public int ValidHours { get; set; } public int? MaxCount { get; set; } public int CodeType { get; set; } public long? ExtraDataId { get; set; } } /// Form fields of POST actionlink/{code}/consume — /// CKBKHENHCAN.txt:1684 (id), :1692 (validHours), :1720 /// (codeType), :1732 (newPlayer), :1745 /// (newInstall). public sealed class ActionLinkConsumeRequest { public string? Id { get; set; } public int? ValidHours { get; set; } public int CodeType { get; set; } public bool NewPlayer { get; set; } public bool NewInstall { get; set; } } /// Sole form field of PUT datalink — CKBKHENHCAN.txt:569. public sealed class DataLinkCreateRequest { public string? Data { get; set; } }