using System.Security.Cryptography; using System.Text.Json; using System.Text.Json.Serialization; using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Server.Browser; namespace FinalFactory.Rendezvous.Server.JoinAttempts; internal sealed class JoinAttemptCursorCodec : IDisposable { private const string Prefix = "rvj1"; private readonly EphemeralCursorProtector _protector = new(); public string Encode( SessionListingId listingId, JoinAttemptId after, DateTimeOffset now) { JoinAttemptCursorPayload payload = new() { ListingId = listingId.ToString(), AfterAttemptId = after.ToString(), ExpiresAtUnixSeconds = now.AddMinutes(5).ToUnixTimeSeconds(), }; byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(payload, ContractJson.Options); try { return _protector.Protect(Prefix, encoded); } finally { CryptographicOperations.ZeroMemory(encoded); } } public bool TryDecode( string? cursor, SessionListingId listingId, DateTimeOffset now, out JoinAttemptId? after) { after = null; if (cursor is null) { return true; } if (!_protector.TryUnprotect(Prefix, cursor, out byte[] encodedPayload)) { return false; } JoinAttemptCursorPayload? payload; try { payload = JsonSerializer.Deserialize( encodedPayload, ContractJson.Options); } catch (JsonException) { payload = null; } finally { CryptographicOperations.ZeroMemory(encodedPayload); } if (payload is null || payload.ExpiresAtUnixSeconds <= now.ToUnixTimeSeconds() || !string.Equals(payload.ListingId, listingId.ToString(), StringComparison.Ordinal) || !JoinAttemptId.TryParse(payload.AfterAttemptId, out JoinAttemptId attemptId)) { return false; } after = attemptId; return true; } public void Dispose() => _protector.Dispose(); public override string ToString() => "[JoinAttemptCursorCodec: key and cursors redacted]"; } internal sealed class JoinAttemptCursorPayload { [JsonRequired] public string ListingId { get; set; } = string.Empty; [JsonRequired] public string AfterAttemptId { get; set; } = string.Empty; [JsonRequired] public long ExpiresAtUnixSeconds { get; set; } }