Editor/Core/SessionServer.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;

namespace TeamCreate;

public sealed class SessionServer : IDisposable
{
    private sealed class Member
    {
        public Wire Wire;

        public string Peer;

        public string Name;

        public string SteamId;

        public Packet Presence;

        public DateTime LastSeen = DateTime.UtcNow;
    }

    private sealed class Refusal
    {
        public Wire Wire;

        public int Generation;
    }

    private sealed class PendingVerification
    {
        public Member Member;

        public Packet Hello;

        public Task<SteamTokenVerdict> Task;
    }

    private readonly Listener listener;

    private readonly List<Member> members = new List<Member>();

    private readonly string project;

    private readonly string baseline;

    private readonly string token;

    private readonly HashSet<string> compatibleBaselines = new HashSet<string>();

    private readonly string sessionId = Guid.NewGuid().ToString();

    private readonly Authority authority;

    private readonly Func<Packet, SteamTokenVerdict> verifyVerdict;

    private readonly List<Refusal> refusals = new List<Refusal>();

    private int pumpGeneration;

    private readonly FileStream journal;

    public const int MaxPendingVerifications = 8;

    private readonly List<PendingVerification> pendingVerifications = new List<PendingVerification>();

    private readonly Func<Packet, Task<SteamTokenVerdict>> asyncVerifier;

    private readonly CancellationTokenSource verificationLifetime = new CancellationTokenSource();

    public string Failure { get; private set; }

    public string StoredFailure => (Failure == null) ? null : CollaborationTransportDiagnostics.BoundedStoredDiagnostic(Failure);

    public bool AllowUnverifiedPeers { get; set; }

    public Func<Packet, PeerFacts> peerFacts { get; set; }

    public string hostLibraryVersion { get; set; }

    public string hostManifest { get; set; }

    public CollaborationConfig config { get; set; }

    /// <summary>
    /// Guests that arrive through the relay instead of the TCP listener. They are drained into the same member
    /// list under the same connection cap, and admitted by the same hello, gates and verification, so a relayed
    /// peer is held to exactly the rules a direct one is.
    /// </summary>
    public RelayHost Relay { get; set; }

    public int PendingVerificationCount => pendingVerifications.Count;

    public SessionServer(string address, int port, string project, string baseline, string token, string journalPath, Func<Packet, SteamTokenVerdict> verifyVerdict = null, Func<Packet, Task<SteamTokenVerdict>> asyncVerifyVerdict = null, byte[] sessionKey = null)
    {
        this.project = project;
        this.baseline = baseline;
        this.token = token;
        this.verifyVerdict = verifyVerdict;
        asyncVerifier = asyncVerifyVerdict;
        compatibleBaselines.Add(baseline);
        Directory.CreateDirectory(Path.GetDirectoryName(journalPath));
        journal = new FileStream(journalPath, FileMode.CreateNew, FileAccess.Write, FileShare.Read);
        authority = new Authority((Packet p) =>
        {
            byte[] bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(p) + "\n");
            journal.Write(bytes);
            journal.Flush(flushToDisk: true);
        });
        try
        {
            listener = new Listener(address, port, sessionKey);
        }
        catch
        {
            journal.Dispose();
            throw;
        }
    }

    public void Pump()
    {
        if (listener.Failure != null)
        {
            throw new IOException(listener.StoredFailure);
        }
        pumpGeneration++;
        CloseExpiredRefusals();
        PollVerifications();
        Wire result;
        while (listener.Accepted.TryDequeue(out result))
        {
            if (members.Count >= 16)
            {
                result.Dispose();
                continue;
            }
            members.Add(new Member
            {
                Wire = result
            });
        }
        Wire relayed;
        while (Relay != null && Relay.Accepted.TryDequeue(out relayed))
        {
            if (members.Count >= 16)
            {
                relayed.Dispose();
                continue;
            }
            members.Add(new Member
            {
                Wire = relayed
            });
        }
        Member[] array = members.ToArray();
        foreach (Member m in array)
        {
            if (m.Wire.StoredFailure != null || DateTime.UtcNow - m.LastSeen > TimeSpan.FromSeconds(15L))
            {
                Remove(m);
                continue;
            }
            int num = 64;
            while (true)
            {
                if (num-- <= 0 || !m.Wire.Incoming.TryDequeue(out var p))
                {
                    break;
                }
                m.LastSeen = DateTime.UtcNow;
                try
                {
                    string text;
                    if (m.Peer == null)
                    {
                        text = Protocol.GetHandshakeError(p, requireValidPeer: true);
                        if (text == null && p.Token != token)
                        {
                            text = "Incorrect session key";
                        }
                        if (text == null && p.Project != project)
                        {
                            text = "Project ID differs. Use the same committed ProjectSettings/collaboration.json.";
                        }
                        if (text == null && !compatibleBaselines.Contains(p.Baseline))
                        {
                            text = "Project/scene baseline differs (engine, source, assets or metadata). Sync copies in Git before joining.";
                        }
                        if (text == null && (string.IsNullOrWhiteSpace(p.Name) || p.Name.Length > 64))
                        {
                            text = "Invalid collaborator name";
                        }
                        if (text != null)
                        {
                            RejectWith(m, "fatal", text);
                            continue;
                        }
                        text = SteamIdentityAdmission.GetDuplicateError(p.SteamId, RosterSteamIds(m));
                        if (text != null)
                        {
                            goto IL_0335;
                        }
                        if (asyncVerifier != null)
                        {
                            if (!TryDeferVerification(m, p))
                            {
                                RejectWith(m, "fatal", "Too many peers are awaiting identity verification");
                            }
                            continue;
                        }
                        text = SteamIdentityAdmission.GetVerificationError(VerifyVerdict(p), AllowUnverifiedPeers);
                        goto IL_0335;
                    }
                    p.Peer = m.Peer;
                    switch (p.Kind)
                    {
                    case "subscribe":
                        if (string.IsNullOrWhiteSpace(p.Scene) || !p.Scene.StartsWith("scenes/", StringComparison.Ordinal) || p.Scene.Contains(".."))
                        {
                            throw new InvalidOperationException("Scene must be under Assets/scenes");
                        }
                        m.Wire.Send(authority.Subscribe(p));
                        break;
                    case "edit":
                    {
                        Packet packet = authority.Apply(p);
                        if (packet.Kind == "reject")
                        {
                            m.Wire.Send(packet);
                        }
                        else
                        {
                            Broadcast(packet);
                        }
                        break;
                    }
                    case "presence":
                        p.Name = m.Name;
                        // The account the host admitted, never whatever the sender wrote: this is what every editor looks the profile
                        // picture up by, so a guest must not be able to show someone else's.
                        p.SteamId = m.SteamId;
                        m.Presence = p;
                        Broadcast(p);
                        break;
                    case "ping":
                        m.Wire.Send(new Packet
                        {
                            Kind = "pong"
                        });
                        break;
                    case "leave":
                        Remove(m);
                        break;
                    default:
                        throw new InvalidOperationException("Unknown message type");
                    }
                    goto end_IL_0174;
                    IL_0335:
                    if (text != null)
                    {
                        Reject(m, text);
                        continue;
                    }
                    text = AdmissionGateError(p);
                    if (text != null)
                    {
                        Reject(m, text);
                        continue;
                    }
                    Member[] array2 = members.Where((Member x) => x != m && x.Peer == p.Peer).ToArray();
                    foreach (Member member in array2)
                    {
                        Remove(member);
                    }
                    m.Peer = p.Peer;
                    m.Name = p.Name;
                    m.SteamId = p.SteamId;
                    m.Wire.Send(new Packet
                    {
                        Kind = "welcome",
                        Session = sessionId
                    });
                    foreach (Member item in members.Where((Member x) => x.Presence != null))
                    {
                        m.Wire.Send(item.Presence);
                    }
                    end_IL_0174:;
                }
                catch (Exception error)
                {
                    m.Wire.Send(new Packet
                    {
                        Kind = "fatal",
                        Error = CollaborationTransportDiagnostics.BoundedExceptionDiagnostic("Host processing failed: ", error)
                    });
                }
            }
        }
    }

    private void Reject(Member member, string reason)
    {
        RejectWith(member, "reject", reason);
    }

    private string AdmissionGateError(Packet hello)
    {
        if (this.peerFacts == null)
        {
            return null;
        }
        PeerFacts peerFacts;
        try
        {
            peerFacts = this.peerFacts(hello);
        }
        catch (Exception error)
        {
            return "Admission requirements could not be evaluated for this peer: " + CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(string.Empty, error);
        }
        PeerFacts peerFacts2 = peerFacts ?? new PeerFacts(hello.SteamId, IdentityVerified: false, IsFriend: false, IsOrgMember: false, hello.LibraryVersion, hello.ProjectManifest, hello.OrgIdent);
        peerFacts2 = peerFacts2 with
        {
            SteamId = hello.SteamId,
            IdentityVerified = true
        };
        string failedGate;
        return AdmissionRules.Evaluate(peerFacts2, config, hostLibraryVersion, hostManifest, out failedGate, hello.Name);
    }

    private bool TryDeferVerification(Member member, Packet hello)
    {
        if (pendingVerifications.Any((PendingVerification v) => v.Member == member))
        {
            return false;
        }
        if (pendingVerifications.Count >= 8)
        {
            return false;
        }
        Task<SteamTokenVerdict> task = asyncVerifier(hello);
        if (task == null)
        {
            return false;
        }
        pendingVerifications.Add(new PendingVerification
        {
            Member = member,
            Hello = hello,
            Task = task
        });
        return true;
    }

    private void PollVerifications()
    {
        if (pendingVerifications.Count == 0)
        {
            return;
        }
        for (int num = pendingVerifications.Count - 1; num >= 0; num--)
        {
            PendingVerification pending = pendingVerifications[num];
            if (pending.Task.IsCompleted)
            {
                pendingVerifications.RemoveAt(num);
                if (pending.Member.Wire != null)
                {
                    SteamTokenVerdict verdict;
                    try
                    {
                        verdict = pending.Task.GetAwaiter().GetResult();
                    }
                    catch (Exception)
                    {
                        verdict = SteamTokenVerdict.ServiceUnavailable;
                    }
                    string verificationError = SteamIdentityAdmission.GetVerificationError(verdict, AllowUnverifiedPeers);
                    if (verificationError != null)
                    {
                        RejectWith(pending.Member, "fatal", verificationError);
                    }
                    else
                    {
                        string duplicateError = SteamIdentityAdmission.GetDuplicateError(pending.Hello.SteamId, RosterSteamIds(pending.Member));
                        if (duplicateError != null)
                        {
                            RejectWith(pending.Member, "fatal", duplicateError);
                        }
                        else
                        {
                            string text = AdmissionGateError(pending.Hello);
                            if (text != null)
                            {
                                Reject(pending.Member, text);
                            }
                            else
                            {
                                Member m = pending.Member;
                                Member[] array = members.Where((Member x) => x != m && x.Peer == pending.Hello.Peer).ToArray();
                                foreach (Member member in array)
                                {
                                    Remove(member);
                                }
                                m.Peer = pending.Hello.Peer;
                                m.Name = pending.Hello.Name;
                                m.SteamId = pending.Hello.SteamId;
                                m.Wire.Send(new Packet
                                {
                                    Kind = "welcome",
                                    Session = sessionId
                                });
                                foreach (Member item in members.Where((Member x) => x.Presence != null))
                                {
                                    m.Wire.Send(item.Presence);
                                }
                            }
                        }
                    }
                }
            }
        }
    }

    private void CancelVerifications()
    {
        try
        {
            verificationLifetime.Cancel();
        }
        catch (ObjectDisposedException)
        {
        }
        foreach (PendingVerification pendingVerification in pendingVerifications)
        {
            Remove(pendingVerification.Member);
        }
        pendingVerifications.Clear();
    }

    private void RejectWith(Member member, string kind, string reason)
    {
        try
        {
            member.Wire.Send(new Packet
            {
                Kind = kind,
                Error = reason
            });
        }
        catch (IOException)
        {
        }
        members.Remove(member);
        refusals.Add(new Refusal
        {
            Wire = member.Wire,
            Generation = pumpGeneration
        });
    }

    private void CloseExpiredRefusals()
    {
        Refusal[] array = refusals.Where((Refusal x) => x.Generation < pumpGeneration).ToArray();
        foreach (Refusal refusal in array)
        {
            refusals.Remove(refusal);
            try
            {
                refusal.Wire.Dispose();
            }
            catch (Exception)
            {
            }
        }
    }

    private IEnumerable<string> RosterSteamIds(Member joining)
    {
        return from x in members
            where x != joining && x.SteamId != null
            select x.SteamId;
    }

    private SteamTokenVerdict VerifyVerdict(Packet p)
    {
        return verifyVerdict?.Invoke(p) ?? SteamTokenVerdict.ServiceUnavailable;
    }

    private void Broadcast(Packet packet)
    {
        Member[] array = members.Where((Member x) => x.Peer != null).ToArray();
        foreach (Member member in array)
        {
            try
            {
                member.Wire.Send(packet);
            }
            catch (Exception error)
            {
                Failure = CollaborationTransportDiagnostics.BoundedExceptionDiagnostic("Broadcast failed: ", error);
                Remove(member);
            }
        }
    }

    public void AllowSavedBaseline(string fingerprint)
    {
        compatibleBaselines.Add(fingerprint);
    }

    private void Remove(Member member)
    {
        if (members.Remove(member))
        {
            member.Wire.Dispose();
            if (member.Peer != null)
            {
                Broadcast(new Packet
                {
                    Kind = "left",
                    Peer = member.Peer
                });
            }
        }
    }

    public void Dispose()
    {
        CancelVerifications();
        verificationLifetime.Dispose();
        listener.Dispose();
        foreach (Refusal refusal in refusals)
        {
            try
            {
                refusal.Wire.Dispose();
            }
            catch (Exception)
            {
            }
        }
        refusals.Clear();
        foreach (Member member in members)
        {
            member.Wire.Dispose();
        }
        members.Clear();
        journal.Dispose();
    }
}