summaryrefslogtreecommitdiff
path: root/src/SMAPI.Web/Framework
diff options
context:
space:
mode:
Diffstat (limited to 'src/SMAPI.Web/Framework')
-rw-r--r--src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs6
-rw-r--r--src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs2
-rw-r--r--src/SMAPI.Web/Framework/Storage/IStorageProvider.cs19
-rw-r--r--src/SMAPI.Web/Framework/Storage/StorageProvider.cs147
-rw-r--r--src/SMAPI.Web/Framework/Storage/StoredFileInfo.cs23
-rw-r--r--src/SMAPI.Web/Framework/Storage/UploadResult.cs33
6 files changed, 223 insertions, 7 deletions
diff --git a/src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs b/src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs
index bb2de356..1ef3ef12 100644
--- a/src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs
+++ b/src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs
@@ -11,12 +11,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/ConfigModels/ApiClientsConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs
index 7119ef03..1e020840 100644
--- a/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs
+++ b/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs
@@ -26,7 +26,7 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels
public string AmazonRegion { get; set; }
/// <summary>The AWS bucket in which to store temporary uploaded logs.</summary>
- public string AmazonLogBucket { get; set; }
+ public string AmazonTempBucket { get; set; }
/****
diff --git a/src/SMAPI.Web/Framework/Storage/IStorageProvider.cs b/src/SMAPI.Web/Framework/Storage/IStorageProvider.cs
new file mode 100644
index 00000000..e222a235
--- /dev/null
+++ b/src/SMAPI.Web/Framework/Storage/IStorageProvider.cs
@@ -0,0 +1,19 @@
+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 Pastebin or Amazon S3, if available.</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);
+
+ /// <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..bbb6e06b
--- /dev/null
+++ b/src/SMAPI.Web/Framework/Storage/StorageProvider.cs
@@ -0,0 +1,147 @@
+using System;
+using System.IO;
+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.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;
+
+
+ /*********
+ ** 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 Pastebin or Amazon S3, if available.</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)
+ {
+ // save to PasteBin
+ string uploadError;
+ {
+ SavePasteResult result = await this.Pastebin.PostAsync(title, 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.AmazonTempBucket,
+ Key = $"uploads/{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>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)
+ {
+ // 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.AmazonTempBucket, $"uploads/{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 StoredFileInfo
+ {
+ Success = true,
+ Content = content,
+ Expiry = expiry,
+ Warning = pastebinError
+ };
+ }
+ catch (AmazonServiceException ex)
+ {
+ return ex.ErrorCode == "NoSuchKey"
+ ? new StoredFileInfo { Error = "There's no file with that ID." }
+ : new StoredFileInfo { Error = $"Could not fetch that file 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 new StoredFileInfo
+ {
+ Success = response.Success,
+ Content = response.Content,
+ Error = response.Error
+ };
+ }
+ }
+ }
+}
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;
+ }
+ }
+}