encapsulate file storage, also handle Pastebin rate limits in JSON validator
This commit is contained in:
parent
0aad3f545a
commit
2b1f607d41
|
@ -4,6 +4,7 @@
|
||||||
## Upcoming release
|
## Upcoming release
|
||||||
|
|
||||||
* For the web UI:
|
* For the web UI:
|
||||||
|
* If a JSON validator upload can't be saved to Pastebin (e.g. due to rate limits), it's now uploaded to Amazon S3 instead. Files uploaded to S3 expire after one month.
|
||||||
* Updated the JSON validator for Content Patcher 1.10.0.
|
* Updated the JSON validator for Content Patcher 1.10.0.
|
||||||
|
|
||||||
## 3.0.1
|
## 3.0.1
|
||||||
|
|
|
@ -9,8 +9,7 @@ using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
using Newtonsoft.Json.Schema;
|
using Newtonsoft.Json.Schema;
|
||||||
using StardewModdingAPI.Web.Framework;
|
using StardewModdingAPI.Web.Framework;
|
||||||
using StardewModdingAPI.Web.Framework.Clients.Pastebin;
|
using StardewModdingAPI.Web.Framework.Storage;
|
||||||
using StardewModdingAPI.Web.Framework.Compression;
|
|
||||||
using StardewModdingAPI.Web.ViewModels.JsonValidator;
|
using StardewModdingAPI.Web.ViewModels.JsonValidator;
|
||||||
|
|
||||||
namespace StardewModdingAPI.Web.Controllers
|
namespace StardewModdingAPI.Web.Controllers
|
||||||
|
@ -21,11 +20,8 @@ namespace StardewModdingAPI.Web.Controllers
|
||||||
/*********
|
/*********
|
||||||
** Fields
|
** Fields
|
||||||
*********/
|
*********/
|
||||||
/// <summary>The underlying Pastebin client.</summary>
|
/// <summary>Provides access to raw data storage.</summary>
|
||||||
private readonly IPastebinClient Pastebin;
|
private readonly IStorageProvider Storage;
|
||||||
|
|
||||||
/// <summary>The underlying text compression helper.</summary>
|
|
||||||
private readonly IGzipHelper GzipHelper;
|
|
||||||
|
|
||||||
/// <summary>The supported JSON schemas (names indexed by ID).</summary>
|
/// <summary>The supported JSON schemas (names indexed by ID).</summary>
|
||||||
private readonly IDictionary<string, string> SchemaFormats = new Dictionary<string, string>
|
private readonly IDictionary<string, string> SchemaFormats = new Dictionary<string, string>
|
||||||
|
@ -49,12 +45,10 @@ namespace StardewModdingAPI.Web.Controllers
|
||||||
** Constructor
|
** Constructor
|
||||||
***/
|
***/
|
||||||
/// <summary>Construct an instance.</summary>
|
/// <summary>Construct an instance.</summary>
|
||||||
/// <param name="pastebin">The Pastebin API client.</param>
|
/// <param name="storage">Provides access to raw data storage.</param>
|
||||||
/// <param name="gzipHelper">The underlying text compression helper.</param>
|
public JsonValidatorController(IStorageProvider storage)
|
||||||
public JsonValidatorController(IPastebinClient pastebin, IGzipHelper gzipHelper)
|
|
||||||
{
|
{
|
||||||
this.Pastebin = pastebin;
|
this.Storage = storage;
|
||||||
this.GzipHelper = gzipHelper;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/***
|
/***
|
||||||
|
@ -62,7 +56,7 @@ namespace StardewModdingAPI.Web.Controllers
|
||||||
***/
|
***/
|
||||||
/// <summary>Render the schema validator UI.</summary>
|
/// <summary>Render the schema validator UI.</summary>
|
||||||
/// <param name="schemaName">The schema name with which to validate the JSON.</param>
|
/// <param name="schemaName">The schema name with which to validate the JSON.</param>
|
||||||
/// <param name="id">The paste ID.</param>
|
/// <param name="id">The stored file ID.</param>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Route("json")]
|
[Route("json")]
|
||||||
[Route("json/{schemaName}")]
|
[Route("json/{schemaName}")]
|
||||||
|
@ -76,16 +70,16 @@ namespace StardewModdingAPI.Web.Controllers
|
||||||
return this.View("Index", result);
|
return this.View("Index", result);
|
||||||
|
|
||||||
// fetch raw JSON
|
// fetch raw JSON
|
||||||
PasteInfo paste = await this.GetAsync(id);
|
StoredFileInfo file = await this.Storage.GetAsync(id);
|
||||||
if (string.IsNullOrWhiteSpace(paste.Content))
|
if (string.IsNullOrWhiteSpace(file.Content))
|
||||||
return this.View("Index", result.SetUploadError("The JSON file seems to be empty."));
|
return this.View("Index", result.SetUploadError("The JSON file seems to be empty."));
|
||||||
result.SetContent(paste.Content);
|
result.SetContent(file.Content, expiry: file.Expiry, uploadWarning: file.Warning);
|
||||||
|
|
||||||
// parse JSON
|
// parse JSON
|
||||||
JToken parsed;
|
JToken parsed;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
parsed = JToken.Parse(paste.Content, new JsonLoadSettings
|
parsed = JToken.Parse(file.Content, new JsonLoadSettings
|
||||||
{
|
{
|
||||||
DuplicatePropertyNameHandling = DuplicatePropertyNameHandling.Error,
|
DuplicatePropertyNameHandling = DuplicatePropertyNameHandling.Error,
|
||||||
CommentHandling = CommentHandling.Load
|
CommentHandling = CommentHandling.Load
|
||||||
|
@ -97,7 +91,7 @@ namespace StardewModdingAPI.Web.Controllers
|
||||||
}
|
}
|
||||||
|
|
||||||
// format JSON
|
// format JSON
|
||||||
result.SetContent(parsed.ToString(Formatting.Indented));
|
result.SetContent(parsed.ToString(Formatting.Indented), expiry: file.Expiry, uploadWarning: file.Warning);
|
||||||
|
|
||||||
// skip if no schema selected
|
// skip if no schema selected
|
||||||
if (schemaName == "none")
|
if (schemaName == "none")
|
||||||
|
@ -132,23 +126,20 @@ namespace StardewModdingAPI.Web.Controllers
|
||||||
public async Task<ActionResult> PostAsync(JsonValidatorRequestModel request)
|
public async Task<ActionResult> PostAsync(JsonValidatorRequestModel request)
|
||||||
{
|
{
|
||||||
if (request == null)
|
if (request == null)
|
||||||
return this.View("Index", new JsonValidatorModel(null, null, this.SchemaFormats).SetUploadError("The request seems to be invalid."));
|
return this.View("Index", this.GetModel(null, null).SetUploadError("The request seems to be invalid."));
|
||||||
|
|
||||||
// normalize schema name
|
// normalize schema name
|
||||||
string schemaName = this.NormalizeSchemaName(request.SchemaName);
|
string schemaName = this.NormalizeSchemaName(request.SchemaName);
|
||||||
|
|
||||||
// get raw log text
|
// get raw text
|
||||||
string input = request.Content;
|
string input = request.Content;
|
||||||
if (string.IsNullOrWhiteSpace(input))
|
if (string.IsNullOrWhiteSpace(input))
|
||||||
return this.View("Index", new JsonValidatorModel(null, schemaName, this.SchemaFormats).SetUploadError("The JSON file seems to be empty."));
|
return this.View("Index", this.GetModel(null, schemaName).SetUploadError("The JSON file seems to be empty."));
|
||||||
|
|
||||||
// upload log
|
// upload file
|
||||||
input = this.GzipHelper.CompressString(input);
|
var result = await this.Storage.SaveAsync(title: $"JSON validator {DateTime.UtcNow:s}", content: input, compress: true);
|
||||||
SavePasteResult result = await this.Pastebin.PostAsync($"JSON validator {DateTime.UtcNow:s}", input);
|
if (!result.Succeeded)
|
||||||
|
return this.View("Index", this.GetModel(result.ID, schemaName).SetUploadError(result.UploadError));
|
||||||
// handle errors
|
|
||||||
if (!result.Success)
|
|
||||||
return this.View("Index", new JsonValidatorModel(result.ID, schemaName, this.SchemaFormats).SetUploadError($"Pastebin error: {result.Error ?? "unknown error"}"));
|
|
||||||
|
|
||||||
// redirect to view
|
// redirect to view
|
||||||
return this.Redirect(this.Url.PlainAction("Index", "JsonValidator", new { schemaName = schemaName, id = result.ID }));
|
return this.Redirect(this.Url.PlainAction("Index", "JsonValidator", new { schemaName = schemaName, id = result.ID }));
|
||||||
|
@ -158,13 +149,12 @@ namespace StardewModdingAPI.Web.Controllers
|
||||||
/*********
|
/*********
|
||||||
** Private methods
|
** Private methods
|
||||||
*********/
|
*********/
|
||||||
/// <summary>Fetch raw text from Pastebin.</summary>
|
/// <summary>Build a JSON validator model.</summary>
|
||||||
/// <param name="id">The Pastebin paste ID.</param>
|
/// <param name="pasteID">The stored file ID.</param>
|
||||||
private async Task<PasteInfo> GetAsync(string id)
|
/// <param name="schemaName">The schema name with which the JSON was validated.</param>
|
||||||
|
private JsonValidatorModel GetModel(string pasteID, string schemaName)
|
||||||
{
|
{
|
||||||
PasteInfo response = await this.Pastebin.GetAsync(id);
|
return new JsonValidatorModel(pasteID, schemaName, this.SchemaFormats);
|
||||||
response.Content = this.GzipHelper.DecompressString(response.Content);
|
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Get a normalized schema name, or the <see cref="DefaultSchemaID"/> if blank.</summary>
|
/// <summary>Get a normalized schema name, or the <see cref="DefaultSchemaID"/> if blank.</summary>
|
||||||
|
|
|
@ -1,22 +1,12 @@
|
||||||
using System;
|
using System;
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
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.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using StardewModdingAPI.Toolkit.Utilities;
|
using StardewModdingAPI.Toolkit.Utilities;
|
||||||
using StardewModdingAPI.Web.Framework;
|
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;
|
||||||
using StardewModdingAPI.Web.Framework.LogParsing.Models;
|
using StardewModdingAPI.Web.Framework.LogParsing.Models;
|
||||||
|
using StardewModdingAPI.Web.Framework.Storage;
|
||||||
using StardewModdingAPI.Web.ViewModels;
|
using StardewModdingAPI.Web.ViewModels;
|
||||||
|
|
||||||
namespace StardewModdingAPI.Web.Controllers
|
namespace StardewModdingAPI.Web.Controllers
|
||||||
|
@ -27,14 +17,8 @@ namespace StardewModdingAPI.Web.Controllers
|
||||||
/*********
|
/*********
|
||||||
** Fields
|
** Fields
|
||||||
*********/
|
*********/
|
||||||
/// <summary>The API client settings.</summary>
|
/// <summary>Provides access to raw data storage.</summary>
|
||||||
private readonly ApiClientsConfig ClientsConfig;
|
private readonly IStorageProvider Storage;
|
||||||
|
|
||||||
/// <summary>The underlying Pastebin client.</summary>
|
|
||||||
private readonly IPastebinClient Pastebin;
|
|
||||||
|
|
||||||
/// <summary>The underlying text compression helper.</summary>
|
|
||||||
private readonly IGzipHelper GzipHelper;
|
|
||||||
|
|
||||||
|
|
||||||
/*********
|
/*********
|
||||||
|
@ -44,21 +28,17 @@ namespace StardewModdingAPI.Web.Controllers
|
||||||
** Constructor
|
** Constructor
|
||||||
***/
|
***/
|
||||||
/// <summary>Construct an instance.</summary>
|
/// <summary>Construct an instance.</summary>
|
||||||
/// <param name="clientsConfig">The API client settings.</param>
|
/// <param name="storage">Provides access to raw data storage.</param>
|
||||||
/// <param name="pastebin">The Pastebin API client.</param>
|
public LogParserController(IStorageProvider storage)
|
||||||
/// <param name="gzipHelper">The underlying text compression helper.</param>
|
|
||||||
public LogParserController(IOptions<ApiClientsConfig> clientsConfig, IPastebinClient pastebin, IGzipHelper gzipHelper)
|
|
||||||
{
|
{
|
||||||
this.ClientsConfig = clientsConfig.Value;
|
this.Storage = storage;
|
||||||
this.Pastebin = pastebin;
|
|
||||||
this.GzipHelper = gzipHelper;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/***
|
/***
|
||||||
** Web UI
|
** Web UI
|
||||||
***/
|
***/
|
||||||
/// <summary>Render the log parser UI.</summary>
|
/// <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>
|
/// <param name="raw">Whether to display the raw unparsed log.</param>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Route("log")]
|
[Route("log")]
|
||||||
|
@ -70,12 +50,12 @@ namespace StardewModdingAPI.Web.Controllers
|
||||||
return this.View("Index", this.GetModel(id));
|
return this.View("Index", this.GetModel(id));
|
||||||
|
|
||||||
// log page
|
// log page
|
||||||
PasteInfo paste = await this.GetAsync(id);
|
StoredFileInfo file = await this.Storage.GetAsync(id);
|
||||||
ParsedLog log = paste.Success
|
ParsedLog log = file.Success
|
||||||
? new LogParser().Parse(paste.Content)
|
? new LogParser().Parse(file.Content)
|
||||||
: new ParsedLog { IsValid = false, Error = paste.Error };
|
: 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."));
|
return this.View("Index", this.GetModel(null, uploadError: "The log file seems to be empty."));
|
||||||
|
|
||||||
// upload log
|
// upload log
|
||||||
input = this.GzipHelper.CompressString(input);
|
UploadResult uploadResult = await this.Storage.SaveAsync(title: $"SMAPI log {DateTime.UtcNow:s}", content: input, compress: true);
|
||||||
var uploadResult = await this.TrySaveLog(input);
|
|
||||||
if (!uploadResult.Succeeded)
|
if (!uploadResult.Succeeded)
|
||||||
return this.View("Index", this.GetModel(null, uploadError: uploadResult.UploadError));
|
return this.View("Index", this.GetModel(null, uploadError: uploadResult.UploadError));
|
||||||
|
|
||||||
|
@ -105,106 +84,8 @@ namespace StardewModdingAPI.Web.Controllers
|
||||||
/*********
|
/*********
|
||||||
** Private methods
|
** 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>
|
/// <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="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="uploadWarning">A non-blocking warning while uploading the log.</param>
|
||||||
/// <param name="uploadError">An error which occurred 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;
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,12 +11,6 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin
|
||||||
/// <summary>The fetched paste content (if <see cref="Success"/> is <c>true</c>).</summary>
|
/// <summary>The fetched paste content (if <see cref="Success"/> is <c>true</c>).</summary>
|
||||||
public string Content { get; set; }
|
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>
|
/// <summary>The error message if saving failed.</summary>
|
||||||
public string Error { get; set; }
|
public string Error { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,7 +26,7 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels
|
||||||
public string AmazonRegion { get; set; }
|
public string AmazonRegion { get; set; }
|
||||||
|
|
||||||
/// <summary>The AWS bucket in which to store temporary uploaded logs.</summary>
|
/// <summary>The AWS bucket in which to store temporary uploaded logs.</summary>
|
||||||
public string AmazonLogBucket { get; set; }
|
public string AmazonTempBucket { get; set; }
|
||||||
|
|
||||||
|
|
||||||
/****
|
/****
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Rewrite;
|
||||||
using Microsoft.AspNetCore.Routing;
|
using Microsoft.AspNetCore.Routing;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using MongoDB.Bson.Serialization;
|
using MongoDB.Bson.Serialization;
|
||||||
using MongoDB.Driver;
|
using MongoDB.Driver;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
@ -24,6 +25,7 @@ using StardewModdingAPI.Web.Framework.Clients.Pastebin;
|
||||||
using StardewModdingAPI.Web.Framework.Compression;
|
using StardewModdingAPI.Web.Framework.Compression;
|
||||||
using StardewModdingAPI.Web.Framework.ConfigModels;
|
using StardewModdingAPI.Web.Framework.ConfigModels;
|
||||||
using StardewModdingAPI.Web.Framework.RewriteRules;
|
using StardewModdingAPI.Web.Framework.RewriteRules;
|
||||||
|
using StardewModdingAPI.Web.Framework.Storage;
|
||||||
|
|
||||||
namespace StardewModdingAPI.Web
|
namespace StardewModdingAPI.Web
|
||||||
{
|
{
|
||||||
|
@ -158,7 +160,13 @@ namespace StardewModdingAPI.Web
|
||||||
}
|
}
|
||||||
|
|
||||||
// init helpers
|
// init helpers
|
||||||
services.AddSingleton<IGzipHelper>(new GzipHelper());
|
services
|
||||||
|
.AddSingleton<IGzipHelper>(new GzipHelper())
|
||||||
|
.AddSingleton<IStorageProvider>(serv => new StorageProvider(
|
||||||
|
serv.GetRequiredService<IOptions<ApiClientsConfig>>(),
|
||||||
|
serv.GetRequiredService<IPastebinClient>(),
|
||||||
|
serv.GetRequiredService<IGzipHelper>()
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The method called by the runtime to configure the HTTP request pipeline.</summary>
|
/// <summary>The method called by the runtime to configure the HTTP request pipeline.</summary>
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
|
||||||
|
@ -24,7 +25,13 @@ namespace StardewModdingAPI.Web.ViewModels.JsonValidator
|
||||||
/// <summary>The schema validation errors, if any.</summary>
|
/// <summary>The schema validation errors, if any.</summary>
|
||||||
public JsonValidatorErrorModel[] Errors { get; set; } = new JsonValidatorErrorModel[0];
|
public JsonValidatorErrorModel[] Errors { get; set; } = new JsonValidatorErrorModel[0];
|
||||||
|
|
||||||
/// <summary>An error which occurred while uploading the JSON to Pastebin.</summary>
|
/// <summary>A non-blocking warning while uploading the file.</summary>
|
||||||
|
public string UploadWarning { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When the uploaded file will no longer be available.</summary>
|
||||||
|
public DateTime? Expiry { get; set; }
|
||||||
|
|
||||||
|
/// <summary>An error which occurred while uploading the JSON.</summary>
|
||||||
public string UploadError { get; set; }
|
public string UploadError { get; set; }
|
||||||
|
|
||||||
/// <summary>An error which occurred while parsing the JSON.</summary>
|
/// <summary>An error which occurred while parsing the JSON.</summary>
|
||||||
|
@ -41,7 +48,7 @@ namespace StardewModdingAPI.Web.ViewModels.JsonValidator
|
||||||
public JsonValidatorModel() { }
|
public JsonValidatorModel() { }
|
||||||
|
|
||||||
/// <summary>Construct an instance.</summary>
|
/// <summary>Construct an instance.</summary>
|
||||||
/// <param name="pasteID">The paste ID.</param>
|
/// <param name="pasteID">The stored file ID.</param>
|
||||||
/// <param name="schemaName">The schema name with which the JSON was validated.</param>
|
/// <param name="schemaName">The schema name with which the JSON was validated.</param>
|
||||||
/// <param name="schemaFormats">The supported JSON schemas (names indexed by ID).</param>
|
/// <param name="schemaFormats">The supported JSON schemas (names indexed by ID).</param>
|
||||||
public JsonValidatorModel(string pasteID, string schemaName, IDictionary<string, string> schemaFormats)
|
public JsonValidatorModel(string pasteID, string schemaName, IDictionary<string, string> schemaFormats)
|
||||||
|
@ -53,14 +60,18 @@ namespace StardewModdingAPI.Web.ViewModels.JsonValidator
|
||||||
|
|
||||||
/// <summary>Set the validated content.</summary>
|
/// <summary>Set the validated content.</summary>
|
||||||
/// <param name="content">The validated content.</param>
|
/// <param name="content">The validated content.</param>
|
||||||
public JsonValidatorModel SetContent(string content)
|
/// <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>
|
||||||
|
public JsonValidatorModel SetContent(string content, DateTime? expiry, string uploadWarning = null)
|
||||||
{
|
{
|
||||||
this.Content = content;
|
this.Content = content;
|
||||||
|
this.Expiry = expiry;
|
||||||
|
this.UploadWarning = uploadWarning;
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Set the error which occurred while uploading the log to Pastebin.</summary>
|
/// <summary>Set the error which occurred while uploading the JSON.</summary>
|
||||||
/// <param name="error">The error message.</param>
|
/// <param name="error">The error message.</param>
|
||||||
public JsonValidatorModel SetUploadError(string error)
|
public JsonValidatorModel SetUploadError(string error)
|
||||||
{
|
{
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
@using Humanizer
|
||||||
@using StardewModdingAPI.Web.Framework
|
@using StardewModdingAPI.Web.Framework
|
||||||
@using StardewModdingAPI.Web.ViewModels.JsonValidator
|
@using StardewModdingAPI.Web.ViewModels.JsonValidator
|
||||||
@model JsonValidatorModel
|
@model JsonValidatorModel
|
||||||
|
@ -26,7 +27,7 @@
|
||||||
{
|
{
|
||||||
<meta name="robots" content="noindex" />
|
<meta name="robots" content="noindex" />
|
||||||
}
|
}
|
||||||
<link rel="stylesheet" href="~/Content/css/json-validator.css" />
|
<link rel="stylesheet" href="~/Content/css/json-validator.css?r=20191203" />
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/themes/sunlight.default.min.css" />
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/themes/sunlight.default.min.css" />
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/jquery@3.3.1/dist/jquery.min.js" crossorigin="anonymous"></script>
|
<script src="https://cdn.jsdelivr.net/npm/jquery@3.3.1/dist/jquery.min.js" crossorigin="anonymous"></script>
|
||||||
|
@ -67,6 +68,18 @@ else if (Model.PasteID != null)
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@* save warnings *@
|
||||||
|
@if (Model.UploadWarning != null || Model.Expiry != null)
|
||||||
|
{
|
||||||
|
<div class="save-metadata" v-pre>
|
||||||
|
@if (Model.Expiry != null)
|
||||||
|
{
|
||||||
|
<text>This log will expire in @((DateTime.UtcNow - Model.Expiry.Value).Humanize()). </text>
|
||||||
|
}
|
||||||
|
<!--@Model.UploadWarning-->
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@* upload new file *@
|
@* upload new file *@
|
||||||
@if (Model.Content == null)
|
@if (Model.Content == null)
|
||||||
{
|
{
|
||||||
|
|
|
@ -26,7 +26,7 @@
|
||||||
"AmazonAccessKey": null,
|
"AmazonAccessKey": null,
|
||||||
"AmazonSecretKey": null,
|
"AmazonSecretKey": null,
|
||||||
"AmazonRegion": "us-east-1",
|
"AmazonRegion": "us-east-1",
|
||||||
"AmazonLogBucket": "smapi-log-parser",
|
"AmazonTempBucket": "smapi-web-temp",
|
||||||
|
|
||||||
"ChucklefishBaseUrl": "https://community.playstarbound.com",
|
"ChucklefishBaseUrl": "https://community.playstarbound.com",
|
||||||
"ChucklefishModPageUrlFormat": "resources/{0}",
|
"ChucklefishModPageUrlFormat": "resources/{0}",
|
||||||
|
|
|
@ -41,6 +41,12 @@
|
||||||
background: #FCC;
|
background: #FCC;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.save-metadata {
|
||||||
|
margin-top: 1em;
|
||||||
|
font-size: 0.8em;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
/*********
|
/*********
|
||||||
** Validation results
|
** Validation results
|
||||||
*********/
|
*********/
|
||||||
|
|
Loading…
Reference in New Issue