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.
// Clamped: this route is anonymous and the value is client-supplied,
// so an unbounded renewal would let anyone holding the code extend
// its life indefinitely past what the creator chose.
if (req.ValidHours is > 0)
payload.ExpiresAt = DateTime.UtcNow.AddHours(Math.Min(req.ValidHours.Value, 720));
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; }
}