summaryrefslogtreecommitdiff
path: root/src/SMAPI.Web/Framework
diff options
context:
space:
mode:
authorJesse Plamondon-Willard <Pathoschild@users.noreply.github.com>2020-01-05 20:18:16 -0500
committerJesse Plamondon-Willard <Pathoschild@users.noreply.github.com>2020-01-05 20:18:16 -0500
commitf976b5c0f095a881fc20f6ce5dcf5a50ebb2b5da (patch)
tree260fa7579e1c361283bda09c2616783c3fdb5b9a /src/SMAPI.Web/Framework
parentd34f369d35290bca96cc7225d9765d1a8a66fa8b (diff)
parent48959375b9ef52abf7c7a9404d43aac6ba780047 (diff)
downloadSMAPI-f976b5c0f095a881fc20f6ce5dcf5a50ebb2b5da.tar.gz
SMAPI-f976b5c0f095a881fc20f6ce5dcf5a50ebb2b5da.tar.bz2
SMAPI-f976b5c0f095a881fc20f6ce5dcf5a50ebb2b5da.zip
Merge branch 'develop' into stable
Diffstat (limited to 'src/SMAPI.Web/Framework')
-rw-r--r--src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs5
-rw-r--r--src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs8
-rw-r--r--src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs58
-rw-r--r--src/SMAPI.Web/Framework/Clients/Pastebin/SavePasteResult.cs15
-rw-r--r--src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs27
-rw-r--r--src/SMAPI.Web/Framework/ConfigModels/MongoDbConfig.cs23
-rw-r--r--src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs3
-rw-r--r--src/SMAPI.Web/Framework/Extensions.cs14
-rw-r--r--src/SMAPI.Web/Framework/LogParsing/LogParser.cs2
-rw-r--r--src/SMAPI.Web/Framework/Storage/IStorageProvider.cs18
-rw-r--r--src/SMAPI.Web/Framework/Storage/StorageProvider.cs181
-rw-r--r--src/SMAPI.Web/Framework/Storage/StoredFileInfo.cs23
-rw-r--r--src/SMAPI.Web/Framework/Storage/UploadResult.cs33
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;
+ }
+ }
+}