summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore3
-rw-r--r--docs/technical-docs.md107
-rw-r--r--src/SMAPI.Web/Controllers/LogParserController.cs157
-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/RewriteSubdomainRule.cs30
-rw-r--r--src/SMAPI.Web/Properties/launchSettings.json11
-rw-r--r--src/SMAPI.Web/Startup.cs23
-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
-rw-r--r--src/SMAPI.sln3
28 files changed, 1623 insertions, 61 deletions
diff --git a/.gitignore b/.gitignore
index f2d50778..7e0c1e9d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,3 +23,6 @@ _ReSharper*/
**/packages/*
*.nuget.props
*.nuget.targets
+
+# sensitive files
+appsettings.Development.json
diff --git a/docs/technical-docs.md b/docs/technical-docs.md
index d37d327d..37ec7f69 100644
--- a/docs/technical-docs.md
+++ b/docs/technical-docs.md
@@ -3,16 +3,25 @@
This file provides more technical documentation about SMAPI. If you only want to use or create
mods, this section isn't relevant to you; see the main README to use or create mods.
-## Contents
-* [Development](#development)
- * [Compiling from source](#compiling-from-source)
- * [Debugging a local build](#debugging-a-local-build)
- * [Preparing a release](#preparing-a-release)
-* [Customisation](#customisation)
- * [Configuration file](#configuration-file)
- * [Command-line arguments](#command-line-arguments)
- * [Compile flags](#compile-flags)
-
+# Contents
+* [SMAPI](#smapi)
+ * [Development](#development)
+ * [Compiling from source](#compiling-from-source)
+ * [Debugging a local build](#debugging-a-local-build)
+ * [Preparing a release](#preparing-a-release)
+ * [Customisation](#customisation)
+ * [Configuration file](#configuration-file)
+ * [Command-line arguments](#command-line-arguments)
+ * [Compile flags](#compile-flags)
+* [SMAPI web services](#smapi-web-services)
+ * [Overview](#overview)
+ * [Log parser](#log-parser)
+ * [Mods API](#mods-api)
+ * [Development](#development-2)
+ * [Local development](#local-development)
+ * [Deploying to Amazon Beanstalk](#deploying-to-amazon-beanstalk)
+
+# SMAPI
## Development
### Compiling from source
Using an official SMAPI release is recommended for most users.
@@ -135,3 +144,81 @@ SMAPI uses a small number of conditional compilation constants, which you can se
flag | purpose
---- | -------
`SMAPI_FOR_WINDOWS` | Indicates that SMAPI is being compiled on Windows for players on Windows. Set automatically in `crossplatform.targets`.
+
+
+# SMAPI web services
+## Overview
+The `StardewModdingAPI.Web` project provides an API and web UI hosted at `*.smapi.io`.
+
+### Log parser
+The log parser provides a web UI for uploading, parsing, and sharing SMAPI logs. The logs are
+persisted in a compressed form to Pastebin.
+
+The log parser lives at https://log.smapi.io.
+
+### Mods API
+The mods API provides version info for mods hosted by Chucklefish, GitHub, or Nexus Mods. It's used
+by SMAPI to perform update checks. The `{version}` URL token is the version of SMAPI making the
+request; it doesn't do anything currently, but lets us version breaking changes if needed.
+
+Each mod is identified by a repository key and unique identifier (like `nexus:541`). The following
+repositories are supported:
+
+key | repository
+------------- | ----------
+`chucklefish` | A mod page on the [Chucklefish mod site](https://community.playstarbound.com/resources/categories/22), identified by the mod ID in the page URL.
+`github` | A repository on [GitHub](https://github.com), identified by its owner and repository name (like `Zoryn4163/SMAPI-Mods`). This checks the version of the latest repository release.
+`nexus` | A mod page on [Nexus Mods](https://www.nexusmods.com/stardewvalley), identified by the mod ID in the page URL.
+
+
+The API accepts either `GET` or `POST` for convenience:
+> ```
+>GET https://api.smapi.io/v2.0/mods?modKeys=nexus:541,chucklefish:4228
+>```
+
+>```
+>POST https://api.smapi.io/v2.0/mods
+>{
+> "ModKeys": [ "nexus:541", "chucklefish:4228" ]
+>}
+>```
+
+It returns a response like this:
+>```
+>{
+> "chucklefish:4228": {
+> "name": "Entoarox Framework",
+> "version": "1.8.0",
+> "url": "https://community.playstarbound.com/resources/4228"
+> },
+> "nexus:541": {
+> "name": "Lookup Anything",
+> "version": "1.16",
+> "url": "http://www.nexusmods.com/stardewvalley/mods/541"
+> }
+>}
+>```
+
+## Development
+### Local development
+`StardewModdingAPI.Web` is a regular ASP.NET MVC Core app, so you can just launch it from within
+Visual Studio to run a local version.
+
+There are two differences when it's run locally: all endpoints use HTTP instead of HTTPS, and the
+subdomain portion becomes a route (e.g. `log.smapi.io` → `localhost:59482/log`).
+
+Before running it locally, you need to enter your credentials in the `appsettings.Development.json`
+file. See the next section for a description of each setting. This file is listed in `.gitignore`
+to prevent accidentally committing credentials.
+
+### Deploying to Amazon Beanstalk
+The app can be deployed to a standard Amazon Beanstalk IIS environment. When creating the
+environment, make sure to specify the following environment properties:
+
+property name | description
+------------------------------- | -----------------
+`LogParser:PastebinDevKey` | The [Pastebin developer key](https://pastebin.com/api#1) used to authenticate with the Pastebin API.
+`LogParser:PastebinUserKey` | The [Pastebin user key](https://pastebin.com/api#8) used to authenticate with the Pastebin API. Can be left blank to post anonymously.
+`LogParser:SectionUrl` | The root URL of the log page, like `https://log.smapi.io/`.
+`ModUpdateCheck:GitHubPassword` | The password with which to authenticate to GitHub when fetching release info.
+`ModUpdateCheck:GitHubUsername` | The username with which to authenticate to GitHub when fetching release info.
diff --git a/src/SMAPI.Web/Controllers/LogParserController.cs b/src/SMAPI.Web/Controllers/LogParserController.cs
new file mode 100644
index 00000000..f143bc5c
--- /dev/null
+++ b/src/SMAPI.Web/Controllers/LogParserController.cs
@@ -0,0 +1,157 @@
+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("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