allow local dev environments without an Azure account

This commit is contained in:
Jesse Plamondon-Willard 2019-12-21 23:52:47 -05:00
parent ba46491ebc
commit c1b15fb372
No known key found for this signature in database
GPG Key ID: CF8B1456B3E29F49
6 changed files with 106 additions and 45 deletions

View File

@ -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.

View File

@ -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));

View File

@ -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));

View File

@ -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>

View File

@ -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
string id = Guid.NewGuid().ToString("N");
// save to Azure
if (this.HasAzure)
{
using Stream stream = new MemoryStream(Encoding.UTF8.GetBytes(content));
string id = Guid.NewGuid().ToString("N");
try
{
using Stream stream = new MemoryStream(Encoding.UTF8.GetBytes(content));
BlobClient blob = this.GetAzureBlobClient(id);
await blob.UploadAsync(stream);
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;
using StreamReader reader = new StreamReader(result.Content);
DateTimeOffset expiry = result.Details.LastModified + TimeSpan.FromDays(this.ClientsConfig.AzureBlobTempExpiryDays);
string content = this.GzipHelper.DecompressString(reader.ReadToEnd());
return new StoredFileInfo
try
{
Success = true,
Content = content,
Expiry = expiry.UtcDateTime
};
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})."
};
}
}
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");
}
}
}

View File

@ -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>