using System; using System.Collections.Generic; using System.Collections.Specialized; using System.Globalization; using System.IO; using System.Linq; using System.Net.Security; using System.Net.Sockets; using System.Security.Authentication; using System.Text; using System.Threading; using MinecraftClient.Proxy; namespace MinecraftClient.Protocol { /// /// Create a new http request and optionally with proxy according to setting /// public class ProxiedWebRequest { public interface ITcpFactory { TcpClient CreateTcpClient(string host, int port); }; private readonly string httpVersion = "HTTP/1.1"; private ITcpFactory? tcpFactory; private bool isProxied = false; // Send absolute Url in request if true private readonly Uri uri; private string Host { get { return uri.Host; } } private int Port { get { return uri.Port; } } private string Path { get { return uri.PathAndQuery; } } private string AbsoluteUrl { get { return uri.AbsoluteUri; } } private bool IsSecure { get { return uri.Scheme == "https"; } } public NameValueCollection Headers = new(); public string UserAgent { get { return Headers.Get("User-Agent") ?? String.Empty; } set { Headers.Set("User-Agent", value); } } public string Accept { get { return Headers.Get("Accept") ?? String.Empty; } set { Headers.Set("Accept", value); } } public string Cookie { set { Headers.Set("Cookie", value); } } /// /// Set to true to tell the http client proxy is enabled /// public bool IsProxy { get { return isProxied; } set { isProxied = value; } } public bool Debug { get { return Settings.Config.Logging.DebugMessages; } } /// /// Create a new http request /// /// Target URL public ProxiedWebRequest(string url) { uri = new Uri(url); SetupBasicHeaders(); } /// /// Create a new http request with cookies /// /// Target URL /// Cookies to use public ProxiedWebRequest(string url, NameValueCollection cookies) { uri = new Uri(url); Headers.Add("Cookie", GetCookieString(cookies)); SetupBasicHeaders(); } /// /// Create a new http request with custom tcp client /// /// Tcp factory to be used /// Target URL public ProxiedWebRequest(ITcpFactory tcpFactory, string url) : this(url) { this.tcpFactory = tcpFactory; } /// /// Create a new http request with custom tcp client and cookies /// /// Tcp factory to be used /// Target URL /// Cookies to use public ProxiedWebRequest(ITcpFactory tcpFactory, string url, NameValueCollection cookies) : this(url, cookies) { this.tcpFactory = tcpFactory; } /// /// Setup some basic headers /// private void SetupBasicHeaders() { Headers.Add("Host", Host); Headers.Add("User-Agent", "MCC/1.0"); Headers.Add("Accept", "*/*"); Headers.Add("Connection", "close"); } /// /// Perform GET request and get the response. Proxy is handled automatically /// /// public Response Get() { return Send("GET"); } /// /// Perform POST request and get the response. Proxy is handled automatically /// /// The content type of request body /// Request body /// public Response Post(string contentType, string body) { Headers.Add("Content-Type", contentType); // Calculate length Headers.Add("Content-Length", Encoding.UTF8.GetBytes(body).Length.ToString()); return Send("POST", body); } /// /// Send a http request to the server. Proxy is handled automatically /// /// Method in string representation /// Optional request body /// private Response Send(string method, string body = "") { List requestMessage = new() { string.Format("{0} {1} {2}", method.ToUpper(), isProxied ? AbsoluteUrl : Path, httpVersion) // Request line }; foreach (string key in Headers) // Headers { var value = Headers[key]; requestMessage.Add(string.Format("{0}: {1}", key, value)); } requestMessage.Add(""); // if (body != "") { requestMessage.Add(body); } else requestMessage.Add(""); // if (Debug) { foreach (string l in requestMessage) { ConsoleIO.WriteLine("< " + l); } } Response response = Response.Empty(); // FIXME: Use TcpFactory interface to avoid direct usage of the ProxyHandler class // TcpClient client = tcpFactory.CreateTcpClient(Host, Port); TcpClient client = ProxyHandler.NewTcpClient(Host, Port, true); Stream stream; if (IsSecure) { stream = new SslStream(client.GetStream()); ((SslStream)stream).AuthenticateAsClient(Host, null, SslProtocols.Tls12, true); // Enable TLS 1.2. Hotfix for #1774 } else { stream = client.GetStream(); } string h = string.Join("\r\n", requestMessage.ToArray()); byte[] data = Encoding.ASCII.GetBytes(h); stream.Write(data, 0, data.Length); stream.Flush(); // Read response int statusCode = ReadHttpStatus(stream); var headers = ReadHeader(stream); string? rbody; if (headers.Get("transfer-encoding") == "chunked") { rbody = ReadBodyChunked(stream); } else { rbody = ReadBody(stream, int.Parse(headers.Get("content-length") ?? "0")); } if (headers.Get("set-cookie") != null) { response.Cookies = ParseSetCookie(headers.GetValues("set-cookie") ?? Array.Empty()); } response.Body = rbody ?? ""; response.StatusCode = statusCode; response.Headers = headers; try { stream.Close(); client.Close(); } catch { } return response; } /// /// Read HTTP response line from a Stream /// /// Stream to read /// /// If server return unknown data private static int ReadHttpStatus(Stream s) { var httpHeader = ReadLine(s); // http header line if (httpHeader.StartsWith("HTTP/1.1") || httpHeader.StartsWith("HTTP/1.0")) { return int.Parse(httpHeader.Split(' ')[1], NumberStyles.Any, CultureInfo.CurrentCulture); } else { throw new InvalidDataException("Unexpect data from server"); } } /// /// Read HTTP headers from a Stream /// /// Stream to read /// Headers in lower-case private static NameValueCollection ReadHeader(Stream s) { var headers = new NameValueCollection(); // Read headers string header; do { header = ReadLine(s); if (!String.IsNullOrEmpty(header)) { var tmp = header.Split(new char[] { ':' }, 2); var name = tmp[0].ToLower(); var value = tmp[1].Trim(); headers.Add(name, value); } } while (!String.IsNullOrEmpty(header)); return headers; } /// /// Read HTTP body from a Stream /// /// Stream to read /// Length of the body (the Content-Length header) /// Body or null if length is zero private static string? ReadBody(Stream s, int length) { if (length > 0) { byte[] buffer = new byte[length]; int r = 0; while (r < length) { var read = s.Read(buffer, r, length - r); r += read; Thread.Sleep(50); } return Encoding.UTF8.GetString(buffer); } else { return null; } } /// /// Read HTTP chunked body from a Stream /// /// Stream to read /// Body or empty string if nothing is received private static string ReadBodyChunked(Stream s) { List buffer1 = new(); while (true) { string l = ReadLine(s); int size = Int32.Parse(l, NumberStyles.HexNumber); if (size == 0) break; byte[] buffer2 = new byte[size]; int r = 0; while (r < size) { var read = s.Read(buffer2, r, size - r); r += read; Thread.Sleep(50); } ReadLine(s); buffer1.AddRange(buffer2); } return Encoding.UTF8.GetString(buffer1.ToArray()); } /// /// Parse the Set-Cookie header value into NameValueCollection. Cookie options are ignored /// /// Array of value strings /// Parsed cookies private static NameValueCollection ParseSetCookie(IEnumerable headerValue) { NameValueCollection cookies = new(); foreach (var value in headerValue) { string[] cookie = value.Split(';'); // cookie options are ignored string[] tmp = cookie[0].Split(new char[] { '=' }, 2); // Split first '=' only string[] options = cookie[1..]; string cname = tmp[0].Trim(); string cvalue = tmp[1].Trim(); // Check expire bool isExpired = false; foreach (var option in options) { var tmp2 = option.Trim().Split(new char[] { '=' }, 2); // Check for Expires= and Max-Age= if (tmp2.Length == 2) { var optName = tmp2[0].Trim().ToLower(); var optValue = tmp2[1].Trim(); switch (optName) { case "expires": { if (DateTime.TryParse(optValue, out var expDate)) { if (expDate < DateTime.Now) isExpired = true; } break; } case "max-age": { if (int.TryParse(optValue, out var expInt)) { if (expInt <= 0) isExpired = true; } break; } } } if (isExpired) break; } if (!isExpired) cookies.Add(cname, cvalue); } return cookies; } /// /// Read a line from a Stream /// /// /// Line break by \r\n and they are not included in returned string /// /// Stream to read /// String private static string ReadLine(Stream s) { List buffer = new(); byte c; while (true) { int b = s.ReadByte(); if (b == -1) break; c = (byte)b; if (c == '\n') { if (buffer.Last() == '\r') { buffer.RemoveAt(buffer.Count - 1); break; } } buffer.Add(c); } return Encoding.UTF8.GetString(buffer.ToArray()); } /// /// Get the cookie string representation to use in header /// /// /// private static string GetCookieString(NameValueCollection cookies) { var sb = new StringBuilder(); foreach (string key in cookies) { var value = cookies[key]; sb.Append(string.Format("{0}={1}; ", key, value)); } string result = sb.ToString(); return result.Remove(result.Length - 2); // Remove "; " at the end } /// /// Basic response object /// public class Response { public int StatusCode; public string Body; public NameValueCollection Headers; public NameValueCollection Cookies; public Response(int statusCode, string body, NameValueCollection headers, NameValueCollection cookies) { StatusCode = statusCode; Body = body; Headers = headers; Cookies = cookies; } /// /// Get an empty response object /// /// public static Response Empty() { return new Response(204 /* No content */, "", new NameValueCollection(), new NameValueCollection()); } public override string ToString() { var sb = new StringBuilder(); sb.AppendLine("Status code: " + StatusCode); sb.AppendLine("Headers:"); foreach (string key in Headers) { sb.AppendLine(string.Format(" {0}: {1}", key, Headers[key])); } if (Cookies.Count > 0) { sb.AppendLine(); sb.AppendLine("Cookies: "); foreach (string key in Cookies) { sb.AppendLine(string.Format(" {0}={1}", key, Cookies[key])); } } if (Body != "") { sb.AppendLine(); if (Body.Length > 200) { sb.AppendLine("Body: (Truncated to 200 characters)"); } else sb.AppendLine("Body: "); sb.AppendLine(Body.Length > 200 ? Body[..200] + "..." : Body); } return sb.ToString(); } } } }