/* TShock, a server mod for Terraria Copyright (C) 2011-2019 Pryaxis & TShock Contributors This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ using System; using System.Collections.Generic; using System.ComponentModel; using System.Net; using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Reflection; using HttpServer; using HttpServer.Headers; using Newtonsoft.Json; using TShockAPI; using HttpListener = HttpServer.HttpListener; using System.Collections; namespace Rests { /// /// Wraps an , providing URI-unescaping for its value /// public class EscapedParameter { private IParameter _parameter; /// /// Name of the parameter /// public string Name => _parameter.Name; /// /// URI-unescaped value of the parameter /// public string Value => Uri.UnescapeDataString(_parameter.Value); /// /// Constructs a new EscapedParameter wrapping the given /// /// public EscapedParameter(IParameter parameter) { _parameter = parameter; } } /// /// Wraps an , providing URI-unescaping for the parameters in the collection /// public class EscapedParameterCollection : IEnumerable { private readonly IParameterCollection _collection; /// /// Retrieve a parameter by name, returning the URI-unescaped value /// /// /// public string this[string key] { get { string value = _collection[key]; return value == null ? value : Uri.UnescapeDataString(value); } } /// /// Constructs a new EscapedParameterCollection wrapping the given /// /// public EscapedParameterCollection(IParameterCollection collection) { _collection = collection; } /// /// Returns an enumerator that can be used to iterate over this collection /// /// public IEnumerator GetEnumerator() { foreach (IParameter param in _collection) { yield return new EscapedParameter(param); } } IEnumerator IEnumerable.GetEnumerator() { return this.GetEnumerator(); } } /// /// Rest command delegate /// /// object containing Verbs, Parameters, Request, and TokenData /// Response object or null to not handle request public delegate object RestCommandD(RestRequestArgs args); /// /// Describes the data contained in a REST request /// public class RestRequestArgs { /// /// Verbs sent in the request /// public RestVerbs Verbs { get; private set; } /// /// Parameters sent in the request /// public EscapedParameterCollection Parameters { get; private set; } /// /// The HTTP request /// public IRequest Request { get; private set; } /// /// Token data used by the request /// public SecureRest.TokenData TokenData { get; private set; } /// /// used by the request /// public IHttpContext Context { get; private set; } /// /// Creates a new instance of with the given verbs, parameters, request, and context. /// No token data is used /// /// Verbs used in the request /// Parameters used in the request /// The HTTP request /// The HTTP context public RestRequestArgs(RestVerbs verbs, IParameterCollection param, IRequest request, IHttpContext context) : this(verbs, param, request, SecureRest.TokenData.None, context) { } /// /// Creates a new instance of with the given verbs, parameters, request, token data, and context. /// /// Verbs used in the request /// Parameters used in the request /// The HTTP request /// Token data used in the request /// The HTTP context public RestRequestArgs(RestVerbs verbs, IParameterCollection param, IRequest request, SecureRest.TokenData tokenData, IHttpContext context) : this(verbs, new EscapedParameterCollection(param), request, tokenData, context) { } /// /// Creates a new instance of with the given verbs, escaped parameters, request, token data, and context. /// /// /// /// /// /// public RestRequestArgs(RestVerbs verbs, EscapedParameterCollection param, IRequest request, SecureRest.TokenData tokenData, IHttpContext context) { Verbs = verbs; Parameters = param; Request = request; TokenData = tokenData; Context = context; } } /// /// A RESTful API service /// public class Rest : IDisposable { private readonly List commands = new List(); /// /// Contains redirect URIs. The key is the base URI. The first item of the tuple is the redirect URI. /// The second item of the tuple is an optional "upgrade" URI which will be added to the REST response. /// private Dictionary> redirects = new Dictionary>(); private HttpListener listener; private StringHeader serverHeader; private Timer tokenBucketTimer; /// /// Contains tokens used to manage REST authentication /// public Dictionary tokenBucket = new Dictionary(); /// /// the REST service is listening on /// public IPAddress Ip { get; set; } /// /// Port the REST service is listening on /// public int Port { get; set; } /// /// Creates a new instance of listening on the given IP and port /// /// to listen on /// Port to listen on public Rest(IPAddress ip, int port) { Ip = ip; Port = port; AssemblyName assembly = this.GetType().Assembly.GetName(); serverHeader = new StringHeader("Server", string.Format("{0}/{1}", assembly.Name, assembly.Version)); } /// /// Starts the RESTful API service /// public virtual void Start() { try { listener = HttpListener.Create(Ip, Port); listener.RequestReceived += OnRequest; listener.Start(int.MaxValue); tokenBucketTimer = new Timer((e) => { DegradeBucket(); }, null, TimeSpan.Zero, TimeSpan.FromMinutes(Math.Max(TShock.Config.Settings.RESTRequestBucketDecreaseIntervalMinutes, 1))); } catch (Exception ex) { TShock.Log.Error(GetString("Fatal Startup Exception")); TShock.Log.Error(ex.ToString()); TShock.Log.ConsoleError(GetString("Invalid REST configuration: \nYou may already have a REST service bound to port {0}. \nPlease adjust your configuration and restart the server. \nPress any key to exit.", Port)); Console.ReadLine(); Environment.Exit(1); } } /// /// Starts the RESTful API service using the given and port /// /// to listen on /// Port to listen on public void Start(IPAddress ip, int port) { Ip = ip; Port = port; Start(); } /// /// Stops the RESTful API service /// public virtual void Stop() { listener.Stop(); } /// /// Registers a command using the given route /// /// URL route /// Command callback public void Register(string path, RestCommandD callback) { AddCommand(new RestCommand(path, callback)); } /// /// Registers a /// /// to register public void Register(RestCommand com) { AddCommand(com); } /// /// Registers a redirection from a given REST route to a target REST route, with an optional upgrade URI /// /// The base URI that will be requested /// The target URI to redirect to from the base URI /// The upgrade route that will be added as an object to the response of the target route /// Whether the route uses parameterized querying or not. public void RegisterRedirect(string baseRoute, string targetRoute, string upgradeRoute = null, bool parameterized = true) { if (redirects.ContainsKey(baseRoute)) { redirects.Add(baseRoute, Tuple.Create(targetRoute, upgradeRoute)); } else { redirects[baseRoute] = Tuple.Create(targetRoute, upgradeRoute); } } /// /// Adds a to the service's command list /// /// to add protected void AddCommand(RestCommand com) { commands.Add(com); } private void DegradeBucket() { var _bucket = new List(tokenBucket.Keys); // Duplicate the keys so we can modify tokenBucket whilst iterating foreach(string key in _bucket) { int tokens = tokenBucket[key]; if(tokens > 0) { tokenBucket[key] -= 1; } if(tokens <= 0) { tokenBucket.Remove(key); } } } /// /// Called when the receives a request /// /// Sender of the request /// RequestEventArgs received protected virtual void OnRequest(object sender, RequestEventArgs e) { var obj = ProcessRequest(sender, e); if (obj == null) throw new NullReferenceException("obj"); var str = JsonConvert.SerializeObject(obj, Formatting.Indented); var jsonp = e.Request.Parameters["jsonp"]; if (!string.IsNullOrWhiteSpace(jsonp)) { str = string.Format("{0}({1});", jsonp, str); } e.Response.ContentType = new ContentTypeHeader("application/json; charset=utf-8"); e.Response.Add(serverHeader); var bytes = Encoding.UTF8.GetBytes(str); e.Response.Body.Write(bytes, 0, bytes.Length); e.Response.Status = HttpStatusCode.OK; if (obj is RestObject rObj && Enum.TryParse(rObj.Status, out HttpStatusCode status)) { e.Response.Status = status; } } /// /// Attempts to process a request received by the /// /// Sender of the request /// RequestEventArgs received /// A describing the state of the request protected virtual object ProcessRequest(object sender, RequestEventArgs e) { try { var uri = e.Request.Uri.AbsolutePath; uri = uri.TrimEnd('/'); string upgrade = null; if (redirects.TryGetValue(uri, out var value)) { upgrade = value.Item2; uri = value.Item1; } foreach (var com in commands) { var verbs = new RestVerbs(); if (com.HasVerbs) { var match = Regex.Match(uri, com.UriVerbMatch); if (!match.Success) continue; if ((match.Groups.Count - 1) != com.UriVerbs.Length) continue; for (int i = 0; i < com.UriVerbs.Length; i++) verbs.Add(com.UriVerbs[i], match.Groups[i + 1].Value); } else if (!string.Equals(com.UriTemplate, uri, StringComparison.OrdinalIgnoreCase)) { continue; } var obj = ExecuteCommand(com, verbs, e.Request.Parameters, e.Request, e.Context); if (obj != null) { if (!string.IsNullOrWhiteSpace(upgrade) && obj is RestObject) { if (!(obj as RestObject).ContainsKey("upgrade")) { (obj as RestObject).Add("upgrade", upgrade); } } return obj; } } } catch (Exception exception) { return new RestObject("500") { {"error", GetString("Internal server error.") }, {"errormsg", exception.Message}, {"stacktrace", exception.StackTrace}, }; } return new RestObject("404") { {"error", GetString("Specified API endpoint doesn't exist. Refer to the documentation for a list of valid endpoints.") } }; } /// /// Executes a using the provided verbs, parameters, request, and context objects /// /// The REST command to execute /// The REST verbs used in the command /// The REST parameters used in the command /// The HTTP request object associated with the command /// The HTTP context associated with the command /// protected virtual object ExecuteCommand(RestCommand cmd, RestVerbs verbs, IParameterCollection parms, IRequest request, IHttpContext context) { object result = cmd.Execute(verbs, parms, request, context); if (cmd.DoLog && TShock.Config.Settings.LogRest) { var endpoint = BuildRequestUri(cmd, verbs, parms, false); TShock.Log.ConsoleInfo(GetString($"Anonymous requested REST endpoint: {endpoint}")); } return result; } /// /// Builds a request URI from the parameters, verbs, and URI template of a /// /// The REST command to take the URI template from /// Verbs used in building the URI string /// Parameters used in building the URI string /// Whether or not to include a token in the URI /// protected virtual string BuildRequestUri( RestCommand cmd, RestVerbs verbs, IParameterCollection parms, bool includeToken = true ) { StringBuilder requestBuilder = new StringBuilder(cmd.UriTemplate); char separator = '?'; foreach (IParameter paramImpl in parms) { Parameter param = (paramImpl as Parameter); if (param == null || (!includeToken && param.Name.Equals("token", StringComparison.InvariantCultureIgnoreCase))) continue; requestBuilder.Append(separator); requestBuilder.Append(param.Name); requestBuilder.Append('='); requestBuilder.Append(param.Value); separator = '&'; } return requestBuilder.ToString(); } #region Dispose /// /// Disposes the RESTful API service /// public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } /// /// Disposes the RESTful API service /// /// protected virtual void Dispose(bool disposing) { if (disposing) { if (listener != null) { listener.Stop(); listener = null; } } } /// /// Destructor /// ~Rest() { Dispose(false); } #endregion } }