From 2b1f607d41b3d4d071c0db0671dbc99b6982909f Mon Sep 17 00:00:00 2001
From: Jesse Plamondon-Willard <Pathoschild@users.noreply.github.com>
Date: Tue, 3 Dec 2019 21:21:28 -0500
Subject: encapsulate file storage, also handle Pastebin rate limits in JSON
 validator

---
 src/SMAPI.Web/Controllers/LogParserController.cs | 178 ++---------------------
 1 file changed, 14 insertions(+), 164 deletions(-)

(limited to 'src/SMAPI.Web/Controllers/LogParserController.cs')

diff --git a/src/SMAPI.Web/Controllers/LogParserController.cs b/src/SMAPI.Web/Controllers/LogParserController.cs
index 318b34d0..e270ae0a 100644
--- a/src/SMAPI.Web/Controllers/LogParserController.cs
+++ b/src/SMAPI.Web/Controllers/LogParserController.cs
@@ -1,22 +1,12 @@
 using System;
-using System.IO;
 using System.Linq;
-using System.Text;
 using System.Threading.Tasks;
-using Amazon;
-using Amazon.Runtime;
-using Amazon.S3;
-using Amazon.S3.Model;
-using Amazon.S3.Transfer;
 using Microsoft.AspNetCore.Mvc;
-using Microsoft.Extensions.Options;
 using StardewModdingAPI.Toolkit.Utilities;
 using StardewModdingAPI.Web.Framework;
-using StardewModdingAPI.Web.Framework.Clients.Pastebin;
-using StardewModdingAPI.Web.Framework.Compression;
-using StardewModdingAPI.Web.Framework.ConfigModels;
 using StardewModdingAPI.Web.Framework.LogParsing;
 using StardewModdingAPI.Web.Framework.LogParsing.Models;
+using StardewModdingAPI.Web.Framework.Storage;
 using StardewModdingAPI.Web.ViewModels;
 
 namespace StardewModdingAPI.Web.Controllers
@@ -27,14 +17,8 @@ namespace StardewModdingAPI.Web.Controllers
         /*********
         ** 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>Provides access to raw data storage.</summary>
+        private readonly IStorageProvider Storage;
 
 
         /*********
@@ -44,21 +28,17 @@ namespace StardewModdingAPI.Web.Controllers
         ** Constructor
         ***/
         /// <summary>Construct an instance.</summary>
-        /// <param name="clientsConfig">The API client settings.</param>
-        /// <param name="pastebin">The Pastebin API client.</param>
-        /// <param name="gzipHelper">The underlying text compression helper.</param>
-        public LogParserController(IOptions<ApiClientsConfig> clientsConfig, IPastebinClient pastebin, IGzipHelper gzipHelper)
+        /// <param name="storage">Provides access to raw data storage.</param>
+        public LogParserController(IStorageProvider storage)
         {
-            this.ClientsConfig = clientsConfig.Value;
-            this.Pastebin = pastebin;
-            this.GzipHelper = gzipHelper;
+            this.Storage = storage;
         }
 
         /***
         ** Web UI
         ***/
         /// <summary>Render the log parser UI.</summary>
-        /// <param name="id">The paste ID.</param>
+        /// <param name="id">The stored file ID.</param>
         /// <param name="raw">Whether to display the raw unparsed log.</param>
         [HttpGet]
         [Route("log")]
@@ -70,12 +50,12 @@ namespace StardewModdingAPI.Web.Controllers
                 return this.View("Index", this.GetModel(id));
 
             // log page
-            PasteInfo paste = await this.GetAsync(id);
-            ParsedLog log = paste.Success
-                ? new LogParser().Parse(paste.Content)
-                : new ParsedLog { IsValid = false, Error = paste.Error };
+            StoredFileInfo file = await this.Storage.GetAsync(id);
+            ParsedLog log = file.Success
+                ? new LogParser().Parse(file.Content)
+                : new ParsedLog { IsValid = false, Error = file.Error };
 
-            return this.View("Index", this.GetModel(id, uploadWarning: paste.Warning, expiry: paste.Expiry).SetResult(log, raw));
+            return this.View("Index", this.GetModel(id, uploadWarning: file.Warning, expiry: file.Expiry).SetResult(log, raw));
         }
 
         /***
@@ -92,8 +72,7 @@ namespace StardewModdingAPI.Web.Controllers
                 return this.View("Index", this.GetModel(null, uploadError: "The log file seems to be empty."));
 
             // upload log
-            input = this.GzipHelper.CompressString(input);
-            var uploadResult = await this.TrySaveLog(input);
+            UploadResult uploadResult = await this.Storage.SaveAsync(title: $"SMAPI log {DateTime.UtcNow:s}", content: input, compress: true);
             if (!uploadResult.Succeeded)
                 return this.View("Index", this.GetModel(null, uploadError: uploadResult.UploadError));
 
@@ -105,106 +84,8 @@ namespace StardewModdingAPI.Web.Controllers
         /*********
         ** Private methods
         *********/
-        /// <summary>Fetch raw text from Pastebin.</summary>
-        /// <param name="id">The Pastebin paste ID.</param>
-        private async Task<PasteInfo> GetAsync(string id)
-        {
-            // get from Amazon S3
-            if (Guid.TryParseExact(id, "N", out Guid _))
-            {
-                var credentials = new BasicAWSCredentials(accessKey: this.ClientsConfig.AmazonAccessKey, secretKey: this.ClientsConfig.AmazonSecretKey);
-
-                using (IAmazonS3 s3 = new AmazonS3Client(credentials, RegionEndpoint.GetBySystemName(this.ClientsConfig.AmazonRegion)))
-                {
-                    try
-                    {
-                        using (GetObjectResponse response = await s3.GetObjectAsync(this.ClientsConfig.AmazonLogBucket, $"logs/{id}"))
-                        using (Stream responseStream = response.ResponseStream)
-                        using (StreamReader reader = new StreamReader(responseStream))
-                        {
-                            DateTime expiry = response.Expiration.ExpiryDateUtc;
-                            string pastebinError = response.Metadata["x-amz-meta-pastebin-error"];
-                            string content = this.GzipHelper.DecompressString(reader.ReadToEnd());
-
-                            return new PasteInfo
-                            {
-                                Success = true,
-                                Content = content,
-                                Expiry = expiry,
-                                Warning = pastebinError
-                            };
-                        }
-                    }
-                    catch (AmazonServiceException ex)
-                    {
-                        return ex.ErrorCode == "NoSuchKey"
-                            ? new PasteInfo { Error = "There's no log with that ID." }
-                            : new PasteInfo { Error = $"Could not fetch that log from AWS S3 ({ex.ErrorCode}: {ex.Message})." };
-                    }
-                }
-            }
-
-            // get from PasteBin
-            else
-            {
-                PasteInfo response = await this.Pastebin.GetAsync(id);
-                response.Content = this.GzipHelper.DecompressString(response.Content);
-                return response;
-            }
-        }
-
-        /// <summary>Save a log to Pastebin or Amazon S3, if available.</summary>
-        /// <param name="content">The content to upload.</param>
-        /// <returns>Returns metadata about the save attempt.</returns>
-        private async Task<UploadResult> TrySaveLog(string content)
-        {
-            // save to PasteBin
-            string uploadError;
-            {
-                SavePasteResult result = await this.Pastebin.PostAsync($"SMAPI log {DateTime.UtcNow:s}", content);
-                if (result.Success)
-                    return new UploadResult(true, result.ID, null);
-
-                uploadError = $"Pastebin error: {result.Error ?? "unknown error"}";
-            }
-
-            // fallback to S3
-            try
-            {
-                var credentials = new BasicAWSCredentials(accessKey: this.ClientsConfig.AmazonAccessKey, secretKey: this.ClientsConfig.AmazonSecretKey);
-
-                using (Stream stream = new MemoryStream(Encoding.UTF8.GetBytes(content)))
-                using (IAmazonS3 s3 = new AmazonS3Client(credentials, RegionEndpoint.GetBySystemName(this.ClientsConfig.AmazonRegion)))
-                using (TransferUtility uploader = new TransferUtility(s3))
-                {
-                    string id = Guid.NewGuid().ToString("N");
-
-                    var uploadRequest = new TransferUtilityUploadRequest
-                    {
-                        BucketName = this.ClientsConfig.AmazonLogBucket,
-                        Key = $"logs/{id}",
-                        InputStream = stream,
-                        Metadata =
-                        {
-                            // note: AWS will lowercase keys and prefix 'x-amz-meta-'
-                            ["smapi-uploaded"] = DateTime.UtcNow.ToString("O"),
-                            ["pastebin-error"] = uploadError
-                        }
-                    };
-
-                    await uploader.UploadAsync(uploadRequest);
-
-                    return new UploadResult(true, id, uploadError);
-                }
-            }
-            catch (Exception ex)
-            {
-                return new UploadResult(false, null, $"{uploadError}\n{ex.Message}");
-            }
-        }
-
         /// <summary>Build a log parser model.</summary>
-        /// <param name="pasteID">The paste ID.</param>
+        /// <param name="pasteID">The stored file ID.</param>
         /// <param name="expiry">When the uploaded file will no longer be available.</param>
         /// <param name="uploadWarning">A non-blocking warning while uploading the log.</param>
         /// <param name="uploadError">An error which occurred while uploading the log.</param>
@@ -243,36 +124,5 @@ namespace StardewModdingAPI.Web.Controllers
                     return null;
             }
         }
-
-        /// <summary>The result of an attempt to upload a file.</summary>
-        private 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;
-            }
-        }
     }
 }
-- 
cgit 


From c1b15fb3725661ebfd8e03cec08343ae49e5d6da Mon Sep 17 00:00:00 2001
From: Jesse Plamondon-Willard <Pathoschild@users.noreply.github.com>
Date: Sat, 21 Dec 2019 23:52:47 -0500
Subject: allow local dev environments without an Azure account

---
 docs/release-notes.md                              |   3 +
 .../Controllers/JsonValidatorController.cs         |   2 +-
 src/SMAPI.Web/Controllers/LogParserController.cs   |   2 +-
 .../Framework/Storage/IStorageProvider.cs          |   3 +-
 src/SMAPI.Web/Framework/Storage/StorageProvider.cs | 116 +++++++++++++++------
 src/SMAPI.Web/Views/LogParser/Index.cshtml         |  21 ++--
 6 files changed, 104 insertions(+), 43 deletions(-)

(limited to 'src/SMAPI.Web/Controllers/LogParserController.cs')

diff --git a/docs/release-notes.md b/docs/release-notes.md
index 591f4cc2..bbe08c13 100644
--- a/docs/release-notes.md
+++ b/docs/release-notes.md
@@ -26,6 +26,9 @@
   * Fixed private textures loaded from content packs not having their `Name` field set.
   * Fixed asset propagation for `Characters\Farmer\farmer_girl_base_bald`.
 
+* For SMAPI developers:
+  * You can now run local environments without configuring Amazon, Azure, and Pastebin accounts.
+
 ## 3.0.1
 Released 02 December 2019 for Stardew Valley 1.4.0.1.
 
diff --git a/src/SMAPI.Web/Controllers/JsonValidatorController.cs b/src/SMAPI.Web/Controllers/JsonValidatorController.cs
index c4bfff3b..2ade3e3d 100644
--- a/src/SMAPI.Web/Controllers/JsonValidatorController.cs
+++ b/src/SMAPI.Web/Controllers/JsonValidatorController.cs
@@ -141,7 +141,7 @@ namespace StardewModdingAPI.Web.Controllers
                 return this.View("Index", this.GetModel(null, schemaName).SetUploadError("The JSON file seems to be empty."));
 
             // upload file
-            UploadResult result = await this.Storage.SaveAsync(title: $"JSON validator {DateTime.UtcNow:s}", content: input, compress: true);
+            UploadResult result = await this.Storage.SaveAsync(input);
             if (!result.Succeeded)
                 return this.View("Index", this.GetModel(result.ID, schemaName).SetUploadError(result.UploadError));
 
diff --git a/src/SMAPI.Web/Controllers/LogParserController.cs b/src/SMAPI.Web/Controllers/LogParserController.cs
index e270ae0a..97c419d9 100644
--- a/src/SMAPI.Web/Controllers/LogParserController.cs
+++ b/src/SMAPI.Web/Controllers/LogParserController.cs
@@ -72,7 +72,7 @@ namespace StardewModdingAPI.Web.Controllers
                 return this.View("Index", this.GetModel(null, uploadError: "The log file seems to be empty."));
 
             // upload log
-            UploadResult uploadResult = await this.Storage.SaveAsync(title: $"SMAPI log {DateTime.UtcNow:s}", content: input, compress: true);
+            UploadResult uploadResult = await this.Storage.SaveAsync(input);
             if (!uploadResult.Succeeded)
                 return this.View("Index", this.GetModel(null, uploadError: uploadResult.UploadError));
 
diff --git a/src/SMAPI.Web/Framework/Storage/IStorageProvider.cs b/src/SMAPI.Web/Framework/Storage/IStorageProvider.cs
index 12a5e421..96a34fbb 100644
--- a/src/SMAPI.Web/Framework/Storage/IStorageProvider.cs
+++ b/src/SMAPI.Web/Framework/Storage/IStorageProvider.cs
@@ -6,11 +6,10 @@ namespace StardewModdingAPI.Web.Framework.Storage
     internal interface IStorageProvider
     {
         /// <summary>Save a text file to storage.</summary>
-        /// <param name="title">The display title, if applicable.</param>
         /// <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 title, string content, bool compress = true);
+        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>
diff --git a/src/SMAPI.Web/Framework/Storage/StorageProvider.cs b/src/SMAPI.Web/Framework/Storage/StorageProvider.cs
index 12a35f18..35538443 100644
--- a/src/SMAPI.Web/Framework/Storage/StorageProvider.cs
+++ b/src/SMAPI.Web/Framework/Storage/StorageProvider.cs
@@ -27,6 +27,12 @@ namespace StardewModdingAPI.Web.Framework.Storage
         /// <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
@@ -43,25 +49,38 @@ namespace StardewModdingAPI.Web.Framework.Storage
         }
 
         /// <summary>Save a text file to storage.</summary>
-        /// <param name="title">The display title, if applicable.</param>
         /// <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 title, string content, bool compress = true)
+        public async Task<UploadResult> SaveAsync(string content, bool compress = true)
         {
-            try
-            {
-                using Stream stream = new MemoryStream(Encoding.UTF8.GetBytes(content));
-                string id = Guid.NewGuid().ToString("N");
+            string id = Guid.NewGuid().ToString("N");
 
-                BlobClient blob = this.GetAzureBlobClient(id);
-                await blob.UploadAsync(stream);
+            // 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);
+                    return new UploadResult(true, id, null);
+                }
+                catch (Exception ex)
+                {
+                    return new UploadResult(false, null, ex.Message);
+                }
             }
-            catch (Exception ex)
+
+            // save to local filesystem for testing
+            else
             {
-                return new UploadResult(false, null, ex.Message);
+                string path = this.GetDevFilePath(id);
+                Directory.CreateDirectory(Path.GetDirectoryName(path));
+
+                File.WriteAllText(path, content);
+                return new UploadResult(true, id, null);
             }
         }
 
@@ -69,39 +88,67 @@ namespace StardewModdingAPI.Web.Framework.Storage
         /// <param name="id">The storage ID returned by <see cref="SaveAsync"/>.</param>
         public async Task<StoredFileInfo> GetAsync(string id)
         {
-            // fetch from Azure/Amazon
+            // fetch from blob storage
             if (Guid.TryParseExact(id, "N", out Guid _))
             {
-                // try Azure
-                try
+                // Azure Blob storage
+                if (this.HasAzure)
                 {
-                    BlobClient blob = this.GetAzureBlobClient(id);
-                    Response<BlobDownloadInfo> response = await blob.DownloadAsync();
-                    using BlobDownloadInfo result = response.Value;
+                    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.ClientsConfig.AzureBlobTempExpiryDays);
-                    string content = this.GzipHelper.DecompressString(reader.ReadToEnd());
+                        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
+                        return new StoredFileInfo
+                        {
+                            Success = true,
+                            Content = content,
+                            Expiry = expiry.UtcDateTime
+                        };
+                    }
+                    catch (RequestFailedException ex)
                     {
-                        Success = true,
-                        Content = content,
-                        Expiry = expiry.UtcDateTime
-                    };
+                        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})."
+                        };
+                    }
                 }
-                catch (RequestFailedException ex)
+
+                // 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 = ex.ErrorCode == "BlobNotFound"
-                            ? "There's no file with that ID."
-                            : $"Could not fetch that file from storage ({ex.ErrorCode}: {ex.Message})."
+                        Error = "There's no file with that ID."
                     };
                 }
             }
 
-            // get from PasteBin
+            // get from Pastebin
             else
             {
                 PasteInfo response = await this.Pastebin.GetAsync(id);
@@ -116,12 +163,19 @@ namespace StardewModdingAPI.Web.Framework.Storage
         }
 
         /// <summary>Get a client for reading and writing to Azure Blob storage.</summary>
-        /// <param name="id">The file ID to fetch.</param>
+        /// <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/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml
index 439167bc..ac951564 100644
--- a/src/SMAPI.Web/Views/LogParser/Index.cshtml
+++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml
@@ -67,12 +67,16 @@ else if (Model.ParsedLog?.IsValid == true)
 @* save warnings *@
 @if (Model.UploadWarning != null || Model.Expiry != null)
 {
+    @if (Model.UploadWarning != null)
+    {
+        <text>⚠️ @Model.UploadWarning<br /></text>
+    }
+
     <div class="save-metadata" v-pre>
         @if (Model.Expiry != null)
         {
-            <text>This log will expire in @((DateTime.UtcNow - Model.Expiry.Value).Humanize()). </text>
+            <text>This log will expire in @((DateTime.UtcNow - Model.Expiry.Value).Humanize()).</text>
         }
-        <!--@Model.UploadWarning-->
     </div>
 }
 
@@ -294,10 +298,7 @@ else if (Model.ParsedLog?.IsValid == true)
                     string sectionFilter = message.Section != null && !message.IsStartOfSection ? $"&& sectionsAllow('{message.Section}')" : null; // filter the message by section if applicable
 
                     <tr class="mod @levelStr @sectionStartClass"
-                        @if (message.IsStartOfSection)
-                        {
-                            <text>v-on:click="toggleSection('@message.Section')"</text>
-                        }
+                        @if (message.IsStartOfSection) { <text> v-on:click="toggleSection('@message.Section')" </text>  }
                         v-show="filtersAllow('@Model.GetSlug(message.Mod)', '@levelStr') @sectionFilter">
                         <td v-pre>@message.Time</td>
                         <td v-pre>@message.Level.ToString().ToUpper()</td>
@@ -307,8 +308,12 @@ else if (Model.ParsedLog?.IsValid == true)
                             @if (message.IsStartOfSection)
                             {
                                 <span class="section-toggle-message">
-                                    <template v-if="sectionsAllow('@message.Section')">This section is shown. Click here to hide it.</template>
-                                    <template v-else>This section is hidden. Click here to show it.</template>
+                                    <template v-if="sectionsAllow('@message.Section')">
+                                        This section is shown. Click here to hide it.
+                                    </template>
+                                    <template v-else>
+                                        This section is hidden. Click here to show it.
+                                    </template>
                                 </span>
                             }
                         </td>
-- 
cgit