diff options
author | Jesse Plamondon-Willard <Pathoschild@users.noreply.github.com> | 2020-01-05 20:18:16 -0500 |
---|---|---|
committer | Jesse Plamondon-Willard <Pathoschild@users.noreply.github.com> | 2020-01-05 20:18:16 -0500 |
commit | f976b5c0f095a881fc20f6ce5dcf5a50ebb2b5da (patch) | |
tree | 260fa7579e1c361283bda09c2616783c3fdb5b9a /src/SMAPI.Web/Framework | |
parent | d34f369d35290bca96cc7225d9765d1a8a66fa8b (diff) | |
parent | 48959375b9ef52abf7c7a9404d43aac6ba780047 (diff) | |
download | SMAPI-f976b5c0f095a881fc20f6ce5dcf5a50ebb2b5da.tar.gz SMAPI-f976b5c0f095a881fc20f6ce5dcf5a50ebb2b5da.tar.bz2 SMAPI-f976b5c0f095a881fc20f6ce5dcf5a50ebb2b5da.zip |
Merge branch 'develop' into stable
Diffstat (limited to 'src/SMAPI.Web/Framework')
13 files changed, 287 insertions, 123 deletions
diff --git a/src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs b/src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs index a635abe3..431fed7b 100644 --- a/src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs @@ -9,10 +9,5 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin /// <summary>Fetch a saved paste.</summary> /// <param name="id">The paste ID.</param> Task<PasteInfo> GetAsync(string id); - - /// <summary>Save a paste to Pastebin.</summary> - /// <param name="name">The paste name.</param> - /// <param name="content">The paste content.</param> - Task<SavePasteResult> PostAsync(string name, string content); } } diff --git a/src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs b/src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs index bb2de356..813ea115 100644 --- a/src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs +++ b/src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs @@ -1,5 +1,3 @@ -using System; - namespace StardewModdingAPI.Web.Framework.Clients.Pastebin { /// <summary>The response for a get-paste request.</summary> @@ -11,12 +9,6 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin /// <summary>The fetched paste content (if <see cref="Success"/> is <c>true</c>).</summary> public string Content { get; set; } - /// <summary>When the file will no longer be available.</summary> - public DateTime? Expiry { get; set; } - - /// <summary>The error message if saving succeeded, but a non-blocking issue was encountered.</summary> - public string Warning { get; set; } - /// <summary>The error message if saving failed.</summary> public string Error { get; set; } } diff --git a/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs b/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs index d695aab6..1be00be7 100644 --- a/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs @@ -1,7 +1,5 @@ using System; -using System.Linq; using System.Net; -using System.Net.Http; using System.Threading.Tasks; using Pathoschild.Http.Client; @@ -16,12 +14,6 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin /// <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 @@ -29,13 +21,9 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin /// <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) + public PastebinClient(string baseUrl, string userAgent) { this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent); - this.UserKey = userKey; - this.DevKey = devKey; } /// <summary>Fetch a saved paste.</summary> @@ -66,50 +54,6 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin } } - /// <summary>Save a paste to Pastebin.</summary> - /// <param name="name">The paste name.</param> - /// <param name="content">The paste content.</param> - public async Task<SavePasteResult> PostAsync(string name, string content) - { - try - { - // validate - if (string.IsNullOrWhiteSpace(content)) - return new SavePasteResult { Error = "The log content can't be empty." }; - - // post to API - string response = await this.Client - .PostAsync("api/api_post.php") - .WithBody(p => p.FormUrlEncoded(new - { - api_option = "paste", - api_user_key = this.UserKey, - api_dev_key = this.DevKey, - api_paste_private = 1, // unlisted - api_paste_name = name, - api_paste_expire_date = "N", // never expire - api_paste_code = content - })) - .AsString(); - - // handle Pastebin errors - if (string.IsNullOrWhiteSpace(response)) - return new SavePasteResult { Error = "Received an empty response from Pastebin." }; - if (response.StartsWith("Bad API request")) - return new SavePasteResult { Error = response }; - if (!response.Contains("/")) - return new SavePasteResult { Error = $"Received an unknown response: {response}" }; - - // return paste ID - string pastebinID = response.Split("/").Last(); - return new SavePasteResult { Success = true, ID = pastebinID }; - } - catch (Exception ex) - { - return new SavePasteResult { Success = false, Error = ex.ToString() }; - } - } - /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> public void Dispose() { diff --git a/src/SMAPI.Web/Framework/Clients/Pastebin/SavePasteResult.cs b/src/SMAPI.Web/Framework/Clients/Pastebin/SavePasteResult.cs deleted file mode 100644 index 89dab697..00000000 --- a/src/SMAPI.Web/Framework/Clients/Pastebin/SavePasteResult.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace StardewModdingAPI.Web.Framework.Clients.Pastebin -{ - /// <summary>The response for a save-log request.</summary> - internal class SavePasteResult - { - /// <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/ConfigModels/ApiClientsConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs index 7119ef03..878130bf 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs @@ -14,19 +14,16 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels /**** - ** Amazon Web Services + ** Azure ****/ - /// <summary>The access key for AWS authentication.</summary> - public string AmazonAccessKey { get; set; } + /// <summary>The connection string for the Azure Blob storage account.</summary> + public string AzureBlobConnectionString { get; set; } - /// <summary>The secret key for AWS authentication.</summary> - public string AmazonSecretKey { get; set; } + /// <summary>The Azure Blob container in which to store temporary uploaded logs.</summary> + public string AzureBlobTempContainer { get; set; } - /// <summary>The AWS region endpoint (like 'us-east-1').</summary> - public string AmazonRegion { get; set; } - - /// <summary>The AWS bucket in which to store temporary uploaded logs.</summary> - public string AmazonLogBucket { get; set; } + /// <summary>The number of days since the blob's last-modified date when it will be deleted.</summary> + public int AzureBlobTempExpiryDays { get; set; } /**** @@ -61,6 +58,7 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels /// <summary>The password with which to authenticate to the GitHub API (if any).</summary> public string GitHubPassword { get; set; } + /**** ** ModDrop ****/ @@ -70,6 +68,7 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels /// <summary>The URL for a ModDrop mod page for the user, where {0} is the mod ID.</summary> public string ModDropModPageUrl { get; set; } + /**** ** Nexus Mods ****/ @@ -85,17 +84,11 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels /// <summary>The Nexus API authentication key.</summary> public string NexusApiKey { get; set; } + /**** ** Pastebin ****/ /// <summary>The base URL for the Pastebin API.</summary> public string PastebinBaseUrl { 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/MongoDbConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/MongoDbConfig.cs index 3c508300..c7b6cb00 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/MongoDbConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/MongoDbConfig.cs @@ -1,5 +1,3 @@ -using System; - namespace StardewModdingAPI.Web.Framework.ConfigModels { /// <summary>The config settings for mod compatibility list.</summary> @@ -8,14 +6,8 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels /********* ** Accessors *********/ - /// <summary>The MongoDB hostname.</summary> - public string Host { get; set; } - - /// <summary>The MongoDB username (if any).</summary> - public string Username { get; set; } - - /// <summary>The MongoDB password (if any).</summary> - public string Password { get; set; } + /// <summary>The MongoDB connection string.</summary> + public string ConnectionString { get; set; } /// <summary>The database name.</summary> public string Database { get; set; } @@ -24,15 +16,10 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels /********* ** Public method *********/ - /// <summary>Get the MongoDB connection string.</summary> - public string GetConnectionString() + /// <summary>Get whether a MongoDB instance is configured.</summary> + public bool IsConfigured() { - bool isLocal = this.Host == "localhost"; - bool hasLogin = !string.IsNullOrWhiteSpace(this.Username) && !string.IsNullOrWhiteSpace(this.Password); - - return $"mongodb{(isLocal ? "" : "+srv")}://" - + (hasLogin ? $"{Uri.EscapeDataString(this.Username)}:{Uri.EscapeDataString(this.Password)}@" : "") - + $"{this.Host}/{this.Database}?retryWrites=true&w=majority"; + return !string.IsNullOrWhiteSpace(this.ConnectionString); } } } diff --git a/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs index d379c423..43969f51 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs @@ -11,5 +11,8 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels /// <summary>A short sentence shown under the beta download button, if any.</summary> public string BetaBlurb { get; set; } + + /// <summary>A list of supports to credit on the main page, in Markdown format.</summary> + public string SupporterList { get; set; } } } diff --git a/src/SMAPI.Web/Framework/Extensions.cs b/src/SMAPI.Web/Framework/Extensions.cs index d7707924..e0da1424 100644 --- a/src/SMAPI.Web/Framework/Extensions.cs +++ b/src/SMAPI.Web/Framework/Extensions.cs @@ -1,4 +1,6 @@ +using System; using JetBrains.Annotations; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; @@ -12,8 +14,9 @@ namespace StardewModdingAPI.Web.Framework /// <param name="action">The name of the action method.</param> /// <param name="controller">The name of the controller.</param> /// <param name="values">An object that contains route values.</param> + /// <param name="absoluteUrl">Get an absolute URL instead of a server-relative path/</param> /// <returns>The generated URL.</returns> - public static string PlainAction(this IUrlHelper helper, [AspMvcAction] string action, [AspMvcController] string controller, object values = null) + public static string PlainAction(this IUrlHelper helper, [AspMvcAction] string action, [AspMvcController] string controller, object values = null, bool absoluteUrl = false) { RouteValueDictionary valuesDict = new RouteValueDictionary(values); foreach (var value in helper.ActionContext.RouteData.Values) @@ -22,7 +25,14 @@ namespace StardewModdingAPI.Web.Framework valuesDict[value.Key] = null; // explicitly remove it from the URL } - return helper.Action(action, controller, valuesDict); + string url = helper.Action(action, controller, valuesDict); + if (absoluteUrl) + { + HttpRequest request = helper.ActionContext.HttpContext.Request; + Uri baseUri = new Uri($"{request.Scheme}://{request.Host}"); + url = new Uri(baseUri, url).ToString(); + } + return url; } } } diff --git a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs index 66a3687f..1210f708 100644 --- a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs +++ b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs @@ -37,7 +37,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing private readonly Regex ContentPackListStartPattern = new Regex(@"^Loaded \d+ content packs:$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching an entry in SMAPI's content pack list.</summary> - private readonly Regex ContentPackListEntryPattern = new Regex(@"^ (?<name>.+) (?<version>.+) by (?<author>.+) \| for (?<for>.+?)(?: \| (?<description>.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly Regex ContentPackListEntryPattern = new Regex(@"^ (?<name>.+?) (?<version>" + SemanticVersion.UnboundedVersionPattern + @")(?: by (?<author>[^\|]+))? \| for (?<for>[^\|]+)(?: \| (?<description>.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching the start of SMAPI's mod update list.</summary> private readonly Regex ModUpdateListStartPattern = new Regex(@"^You can update \d+ mods?:$", RegexOptions.Compiled | RegexOptions.IgnoreCase); diff --git a/src/SMAPI.Web/Framework/Storage/IStorageProvider.cs b/src/SMAPI.Web/Framework/Storage/IStorageProvider.cs new file mode 100644 index 00000000..96a34fbb --- /dev/null +++ b/src/SMAPI.Web/Framework/Storage/IStorageProvider.cs @@ -0,0 +1,18 @@ +using System.Threading.Tasks; + +namespace StardewModdingAPI.Web.Framework.Storage +{ + /// <summary>Provides access to raw data storage.</summary> + internal interface IStorageProvider + { + /// <summary>Save a text file to storage.</summary> + /// <param name="content">The content to upload.</param> + /// <param name="compress">Whether to gzip the text.</param> + /// <returns>Returns metadata about the save attempt.</returns> + Task<UploadResult> SaveAsync(string content, bool compress = true); + + /// <summary>Fetch raw text from storage.</summary> + /// <param name="id">The storage ID returned by <see cref="SaveAsync"/>.</param> + Task<StoredFileInfo> GetAsync(string id); + } +} diff --git a/src/SMAPI.Web/Framework/Storage/StorageProvider.cs b/src/SMAPI.Web/Framework/Storage/StorageProvider.cs new file mode 100644 index 00000000..35538443 --- /dev/null +++ b/src/SMAPI.Web/Framework/Storage/StorageProvider.cs @@ -0,0 +1,181 @@ +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Azure; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; +using Microsoft.Extensions.Options; +using StardewModdingAPI.Web.Framework.Clients.Pastebin; +using StardewModdingAPI.Web.Framework.Compression; +using StardewModdingAPI.Web.Framework.ConfigModels; + +namespace StardewModdingAPI.Web.Framework.Storage +{ + /// <summary>Provides access to raw data storage.</summary> + internal class StorageProvider : IStorageProvider + { + /********* + ** Fields + *********/ + /// <summary>The API client settings.</summary> + private readonly ApiClientsConfig ClientsConfig; + + /// <summary>The underlying Pastebin client.</summary> + private readonly IPastebinClient Pastebin; + + /// <summary>The underlying text compression helper.</summary> + private readonly IGzipHelper GzipHelper; + + /// <summary>Whether Azure blob storage is configured.</summary> + private bool HasAzure => !string.IsNullOrWhiteSpace(this.ClientsConfig.AzureBlobConnectionString); + + /// <summary>The number of days since the blob's last-modified date when it will be deleted.</summary> + private int ExpiryDays => this.ClientsConfig.AzureBlobTempExpiryDays; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="clientsConfig">The API client settings.</param> + /// <param name="pastebin">The underlying Pastebin client.</param> + /// <param name="gzipHelper">The underlying text compression helper.</param> + public StorageProvider(IOptions<ApiClientsConfig> clientsConfig, IPastebinClient pastebin, IGzipHelper gzipHelper) + { + this.ClientsConfig = clientsConfig.Value; + this.Pastebin = pastebin; + this.GzipHelper = gzipHelper; + } + + /// <summary>Save a text file to storage.</summary> + /// <param name="content">The content to upload.</param> + /// <param name="compress">Whether to gzip the text.</param> + /// <returns>Returns metadata about the save attempt.</returns> + public async Task<UploadResult> SaveAsync(string content, bool compress = true) + { + string id = Guid.NewGuid().ToString("N"); + + // save to Azure + if (this.HasAzure) + { + try + { + using Stream stream = new MemoryStream(Encoding.UTF8.GetBytes(content)); + BlobClient blob = this.GetAzureBlobClient(id); + await blob.UploadAsync(stream); + + return new UploadResult(true, id, null); + } + catch (Exception ex) + { + return new UploadResult(false, null, ex.Message); + } + } + + // save to local filesystem for testing + else + { + string path = this.GetDevFilePath(id); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + + File.WriteAllText(path, content); + return new UploadResult(true, id, null); + } + } + + /// <summary>Fetch raw text from storage.</summary> + /// <param name="id">The storage ID returned by <see cref="SaveAsync"/>.</param> + public async Task<StoredFileInfo> GetAsync(string id) + { + // fetch from blob storage + if (Guid.TryParseExact(id, "N", out Guid _)) + { + // Azure Blob storage + if (this.HasAzure) + { + try + { + BlobClient blob = this.GetAzureBlobClient(id); + Response<BlobDownloadInfo> response = await blob.DownloadAsync(); + using BlobDownloadInfo result = response.Value; + + using StreamReader reader = new StreamReader(result.Content); + DateTimeOffset expiry = result.Details.LastModified + TimeSpan.FromDays(this.ExpiryDays); + string content = this.GzipHelper.DecompressString(reader.ReadToEnd()); + + return new StoredFileInfo + { + Success = true, + Content = content, + Expiry = expiry.UtcDateTime + }; + } + catch (RequestFailedException ex) + { + return new StoredFileInfo + { + Error = ex.ErrorCode == "BlobNotFound" + ? "There's no file with that ID." + : $"Could not fetch that file from storage ({ex.ErrorCode}: {ex.Message})." + }; + } + } + + // local filesystem for testing + else + { + FileInfo file = new FileInfo(this.GetDevFilePath(id)); + if (file.Exists) + { + if (file.LastWriteTimeUtc.AddDays(this.ExpiryDays) < DateTime.UtcNow) + file.Delete(); + else + { + return new StoredFileInfo + { + Success = true, + Content = File.ReadAllText(file.FullName), + Expiry = DateTime.UtcNow.AddDays(this.ExpiryDays), + Warning = "This file was saved temporarily to the local computer. This should only happen in a local development environment." + }; + } + } + return new StoredFileInfo + { + Error = "There's no file with that ID." + }; + } + } + + // get from Pastebin + else + { + PasteInfo response = await this.Pastebin.GetAsync(id); + response.Content = this.GzipHelper.DecompressString(response.Content); + return new StoredFileInfo + { + Success = response.Success, + Content = response.Content, + Error = response.Error + }; + } + } + + /// <summary>Get a client for reading and writing to Azure Blob storage.</summary> + /// <param name="id">The file ID.</param> + private BlobClient GetAzureBlobClient(string id) + { + var azure = new BlobServiceClient(this.ClientsConfig.AzureBlobConnectionString); + var container = azure.GetBlobContainerClient(this.ClientsConfig.AzureBlobTempContainer); + return container.GetBlobClient($"uploads/{id}"); + } + + /// <summary>Get the absolute file path for an upload when running in a local test environment with no Azure account configured.</summary> + /// <param name="id">The file ID.</param> + private string GetDevFilePath(string id) + { + return Path.Combine(Path.GetTempPath(), "smapi-web-temp", $"{id}.txt"); + } + } +} diff --git a/src/SMAPI.Web/Framework/Storage/StoredFileInfo.cs b/src/SMAPI.Web/Framework/Storage/StoredFileInfo.cs new file mode 100644 index 00000000..30676c88 --- /dev/null +++ b/src/SMAPI.Web/Framework/Storage/StoredFileInfo.cs @@ -0,0 +1,23 @@ +using System; + +namespace StardewModdingAPI.Web.Framework.Storage +{ + /// <summary>The response for a get-file request.</summary> + internal class StoredFileInfo + { + /// <summary>Whether the file was successfully fetched.</summary> + public bool Success { get; set; } + + /// <summary>The fetched file content (if <see cref="Success"/> is <c>true</c>).</summary> + public string Content { get; set; } + + /// <summary>When the file will no longer be available.</summary> + public DateTime? Expiry { get; set; } + + /// <summary>The error message if saving succeeded, but a non-blocking issue was encountered.</summary> + public string Warning { get; set; } + + /// <summary>The error message if saving failed.</summary> + public string Error { get; set; } + } +} diff --git a/src/SMAPI.Web/Framework/Storage/UploadResult.cs b/src/SMAPI.Web/Framework/Storage/UploadResult.cs new file mode 100644 index 00000000..483c1769 --- /dev/null +++ b/src/SMAPI.Web/Framework/Storage/UploadResult.cs @@ -0,0 +1,33 @@ +namespace StardewModdingAPI.Web.Framework.Storage +{ + /// <summary>The result of an attempt to upload a file.</summary> + internal class UploadResult + { + /********* + ** Accessors + *********/ + /// <summary>Whether the file upload succeeded.</summary> + public bool Succeeded { get; } + + /// <summary>The file ID, if applicable.</summary> + public string ID { get; } + + /// <summary>The upload error, if any.</summary> + public string UploadError { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="succeeded">Whether the file upload succeeded.</param> + /// <param name="id">The file ID, if applicable.</param> + /// <param name="uploadError">The upload error, if any.</param> + public UploadResult(bool succeeded, string id, string uploadError) + { + this.Succeeded = succeeded; + this.ID = id; + this.UploadError = uploadError; + } + } +} |