using System.Security.Cryptography; using System.Text.Json; using System.Text.Json.Serialization; using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Server.State; namespace FinalFactory.Rendezvous.Server.Browser; internal sealed class SessionStreamCursorCodec : IDisposable { private const string Prefix = "rvs1"; private readonly EphemeralCursorProtector _protector = new(); public string Encode(VisibleListingQuery query, long revision, DateTimeOffset now) { ArgumentOutOfRangeException.ThrowIfNegative(revision); SessionStreamCursorPayload payload = new() { GameId = query.Scope.GameId.Value, EnvironmentId = query.Scope.EnvironmentId.Value, ProtocolVersion = query.ProtocolVersion, RegionId = query.RegionId?.Value, ExcludeFull = query.ExcludeFull, Revision = revision, ExpiresAtUnixSeconds = now.AddMinutes(10).ToUnixTimeSeconds(), }; byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(payload, ContractJson.Options); try { return _protector.Protect(Prefix, encoded); } finally { CryptographicOperations.ZeroMemory(encoded); } } public bool TryDecode( string? cursor, VisibleListingQuery query, DateTimeOffset now, out long revision) { revision = 0; if (cursor is null || !_protector.TryUnprotect(Prefix, cursor, out byte[] encodedPayload)) { return false; } SessionStreamCursorPayload? payload; try { payload = JsonSerializer.Deserialize( encodedPayload, ContractJson.Options); } catch (JsonException) { payload = null; } finally { CryptographicOperations.ZeroMemory(encodedPayload); } if (payload is null || payload.Revision < 0 || payload.ExpiresAtUnixSeconds <= now.ToUnixTimeSeconds() || !string.Equals(payload.GameId, query.Scope.GameId.Value, StringComparison.Ordinal) || !string.Equals(payload.EnvironmentId, query.Scope.EnvironmentId.Value, StringComparison.Ordinal) || payload.ProtocolVersion != query.ProtocolVersion || !string.Equals(payload.RegionId, query.RegionId?.Value, StringComparison.Ordinal) || payload.ExcludeFull != query.ExcludeFull) { return false; } revision = payload.Revision; return true; } public void Dispose() => _protector.Dispose(); public override string ToString() => "[SessionStreamCursorCodec: key and cursors redacted]"; } internal sealed class SessionStreamCursorPayload { [JsonRequired] public string GameId { get; set; } = string.Empty; [JsonRequired] public string EnvironmentId { get; set; } = string.Empty; [JsonRequired] public uint ProtocolVersion { get; set; } public string? RegionId { get; set; } [JsonRequired] public bool ExcludeFull { get; set; } [JsonRequired] public long Revision { get; set; } [JsonRequired] public long ExpiresAtUnixSeconds { get; set; } }