diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/SMAPI.Web/Controllers/JsonValidatorController.cs | 2 | ||||
-rw-r--r-- | src/SMAPI.Web/Controllers/LogParserController.cs | 2 | ||||
-rw-r--r-- | src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs | 2 | ||||
-rw-r--r-- | src/SMAPI.Web/Framework/Clients/Pastebin/SavePasteResult.cs | 15 | ||||
-rw-r--r-- | src/SMAPI.Web/Framework/ConfigModels/MongoDbConfig.cs | 6 | ||||
-rw-r--r-- | src/SMAPI.Web/Framework/Storage/IStorageProvider.cs | 3 | ||||
-rw-r--r-- | src/SMAPI.Web/Framework/Storage/StorageProvider.cs | 116 | ||||
-rw-r--r-- | src/SMAPI.Web/SMAPI.Web.csproj | 2 | ||||
-rw-r--r-- | src/SMAPI.Web/Startup.cs | 25 | ||||
-rw-r--r-- | src/SMAPI.Web/Views/Index/Privacy.cshtml | 8 | ||||
-rw-r--r-- | src/SMAPI.Web/Views/LogParser/Index.cshtml | 21 | ||||
-rw-r--r-- | src/SMAPI.Web/appsettings.Development.json | 6 | ||||
-rw-r--r-- | src/SMAPI.Web/wwwroot/Content/js/json-validator.js | 6 |
13 files changed, 143 insertions, 71 deletions
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/Clients/Pastebin/PasteInfo.cs b/src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs index 1ef3ef12..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> 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/MongoDbConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/MongoDbConfig.cs index 3c508300..e2e18477 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/MongoDbConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/MongoDbConfig.cs @@ -24,6 +24,12 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels /********* ** Public method *********/ + /// <summary>Get whether a MongoDB instance is configured.</summary> + public bool IsConfigured() + { + return !string.IsNullOrWhiteSpace(this.Host); + } + /// <summary>Get the MongoDB connection string.</summary> public string GetConnectionString() { 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/SMAPI.Web.csproj b/src/SMAPI.Web/SMAPI.Web.csproj index 22f5e975..504254cd 100644 --- a/src/SMAPI.Web/SMAPI.Web.csproj +++ b/src/SMAPI.Web/SMAPI.Web.csproj @@ -14,6 +14,7 @@ <ItemGroup> <PackageReference Include="Azure.Storage.Blobs" Version="12.1.0" /> <PackageReference Include="Hangfire.AspNetCore" Version="1.7.7" /> + <PackageReference Include="Hangfire.MemoryStorage" Version="1.6.3" /> <PackageReference Include="Hangfire.Mongo" Version="0.6.5" /> <PackageReference Include="HtmlAgilityPack" Version="1.11.16" /> <PackageReference Include="Humanizer.Core" Version="2.7.9" /> @@ -23,6 +24,7 @@ <PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.2.0" /> <PackageReference Include="Microsoft.AspNetCore.Rewrite" Version="2.2.0" /> <PackageReference Include="Microsoft.AspNetCore.StaticFiles" Version="2.2.0" /> + <PackageReference Include="Mongo2Go" Version="2.2.12" /> <PackageReference Include="MongoDB.Driver" Version="2.9.3" /> <PackageReference Include="Newtonsoft.Json.Schema" Version="3.0.11" /> <PackageReference Include="Pathoschild.FluentNexus" Version="0.8.0" /> diff --git a/src/SMAPI.Web/Startup.cs b/src/SMAPI.Web/Startup.cs index 07ee0c9e..338cd2d5 100644 --- a/src/SMAPI.Web/Startup.cs +++ b/src/SMAPI.Web/Startup.cs @@ -1,5 +1,7 @@ +using System; using System.Collections.Generic; using Hangfire; +using Hangfire.MemoryStorage; using Hangfire.Mongo; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -8,6 +10,7 @@ using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using Mongo2Go; using MongoDB.Bson.Serialization; using MongoDB.Driver; using Newtonsoft.Json; @@ -89,10 +92,20 @@ namespace StardewModdingAPI.Web } // init MongoDB + services.AddSingleton<MongoDbRunner>(serv => !mongoConfig.IsConfigured() + ? MongoDbRunner.Start() + : throw new InvalidOperationException("The MongoDB connection is configured, so the local development version should not be used.") + ); services.AddSingleton<IMongoDatabase>(serv => { + // get connection string + string connectionString = mongoConfig.IsConfigured() + ? mongoConfig.GetConnectionString() + : serv.GetRequiredService<MongoDbRunner>().ConnectionString; + + // get client BsonSerializer.RegisterSerializer(new UtcDateTimeOffsetSerializer()); - return new MongoClient(mongoConfig.GetConnectionString()).GetDatabase(mongoConfig.Database); + return new MongoClient(connectionString).GetDatabase(mongoConfig.Database); }); services.AddSingleton<IModCacheRepository>(serv => new ModCacheRepository(serv.GetRequiredService<IMongoDatabase>())); services.AddSingleton<IWikiCacheRepository>(serv => new WikiCacheRepository(serv.GetRequiredService<IMongoDatabase>())); @@ -104,12 +117,18 @@ namespace StardewModdingAPI.Web config .SetDataCompatibilityLevel(CompatibilityLevel.Version_170) .UseSimpleAssemblyNameTypeSerializer() - .UseRecommendedSerializerSettings() - .UseMongoStorage(mongoConfig.GetConnectionString(), $"{mongoConfig.Database}-hangfire", new MongoStorageOptions + .UseRecommendedSerializerSettings(); + + if (mongoConfig.IsConfigured()) + { + config.UseMongoStorage(mongoConfig.GetConnectionString(), $"{mongoConfig.Database}-hangfire", new MongoStorageOptions { MigrationOptions = new MongoMigrationOptions(MongoMigrationStrategy.Drop), CheckConnection = false // error on startup takes down entire process }); + } + else + config.UseMemoryStorage(); }); // init API clients diff --git a/src/SMAPI.Web/Views/Index/Privacy.cshtml b/src/SMAPI.Web/Views/Index/Privacy.cshtml index 7327de3d..fd78f908 100644 --- a/src/SMAPI.Web/Views/Index/Privacy.cshtml +++ b/src/SMAPI.Web/Views/Index/Privacy.cshtml @@ -22,10 +22,10 @@ <h2>Data collected and transmitted</h2> <h3 id="web-logging">Web logging</h3> -<p>This website and SMAPI's web API are hosted by Amazon Web Services. Their servers may automatically collect diagnostics like your IP address, but this information is not visible to SMAPI's web application or developers. For more information, see the <a href="https://aws.amazon.com/privacy/">Amazon Privacy Notice</a>.</p> +<p>This website and SMAPI's web API are hosted on Microsoft Azure. Their servers may automatically collect diagnostics like your IP address, but this information is not visible to SMAPI's web apps or its developers. For more information, see the <a href="https://azure.microsoft.com/en-ca/support/legal/">Microsoft Azure legal resources</a>.</p> <h3>Update checks</h3> -<p>SMAPI notifies you when there's a new version of SMAPI or your mods available. To do so, it sends your game/SMAPI/mod versions and platform type to its web API. No personal information is stored by the web application, but see <em><a href="#web-logging">web logging</a></em>.</p> +<p>SMAPI notifies you when there's a new version of SMAPI or your mods available. To do so, it sends basic metadata like your game/SMAPI/mod versions and platform type to its web API. No personal information is stored by the web app.</p> <p>You can disable update checks, and no information will be transmitted to the web API. To do so:</p> <ol> @@ -34,8 +34,8 @@ <li>change <code>"CheckForUpdates": true</code> to <code>"CheckForUpdates": false</code>.</li> </ol> -<h3>Log parser</h3> -<p>The <a href="https://smapi.io/log">log parser page</a> lets you store a log file for analysis and sharing. The log data is stored indefinitely in an obfuscated form as unlisted pastes in <a href="https://pastebin.com/">Pastebin</a>. No personal information is stored by the log parser beyond what you choose to upload, but see <em><a href="#web-logging">web logging</a></em> and the <a href="https://pastebin.com/doc_privacy_statement">Pastebin Privacy Statement</a>.</p> +<h3>Log parser and JSON validator</h3> +<p>The <a href="https://smapi.io/log">log parser</a> and <a href="https://smapi.io/json">JSON validator</a> let you upload files to analyze and share with other users. The log data is stored for 30 days in an obfuscated form in a private Microsoft Azure Blob storage account. No personal information is stored by the log parser beyond what you choose to upload as part of those files.</p> <h3>Multiplayer sync</h3> <p>As part of its multiplayer API, SMAPI transmits basic context to players you connect to (mainly your OS, SMAPI version, game version, and installed mods). This is used to enable multiplayer features like inter-mod messages, compatibility checks, etc. Although this information is normally hidden from players, it may be visible due to mods or configuration changes.</p> 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> diff --git a/src/SMAPI.Web/appsettings.Development.json b/src/SMAPI.Web/appsettings.Development.json index 3c2001ef..a6e48c69 100644 --- a/src/SMAPI.Web/appsettings.Development.json +++ b/src/SMAPI.Web/appsettings.Development.json @@ -18,9 +18,13 @@ }, "MongoDB": { - "Host": "localhost", + "Host": null, "Username": null, "Password": null, "Database": "smapi-edge" + }, + + "BackgroundServices": { + "Enabled": true } } diff --git a/src/SMAPI.Web/wwwroot/Content/js/json-validator.js b/src/SMAPI.Web/wwwroot/Content/js/json-validator.js index 401efbee..72b8192b 100644 --- a/src/SMAPI.Web/wwwroot/Content/js/json-validator.js +++ b/src/SMAPI.Web/wwwroot/Content/js/json-validator.js @@ -71,9 +71,9 @@ smapi.LineNumberRange = function (maxLines) { /** * UI logic for the JSON validator page. * @param {string} urlFormat The URL format for a file, with $schemaName and $id placeholders. - * @param {string} pasteID The Pastebin paste ID for the content being viewed, if any. + * @param {string} fileId The file ID for the content being viewed, if any. */ -smapi.jsonValidator = function (urlFormat, pasteID) { +smapi.jsonValidator = function (urlFormat, fileId) { /** * The original content element. */ @@ -138,7 +138,7 @@ smapi.jsonValidator = function (urlFormat, pasteID) { // change format $("#output #format").on("change", function() { var schemaName = $(this).val(); - location.href = urlFormat.replace("$schemaName", schemaName).replace("$id", pasteID); + location.href = urlFormat.replace("$schemaName", schemaName).replace("$id", fileId); }); // upload form |