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 SessionBrowserCursorCodec : IDisposable { private const string Prefix = "rvc1"; private readonly EphemeralCursorProtector _protector = new(); public string Encode(VisibleListingQuery query, SessionListingId after, DateTimeOffset now) { BrowserCursorPayload payload = new() { GameId = query.Scope.GameId.Value, EnvironmentId = query.Scope.EnvironmentId.Value, ProtocolVersion = query.ProtocolVersion, RegionId = query.RegionId?.Value, ExcludeFull = query.ExcludeFull, AfterListingId = 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, TenantScope scope, uint protocolVersion, RegionId? regionId, bool excludeFull, DateTimeOffset now, out SessionListingId? after) { after = null; if (cursor is null) { return true; } if (!_protector.TryUnprotect(Prefix, cursor, out byte[] encodedPayload)) { return false; } BrowserCursorPayload? 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.GameId, scope.GameId.Value, StringComparison.Ordinal) || !string.Equals(payload.EnvironmentId, scope.EnvironmentId.Value, StringComparison.Ordinal) || payload.ProtocolVersion != protocolVersion || !string.Equals(payload.RegionId, regionId?.Value, StringComparison.Ordinal) || payload.ExcludeFull != excludeFull || !SessionListingId.TryParse(payload.AfterListingId, out SessionListingId listingId)) { return false; } after = listingId; return true; } public void Dispose() => _protector.Dispose(); public override string ToString() => "[SessionBrowserCursorCodec: key and cursors redacted]"; } internal sealed class BrowserCursorPayload { [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 string AfterListingId { get; set; } = string.Empty; [JsonRequired] public long ExpiresAtUnixSeconds { get; set; } }