summaryrefslogtreecommitdiff
path: root/src/SMAPI.Web
diff options
context:
space:
mode:
authorJesse Plamondon-Willard <github@jplamondonw.com>2017-11-01 17:42:18 -0400
committerJesse Plamondon-Willard <github@jplamondonw.com>2017-11-01 17:42:18 -0400
commite0b72374cd14298aacc6f71dc391fdc9814be37c (patch)
tree2e5d85937c34539c1a0df48423b5136508693ca8 /src/SMAPI.Web
parent79118316065a01322d8ea12a14589ec016794c32 (diff)
parent089e6de749ae7cb109af00164d2597c6644c255e (diff)
downloadSMAPI-e0b72374cd14298aacc6f71dc391fdc9814be37c.tar.gz
SMAPI-e0b72374cd14298aacc6f71dc391fdc9814be37c.tar.bz2
SMAPI-e0b72374cd14298aacc6f71dc391fdc9814be37c.zip
Merge branch 'develop' into stable
Diffstat (limited to 'src/SMAPI.Web')
-rw-r--r--src/SMAPI.Web/Controllers/LogParserController.cs158
-rw-r--r--src/SMAPI.Web/Controllers/ModsApiController.cs (renamed from src/SMAPI.Web/Controllers/ModsController.cs)6
-rw-r--r--src/SMAPI.Web/Framework/AllowLargePostsAttribute.cs52
-rw-r--r--src/SMAPI.Web/Framework/BeanstalkEnvPropsConfigProvider.cs54
-rw-r--r--src/SMAPI.Web/Framework/ConfigModels/LogParserConfig.cs24
-rw-r--r--src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs2
-rw-r--r--src/SMAPI.Web/Framework/LogParser/GetPasteResponse.cs15
-rw-r--r--src/SMAPI.Web/Framework/LogParser/PastebinClient.cs117
-rw-r--r--src/SMAPI.Web/Framework/LogParser/SavePasteResponse.cs15
-rw-r--r--src/SMAPI.Web/Framework/RewriteRules/ConditionalRedirectToHttpsRule.cs62
-rw-r--r--src/SMAPI.Web/Framework/RewriteRules/ConditionalRewriteSubdomainRule.cs48
-rw-r--r--src/SMAPI.Web/Framework/RewriteRules/RedirectToUrlRule.cs61
-rw-r--r--src/SMAPI.Web/Framework/RewriteSubdomainRule.cs30
-rw-r--r--src/SMAPI.Web/Properties/launchSettings.json11
-rw-r--r--src/SMAPI.Web/Startup.cs33
-rw-r--r--src/SMAPI.Web/ViewModels/LogParserModel.cs31
-rw-r--r--src/SMAPI.Web/Views/LogParser/Index.cshtml119
-rw-r--r--src/SMAPI.Web/Views/Shared/_Layout.cshtml30
-rw-r--r--src/SMAPI.Web/Views/_ViewStart.cshtml3
-rw-r--r--src/SMAPI.Web/appsettings.Development.json20
-rw-r--r--src/SMAPI.Web/appsettings.json19
-rw-r--r--src/SMAPI.Web/wwwroot/Content/css/log-parser.css348
-rw-r--r--src/SMAPI.Web/wwwroot/Content/css/main.css107
-rw-r--r--src/SMAPI.Web/wwwroot/Content/images/sidebar-bg.gifbin0 -> 1104 bytes
-rw-r--r--src/SMAPI.Web/wwwroot/Content/js/log-parser.js278
-rw-r--r--src/SMAPI.Web/wwwroot/favicon.icobin0 -> 15086 bytes
26 files changed, 1593 insertions, 50 deletions
diff --git a/src/SMAPI.Web/Controllers/LogParserController.cs b/src/SMAPI.Web/Controllers/LogParserController.cs
new file mode 100644
index 00000000..454440bb
--- /dev/null
+++ b/src/SMAPI.Web/Controllers/LogParserController.cs
@@ -0,0 +1,158 @@
+using System;
+using System.IO;
+using System.IO.Compression;
+using System.Text;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Options;
+using StardewModdingAPI.Web.Framework;
+using StardewModdingAPI.Web.Framework.ConfigModels;
+using StardewModdingAPI.Web.Framework.LogParser;
+using StardewModdingAPI.Web.ViewModels;
+
+namespace StardewModdingAPI.Web.Controllers
+{
+ /// <summary>Provides a web UI and API for parsing SMAPI log files.</summary>
+ internal class LogParserController : Controller
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>The log parser config settings.</summary>
+ private readonly LogParserConfig Config;
+
+ /// <summary>The underlying Pastebin client.</summary>
+ private readonly PastebinClient PastebinClient;
+
+ /// <summary>The first bytes in a valid zip file.</summary>
+ /// <remarks>See <a href="https://en.wikipedia.org/wiki/Zip_(file_format)#File_headers"/>.</remarks>
+ private const uint GzipLeadBytes = 0x8b1f;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /***
+ ** Constructor
+ ***/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="configProvider">The log parser config settings.</param>
+ public LogParserController(IOptions<LogParserConfig> configProvider)
+ {
+ // init Pastebin client
+ this.Config = configProvider.Value;
+ string version = this.GetType().Assembly.GetName().Version.ToString(3);
+ string userAgent = string.Format(this.Config.PastebinUserAgent, version);
+ this.PastebinClient = new PastebinClient(this.Config.PastebinBaseUrl, userAgent, this.Config.PastebinUserKey, this.Config.PastebinDevKey);
+ }
+
+ /***
+ ** Web UI
+ ***/
+ /// <summary>Render the log parser UI.</summary>
+ /// <param name="id">The paste ID.</param>
+ [HttpGet]
+ [Route("")]
+ [Route("log")]
+ [Route("log/{id}")]
+ public ViewResult Index(string id = null)
+ {
+ return this.View("Index", new LogParserModel(this.Config.SectionUrl, id));
+ }
+
+ /***
+ ** JSON
+ ***/
+ /// <summary>Fetch raw text from Pastebin.</summary>
+ /// <param name="id">The Pastebin paste ID.</param>
+ [HttpGet, Produces("application/json")]
+ [Route("log/fetch/{id}")]
+ public async Task<GetPasteResponse> GetAsync(string id)
+ {
+ GetPasteResponse response = await this.PastebinClient.GetAsync(id);
+ response.Content = this.DecompressString(response.Content);
+ return response;
+ }
+
+ /// <summary>Save raw log data.</summary>
+ /// <param name="content">The log content to save.</param>
+ [HttpPost, Produces("application/json"), AllowLargePosts]
+ [Route("log/save")]
+ public async Task<SavePasteResponse> PostAsync([FromBody] string content)
+ {
+ content = this.CompressString(content);
+ return await this.PastebinClient.PostAsync(content);
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Compress a string.</summary>
+ /// <param name="text">The text to compress.</param>
+ /// <remarks>Derived from <a href="https://stackoverflow.com/a/17993002/262123"/>.</remarks>
+ private string CompressString(string text)
+ {
+ // get raw bytes
+ byte[] buffer = Encoding.UTF8.GetBytes(text);
+
+ // compressed
+ byte[] compressedData;
+ using (MemoryStream stream = new MemoryStream())
+ {
+ using (GZipStream zipStream = new GZipStream(stream, CompressionLevel.Optimal, leaveOpen: true))
+ zipStream.Write(buffer, 0, buffer.Length);
+
+ stream.Position = 0;
+ compressedData = new byte[stream.Length];
+ stream.Read(compressedData, 0, compressedData.Length);
+ }
+
+ // prefix length
+ var zipBuffer = new byte[compressedData.Length + 4];
+ Buffer.BlockCopy(compressedData, 0, zipBuffer, 4, compressedData.Length);
+ Buffer.BlockCopy(BitConverter.GetBytes(buffer.Length), 0, zipBuffer, 0, 4);
+
+ // return string representation
+ return Convert.ToBase64String(zipBuffer);
+ }
+
+ /// <summary>Decompress a string.</summary>
+ /// <param name="rawText">The compressed text.</param>
+ /// <remarks>Derived from <a href="https://stackoverflow.com/a/17993002/262123"/>.</remarks>
+ private string DecompressString(string rawText)
+ {
+ // get raw bytes
+ byte[] zipBuffer;
+ try
+ {
+ zipBuffer = Convert.FromBase64String(rawText);
+ }
+ catch
+ {
+ return rawText; // not valid base64, wasn't compressed by the log parser
+ }
+
+ // skip if not gzip
+ if (BitConverter.ToUInt16(zipBuffer, 4) != LogParserController.GzipLeadBytes)
+ return rawText;
+
+ // decompress
+ using (MemoryStream memoryStream = new MemoryStream())
+ {
+ // read length prefix
+ int dataLength = BitConverter.ToInt32(zipBuffer, 0);
+ memoryStream.Write(zipBuffer, 4, zipBuffer.Length - 4);
+
+ // read data
+ var buffer = new byte[dataLength];
+ memoryStream.Position = 0;
+ using (GZipStream gZipStream = new GZipStream(memoryStream, CompressionMode.Decompress))
+ gZipStream.Read(buffer, 0, buffer.Length);
+
+ // return original string
+ return Encoding.UTF8.GetString(buffer);
+ }
+ }
+ }
+}
diff --git a/src/SMAPI.Web/Controllers/ModsController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs
index a671ddca..a600662c 100644
--- a/src/SMAPI.Web/Controllers/ModsController.cs
+++ b/src/SMAPI.Web/Controllers/ModsApiController.cs
@@ -14,8 +14,8 @@ namespace StardewModdingAPI.Web.Controllers
{
/// <summary>Provides an API to perform mod update checks.</summary>
[Produces("application/json")]
- [Route("api/{version:semanticVersion}/[controller]")]
- internal class ModsController : Controller
+ [Route("api/v{version:semanticVersion}/mods")]
+ internal class ModsApiController : Controller
{
/*********
** Properties
@@ -39,7 +39,7 @@ namespace StardewModdingAPI.Web.Controllers
/// <summary>Construct an instance.</summary>
/// <param name="cache">The cache in which to store mod metadata.</param>
/// <param name="configProvider">The config settings for mod update checks.</param>
- public ModsController(IMemoryCache cache, IOptions<ModUpdateCheckConfig> configProvider)
+ public ModsApiController(IMemoryCache cache, IOptions<ModUpdateCheckConfig> configProvider)
{
ModUpdateCheckConfig config = configProvider.Value;
diff --git a/src/SMAPI.Web/Framework/AllowLargePostsAttribute.cs b/src/SMAPI.Web/Framework/AllowLargePostsAttribute.cs
new file mode 100644
index 00000000..68ead3c2
--- /dev/null
+++ b/src/SMAPI.Web/Framework/AllowLargePostsAttribute.cs
@@ -0,0 +1,52 @@
+using System;
+using Microsoft.AspNetCore.Http.Features;
+using Microsoft.AspNetCore.Mvc.Filters;
+
+namespace StardewModdingAPI.Web.Framework
+{
+ /// <summary>A filter which increases the maximum request size for an endpoint.</summary>
+ /// <remarks>Derived from <a href="https://stackoverflow.com/a/38360093/262123"/>.</remarks>
+ [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
+ public class AllowLargePostsAttribute : Attribute, IAuthorizationFilter, IOrderedFilter
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>The underlying form options.</summary>
+ private readonly FormOptions FormOptions;
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The attribute order.</summary>
+ public int Order { get; set; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ public AllowLargePostsAttribute()
+ {
+ this.FormOptions = new FormOptions
+ {
+ ValueLengthLimit = 200 * 1024 * 1024 // 200MB
+ };
+ }
+
+ /// <summary>Called early in the filter pipeline to confirm request is authorized.</summary>
+ /// <param name="context">The authorisation filter context.</param>
+ public void OnAuthorization(AuthorizationFilterContext context)
+ {
+ IFeatureCollection features = context.HttpContext.Features;
+ IFormFeature formFeature = features.Get<IFormFeature>();
+
+ if (formFeature?.Form == null)
+ {
+ // Request form has not been read yet, so set the limits
+ features.Set<IFormFeature>(new FormFeature(context.HttpContext.Request, this.FormOptions));
+ }
+ }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/BeanstalkEnvPropsConfigProvider.cs b/src/SMAPI.Web/Framework/BeanstalkEnvPropsConfigProvider.cs
new file mode 100644
index 00000000..b39a3b61
--- /dev/null
+++ b/src/SMAPI.Web/Framework/BeanstalkEnvPropsConfigProvider.cs
@@ -0,0 +1,54 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using Microsoft.Extensions.Configuration;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+
+namespace StardewModdingAPI.Web.Framework
+{
+ /// <summary>Reads configuration values from the AWS Beanstalk environment properties file (if present).</summary>
+ /// <remarks>This is a workaround for AWS Beanstalk injection not working with .NET Core apps.</remarks>
+ internal class BeanstalkEnvPropsConfigProvider : ConfigurationProvider, IConfigurationSource
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>The absolute path to the container configuration file on an Amazon EC2 instance.</summary>
+ private const string ContainerConfigPath = @"C:\Program Files\Amazon\ElasticBeanstalk\config\containerconfiguration";
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Build the configuration provider for this source.</summary>
+ /// <param name="builder">The configuration builder.</param>
+ public IConfigurationProvider Build(IConfigurationBuilder builder)
+ {
+ return new BeanstalkEnvPropsConfigProvider();
+ }
+
+ /// <summary>Load the environment properties.</summary>
+ public override void Load()
+ {
+ this.Data = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+
+ // get Beanstalk config file
+ FileInfo file = new FileInfo(BeanstalkEnvPropsConfigProvider.ContainerConfigPath);
+ if (!file.Exists)
+ return;
+
+ // parse JSON
+ JObject jsonRoot = (JObject)JsonConvert.DeserializeObject(File.ReadAllText(file.FullName));
+ if (jsonRoot["iis"]?["env"] is JArray jsonProps)
+ {
+ foreach (string prop in jsonProps.Values<string>())
+ {
+ string[] parts = prop.Split('=', 2); // key=value
+ if (parts.Length == 2)
+ this.Data[parts[0]] = parts[1];
+ }
+ }
+ }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/ConfigModels/LogParserConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/LogParserConfig.cs
new file mode 100644
index 00000000..df5d605d
--- /dev/null
+++ b/src/SMAPI.Web/Framework/ConfigModels/LogParserConfig.cs
@@ -0,0 +1,24 @@
+namespace StardewModdingAPI.Web.Framework.ConfigModels
+{
+ /// <summary>The config settings for the log parser.</summary>
+ internal class LogParserConfig
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The root URL for the log parser controller.</summary>
+ public string SectionUrl { get; set; }
+
+ /// <summary>The base URL for the Pastebin API.</summary>
+ public string PastebinBaseUrl { get; set; }
+
+ /// <summary>The user agent for the Pastebin API client, where {0} is the SMAPI version.</summary>
+ public string PastebinUserAgent { get; set; }
+
+ /// <summary>The user key used to authenticate with the Pastebin API.</summary>
+ public string PastebinUserKey { get; set; }
+
+ /// <summary>The developer key used to authenticate with the Pastebin API.</summary>
+ public string PastebinDevKey { get; set; }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs
index 03de639e..2fb5b97e 100644
--- a/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs
+++ b/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs
@@ -1,7 +1,7 @@
namespace StardewModdingAPI.Web.Framework.ConfigModels
{
/// <summary>The config settings for mod update checks.</summary>
- public class ModUpdateCheckConfig
+ internal class ModUpdateCheckConfig
{
/*********
** Accessors
diff --git a/src/SMAPI.Web/Framework/LogParser/GetPasteResponse.cs b/src/SMAPI.Web/Framework/LogParser/GetPasteResponse.cs
new file mode 100644
index 00000000..4f8794db
--- /dev/null
+++ b/src/SMAPI.Web/Framework/LogParser/GetPasteResponse.cs
@@ -0,0 +1,15 @@
+namespace StardewModdingAPI.Web.Framework.LogParser
+{
+ /// <summary>The response for a get-paste request.</summary>
+ internal class GetPasteResponse
+ {
+ /// <summary>Whether the log was successfully fetched.</summary>
+ public bool Success { get; set; }
+
+ /// <summary>The fetched paste content (if <see cref="Success"/> is <c>true</c>).</summary>
+ public string Content { get; set; }
+
+ /// <summary>The error message (if saving failed).</summary>
+ public string Error { get; set; }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/LogParser/PastebinClient.cs b/src/SMAPI.Web/Framework/LogParser/PastebinClient.cs
new file mode 100644
index 00000000..738330d3
--- /dev/null
+++ b/src/SMAPI.Web/Framework/LogParser/PastebinClient.cs
@@ -0,0 +1,117 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Threading.Tasks;
+using Pathoschild.Http.Client;
+
+namespace StardewModdingAPI.Web.Framework.LogParser
+{
+ /// <summary>An API client for Pastebin.</summary>
+ internal class PastebinClient : IDisposable
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>The underlying HTTP client.</summary>
+ private readonly IClient Client;
+
+ /// <summary>The user key used to authenticate with the Pastebin API.</summary>
+ private readonly string UserKey;
+
+ /// <summary>The developer key used to authenticate with the Pastebin API.</summary>
+ private readonly string DevKey;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="baseUrl">The base URL for the Pastebin API.</param>
+ /// <param name="userAgent">The user agent for the API client.</param>
+ /// <param name="userKey">The user key used to authenticate with the Pastebin API.</param>
+ /// <param name="devKey">The developer key used to authenticate with the Pastebin API.</param>
+ public PastebinClient(string baseUrl, string userAgent, string userKey, string devKey)
+ {
+ this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent);
+ this.UserKey = userKey;
+ this.DevKey = devKey;
+ }
+
+ /// <summary>Fetch a saved paste.</summary>
+ /// <param name="id">The paste ID.</param>
+ public async Task<GetPasteResponse> GetAsync(string id)
+ {
+ try
+ {
+ // get from API
+ string content = await this.Client
+ .GetAsync($"raw/{id}")
+ .AsString();
+
+ // handle Pastebin errors
+ if (string.IsNullOrWhiteSpace(content))
+ return new GetPasteResponse { Error = "Received an empty response from Pastebin." };
+ if (content.StartsWith("<!DOCTYPE"))
+ return new GetPasteResponse { Error = $"Received a captcha challenge from Pastebin. Please visit https://pastebin.com/{id} in a new window to solve it." };
+ return new GetPasteResponse { Success = true, Content = content };
+ }
+ catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound)
+ {
+ return new GetPasteResponse { Error = "There's no log with that ID." };
+ }
+ catch (Exception ex)
+ {
+ return new GetPasteResponse { Error = ex.ToString() };
+ }
+ }
+
+ public async Task<SavePasteResponse> PostAsync(string content)
+ {
+ try
+ {
+ // validate
+ if (string.IsNullOrWhiteSpace(content))
+ return new SavePasteResponse { Error = "The log content can't be empty." };
+
+ // post to API
+ string response = await this.Client
+ .PostAsync("api/api_post.php")
+ .WithBodyContent(new FormUrlEncodedContent(new Dictionary<string, string>
+ {
+ ["api_option"] = "paste",
+ ["api_user_key"] = this.UserKey,
+ ["api_dev_key"] = this.DevKey,
+ ["api_paste_private"] = "1", // unlisted
+ ["api_paste_name"] = $"SMAPI log {DateTime.UtcNow:s}",
+ ["api_paste_expire_date"] = "1W", // one week
+ ["api_paste_code"] = content
+ }))
+ .AsString();
+
+ // handle Pastebin errors
+ if (string.IsNullOrWhiteSpace(response))
+ return new SavePasteResponse { Error = "Received an empty response from Pastebin." };
+ if (response.StartsWith("Bad API request"))
+ return new SavePasteResponse { Error = response };
+ if (!response.Contains("/"))
+ return new SavePasteResponse { Error = $"Received an unknown response: {response}" };
+
+ // return paste ID
+ string pastebinID = response.Split("/").Last();
+ return new SavePasteResponse { Success = true, ID = pastebinID };
+ }
+ catch (Exception ex)
+ {
+ return new SavePasteResponse { Success = false, Error = ex.ToString() };
+ }
+ }
+
+ /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary>
+ public void Dispose()
+ {
+ this.Client.Dispose();
+ }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/LogParser/SavePasteResponse.cs b/src/SMAPI.Web/Framework/LogParser/SavePasteResponse.cs
new file mode 100644
index 00000000..1c0960a4
--- /dev/null
+++ b/src/SMAPI.Web/Framework/LogParser/SavePasteResponse.cs
@@ -0,0 +1,15 @@
+namespace StardewModdingAPI.Web.Framework.LogParser
+{
+ /// <summary>The response for a save-log request.</summary>
+ internal class SavePasteResponse
+ {
+ /// <summary>Whether the log was successfully saved.</summary>
+ public bool Success { get; set; }
+
+ /// <summary>The saved paste ID (if <see cref="Success"/> is <c>true</c>).</summary>
+ public string ID { get; set; }
+
+ /// <summary>The error message (if saving failed).</summary>
+ public string Error { get; set; }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/RewriteRules/ConditionalRedirectToHttpsRule.cs b/src/SMAPI.Web/Framework/RewriteRules/ConditionalRedirectToHttpsRule.cs
new file mode 100644
index 00000000..d6a56bb7
--- /dev/null
+++ b/src/SMAPI.Web/Framework/RewriteRules/ConditionalRedirectToHttpsRule.cs
@@ -0,0 +1,62 @@
+using System;
+using System.Net;
+using System.Text;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Rewrite;
+
+namespace StardewModdingAPI.Web.Framework.RewriteRules
+{
+ /// <summary>Redirect requests to HTTPS.</summary>
+ /// <remarks>Derived from <a href="https://stackoverflow.com/a/44526747/262123" /> and <see cref="Microsoft.AspNetCore.Rewrite.Internal.RedirectToHttpsRule"/>.</remarks>
+ internal class ConditionalRedirectToHttpsRule : IRule
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>A predicate which indicates when the rule should be applied.</summary>
+ private readonly Func<HttpRequest, bool> ShouldRewrite;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="shouldRewrite">A predicate which indicates when the rule should be applied.</param>
+ public ConditionalRedirectToHttpsRule(Func<HttpRequest, bool> shouldRewrite = null)
+ {
+ this.ShouldRewrite = shouldRewrite ?? (req => true);
+ }
+
+ /// <summary>Applies the rule. Implementations of ApplyRule should set the value for <see cref="RewriteContext.Result" /> (defaults to RuleResult.ContinueRules).</summary>
+ /// <param name="context">The rewrite context.</param>
+ public void ApplyRule(RewriteContext context)
+ {
+ HttpRequest request = context.HttpContext.Request;
+
+ // check condition
+ if (this.IsSecure(request) || !this.ShouldRewrite(request))
+ return;
+
+ // redirect request
+ HttpResponse response = context.HttpContext.Response;
+ response.StatusCode = (int)HttpStatusCode.RedirectKeepVerb;
+ response.Headers["Location"] = new StringBuilder()
+ .Append("https://")
+ .Append(request.Host.Host)
+ .Append(request.PathBase)
+ .Append(request.Path)
+ .Append(request.QueryString)
+ .ToString();
+ context.Result = RuleResult.EndResponse;
+ }
+
+ /// <summary>Get whether the request was received over HTTPS.</summary>
+ /// <param name="request">The request to check.</param>
+ public bool IsSecure(HttpRequest request)
+ {
+ return
+ request.IsHttps // HTTPS to server
+ || string.Equals(request.Headers["x-forwarded-proto"], "HTTPS", StringComparison.OrdinalIgnoreCase); // HTTPS to AWS load balancer
+ }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/RewriteRules/ConditionalRewriteSubdomainRule.cs b/src/SMAPI.Web/Framework/RewriteRules/ConditionalRewriteSubdomainRule.cs
new file mode 100644
index 00000000..920632ab
--- /dev/null
+++ b/src/SMAPI.Web/Framework/RewriteRules/ConditionalRewriteSubdomainRule.cs
@@ -0,0 +1,48 @@
+using System;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Rewrite;
+
+namespace StardewModdingAPI.Web.Framework.RewriteRules
+{
+ /// <summary>Rewrite requests to prepend the subdomain portion (if any) to the path.</summary>
+ /// <remarks>Derived from <a href="https://stackoverflow.com/a/44526747/262123" />.</remarks>
+ internal class ConditionalRewriteSubdomainRule : IRule
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>A predicate which indicates when the rule should be applied.</summary>
+ private readonly Func<HttpRequest, bool> ShouldRewrite;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="shouldRewrite">A predicate which indicates when the rule should be applied.</param>
+ public ConditionalRewriteSubdomainRule(Func<HttpRequest, bool> shouldRewrite = null)
+ {
+ this.ShouldRewrite = shouldRewrite ?? (req => true);
+ }
+
+ /// <summary>Applies the rule. Implementations of ApplyRule should set the value for <see cref="RewriteContext.Result" /> (defaults to RuleResult.ContinueRules).</summary>
+ /// <param name="context">The rewrite context.</param>
+ public void ApplyRule(RewriteContext context)
+ {
+ HttpRequest request = context.HttpContext.Request;
+
+ // check condition
+ if (!this.ShouldRewrite(request))
+ return;
+
+ // get host parts
+ string host = request.Host.Host;
+ string[] parts = host.Split('.');
+ if (parts.Length < 2)
+ return;
+
+ // prepend to path
+ request.Path = $"/{parts[0]}{request.Path}";
+ }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/RewriteRules/RedirectToUrlRule.cs b/src/SMAPI.Web/Framework/RewriteRules/RedirectToUrlRule.cs
new file mode 100644
index 00000000..0719e311
--- /dev/null
+++ b/src/SMAPI.Web/Framework/RewriteRules/RedirectToUrlRule.cs
@@ -0,0 +1,61 @@
+using System;
+using System.Net;
+using System.Text.RegularExpressions;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Rewrite;
+
+namespace StardewModdingAPI.Web.Framework.RewriteRules
+{
+ /// <summary>Redirect requests to an external URL if they match a condition.</summary>
+ internal class RedirectToUrlRule : IRule
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>A predicate which indicates when the rule should be applied.</summary>
+ private readonly Func<HttpRequest, bool> ShouldRewrite;
+
+ /// <summary>The new URL to which to redirect.</summary>
+ private readonly string NewUrl;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="shouldRewrite">A predicate which indicates when the rule should be applied.</param>
+ /// <param name="url">The new URL to which to redirect.</param>
+ public RedirectToUrlRule(Func<HttpRequest, bool> shouldRewrite, string url)
+ {
+ this.ShouldRewrite = shouldRewrite ?? (req => true);
+ this.NewUrl = url;
+ }
+
+ /// <summary>Construct an instance.</summary>
+ /// <param name="pathRegex">A case-insensitive regex to match against the path.</param>
+ /// <param name="url">The external URL.</param>
+ public RedirectToUrlRule(string pathRegex, string url)
+ {
+ Regex regex = new Regex(pathRegex, RegexOptions.IgnoreCase | RegexOptions.Compiled);
+ this.ShouldRewrite = req => req.Path.HasValue && regex.IsMatch(req.Path.Value);
+ this.NewUrl = url;
+ }
+
+ /// <summary>Applies the rule. Implementations of ApplyRule should set the value for <see cref="RewriteContext.Result" /> (defaults to RuleResult.ContinueRules).</summary>
+ /// <param name="context">The rewrite context.</param>
+ public void ApplyRule(RewriteContext context)
+ {
+ HttpRequest request = context.HttpContext.Request;
+
+ // check condition
+ if (!this.ShouldRewrite(request))
+ return;
+
+ // redirect request
+ HttpResponse response = context.HttpContext.Response;
+ response.StatusCode = (int)HttpStatusCode.Redirect;
+ response.Headers["Location"] = this.NewUrl;
+ context.Result = RuleResult.EndResponse;
+ }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/RewriteSubdomainRule.cs b/src/SMAPI.Web/Framework/RewriteSubdomainRule.cs
deleted file mode 100644
index 5a56844f..00000000
--- a/src/SMAPI.Web/Framework/RewriteSubdomainRule.cs
+++ /dev/null
@@ -1,30 +0,0 @@
-using System;
-using Micro