diff options
Diffstat (limited to 'src')
70 files changed, 870 insertions, 637 deletions
diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj b/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj index e2be66d9..5ae6574d 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj +++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj @@ -7,7 +7,7 @@ <ItemGroup> <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="2.10.0" /> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.5.0" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.6.1" /> <PackageReference Include="NUnit" Version="3.12.0" /> <PackageReference Include="NUnit3TestAdapter" Version="3.16.1"> <PrivateAssets>all</PrivateAssets> diff --git a/src/SMAPI.ModBuildConfig/DeployModTask.cs b/src/SMAPI.ModBuildConfig/DeployModTask.cs index 96d95e06..ced05a28 100644 --- a/src/SMAPI.ModBuildConfig/DeployModTask.cs +++ b/src/SMAPI.ModBuildConfig/DeployModTask.cs @@ -153,23 +153,22 @@ namespace StardewModdingAPI.ModBuildConfig // create zip file Directory.CreateDirectory(outputFolderPath); - using (Stream zipStream = new FileStream(zipPath, FileMode.Create, FileAccess.Write)) - using (ZipArchive archive = new ZipArchive(zipStream, ZipArchiveMode.Create)) + using Stream zipStream = new FileStream(zipPath, FileMode.Create, FileAccess.Write); + using ZipArchive archive = new ZipArchive(zipStream, ZipArchiveMode.Create); + + foreach (var fileEntry in files) { - foreach (var fileEntry in files) - { - string relativePath = fileEntry.Key; - FileInfo file = fileEntry.Value; + string relativePath = fileEntry.Key; + FileInfo file = fileEntry.Value; - // get file info - string filePath = file.FullName; - string entryName = folderName + '/' + relativePath.Replace(Path.DirectorySeparatorChar, '/'); + // get file info + string filePath = file.FullName; + string entryName = folderName + '/' + relativePath.Replace(Path.DirectorySeparatorChar, '/'); - // add to zip - using (Stream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read)) - using (Stream fileStreamInZip = archive.CreateEntry(entryName).Open()) - fileStream.CopyTo(fileStreamInZip); - } + // add to zip + using Stream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read); + using Stream fileStreamInZip = archive.CreateEntry(entryName).Open(); + fileStream.CopyTo(fileStreamInZip); } } diff --git a/src/SMAPI.Tests/SMAPI.Tests.csproj b/src/SMAPI.Tests/SMAPI.Tests.csproj index 639c22a4..b1548e3a 100644 --- a/src/SMAPI.Tests/SMAPI.Tests.csproj +++ b/src/SMAPI.Tests/SMAPI.Tests.csproj @@ -16,7 +16,7 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="Moq" Version="4.13.1" /> + <PackageReference Include="Moq" Version="4.14.1" /> <PackageReference Include="Newtonsoft.Json" Version="12.0.3" /> <PackageReference Include="NUnit" Version="3.12.0" /> </ItemGroup> diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/WebApiClient.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/WebApiClient.cs index f0a7c82a..2fb6ed20 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/WebApi/WebApiClient.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/WebApiClient.cs @@ -62,16 +62,15 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi private TResult Post<TBody, TResult>(string url, TBody content) { // note: avoid HttpClient for Mac compatibility - using (WebClient client = new WebClient()) - { - Uri fullUrl = new Uri(this.BaseUrl, url); - string data = JsonConvert.SerializeObject(content); + using WebClient client = new WebClient(); - client.Headers["Content-Type"] = "application/json"; - client.Headers["User-Agent"] = $"SMAPI/{this.Version}"; - string response = client.UploadString(fullUrl, data); - return JsonConvert.DeserializeObject<TResult>(response, this.JsonSettings); - } + Uri fullUrl = new Uri(this.BaseUrl, url); + string data = JsonConvert.SerializeObject(content); + + client.Headers["Content-Type"] = "application/json"; + client.Headers["User-Agent"] = $"SMAPI/{this.Version}"; + string response = client.UploadString(fullUrl, data); + return JsonConvert.DeserializeObject<TResult>(response, this.JsonSettings); } } } diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityStatus.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityStatus.cs index a1d2dfae..5cdf489f 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityStatus.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityStatus.cs @@ -3,25 +3,28 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki /// <summary>The compatibility status for a mod.</summary> public enum WikiCompatibilityStatus { + /// <summary>The status is unknown.</summary> + Unknown, + /// <summary>The mod is compatible.</summary> - Ok = 0, + Ok, /// <summary>The mod is compatible if you use an optional official download.</summary> - Optional = 1, + Optional, /// <summary>The mod is compatible if you use an unofficial update.</summary> - Unofficial = 2, + Unofficial, /// <summary>The mod isn't compatible, but the player can fix it or there's a good alternative.</summary> - Workaround = 3, + Workaround, /// <summary>The mod isn't compatible.</summary> - Broken = 4, + Broken, /// <summary>The mod is no longer maintained by the author, and an unofficial update or continuation is unlikely.</summary> - Abandoned = 5, + Abandoned, /// <summary>The mod is no longer needed and should be removed.</summary> - Obsolete = 6 + Obsolete } } diff --git a/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs b/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs index 212c70ef..4eec3424 100644 --- a/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs +++ b/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs @@ -124,8 +124,8 @@ namespace StardewModdingAPI.Toolkit.Framework.GameScanning XElement root; try { - using (FileStream stream = file.OpenRead()) - root = XElement.Load(stream); + using FileStream stream = file.OpenRead(); + root = XElement.Load(stream); } catch { diff --git a/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj b/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj index edb1d612..4e6918ad 100644 --- a/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj +++ b/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj @@ -14,7 +14,7 @@ <ItemGroup> <PackageReference Include="HtmlAgilityPack" Version="1.11.23" /> <PackageReference Include="Newtonsoft.Json" Version="12.0.3" /> - <PackageReference Include="Pathoschild.Http.FluentClient" Version="3.3.1" /> + <PackageReference Include="Pathoschild.Http.FluentClient" Version="4.0.0" /> <PackageReference Include="System.Management" Version="4.5.0" Condition="'$(OS)' == 'Windows_NT'" /> <PackageReference Include="Microsoft.Win32.Registry" Version="4.5.0" Condition="'$(OS)' == 'Windows_NT' AND '$(TargetFramework)' == 'netstandard2.0'" /> </ItemGroup> diff --git a/src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs b/src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs index c45448f3..1e490448 100644 --- a/src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs +++ b/src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs @@ -30,10 +30,7 @@ namespace StardewModdingAPI.Toolkit.Utilities /// <summary>Detect the current OS.</summary> public static Platform DetectPlatform() { - if (EnvironmentUtility.CachedPlatform == null) - EnvironmentUtility.CachedPlatform = EnvironmentUtility.DetectPlatformImpl(); - - return EnvironmentUtility.CachedPlatform.Value; + return EnvironmentUtility.CachedPlatform ??= EnvironmentUtility.DetectPlatformImpl(); } diff --git a/src/SMAPI.Web.LegacyRedirects/Controllers/ModsApiController.cs b/src/SMAPI.Web.LegacyRedirects/Controllers/ModsApiController.cs deleted file mode 100644 index 44ed0b6b..00000000 --- a/src/SMAPI.Web.LegacyRedirects/Controllers/ModsApiController.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Mvc; -using Pathoschild.Http.Client; -using StardewModdingAPI.Toolkit.Framework.Clients.WebApi; - -namespace SMAPI.Web.LegacyRedirects.Controllers -{ - /// <summary>Provides an API to perform mod update checks.</summary> - [ApiController] - [Produces("application/json")] - [Route("api/v{version}/mods")] - public class ModsApiController : Controller - { - /********* - ** Public methods - *********/ - /// <summary>Fetch version metadata for the given mods.</summary> - /// <param name="model">The mod search criteria.</param> - [HttpPost] - public async Task<IEnumerable<ModEntryModel>> PostAsync([FromBody] ModSearchModel model) - { - using IClient client = new FluentClient("https://smapi.io/api"); - - Startup.ConfigureJsonNet(client.Formatters.JsonFormatter.SerializerSettings); - - return await client - .PostAsync(this.Request.Path) - .WithBody(model) - .AsArray<ModEntryModel>(); - } - } -} diff --git a/src/SMAPI.Web.LegacyRedirects/Framework/LambdaRewriteRule.cs b/src/SMAPI.Web.LegacyRedirects/Framework/LambdaRewriteRule.cs deleted file mode 100644 index e5138e5c..00000000 --- a/src/SMAPI.Web.LegacyRedirects/Framework/LambdaRewriteRule.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Rewrite; - -namespace SMAPI.Web.LegacyRedirects.Framework -{ - /// <summary>Rewrite requests to prepend the subdomain portion (if any) to the path.</summary> - /// <remarks>Derived from <a href="https://stackoverflow.com/a/44526747/262123" />.</remarks> - internal class LambdaRewriteRule : IRule - { - /********* - ** Accessors - *********/ - /// <summary>Rewrite an HTTP request if needed.</summary> - private readonly Action<RewriteContext, HttpRequest, HttpResponse> Rewrite; - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="rewrite">Rewrite an HTTP request if needed.</param> - public LambdaRewriteRule(Action<RewriteContext, HttpRequest, HttpResponse> rewrite) - { - this.Rewrite = rewrite ?? throw new ArgumentNullException(nameof(rewrite)); - } - - /// <summary>Applies the rule. Implementations of ApplyRule should set the value for <see cref="RewriteContext.Result" /> (defaults to RuleResult.ContinueRules).</summary> - /// <param name="context">The rewrite context.</param> - public void ApplyRule(RewriteContext context) - { - HttpRequest request = context.HttpContext.Request; - HttpResponse response = context.HttpContext.Response; - this.Rewrite(context, request, response); - } - } -} diff --git a/src/SMAPI.Web.LegacyRedirects/Program.cs b/src/SMAPI.Web.LegacyRedirects/Program.cs deleted file mode 100644 index 6adee877..00000000 --- a/src/SMAPI.Web.LegacyRedirects/Program.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Hosting; - -namespace SMAPI.Web.LegacyRedirects -{ - /// <summary>The main app entry point.</summary> - public class Program - { - /********* - ** Public methods - *********/ - /// <summary>The main app entry point.</summary> - /// <param name="args">The command-line arguments.</param> - public static void Main(string[] args) - { - Host - .CreateDefaultBuilder(args) - .ConfigureWebHostDefaults(builder => builder.UseStartup<Startup>()) - .Build() - .Run(); - } - } -} diff --git a/src/SMAPI.Web.LegacyRedirects/Properties/launchSettings.json b/src/SMAPI.Web.LegacyRedirects/Properties/launchSettings.json deleted file mode 100644 index e9a1b210..00000000 --- a/src/SMAPI.Web.LegacyRedirects/Properties/launchSettings.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:52756", - "sslPort": 0 - } - }, - "$schema": "http://json.schemastore.org/launchsettings.json", - "profiles": { - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "SMAPI.Web.LegacyRedirects": { - "commandName": "Project", - "launchBrowser": true, - "launchUrl": "/", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "applicationUrl": "https://localhost:5001;http://localhost:5000" - } - } -}
\ No newline at end of file diff --git a/src/SMAPI.Web.LegacyRedirects/SMAPI.Web.LegacyRedirects.csproj b/src/SMAPI.Web.LegacyRedirects/SMAPI.Web.LegacyRedirects.csproj deleted file mode 100644 index 36831961..00000000 --- a/src/SMAPI.Web.LegacyRedirects/SMAPI.Web.LegacyRedirects.csproj +++ /dev/null @@ -1,21 +0,0 @@ -<Project Sdk="Microsoft.NET.Sdk.Web"> - - <PropertyGroup> - <TargetFramework>netcoreapp3.0</TargetFramework> - </PropertyGroup> - - <ItemGroup> - <Content Remove="aws-beanstalk-tools-defaults.json" /> - </ItemGroup> - - <ItemGroup> - <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="3.0.2" /> - <PackageReference Include="Pathoschild.Http.FluentClient" Version="3.3.1" /> - </ItemGroup> - - <ItemGroup> - <ProjectReference Include="..\SMAPI.Toolkit.CoreInterfaces\SMAPI.Toolkit.CoreInterfaces.csproj" /> - <ProjectReference Include="..\SMAPI.Toolkit\SMAPI.Toolkit.csproj" /> - </ItemGroup> - -</Project> diff --git a/src/SMAPI.Web/BackgroundService.cs b/src/SMAPI.Web/BackgroundService.cs index ee7a60f3..275622fe 100644 --- a/src/SMAPI.Web/BackgroundService.cs +++ b/src/SMAPI.Web/BackgroundService.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; using Hangfire; @@ -36,7 +37,9 @@ namespace StardewModdingAPI.Web /// <summary>Construct an instance.</summary> /// <param name="wikiCache">The cache in which to store wiki metadata.</param> /// <param name="modCache">The cache in which to store mod data.</param> - public BackgroundService(IWikiCacheRepository wikiCache, IModCacheRepository modCache) + /// <param name="hangfireStorage">The Hangfire storage implementation.</param> + [SuppressMessage("ReSharper", "UnusedParameter.Local", Justification = "The Hangfire reference forces it to initialize first, since it's needed by the background service.")] + public BackgroundService(IWikiCacheRepository wikiCache, IModCacheRepository modCache, JobStorage hangfireStorage) { BackgroundService.WikiCache = wikiCache; BackgroundService.ModCache = modCache; diff --git a/src/SMAPI.Web/Controllers/JsonValidatorController.cs b/src/SMAPI.Web/Controllers/JsonValidatorController.cs index 2ade3e3d..c43fb929 100644 --- a/src/SMAPI.Web/Controllers/JsonValidatorController.cs +++ b/src/SMAPI.Web/Controllers/JsonValidatorController.cs @@ -275,21 +275,20 @@ namespace StardewModdingAPI.Web.Controllers errors = new Dictionary<string, string>(errors, StringComparer.InvariantCultureIgnoreCase); // match error by type and message - foreach (var pair in errors) + foreach ((string target, string errorMessage) in errors) { - if (!pair.Key.Contains(":")) + if (!target.Contains(":")) continue; - string[] parts = pair.Key.Split(':', 2); + string[] parts = target.Split(':', 2); if (parts[0].Equals(error.ErrorType.ToString(), StringComparison.InvariantCultureIgnoreCase) && Regex.IsMatch(error.Message, parts[1])) - return pair.Value?.Trim(); + return errorMessage?.Trim(); } // match by type - if (errors.TryGetValue(error.ErrorType.ToString(), out string message)) - return message?.Trim(); - - return null; + return errors.TryGetValue(error.ErrorType.ToString(), out string message) + ? message?.Trim() + : null; } return GetRawOverrideError() @@ -304,10 +303,10 @@ namespace StardewModdingAPI.Web.Controllers { if (schema.ExtensionData != null) { - foreach (var pair in schema.ExtensionData) + foreach ((string curKey, JToken value) in schema.ExtensionData) { - if (pair.Key.Equals(key, StringComparison.InvariantCultureIgnoreCase)) - return pair.Value.ToObject<T>(); + if (curKey.Equals(key, StringComparison.InvariantCultureIgnoreCase)) + return value.ToObject<T>(); } } @@ -318,14 +317,11 @@ namespace StardewModdingAPI.Web.Controllers /// <param name="value">The value to format.</param> private string FormatValue(object value) { - switch (value) + return value switch { - case List<string> list: - return string.Join(", ", list); - - default: - return value?.ToString() ?? "null"; - } + List<string> list => string.Join(", ", list), + _ => value?.ToString() ?? "null" + }; } } } diff --git a/src/SMAPI.Web/Controllers/ModsApiController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs index 06768f03..6032186f 100644 --- a/src/SMAPI.Web/Controllers/ModsApiController.cs +++ b/src/SMAPI.Web/Controllers/ModsApiController.cs @@ -61,7 +61,7 @@ namespace StardewModdingAPI.Web.Controllers /// <param name="github">The GitHub API client.</param> /// <param name="modDrop">The ModDrop API client.</param> /// <param name="nexus">The Nexus API client.</param> - public ModsApiController(IHostingEnvironment environment, IWikiCacheRepository wikiCache, IModCacheRepository modCache, IOptions<ModUpdateCheckConfig> config, IChucklefishClient chucklefish, ICurseForgeClient curseForge, IGitHubClient github, IModDropClient modDrop, INexusClient nexus) + public ModsApiController(IWebHostEnvironment environment, IWikiCacheRepository wikiCache, IModCacheRepository modCache, IOptions<ModUpdateCheckConfig> config, IChucklefishClient chucklefish, ICurseForgeClient curseForge, IGitHubClient github, IModDropClient modDrop, INexusClient nexus) { this.ModDatabase = new ModToolkit().GetModDatabase(Path.Combine(environment.WebRootPath, "SMAPI.metadata.json")); @@ -388,9 +388,9 @@ namespace StardewModdingAPI.Web.Controllers if (map.ContainsKey(parsed.ToString())) return map[parsed.ToString()]; - foreach (var pair in map) + foreach ((string fromRaw, string toRaw) in map) { - if (SemanticVersion.TryParse(pair.Key, allowNonStandard, out ISemanticVersion target) && parsed.Equals(target) && SemanticVersion.TryParse(pair.Value, allowNonStandard, out ISemanticVersion newVersion)) + if (SemanticVersion.TryParse(fromRaw, allowNonStandard, out ISemanticVersion target) && parsed.Equals(target) && SemanticVersion.TryParse(toRaw, allowNonStandard, out ISemanticVersion newVersion)) return newVersion.ToString(); } } diff --git a/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs index bcec8b36..08749f3b 100644 --- a/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs @@ -4,7 +4,7 @@ using StardewModdingAPI.Web.Framework.ModRepositories; namespace StardewModdingAPI.Web.Framework.Caching.Mods { - /// <summary>Encapsulates logic for accessing the mod data cache.</summary> + /// <summary>Manages cached mod data.</summary> internal interface IModCacheRepository : ICacheRepository { /********* diff --git a/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs b/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs new file mode 100644 index 00000000..9c5a217e --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StardewModdingAPI.Toolkit.Framework.UpdateData; +using StardewModdingAPI.Web.Framework.ModRepositories; + +namespace StardewModdingAPI.Web.Framework.Caching.Mods +{ + /// <summary>Manages cached mod data in-memory.</summary> + internal class ModCacheMemoryRepository : BaseCacheRepository, IModCacheRepository + { + /********* + ** Fields + *********/ + /// <summary>The cached mod data indexed by <c>{site key}:{ID}</c>.</summary> + private readonly IDictionary<string, CachedMod> Mods = new Dictionary<string, CachedMod>(StringComparer.InvariantCultureIgnoreCase); + + + /********* + ** Public methods + *********/ + /// <summary>Get the cached mod data.</summary> + /// <param name="site">The mod site to search.</param> + /// <param name="id">The mod's unique ID within the <paramref name="site"/>.</param> + /// <param name="mod">The fetched mod.</param> + /// <param name="markRequested">Whether to update the mod's 'last requested' date.</param> + public bool TryGetMod(ModRepositoryKey site, string id, out CachedMod mod, bool markRequested = true) + { + // get mod + if (!this.Mods.TryGetValue(this.GetKey(site, id), out mod)) + return false; + + // bump 'last requested' + if (markRequested) + { + mod.LastRequested = DateTimeOffset.UtcNow; + mod = this.SaveMod(mod); + } + + return true; + } + + /// <summary>Save data fetched for a mod.</summary> + /// <param name="site">The mod site on which the mod is found.</param> + /// <param name="id">The mod's unique ID within the <paramref name="site"/>.</param> + /// <param name="mod">The mod data.</param> + /// <param name="cachedMod">The stored mod record.</param> + public void SaveMod(ModRepositoryKey site, string id, ModInfoModel mod, out CachedMod cachedMod) + { + string key = this.GetKey(site, id); + cachedMod = this.SaveMod(new CachedMod(site, id, mod)); + } + + /// <summary>Delete data for mods which haven't been requested within a given time limit.</summary> + /// <param name="age">The minimum age for which to remove mods.</param> + public void RemoveStaleMods(TimeSpan age) + { + DateTimeOffset minDate = DateTimeOffset.UtcNow.Subtract(age); + + string[] staleKeys = this.Mods + .Where(p => p.Value.LastRequested < minDate) + .Select(p => p.Key) + .ToArray(); + + foreach (string key in staleKeys) + this.Mods.Remove(key); + } + + /// <summary>Save data fetched for a mod.</summary> + /// <param name="mod">The mod data.</param> + public CachedMod SaveMod(CachedMod mod) + { + string key = this.GetKey(mod.Site, mod.ID); + return this.Mods[key] = mod; + } + + + /********* + ** Private methods + *********/ + /// <summary>Get a cache key.</summary> + /// <param name="site">The mod site.</param> + /// <param name="id">The mod ID.</param> + public string GetKey(ModRepositoryKey site, string id) + { + return $"{site}:{id.Trim()}".ToLower(); + } + } +} diff --git a/src/SMAPI.Web/Framework/Caching/Mods/ModCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMongoRepository.cs index 2e7804a7..f105baab 100644 --- a/src/SMAPI.Web/Framework/Caching/Mods/ModCacheRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMongoRepository.cs @@ -5,8 +5,8 @@ using StardewModdingAPI.Web.Framework.ModRepositories; namespace StardewModdingAPI.Web.Framework.Caching.Mods { - /// <summary>Encapsulates logic for accessing the mod data cache.</summary> - internal class ModCacheRepository : BaseCacheRepository, IModCacheRepository + /// <summary>Manages cached mod data in MongoDB.</summary> + internal class ModCacheMongoRepository : BaseCacheRepository, IModCacheRepository { /********* ** Fields @@ -20,7 +20,7 @@ namespace StardewModdingAPI.Web.Framework.Caching.Mods *********/ /// <summary>Construct an instance.</summary> /// <param name="database">The authenticated MongoDB database.</param> - public ModCacheRepository(IMongoDatabase database) + public ModCacheMongoRepository(IMongoDatabase database) { // get collections this.Mods = database.GetCollection<CachedMod>("mods"); @@ -29,6 +29,7 @@ namespace StardewModdingAPI.Web.Framework.Caching.Mods this.Mods.Indexes.CreateOne(new CreateIndexModel<CachedMod>(Builders<CachedMod>.IndexKeys.Ascending(p => p.ID).Ascending(p => p.Site))); } + /********* ** Public methods *********/ @@ -72,13 +73,9 @@ namespace StardewModdingAPI.Web.Framework.Caching.Mods public void RemoveStaleMods(TimeSpan age) { DateTimeOffset minDate = DateTimeOffset.UtcNow.Subtract(age); - var result = this.Mods.DeleteMany(p => p.LastRequested < minDate); + this.Mods.DeleteMany(p => p.LastRequested < minDate); } - - /********* - ** Private methods - *********/ /// <summary>Save data fetched for a mod.</summary> /// <param name="mod">The mod data.</param> public CachedMod SaveMod(CachedMod mod) @@ -88,12 +85,16 @@ namespace StardewModdingAPI.Web.Framework.Caching.Mods this.Mods.ReplaceOne( entry => entry.ID == id && entry.Site == mod.Site, mod, - new UpdateOptions { IsUpsert = true } + new ReplaceOptions { IsUpsert = true } ); return mod; } + + /********* + ** Private methods + *********/ /// <summary>Normalize a mod ID for case-insensitive search.</summary> /// <param name="id">The mod ID.</param> public string NormalizeId(string id) diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/IWikiCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/Wiki/IWikiCacheRepository.cs index b54c8a2f..02097f52 100644 --- a/src/SMAPI.Web/Framework/Caching/Wiki/IWikiCacheRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/Wiki/IWikiCacheRepository.cs @@ -5,7 +5,7 @@ using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; namespace StardewModdingAPI.Web.Framework.Caching.Wiki { - /// <summary>Encapsulates logic for accessing the wiki data cache.</summary> + /// <summary>Manages cached wiki data.</summary> internal interface IWikiCacheRepository : ICacheRepository { /********* diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMemoryRepository.cs b/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMemoryRepository.cs new file mode 100644 index 00000000..4621f5e3 --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMemoryRepository.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; + +namespace StardewModdingAPI.Web.Framework.Caching.Wiki +{ + /// <summary>Manages cached wiki data in-memory.</summary> + internal class WikiCacheMemoryRepository : BaseCacheRepository, IWikiCacheRepository + { + /********* + ** Fields + *********/ + /// <summary>The saved wiki metadata.</summary> + private CachedWikiMetadata Metadata; + + /// <summary>The cached wiki data.</summary> + private CachedWikiMod[] Mods = new CachedWikiMod[0]; + + + /********* + ** Public methods + *********/ + /// <summary>Get the cached wiki metadata.</summary> + /// <param name="metadata">The fetched metadata.</param> + public bool TryGetWikiMetadata(out CachedWikiMetadata metadata) + { + metadata = this.Metadata; + return metadata != null; + } + + /// <summary>Get the cached wiki mods.</summary> + /// <param name="filter">A filter to apply, if any.</param> + public IEnumerable<CachedWikiMod> GetWikiMods(Expression<Func<CachedWikiMod, bool>> filter = null) + { + return filter != null + ? this.Mods.Where(filter.Compile()) + : this.Mods.ToArray(); + } + + /// <summary>Save data fetched from the wiki compatibility list.</summary> + /// <param name="stableVersion">The current stable Stardew Valley version.</param> + /// <param name="betaVersion">The current beta Stardew Valley version.</param> + /// <param name="mods">The mod data.</param> + /// <param name="cachedMetadata">The stored metadata record.</param> + /// <param name="cachedMods">The stored mod records.</param> + public void SaveWikiData(string stableVersion, string betaVersion, IEnumerable<WikiModEntry> mods, out CachedWikiMetadata cachedMetadata, out CachedWikiMod[] cachedMods) + { + this.Metadata = cachedMetadata = new CachedWikiMetadata(stableVersion, betaVersion); + this.Mods = cachedMods = mods.Select(mod => new CachedWikiMod(mod)).ToArray(); + } + } +} diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMongoRepository.cs index 1ae9d38f..07e7c721 100644 --- a/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMongoRepository.cs @@ -7,17 +7,17 @@ using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; namespace StardewModdingAPI.Web.Framework.Caching.Wiki { - /// <summary>Encapsulates logic for accessing the wiki data cache.</summary> - internal class WikiCacheRepository : BaseCacheRepository, IWikiCacheRepository + /// <summary>Manages cached wiki data in MongoDB.</summary> + internal class WikiCacheMongoRepository : BaseCacheRepository, IWikiCacheRepository { /********* ** Fields *********/ /// <summary>The collection for wiki metadata.</summary> - private readonly IMongoCollection<CachedWikiMetadata> WikiMetadata; + private readonly IMongoCollection<CachedWikiMetadata> Metadata; /// <summary>The collection for wiki mod data.</summary> - private readonly IMongoCollection<CachedWikiMod> WikiMods; + private readonly IMongoCollection<CachedWikiMod> Mods; /********* @@ -25,21 +25,21 @@ namespace StardewModdingAPI.Web.Framework.Caching.Wiki *********/ /// <summary>Construct an instance.</summary> /// <param name="database">The authenticated MongoDB database.</param> - public WikiCacheRepository(IMongoDatabase database) + public WikiCacheMongoRepository(IMongoDatabase database) { // get collections - this.WikiMetadata = database.GetCollection<CachedWikiMetadata>("wiki-metadata"); - this.WikiMods = database.GetCollection<CachedWikiMod>("wiki-mods"); + this.Metadata = database.GetCollection<CachedWikiMetadata>("wiki-metadata"); + this.Mods = database.GetCollection<CachedWikiMod>("wiki-mods"); // add indexes if needed - this.WikiMods.Indexes.CreateOne(new CreateIndexModel<CachedWikiMod>(Builders<CachedWikiMod>.IndexKeys.Ascending(p => p.ID))); + this.Mods.Indexes.CreateOne(new CreateIndexModel<CachedWikiMod>(Builders<CachedWikiMod>.IndexKeys.Ascending(p => p.ID))); } /// <summary>Get the cached wiki metadata.</summary> /// <param name="metadata">The fetched metadata.</param> public bool TryGetWikiMetadata(out CachedWikiMetadata metadata) { - metadata = this.WikiMetadata.Find("{}").FirstOrDefault(); + metadata = this.Metadata.Find("{}").FirstOrDefault(); return metadata != null; } @@ -48,8 +48,8 @@ namespace StardewModdingAPI.Web.Framework.Caching.Wiki public IEnumerable<CachedWikiMod> GetWikiMods(Expression<Func<CachedWikiMod, bool>> filter = null) { return filter != null - ? this.WikiMods.Find(filter).ToList() - : this.WikiMods.Find("{}").ToList(); + ? this.Mods.Find(filter).ToList() + : this.Mods.Find("{}").ToList(); } /// <summary>Save data fetched from the wiki compatibility list.</summary> @@ -63,11 +63,11 @@ namespace StardewModdingAPI.Web.Framework.Caching.Wiki cachedMetadata = new CachedWikiMetadata(stableVersion, betaVersion); cachedMods = mods.Select(mod => new CachedWikiMod(mod)).ToArray(); - this.WikiMods.DeleteMany("{}"); - this.WikiMods.InsertMany(cachedMods); + this.Mods.DeleteMany("{}"); + this.Mods.InsertMany(cachedMods); - this.WikiMetadata.DeleteMany("{}"); - this.WikiMetadata.InsertOne(cachedMetadata); + this.Metadata.DeleteMany("{}"); + this.Metadata.InsertOne(cachedMetadata); } } } diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs index 140b854e..a6fd21fd 100644 --- a/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs +++ b/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs @@ -57,8 +57,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.CurseForge if (!SemanticVersion.TryParse(raw, out version)) { - if (invalidVersion == null) - invalidVersion = raw; + invalidVersion ??= raw; continue; } } diff --git a/src/SMAPI.Web/Framework/Compression/GzipHelper.cs b/src/SMAPI.Web/Framework/Compression/GzipHelper.cs index cc8f4737..676d660d 100644 --- a/src/SMAPI.Web/Framework/Compression/GzipHelper.cs +++ b/src/SMAPI.Web/Framework/Compression/GzipHelper.cs @@ -69,7 +69,7 @@ namespace StardewModdingAPI.Web.Framework.Compression return rawText; // decompress - using (MemoryStream memoryStream = new MemoryStream()) + using MemoryStream memoryStream = new MemoryStream(); { // read length prefix int dataLength = BitConverter.ToInt32(zipBuffer, 0); diff --git a/src/SMAPI.Web/Framework/ConfigModels/MongoDbConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/MongoDbConfig.cs deleted file mode 100644 index c7b6cb00..00000000 --- a/src/SMAPI.Web/Framework/ConfigModels/MongoDbConfig.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace StardewModdingAPI.Web.Framework.ConfigModels -{ - /// <summary>The config settings for mod compatibility list.</summary> - internal class MongoDbConfig - { - /********* - ** Accessors - *********/ - /// <summary>The MongoDB connection string.</summary> - public string ConnectionString { get; set; } - - /// <summary>The database name.</summary> - public string Database { get; set; } - - - /********* - ** Public method - *********/ - /// <summary>Get whether a MongoDB instance is configured.</summary> - public bool IsConfigured() - { - return !string.IsNullOrWhiteSpace(this.ConnectionString); - } - } -} diff --git a/src/SMAPI.Web/Framework/ConfigModels/StorageConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/StorageConfig.cs new file mode 100644 index 00000000..61cc4855 --- /dev/null +++ b/src/SMAPI.Web/Framework/ConfigModels/StorageConfig.cs @@ -0,0 +1,18 @@ +namespace StardewModdingAPI.Web.Framework.ConfigModels +{ + /// <summary>The config settings for cache storage.</summary> + internal class StorageConfig + { + /********* + ** Accessors + *********/ + /// <summary>The storage mechanism to use.</summary> + public StorageMode Mode { get; set; } + + /// <summary>The connection string for the storage mechanism, if applicable.</summary> + public string ConnectionString { get; set; } + + /// <summary>The database name for the storage mechanism, if applicable.</summary> + public string Database { get; set; } + } +} diff --git a/src/SMAPI.Web/Framework/ConfigModels/StorageMode.cs b/src/SMAPI.Web/Framework/ConfigModels/StorageMode.cs new file mode 100644 index 00000000..4c2ea801 --- /dev/null +++ b/src/SMAPI.Web/Framework/ConfigModels/StorageMode.cs @@ -0,0 +1,15 @@ +namespace StardewModdingAPI.Web.Framework.ConfigModels +{ + /// <summary>Indicates a storage mechanism to use.</summary> + internal enum StorageMode + { + /// <summary>Store data in a hosted MongoDB instance.</summary> + Mongo, + + /// <summary>Store data in an in-memory MongoDB instance. This is useful for testing MongoDB storage locally, but will likely fail when deployed since it needs permission to open a local port.</summary> + MongoInMemory, + + /// <summary>Store data in-memory. This is suitable for local testing or single-instance servers, but will cause issues when distributed across multiple servers.</summary> + InMemory + } +} diff --git a/src/SMAPI.Web/Framework/Extensions.cs b/src/SMAPI.Web/Framework/Extensions.cs index e0da1424..ad7e645a 100644 --- a/src/SMAPI.Web/Framework/Extensions.cs +++ b/src/SMAPI.Web/Framework/Extensions.cs @@ -1,8 +1,12 @@ using System; using JetBrains.Annotations; +using Microsoft.AspNetCore.Html; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Razor; +using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Routing; +using Newtonsoft.Json; namespace StardewModdingAPI.Web.Framework { @@ -18,6 +22,7 @@ namespace StardewModdingAPI.Web.Framework /// <returns>The generated URL.</returns> public static string PlainAction(this IUrlHelper helper, [AspMvcAction] string action, [AspMvcController] string controller, object values = null, bool absoluteUrl = false) { + // get route values RouteValueDictionary valuesDict = new RouteValueDictionary(values); foreach (var value in helper.ActionContext.RouteData.Values) { @@ -25,14 +30,31 @@ namespace StardewModdingAPI.Web.Framework valuesDict[value.Key] = null; // explicitly remove it from the URL } + // get relative URL string url = helper.Action(action, controller, valuesDict); + if (url == null && action.EndsWith("Async")) + url = helper.Action(action[..^"Async".Length], controller, valuesDict); + + // get absolute URL if (absoluteUrl) { HttpRequest request = helper.ActionContext.HttpContext.Request; Uri baseUri = new Uri($"{request.Scheme}://{request.Host}"); url = new Uri(baseUri, url).ToString(); } + return url; } + + /// <summary>Get a serialized JSON representation of the value.</summary> + /// <param name="page">The page to extend.</param> + /// <param name="value">The value to serialize.</param> + /// <returns>The serialized JSON.</returns> + /// <remarks>This bypasses unnecessary validation (e.g. not allowing null values) in <see cref="IJsonHelper.Serialize"/>.</remarks> + public static IHtmlContent ForJson(this RazorPageBase page, object value) + { + string json = JsonConvert.SerializeObject(value); + return new HtmlString(json); + } } } diff --git a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs index cce80816..227dcd89 100644 --- a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs +++ b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs @@ -45,7 +45,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing private readonly Regex ModUpdateListEntryPattern = new Regex(@"^ (?<name>.+?) (?<version>[^\s]+): (?<link>.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching SMAPI's update line.</summary> - private readonly Regex SMAPIUpdatePattern = new Regex(@"^You can update SMAPI to (?<version>[^\s]+): (?<link>.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly Regex SmapiUpdatePattern = new Regex(@"^You can update SMAPI to (?<version>[^\s]+): (?<link>.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /********* @@ -181,9 +181,9 @@ namespace StardewModdingAPI.Web.Framework.LogParsing message.Section = LogSection.ModUpdateList; } - else if (message.Level == LogLevel.Alert && this.SMAPIUpdatePattern.IsMatch(message.Text)) + else if (message.Level == LogLevel.Alert && this.SmapiUpdatePattern.IsMatch(message.Text)) { - Match match = this.SMAPIUpdatePattern.Match(message.Text); + Match match = this.SmapiUpdatePattern.Match(message.Text); string version = match.Groups["version"].Value; string link = match.Groups["link"].Value; smapiMod.UpdateVersion = version; diff --git a/src/SMAPI.Web/Framework/RedirectRules/RedirectHostsToUrlsRule.cs b/src/SMAPI.Web/Framework/RedirectRules/RedirectHostsToUrlsRule.cs new file mode 100644 index 00000000..d75ee791 --- /dev/null +++ b/src/SMAPI.Web/Framework/RedirectRules/RedirectHostsToUrlsRule.cs @@ -0,0 +1,54 @@ +using System; +using System.Net; +using Microsoft.AspNetCore.Rewrite; + +namespace StardewModdingAPI.Web.Framework.RedirectRules +{ + /// <summary>Redirect hostnames to a URL if they match a condition.</summary> + internal class RedirectHostsToUrlsRule : RedirectMatchRule + { + /********* + ** Fields + *********/ + /// <summary>Maps a lowercase hostname to the resulting redirect URL.</summary> + private readonly Func<string, string> Map; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="statusCode">The status code to use for redirects.</param> + /// <param name="map">Hostnames mapped to the resulting redirect URL.</param> + public RedirectHostsToUrlsRule(HttpStatusCode statusCode, Func<string, string> map) + { + this.StatusCode = statusCode; + this.Map = map ?? throw new ArgumentNullException(nameof(map)); + } + + + /********* + ** Private methods + *********/ + /// <summary>Get the new redirect URL.</summary> + /// <param name="context">The rewrite context.</param> + /// <returns>Returns the redirect URL, or <c>null</c> if the redirect doesn't apply.</returns> + protected override string GetNewUrl(RewriteContext context) + { + // get requested host + string host = context.HttpContext.Request.Host.Host; + if (host == null) + return null; + + // get new host + host = this.Map(host); + if (host == null) + return null; + + // rewrite URL + UriBuilder uri = this.GetUrl(context.HttpContext.Request); + uri.Host = host; + return uri.ToString(); + } + } +} diff --git a/src/SMAPI.Web/Framework/RedirectRules/RedirectMatchRule.cs b/src/SMAPI.Web/Framework/RedirectRules/RedirectMatchRule.cs new file mode 100644 index 00000000..6e81c4ca --- /dev/null +++ b/src/SMAPI.Web/Framework/RedirectRules/RedirectMatchRule.cs @@ -0,0 +1,58 @@ +using System; +using System.Net; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Rewrite; + +namespace StardewModdingAPI.Web.Framework.RedirectRules +{ + /// <summary>Redirect matching requests to a URL.</summary> + internal abstract class RedirectMatchRule : IRule + { + /********* + ** Fields + *********/ + /// <summary>The status code to use for redirects.</summary> + protected HttpStatusCode StatusCode { get; set; } = HttpStatusCode.Redirect; + + + /********* + ** Public methods + *********/ + /// <summary>Applies the rule. Implementations of ApplyRule should set the value for <see cref="RewriteContext.Result" /> (defaults to RuleResult.ContinueRules).</summary> + /// <param name="context">The rewrite context.</param> + public void ApplyRule(RewriteContext context) + { + string newUrl = this.GetNewUrl(context); + if (newUrl == null) + return; + + HttpResponse response = context.HttpContext.Response; + response.StatusCode = (int)HttpStatusCode.Redirect; + response.Headers["Location"] = newUrl; + context.Result = RuleResult.EndResponse; + } + + + /********* + ** Protected methods + *********/ + /// <summary>Get the new redirect URL.</summary> + /// <param name="context">The rewrite context.</param> + /// <returns>Returns the redirect URL, or <c>null</c> if the redirect doesn't apply.</returns> + protected abstract string GetNewUrl(RewriteContext context); + + /// <summary>Get the full request URL.</summary> + /// <param name="request">The request.</param> + protected UriBuilder GetUrl(HttpRequest request) + { + return new UriBuilder + { + Scheme = request.Scheme, + Host = request.Host.Host, + Port = request.Host.Port ?? -1, + Path = request.PathBase + request.Path, + Query = request.QueryString.Value + }; + } + } +} diff --git a/src/SMAPI.Web/Framework/RedirectRules/RedirectPathsToUrlsRule.cs b/src/SMAPI.Web/Framework/RedirectRules/RedirectPathsToUrlsRule.cs new file mode 100644 index 00000000..16397c1e --- /dev/null +++ b/src/SMAPI.Web/Framework/RedirectRules/RedirectPathsToUrlsRule.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Rewrite; + +namespace StardewModdingAPI.Web.Framework.RedirectRules +{ + /// <summary>Redirect paths to URLs if they match a condition.</summary> + internal class RedirectPathsToUrlsRule : RedirectMatchRule + { + /********* + ** Fields + *********/ + /// <summary>Regex patterns matching the current URL mapped to the resulting redirect URL.</summary> + private readonly IDictionary<Regex, string> Map; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="map">Regex patterns matching the current URL mapped to the resulting redirect URL.</param> + public RedirectPathsToUrlsRule(IDictionary<string, string> map) + { + this.Map = map.ToDictionary( + p => new Regex(p.Key, RegexOptions.IgnoreCase | RegexOptions.Compiled), + p => p.Value + ); + } + + + /********* + ** Protected methods + *********/ + /// <summary>Get the new redirect URL.</summary> + /// <param name="context">The rewrite context.</param> + /// <returns>Returns the redirect URL, or <c>null</c> if the redirect doesn't apply.</returns> + protected override string GetNewUrl(RewriteContext context) + { + string path = context.HttpContext.Request.Path.Value; + + if (!string.IsNullOrWhiteSpace(path)) + { + foreach ((Regex pattern, string url) in this.Map) + { + if (pattern.IsMatch(path)) + return pattern.Replace(path, url); + } + } + + return null; + } + } +} diff --git a/src/SMAPI.Web/Framework/RedirectRules/RedirectToHttpsRule.cs b/src/SMAPI.Web/Framework/RedirectRules/RedirectToHttpsRule.cs new file mode 100644 index 00000000..2a503ae3 --- /dev/null +++ b/src/SMAPI.Web/Framework/RedirectRules/RedirectToHttpsRule.cs @@ -0,0 +1,47 @@ +using System; +using System.Net; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Rewrite; + +namespace StardewModdingAPI.Web.Framework.RedirectRules +{ + /// <summary>Redirect requests to HTTPS.</summary> + internal class RedirectToHttpsRule : RedirectMatchRule + { + /********* + ** Fields + *********/ + /// <summary>Matches requests which should be ignored.</summary> + private readonly Func<HttpRequest, bool> Except; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="except">Matches requests which should be ignored.</param> + public RedirectToHttpsRule(Func<HttpRequest, bool> except = null) + { + this.Except = except ?? (req => false); + this.StatusCode = HttpStatusCode.RedirectKeepVerb; + } + + + /********* + ** Protected methods + *********/ + /// <summary>Get the new redirect URL.</summary> + /// <param name="context">The rewrite context.</param> + /// <returns>Returns the redirect URL, or <c>null</c> if the redirect doesn't apply.</returns> + protected override string GetNewUrl(RewriteContext context) + { + HttpRequest request = context.HttpContext.Request; + if (request.IsHttps || this.Except(request)) + return null; + + UriBuilder uri = this.GetUrl(request); + uri.Scheme = "https"; + return uri.ToString(); + } + } +} diff --git a/src/SMAPI.Web/Framework/RewriteRules/ConditionalRedirectToHttpsRule.cs b/src/SMAPI.Web/Framework/RewriteRules/ConditionalRedirectToHttpsRule.cs deleted file mode 100644 index 36effd82..00000000 --- a/src/SMAPI.Web/Framework/RewriteRules/ConditionalRedirectToHttpsRule.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System; -using System.Net; -using System.Text; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Rewrite; - -namespace StardewModdingAPI.Web.Framework.RewriteRules -{ - /// <summary>Redirect requests to HTTPS.</summary> - /// <remarks>Derived from <a href="https://stackoverflow.com/a/44526747/262123" /> and <see cref="Microsoft.AspNetCore.Rewrite.Internal.RedirectToHttpsRule"/>.</remarks> - internal class ConditionalRedirectToHttpsRule : IRule - { - /********* - ** Fields - *********/ - /// <summary>A predicate which indicates when the rule should be applied.</summary> - private readonly Func<HttpRequest, bool> ShouldRewrite; - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="shouldRewrite">A predicate which indicates when the rule should be applied.</param> - public ConditionalRedirectToHttpsRule(Func<HttpRequest, bool> shouldRewrite = null) - { - this.ShouldRewrite = shouldRewrite ?? (req => true); - } - - /// <summary>Applies the rule. Implementations of ApplyRule should set the value for <see cref="RewriteContext.Result" /> (defaults to RuleResult.ContinueRules).</summary> - /// <param name="context">The rewrite context.</param> - public void ApplyRule(RewriteContext context) - { - HttpRequest request = context.HttpContext.Request; - - // check condition - if (this.IsSecure(request) || !this.ShouldRewrite(request)) - return; - - // redirect request - HttpResponse response = context.HttpContext.Response; - response.StatusCode = (int)HttpStatusCode.RedirectKeepVerb; - response.Headers["Location"] = new StringBuilder() - .Append("https://") - .Append(request.Host.Host) - .Append(request.PathBase) - .Append(request.Path) - .Append(request.QueryString) - .ToString(); - context.Result = RuleResult.EndResponse; - } - - /// <summary>Get whether the request was received over HTTPS.</summary> - /// <param name="request">The request to check.</param> - public bool IsSecure(HttpRequest request) - { - return - request.IsHttps // HTTPS to server - || string.Equals(request.Headers["x-forwarded-proto"], "HTTPS", StringComparison.OrdinalIgnoreCase); // HTTPS to AWS load balancer - } - } -} diff --git a/src/SMAPI.Web/Framework/RewriteRules/RedirectToUrlRule.cs b/src/SMAPI.Web/Framework/RewriteRules/RedirectToUrlRule.cs deleted file mode 100644 index ab9e019c..00000000 --- a/src/SMAPI.Web/Framework/RewriteRules/RedirectToUrlRule.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System; -using System.Net; -using System.Text.RegularExpressions; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Rewrite; - -namespace StardewModdingAPI.Web.Framework.RewriteRules -{ - /// <summary>Redirect requests to an external URL if they match a condition.</summary> - internal class RedirectToUrlRule : IRule - { - /********* - ** Fields - *********/ - /// <summary>Get the new URL to which to redirect (or <c>null</c> to skip).</summary> - private readonly Func<HttpRequest, string> NewUrl; - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="shouldRewrite">A predicate which indicates when the rule should be applied.</param> - /// <param name="url">The new URL to which to redirect.</param> - public RedirectToUrlRule(Func<HttpRequest, bool> shouldRewrite, string url) - { - this.NewUrl = req => shouldRewrite(req) ? url : null; - } - - /// <summary>Construct an instance.</summary> - /// <param name="pathRegex">A case-insensitive regex to match against the path.</param> - /// <param name="url">The external URL.</param> - public RedirectToUrlRule(string pathRegex, string url) - { - Regex regex = new Regex(pathRegex, RegexOptions.IgnoreCase | RegexOptions.Compiled); - this.NewUrl = req => req.Path.HasValue ? regex.Replace(req.Path.Value, url) : null; - } - - /// <summary>Applies the rule. Implementations of ApplyRule should set the value for <see cref="RewriteContext.Result" /> (defaults to RuleResult.ContinueRules).</summary> - /// <param name="context">The rewrite context.</param> - public void ApplyRule(RewriteContext context) - { - HttpRequest request = context.HttpContext.Request; - - // check rewrite - string newUrl = this.NewUrl(request); - if (newUrl == null || newUrl == request.Path.Value) - return; - - // redirect request - HttpResponse response = context.HttpContext.Response; - response.StatusCode = (int)HttpStatusCode.Redirect; - response.Headers["Location"] = newUrl; - context.Result = RuleResult.EndResponse; - } - } -} diff --git a/src/SMAPI.Web/Program.cs b/src/SMAPI.Web/Program.cs index 70082160..1fdd3185 100644 --- a/src/SMAPI.Web/Program.cs +++ b/src/SMAPI.Web/Program.cs @@ -1,5 +1,5 @@ -using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Hosting; namespace StardewModdingAPI.Web { @@ -13,13 +13,13 @@ namespace StardewModdingAPI.Web /// <param name="args">The command-line arguments.</param> public static void Main(string[] args) { - // configure web server - WebHost + Host .CreateDefaultBuilder(args) - .CaptureStartupErrors(true) - .UseSetting("detailedErrors", "true") - .UseKestrel().UseIISIntegration() // must be used together; fixes intermittent errors on Azure: https://stackoverflow.com/a/38312175/262123 - .UseStartup<Startup>() + .ConfigureWebHostDefaults(builder => builder + .CaptureStartupErrors(true) + .UseSetting("detailedErrors", "true") + .UseStartup<Startup>() + ) .Build() .Run(); } diff --git a/src/SMAPI.Web/SMAPI.Web.csproj b/src/SMAPI.Web/SMAPI.Web.csproj index 0a978b30..7ed79ea3 100644 --- a/src/SMAPI.Web/SMAPI.Web.csproj +++ b/src/SMAPI.Web/SMAPI.Web.csproj @@ -3,7 +3,7 @@ <PropertyGroup> <AssemblyName>SMAPI.Web</AssemblyName> <RootNamespace>StardewModdingAPI.Web</RootNamespace> - <TargetFramework>netcoreapp2.0</TargetFramework> + <TargetFramework>netcoreapp3.1</TargetFramework> <LangVersion>latest</LangVersion> </PropertyGroup> @@ -12,23 +12,20 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="Azure.Storage.Blobs" Version="12.4.0" /> - <PackageReference Include="Hangfire.AspNetCore" Version="1.7.9" /> + <PackageReference Include="Azure.Storage.Blobs" Version="12.4.2" /> + <PackageReference Include="Hangfire.AspNetCore" Version="1.7.11" /> <PackageReference Include="Hangfire.MemoryStorage" Version="1.7.0" /> <PackageReference Include="Hangfire.Mongo" Version="0.6.7" /> <PackageReference Include="HtmlAgilityPack" Version="1.11.23" /> - <PackageReference Include="Humanizer.Core" Version="2.7.9" /> - <PackageReference Include="JetBrains.Annotations" Version="2019.1.3" /> - <PackageReference Include="Markdig" Version="0.18.3" /> - <PackageReference Include="Microsoft.AspNetCore" Version="2.2.0" /> - <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="Humanizer.Core" Version="2.8.11" /> + <PackageReference Include="JetBrains.Annotations" Version="2020.1.0" /> + <PackageReference Include="Markdig" Version="0.20.0" /> + <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="3.0.2" /> <PackageReference Include="Mongo2Go" Version="2.2.12" /> - <PackageReference Include="MongoDB.Driver" Version="2.10.2" /> + <PackageReference Include="MongoDB.Driver" Version="2.10.4" /> <PackageReference Include="Newtonsoft.Json.Schema" Version="3.0.13" /> - <PackageReference Include="Pathoschild.FluentNexus" Version="1.0.0" /> - <PackageReference Include="Pathoschild.Http.FluentClient" Version="3.3.1" /> + <PackageReference Include="Pathoschild.FluentNexus" Version="1.0.1" /> + <PackageReference Include="Pathoschild.Http.FluentClient" Version="4.0.0" /> </ItemGroup> <ItemGroup> <DotNetCliToolReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Tools" Version="1.0.1" /> diff --git a/src/SMAPI.Web/Startup.cs b/src/SMAPI.Web/Startup.cs index 56ef9a79..dee2edc2 100644 --- a/src/SMAPI.Web/Startup.cs +++ b/src/SMAPI.Web/Startup.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Net; using Hangfire; using Hangfire.MemoryStorage; using Hangfire.Mongo; @@ -27,7 +28,7 @@ using StardewModdingAPI.Web.Framework.Clients.Nexus; using StardewModdingAPI.Web.Framework.Clients.Pastebin; using StardewModdingAPI.Web.Framework.Compression; using StardewModdingAPI.Web.Framework.ConfigModels; -using StardewModdingAPI.Web.Framework.RewriteRules; +using StardewModdingAPI.Web.Framework.RedirectRules; using StardewModdingAPI.Web.Framework.Storage; namespace StardewModdingAPI.Web @@ -47,7 +48,7 @@ namespace StardewModdingAPI.Web *********/ /// <summary>Construct an instance.</summary> /// <param name="env">The hosting environment.</param> - public Startup(IHostingEnvironment env) + public Startup(IWebHostEnvironment env) { this.Configuration = new ConfigurationBuilder() .SetBasePath(env.ContentRootPath) @@ -67,70 +68,91 @@ namespace StardewModdingAPI.Web .Configure<BackgroundServicesConfig>(this.Configuration.GetSection("BackgroundServices")) .Configure<ModCompatibilityListConfig>(this.Configuration.GetSection("ModCompatibilityList")) .Configure<ModUpdateCheckConfig>(this.Configuration.GetSection("ModUpdateCheck")) - .Configure<MongoDbConfig>(this.Configuration.GetSection("MongoDB")) + .Configure<StorageConfig>(this.Configuration.GetSection("Storage")) .Configure<SiteConfig>(this.Configuration.GetSection("Site")) .Configure<RouteOptions>(options => options.ConstraintMap.Add("semanticVersion", typeof(VersionConstraint))) .AddLogging() - .AddMemoryCache() - .AddMvc() - .ConfigureApplicationPartManager(manager => manager.FeatureProviders.Add(new InternalControllerFeatureProvider())) - .AddJsonOptions(options => - { - foreach (JsonConverter converter in new JsonHelper().JsonSettings.Converters) - options.SerializerSettings.Converters.Add(converter); + .AddMemoryCache(); + StorageConfig storageConfig = this.Configuration.GetSection("Storage").Get<StorageConfig>(); + StorageMode storageMode = storageConfig.Mode; - options.SerializerSettings.Formatting = Formatting.Indented; - options.SerializerSettings.NullValueHandling = NullValueHandling.Ignore; - }); - MongoDbConfig mongoConfig = this.Configuration.GetSection("MongoDB").Get<MongoDbConfig>(); + // init MVC + services + .AddControllers() + .AddNewtonsoftJson(options => this.ConfigureJsonNet(options.SerializerSettings)) + .ConfigureApplicationPartManager(manager => manager.FeatureProviders.Add(new InternalControllerFeatureProvider())); + services + .AddRazorPages(); - // init background service + // init storage + switch (storageMode) { - BackgroundServicesConfig config = this.Configuration.GetSection("BackgroundServices").Get<BackgroundServicesConfig>(); - if (config.Enabled) - services.AddHostedService<BackgroundService>(); - } + case StorageMode.InMemory: + services.AddSingleton<IModCacheRepository>(new ModCacheMemoryRepository()); + services.AddSingleton<IWikiCacheRepository>(new WikiCacheMemoryRepository()); + break; - // 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.ConnectionString - : serv.GetRequiredService<MongoDbRunner>().ConnectionString; - - // get client - BsonSerializer.RegisterSerializer(new UtcDateTimeOffsetSerializer()); - 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>())); + case StorageMode.Mongo: + case StorageMode.MongoInMemory: + { + // local MongoDB instance + services.AddSingleton<MongoDbRunner>(_ => storageMode == StorageMode.MongoInMemory + ? MongoDbRunner.Start() + : throw new NotSupportedException($"The in-memory MongoDB runner isn't available in storage mode {storageMode}.") + ); + + // MongoDB + services.AddSingleton<IMongoDatabase>(serv => + { + BsonSerializer.RegisterSerializer(new UtcDateTimeOffsetSerializer()); + return new MongoClient(this.GetMongoDbConnectionString(serv, storageConfig)) + .GetDatabase(storageConfig.Database); + }); + + // repositories + services.AddSingleton<IModCacheRepository>(serv => new ModCacheMongoRepository(serv.GetRequiredService<IMongoDatabase>())); + services.AddSingleton<IWikiCacheRepository>(serv => new WikiCacheMongoRepository(serv.GetRequiredService<IMongoDatabase>())); + } + break; + + default: + throw new NotSupportedException($"Unhandled storage mode '{storageMode}'."); + } // init Hangfire services - .AddHangfire(config => + .AddHangfire((serv, config) => { config .SetDataCompatibilityLevel(CompatibilityLevel.Version_170) .UseSimpleAssemblyNameTypeSerializer() .UseRecommendedSerializerSettings(); - if (mongoConfig.IsConfigured()) + switch (storageMode) { - config.UseMongoStorage(mongoConfig.ConnectionString, $"{mongoConfig.Database}-hangfire", new MongoStorageOptions - { - MigrationOptions = new MongoMigrationOptions(MongoMigrationStrategy.Drop), - CheckConnection = false // error on startup takes down entire process - }); + case StorageMode.InMemory: + config.UseMemoryStorage(); + break; + + case StorageMode.MongoInMemory: + case StorageMode.Mongo: + string connectionString = this.GetMongoDbConnectionString(serv, storageConfig); + config.UseMongoStorage(MongoClientSettings.FromConnectionString(connectionString), $"{storageConfig.Database}-hangfire", new MongoStorageOptions + { + MigrationOptions = new MongoMigrationOptions(MongoMigrationStrategy.Drop), + CheckConnection = false // error on startup takes down entire process + }); + break; } - else - config.UseMemoryStorage(); }); + // init background service + { + BackgroundServicesConfig config = this.Configuration.GetSection("BackgroundServices").Get<BackgroundServicesConfig>(); + if (config.Enabled) + services.AddHostedService<BackgroundService>(); + } + // init API clients { ApiClientsConfig api = this.Configuration.GetSection("ApiClients").Get<ApiClientsConfig>(); @@ -142,6 +164,7 @@ namespace StardewModdingAPI.Web baseUrl: api.ChucklefishBaseUrl, modPageUrlFormat: api.ChucklefishModPageUrlFormat )); + services.AddSingleton<ICurseForgeClient>(new CurseForgeClient( userAgent: userAgent, apiUrl: api.CurseForgeBaseUrl @@ -188,8 +211,7 @@ namespace StardewModdingAPI.Web /// <summary>The method called by the runtime to configure the HTTP request pipeline.</summary> /// <param name="app">The application builder.</param> - /// <param name="env">The hosting environment.</param> - public void Configure(IApplicationBuilder app, IHostingEnvironment env) + public void Configure(IApplicationBuilder app) { // basic config app.UseDeveloperExceptionPage(); @@ -201,7 +223,13 @@ namespace StardewModdingAPI.Web ) .UseRewriter(this.GetRedirectRules()) .UseStaticFiles() // wwwroot folder - .UseMvc(); + .UseRouting() + .UseAuthorization() + .UseEndpoints(p => + { + p.MapControllers(); + p.MapRazorPages(); + }); // enable Hangfire dashboard app.UseHangfireDashboard("/tasks", new DashboardOptions @@ -215,29 +243,77 @@ namespace StardewModdingAPI.Web /********* ** Private methods *********/ + /// <summary>Configure a Json.NET serializer.</summary> + /// <param name="settings">The serializer settings to edit.</param> + private void ConfigureJsonNet(JsonSerializerSettings settings) + { + foreach (JsonConverter converter in new JsonHelper().JsonSettings.Converters) + settings.Converters.Add(converter); + + settings.Formatting = Formatting.Indented; + settings.NullValueHandling = NullValueHandling.Ignore; + } + + /// <summary>Get the MongoDB connection string for the given storage configuration.</summary> + /// <param name="services">The service provider.</param> + /// <param name="storageConfig">The storage configuration</param> + /// <exception cref="NotSupportedException">There's no MongoDB instance in the given storage mode.</exception> + private string GetMongoDbConnectionString(IServiceProvider services, StorageConfig storageConfig) + { + return storageConfig.Mode switch + { + StorageMode.Mongo => storageConfig.ConnectionString, + StorageMode.MongoInMemory => services.GetRequiredService<MongoDbRunner>().ConnectionString, + _ => throw new NotSupportedException($"There's no MongoDB instance in storage mode {storageConfig.Mode}.") + }; + } + /// <summary>Get the redirect rules to apply.</summary> private RewriteOptions GetRedirectRules() { - var redirects = new RewriteOptions(); - - // redirect to HTTPS (except API for Linux/Mac Mono compatibility) - redirects.Add(new ConditionalRedirectToHttpsRule( - shouldRewrite: req => - req.Host.Host != "localhost" - && !req.Path.StartsWithSegments("/api") - )); - - // shortcut redirects - redirects.Add(new RedirectToUrlRule(@"^/3\.0\.?$", "https://stardewvalleywiki.com/Modding:Migrate_to_SMAPI_3.0")); - redirects.Add(new RedirectToUrlRule(@"^/(?:buildmsg|package)(?:/?(.*))$", "https://github.com/Pathoschild/SMAPI/blob/develop/docs/technical/mod-package.md#$1")); // buildmsg deprecated, remove when SDV 1.4 is released - redirects.Add(new RedirectToUrlRule(@"^/community\.?$", "https://stardewvalleywiki.com/Modding:Community")); - redirects.Add(new RedirectToUrlRule(@"^/compat\.?$", "https://smapi.io/mods")); - redirects.Add(new RedirectToUrlRule(@"^/docs\.?$", "https://stardewvalleywiki.com/Modding:Index")); - redirects.Add(new RedirectToUrlRule(@"^/install\.?$", "https://stardewvalleywiki.com/Modding:Player_Guide/Getting_Started#Install_SMAPI")); - redirects.Add(new RedirectToUrlRule(@"^/troubleshoot(.*)$", "https://stardewvalleywiki.com/Modding:Player_Guide/Troubleshooting$1")); - redirects.Add(new RedirectToUrlRule(@"^/xnb\.?$", "https://stardewvalleywiki.com/Modding:Using_XNB_mods")); - - // redirect legacy canimod.com URLs + var redirects = new RewriteOptions() + // shortcut paths + .Add(new RedirectPathsToUrlsRule(new Dictionary<string, string> + { + [@"^/3\.0\.?$"] = "https://stardewvalleywiki.com/Modding:Migrate_to_SMAPI_3.0", + [@"^/(?:buildmsg|package)(?:/?(.*))$"] = "https://github.com/Pathoschild/SMAPI/blob/develop/docs/technical/mod-package.md#$1", // buildmsg deprecated, remove when SDV 1.4 is released + [@"^/community\.?$"] = "https://stardewvalleywiki.com/Modding:Community", + [@"^/compat\.?$"] = "https://smapi.io/mods", + [@"^/docs\.?$"] = "https://stardewvalleywiki.com/Modding:Index", + [@"^/install\.?$"] = "https://stardewvalleywiki.com/Modding:Player_Guide/Getting_Started#Install_SMAPI", + [@"^/troubleshoot(.*)$"] = "https://stardewvalleywiki.com/Modding:Player_Guide/Troubleshooting$1", + [@"^/xnb\.?$"] = "https://stardewvalleywiki.com/Modding:Using_XNB_mods" + })) + + // legacy paths + .Add(new RedirectPathsToUrlsRule(this.GetLegacyPathRedirects())) + + // subdomains + .Add(new RedirectHostsToUrlsRule(HttpStatusCode.PermanentRedirect, host => host switch + { + "api.smapi.io" => "smapi.io/api", + "json.smapi.io" => "smapi.io/json", + "log.smapi.io" => "smapi.io/log", + "mods.smapi.io" => "smapi.io/mods", + _ => host.EndsWith(".smapi.io") + ? "smapi.io" + : null + })) + + // redirect to HTTPS (except API for Linux/Mac Mono compatibility) + .Add( + new RedirectToHttpsRule(except: req => req.Host.Host == "localhost" || req.Path.StartsWithSegments("/api")) + ); + + return redirects; + } + + /// <summary>Get the redirects for legacy paths that have been moved elsewhere.</summary> + private IDictionary<string, string> GetLegacyPathRedirects() + { + var redirects = new Dictionary<string, string>(); + + // canimod.com => wiki var wikiRedirects = new Dictionary<string, string[]> { ["Modding:Index#Migration_guides"] = new[] { "^/for-devs/updating-a-smapi-mod", "^/guides/updating-a-smapi-mod" }, @@ -251,10 +327,10 @@ namespace StardewModdingAPI.Web ["Modding:Object_data"] = new[] { "^/for-devs/object-data", "^/guides/object-data" }, ["Modding:Weather_data"] = new[] { "^/for-devs/weather", "^/guides/weather" } }; - foreach (KeyValuePair<string, string[]> pair in wikiRedirects) + foreach ((string page, string[] patterns) in wikiRedirects) { - foreach (string pattern in pair.Value) - redirects.Add(new RedirectToUrlRule(pattern, "https://stardewvalleywiki.com/" + pair.Key)); + foreach (string pattern in patterns) + redirects.Add(pattern, "https://stardewvalleywiki.com/" + page); } return redirects; diff --git a/src/SMAPI.Web/ViewModels/ModListModel.cs b/src/SMAPI.Web/ViewModels/ModListModel.cs index ff7513bc..6b8279c1 100644 --- a/src/SMAPI.Web/ViewModels/ModListModel.cs +++ b/src/SMAPI.Web/ViewModels/ModListModel.cs @@ -26,7 +26,7 @@ namespace StardewModdingAPI.Web.ViewModels public bool IsStale { get; set; } /// <summary>Whether the mod metadata is available.</summary> - public bool HasData => this.Mods != null; + public bool HasData => this.Mods?.Any() == true; /********* diff --git a/src/SMAPI.Web/ViewModels/ModModel.cs b/src/SMAPI.Web/ViewModels/ModModel.cs index 56316ab7..45b12397 100644 --- a/src/SMAPI.Web/ViewModels/ModModel.cs +++ b/src/SMAPI.Web/ViewModels/ModModel.cs @@ -22,6 +22,9 @@ namespace StardewModdingAPI.Web.ViewModels /// <summary>The mod author's alternative names, if any.</summary> public string AlternateAuthors { get; set; } + /// <summary>The GitHub repo, if any.</summary> + public string GitHubRepo { get; set; } + /// <summary>The URL to the mod's source code, if any.</summary> public string SourceUrl { get; set; } @@ -62,6 +65,7 @@ namespace StardewModdingAPI.Web.ViewModels this.AlternateNames = string.Join(", ", entry.Name.Skip(1).ToArray()); this.Author = entry.Author.FirstOrDefault(); this.AlternateAuthors = string.Join(", ", entry.Author.Skip(1).ToArray()); + this.GitHubRepo = entry.GitHubRepo; this.SourceUrl = this.GetSourceUrl(entry); this.Compatibility = new ModCompatibilityModel(entry.Compatibility); this.BetaCompatibility = entry.BetaCompatibility != null ? new ModCompatibilityModel(entry.BetaCompatibility) : null; diff --git a/src/SMAPI.Web/Views/Index/Index.cshtml b/src/SMAPI.Web/Views/Index/Index.cshtml index eded9df3..d78a155e 100644 --- a/src/SMAPI.Web/Views/Index/Index.cshtml +++ b/src/SMAPI.Web/Views/Index/Index.cshtml @@ -9,7 +9,7 @@ } @section Head { <link rel="stylesheet" href="~/Content/css/index.css?r=20200105" /> - <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.5.1" crossorigin="anonymous"></script> <script src="~/Content/js/index.js?r=20200105"></script> } diff --git a/src/SMAPI.Web/Views/JsonValidator/Index.cshtml b/src/SMAPI.Web/Views/JsonValidator/Index.cshtml index 7287e00b..f23bd150 100644 --- a/src/SMAPI.Web/Views/JsonValidator/Index.cshtml +++ b/src/SMAPI.Web/Views/JsonValidator/Index.cshtml @@ -32,7 +32,7 @@ <link rel="stylesheet" href="~/Content/css/json-validator.css?r=202002" /> <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.5.1" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/sunlight.min.js" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/plugins/sunlight-plugin.linenumbers.min.js" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/lang/sunlight.javascript.min.js" crossorigin="anonymous"></script> @@ -40,7 +40,7 @@ <script src="~/Content/js/json-validator.js?r=202002"></script> <script> $(function() { - smapi.jsonValidator(@Json.Serialize(this.Url.PlainAction("Index", "JsonValidator", new { schemaName = "$schemaName", id = "$id" })), @Json.Serialize(Model.PasteID)); + smapi.jsonValidator(@this.ForJson(this.Url.PlainAction("Index", "JsonValidator", new { schemaName = "$schemaName", id = "$id" })), @this.ForJson(Model.PasteID)); }); </script> } diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml index 2183992b..71e12d47 100644 --- a/src/SMAPI.Web/Views/LogParser/Index.cshtml +++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml @@ -1,5 +1,4 @@ @using Humanizer -@using Newtonsoft.Json @using StardewModdingAPI.Toolkit.Utilities @using StardewModdingAPI.Web.Framework @using StardewModdingAPI.Web.Framework.LogParsing.Models @@ -12,7 +11,6 @@ .GetValues(typeof(LogLevel)) .Cast<LogLevel>() .ToDictionary(level => level.ToString().ToLower(), level => level != LogLevel.Trace); - JsonSerializerSettings noFormatting = new JsonSerializerSettings { Formatting = Formatting.None }; string curPageUrl = this.Url.PlainAction("Index", "LogParser", new { id = Model.PasteID }, absoluteUrl: true); } @@ -25,19 +23,19 @@ <link rel="stylesheet" href="~/Content/css/file-upload.css?r=202002" /> <link rel="stylesheet" href="~/Content/css/log-parser.css?r=202002" /> - <script src="https://cdn.jsdelivr.net/npm/vue@2.5.17/dist/vue.min.js" crossorigin="anonymous"></script> - <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/vue@2.6.11" crossorigin="anonymous"></script> + <script src="https://cdn.jsdelivr.net/npm/jquery@3.5.1" crossorigin="anonymous"></script> <script src="~/Content/js/file-upload.js?r=202002"></script> <script src="~/Content/js/log-parser.js?r=202002"></script> <script> $(function() { smapi.logParser({ - logStarted: new Date(@Json.Serialize(Model.ParsedLog?.Timestamp)), - showPopup: @Json.Serialize(Model.ParsedLog == null), - showMods: @Json.Serialize(Model.ParsedLog?.Mods?.Select(p => Model.GetSlug(p.Name)).Distinct().ToDictionary(slug => slug, slug => true), noFormatting), - showSections: @Json.Serialize(Enum.GetNames(typeof(LogSection)).ToDictionary(section => section, section => false), noFormatting), - showLevels: @Json.Serialize(defaultFilters, noFormatting), - enableFilters: @Json.Serialize(!Model.ShowRaw) + logStarted: new Date(@this.ForJson(Model.ParsedLog?.Timestamp)), + showPopup: @this.ForJson(Model.ParsedLog == null), + showMods: @this.ForJson(Model.ParsedLog?.Mods?.Select(p => Model.GetSlug(p.Name)).Distinct().ToDictionary(slug => slug, slug => true)), + showSections: @this.ForJson(Enum.GetNames(typeof(LogSection)).ToDictionary(section => section, section => false)), + showLevels: @this.ForJson(defaultFilters), + enableFilters: @this.ForJson(!Model.ShowRaw) }, '@this.Url.PlainAction("Index", "LogParser", values: null)'); }); </script> diff --git a/src/SMAPI.Web/Views/Mods/Index.cshtml b/src/SMAPI.Web/Views/Mods/Index.cshtml index b1d9ae2c..cda2923d 100644 --- a/src/SMAPI.Web/Views/Mods/Index.cshtml +++ b/src/SMAPI.Web/Views/Mods/Index.cshtml @@ -1,22 +1,26 @@ @using Humanizer @using Humanizer.Localisation -@using Newtonsoft.Json +@using StardewModdingAPI.Web.Framework +@using StardewModdingAPI.Web.ViewModels @model StardewModdingAPI.Web.ViewModels.ModListModel @{ ViewData["Title"] = "Mod compatibility"; TimeSpan staleAge = DateTimeOffset.UtcNow - Model.LastUpdated; + + bool hasBeta = true; // Model.BetaVersion != null; + string betaLabel = "SMAPI 3.6 only"; //"SDV @Model.BetaVersion only"; } @section Head { <link rel="stylesheet" href="~/Content/css/mods.css?r=20200218" /> - <script src="https://cdn.jsdelivr.net/npm/vue@2.5.17/dist/vue.min.js" crossorigin="anonymous"></script> - <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/tablesorter@2.31.0/dist/js/jquery.tablesorter.combined.min.js" crossorigin="anonymous"></script> + <script src="https://cdn.jsdelivr.net/npm/vue@2.6.11" crossorigin="anonymous"></script> + <script src="https://cdn.jsdelivr.net/npm/jquery@3.5.1" crossorigin="anonymous"></script> + <script src="https://cdn.jsdelivr.net/npm/tablesorter@2.31.3" crossorigin="anonymous"></script> <script src="~/Content/js/mods.js?r=20200218"></script> <script> $(function() { - var data = @Json.Serialize(Model.Mods, new JsonSerializerSettings { Formatting = Formatting.None }); - var enableBeta = @Json.Serialize(Model.BetaVersion != null); + var data = @this.ForJson(Model.Mods ?? new ModModel[0]); + var enableBeta = @this.ForJson(hasBeta); smapi.modList(data, enableBeta); }); </script> @@ -39,9 +43,9 @@ else <p>The list is updated every few days (you can help <a href="https://stardewvalleywiki.com/Modding:Mod_compatibility">update it</a>!). It doesn't include XNB mods (see <a href="https://stardewvalleywiki.com/Modding:Using_XNB_mods"><em>using XNB mods</em> on the wiki</a> instead) or compatible content packs.</p> - @if (Model.BetaVersion != null) + @if (hasBeta) { - <p id="beta-blurb" v-show="showAdvanced"><strong>Note:</strong> "SDV @Model.BetaVersion only" lines are for an unreleased version of the game, not the stable version most players have. If a mod doesn't have that line, the info applies to both versions of the game.</p> + <p id="beta-blurb" v-show="showAdvanced"><strong>Note:</strong> "@betaLabel" lines are for an unreleased version of SMAPI, not the stable version most players have. If a mod doesn't have that line, the info applies to both versions of SMAPI.</p> } </div> @@ -79,14 +83,14 @@ else </tr> </thead> <tbody> - <tr v-for="mod in mods" :key="mod.Name" v-bind:id="mod.Slug" :key="mod.Slug" v-bind:data-status="mod.Compatibility.Status" v-show="mod.Visible"> + <tr v-for="mod in mods" :key="mod.Slug" v-bind:id="mod.Slug" v-bind:data-status="mod.Compatibility.Status" v-show="mod.Visible"> <td> {{mod.Name}} <small class="mod-alt-names" v-if="mod.AlternateNames">(aka {{mod.AlternateNames}})</small> </td> <td class="mod-page-links"> <span v-for="(link, i) in mod.ModPages"> - <a v-bind:href="link.Url">{{link.Text}}</a>{{i < mod.ModPages.length - 1 ? ', ' : ''}} + <a v-bind:href="link.Url">{{link.Text}}</a>{{i < mod.ModPages.length - 1 ? ', ' : ''}} </span> </td> <td> @@ -96,14 +100,20 @@ else <td> <div v-html="mod.Compatibility.Summary"></div> <div v-if="mod.BetaCompatibility" v-show="showAdvanced"> - <strong v-if="mod.BetaCompatibility">SDV @Model.BetaVersion only:</strong> + <strong v-if="mod.BetaCompatibility">@betaLabel:</strong> <span v-html="mod.BetaCompatibility.Summary"></span> </div> <div v-for="(warning, i) in mod.Warnings">⚠ {{warning}}</div> </td> <td class="mod-broke-in" v-html="mod.LatestCompatibility.BrokeIn" v-show="showAdvanced"></td> <td v-show="showAdvanced"> - <span v-if="mod.SourceUrl"><a v-bind:href="mod.SourceUrl">source</a></span> + <span v-if="mod.SourceUrl"> + <a v-bind:href="mod.SourceUrl">source</a> + <span v-if="mod.GitHubRepo"> + @* see https://shields.io/category/license *@ + (<img v-bind:src="'https://img.shields.io/github/license/' + mod.GitHubRepo + '?style=flat-square.png&label='" class="license-badge" alt="source" />) + </span> + </span> <span v-else class="mod-closed-source">no source</span> </td> <td> diff --git a/src/SMAPI.Web/Views/Shared/_Layout.cshtml b/src/SMAPI.Web/Views/Shared/_Layout.cshtml index 2d06ceb1..67dcd3b3 100644 --- a/src/SMAPI.Web/Views/Shared/_Layout.cshtml +++ b/src/SMAPI.Web/Views/Shared/_Layout.cshtml @@ -29,7 +29,7 @@ </div> <div id="content-column"> <div id="content"> - @if (ViewData["ViewTitle"] != string.Empty) + @if (ViewData["ViewTitle"] as string != string.Empty) { <h1>@(ViewData["ViewTitle"] ?? ViewData["Title"])</h1> } diff --git a/src/SMAPI.Web/appsettings.Development.json b/src/SMAPI.Web/appsettings.Development.json index 54460c46..41c00e79 100644 --- a/src/SMAPI.Web/appsettings.Development.json +++ b/src/SMAPI.Web/appsettings.Development.json @@ -17,7 +17,8 @@ "NexusApiKey": null }, - "MongoDB": { + "Storage": { + "Mode": "MongoInMemory", "ConnectionString": null, "Database": "smapi-edge" }, diff --git a/src/SMAPI.Web/appsettings.json b/src/SMAPI.Web/appsettings.json index 9cd1efc8..b1d39a6f 100644 --- a/src/SMAPI.Web/appsettings.json +++ b/src/SMAPI.Web/appsettings.json @@ -49,7 +49,8 @@ "PastebinBaseUrl": "https://pastebin.com/" }, - "MongoDB": { + "Storage": { + "Mode": "InMemory", "ConnectionString": null, "Database": "smapi" }, diff --git a/src/SMAPI.Web/wwwroot/Content/css/mods.css b/src/SMAPI.Web/wwwroot/Content/css/mods.css index 697ba514..4f6578cb 100644 --- a/src/SMAPI.Web/wwwroot/Content/css/mods.css +++ b/src/SMAPI.Web/wwwroot/Content/css/mods.css @@ -153,3 +153,7 @@ table.wikitable > caption { #mod-list td.smapi-3-col span { border-bottom: 1px dashed gray; } + +#mod-list .license-badge { + vertical-align: middle; +} diff --git a/src/SMAPI.Web/wwwroot/Content/js/mods.js b/src/SMAPI.Web/wwwroot/Content/js/mods.js index 35098b60..ac2754a4 100644 --- a/src/SMAPI.Web/wwwroot/Content/js/mods.js +++ b/src/SMAPI.Web/wwwroot/Content/js/mods.js @@ -1,5 +1,3 @@ -/* globals $ */ - var smapi = smapi || {}; var app; smapi.modList = function (mods, enableBeta) { diff --git a/src/SMAPI.Web/wwwroot/schemas/content-patcher.json b/src/SMAPI.Web/wwwroot/schemas/content-patcher.json index f627ab95..726b50be 100644 --- a/src/SMAPI.Web/wwwroot/schemas/content-patcher.json +++ b/src/SMAPI.Web/wwwroot/schemas/content-patcher.json @@ -11,9 +11,9 @@ "title": "Format version", "description": "The format version. You should always use the latest version to enable the latest features and avoid obsolete behavior.", "type": "string", - "const": "1.13.0", + "const": "1.14.0", "@errorMessages": { - "const": "Incorrect value '@value'. This should be set to the latest format version, currently '1.13.0'." + "const": "Incorrect value '@value'. This should be set to the latest format version, currently '1.14.0'." } }, "ConfigSchema": { diff --git a/src/SMAPI.sln b/src/SMAPI.sln index f9c537c4..92b0cd2c 100644 --- a/src/SMAPI.sln +++ b/src/SMAPI.sln @@ -77,8 +77,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.Toolkit.CoreInterface EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.Web", "SMAPI.Web\SMAPI.Web.csproj", "{80EFD92F-728F-41E0-8A5B-9F6F49A91899}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.Web.LegacyRedirects", "SMAPI.Web.LegacyRedirects\SMAPI.Web.LegacyRedirects.csproj", "{159AA5A5-35C2-488C-B23F-1613C80594AE}" -EndProject Global GlobalSection(SharedMSBuildProjectFiles) = preSolution SMAPI.Internal\SMAPI.Internal.projitems*{0634ea4c-3b8f-42db-aea6-ca9e4ef6e92f}*SharedItemsImports = 5 @@ -138,10 +136,6 @@ Global {80EFD92F-728F-41E0-8A5B-9F6F49A91899}.Debug|Any CPU.Build.0 = Debug|Any CPU {80EFD92F-728F-41E0-8A5B-9F6F49A91899}.Release|Any CPU.ActiveCfg = Release|Any CPU {80EFD92F-728F-41E0-8A5B-9F6F49A91899}.Release|Any CPU.Build.0 = Release|Any CPU - {159AA5A5-35C2-488C-B23F-1613C80594AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {159AA5A5-35C2-488C-B23F-1613C80594AE}.Debug|Any CPU.Build.0 = Debug|Any CPU - {159AA5A5-35C2-488C-B23F-1613C80594AE}.Release|Any CPU.ActiveCfg = Release|Any CPU - {159AA5A5-35C2-488C-B23F-1613C80594AE}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/SMAPI/Framework/IModMetadata.cs b/src/SMAPI/Framework/IModMetadata.cs index 37927482..1231b494 100644 --- a/src/SMAPI/Framework/IModMetadata.cs +++ b/src/SMAPI/Framework/IModMetadata.cs @@ -112,9 +112,9 @@ namespace StardewModdingAPI.Framework /// <summary>Whether the mod has at least one valid update key set.</summary> bool HasValidUpdateKeys(); - /// <summary>Get whether the mod has a given warning and it hasn't been suppressed in the <see cref="DataRecord"/>.</summary> - /// <param name="warning">The warning to check.</param> - bool HasUnsuppressWarning(ModWarning warning); + /// <summary>Get whether the mod has any of the given warnings which haven't been suppressed in the <see cref="DataRecord"/>.</summary> + /// <param name="warnings">The warnings to check.</param> + bool HasUnsuppressedWarnings(params ModWarning[] warnings); /// <summary>Get a relative path which includes the root folder name.</summary> string GetRelativePathWithRoot(); diff --git a/src/SMAPI/Framework/Input/GamePadStateBuilder.cs b/src/SMAPI/Framework/Input/GamePadStateBuilder.cs index 36622066..2657fd12 100644 --- a/src/SMAPI/Framework/Input/GamePadStateBuilder.cs +++ b/src/SMAPI/Framework/Input/GamePadStateBuilder.cs @@ -205,16 +205,13 @@ namespace StardewModdingAPI.Framework.Input /// <summary>Get the equivalent state.</summary> public GamePadState GetState() { - if (this.State == null) - { - this.State = new GamePadState( - leftThumbStick: this.LeftStickPos, - rightThumbStick: this.RightStickPos, - leftTrigger: this.LeftTrigger, - rightTrigger: this.RightTrigger, - buttons: this.GetButtonBitmask() // MonoGame requires one bitmask here; don't specify multiple values - ); - } + this.State ??= new GamePadState( + leftThumbStick: this.LeftStickPos, + rightThumbStick: this.RightStickPos, + leftTrigger: this.LeftTrigger, + rightTrigger: this.RightTrigger, + buttons: this.GetButtonBitmask() // MonoGame requires one bitmask here; don't specify multiple values + ); return this.State.Value; } diff --git a/src/SMAPI/Framework/Input/MouseStateBuilder.cs b/src/SMAPI/Framework/Input/MouseStateBuilder.cs index 59956feb..1cc16ca9 100644 --- a/src/SMAPI/Framework/Input/MouseStateBuilder.cs +++ b/src/SMAPI/Framework/Input/MouseStateBuilder.cs @@ -89,19 +89,16 @@ namespace StardewModdingAPI.Framework.Input /// <summary>Get the equivalent state.</summary> public MouseState GetState() { - if (this.State == null) - { - this.State = new MouseState( - x: this.X, - y: this.Y, - scrollWheel: this.ScrollWheelValue, - leftButton: this.ButtonStates[SButton.MouseLeft], - middleButton: this.ButtonStates[SButton.MouseMiddle], - rightButton: this.ButtonStates[SButton.MouseRight], - xButton1: this.ButtonStates[SButton.MouseX1], - xButton2: this.ButtonStates[SButton.MouseX2] - ); - } + this.State ??= new MouseState( + x: this.X, + y: this.Y, + scrollWheel: this.ScrollWheelValue, + leftButton: this.ButtonStates[SButton.MouseLeft], + middleButton: this.ButtonStates[SButton.MouseMiddle], + rightButton: this.ButtonStates[SButton.MouseRight], + xButton1: this.ButtonStates[SButton.MouseX1], + xButton2: this.ButtonStates[SButton.MouseX2] + ); return this.State.Value; } diff --git a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs index d9b4af1b..5218938f 100644 --- a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs +++ b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs @@ -127,7 +127,6 @@ namespace StardewModdingAPI.Framework.ModLoading { if (!oneAssembly) this.Monitor.Log($" Loading {assembly.File.Name} (rewritten)...", LogLevel.Trace); - using MemoryStream outStream = new MemoryStream(); assembly.Definition.Write(outStream); byte[] bytes = outStream.ToArray(); diff --git a/src/SMAPI/Framework/ModLoading/ModMetadata.cs b/src/SMAPI/Framework/ModLoading/ModMetadata.cs index 0e90362e..30701552 100644 --- a/src/SMAPI/Framework/ModLoading/ModMetadata.cs +++ b/src/SMAPI/Framework/ModLoading/ModMetadata.cs @@ -215,13 +215,14 @@ namespace StardewModdingAPI.Framework.ModLoading return this.GetUpdateKeys(validOnly: true).Any(); } - /// <summary>Get whether the mod has a given warning and it hasn't been suppressed in the <see cref="DataRecord"/>.</summary> - /// <param name="warning">The warning to check.</param> - public bool HasUnsuppressWarning(ModWarning warning) + /// <summary>Get whether the mod has any of the given warnings which haven't been suppressed in the <see cref="IModMetadata.DataRecord"/>.</summary> + /// <param name="warnings">The warnings to check.</param> + public bool HasUnsuppressedWarnings(params ModWarning[] warnings) { - return + return warnings.Any(warning => this.Warnings.HasFlag(warning) - && (this.DataRecord?.DataRecord == null || !this.DataRecord.DataRecord.SuppressWarnings.HasFlag(warning)); + && (this.DataRecord?.DataRecord == null || !this.DataRecord.DataRecord.SuppressWarnings.HasFlag(warning)) + ); } /// <summary>Get a relative path which includes the root folder name.</summary> diff --git a/src/SMAPI/Framework/Networking/SGalaxyNetServer.cs b/src/SMAPI/Framework/Networking/SGalaxyNetServer.cs index 7dbfa767..ac9cf313 100644 --- a/src/SMAPI/Framework/Networking/SGalaxyNetServer.cs +++ b/src/SMAPI/Framework/Networking/SGalaxyNetServer.cs @@ -45,23 +45,22 @@ namespace StardewModdingAPI.Framework.Networking [SuppressMessage("ReSharper", "AccessToDisposedClosure", Justification = "The callback is invoked synchronously.")] protected override void onReceiveMessage(GalaxyID peer, Stream messageStream) { - using (IncomingMessage message = new IncomingMessage()) - using (BinaryReader reader = new BinaryReader(messageStream)) + using IncomingMessage message = new IncomingMessage(); + using BinaryReader reader = new BinaryReader(messageStream); + + message.Read(reader); + ulong peerID = peer.ToUint64(); // note: GalaxyID instances get reused, so need to store the underlying ID instead + this.OnProcessingMessage(message, outgoing => this.SendMessageToPeerID(peerID, outgoing), () => { - message.Read(reader); - ulong peerID = peer.ToUint64(); // note: GalaxyID instances get reused, so need to store the underlying ID instead - this.OnProcessingMessage(message, outgoing => this.SendMessageToPeerID(peerID, outgoing), () => + if (this.peers.ContainsLeft(message.FarmerID) && (long)this.peers[message.FarmerID] == (long)peerID) + this.gameServer.processIncomingMessage(message); + else if (message.MessageType == StardewValley.Multiplayer.playerIntroduction) { - if (this.peers.ContainsLeft(message.FarmerID) && (long)this.peers[message.FarmerID] == (long)peerID) - this.gameServer.processIncomingMessage(message); - else if (message.MessageType == StardewValley.Multiplayer.playerIntroduction) - { - NetFarmerRoot farmer = this.Multiplayer.readFarmer(message.Reader); - GalaxyID capturedPeer = new GalaxyID(peerID); - this.gameServer.checkFarmhandRequest(Convert.ToString(peerID), this.getConnectionId(peer), farmer, msg => this.sendMessage(capturedPeer, msg), () => this.peers[farmer.Value.UniqueMultiplayerID] = capturedPeer.ToUint64()); - } - }); - } + NetFarmerRoot farmer = this.Multiplayer.readFarmer(message.Reader); + GalaxyID capturedPeer = new GalaxyID(peerID); + this.gameServer.checkFarmhandRequest(Convert.ToString(peerID), this.getConnectionId(peer), farmer, msg => this.sendMessage(capturedPeer, msg), () => this.peers[farmer.Value.UniqueMultiplayerID] = capturedPeer.ToUint64()); + } + }); } /// <summary>Send a message to a remote peer.</summary> diff --git a/src/SMAPI/Framework/Networking/SLidgrenServer.cs b/src/SMAPI/Framework/Networking/SLidgrenServer.cs index f2c61917..05c8b872 100644 --- a/src/SMAPI/Framework/Networking/SLidgrenServer.cs +++ b/src/SMAPI/Framework/Networking/SLidgrenServer.cs @@ -44,25 +44,24 @@ namespace StardewModdingAPI.Framework.Networking { // add hook to call multiplayer core NetConnection peer = rawMessage.SenderConnection; - using (IncomingMessage message = new IncomingMessage()) - using (Stream readStream = new NetBufferReadStream(rawMessage)) - using (BinaryReader reader = new BinaryReader(readStream)) + using IncomingMessage message = new IncomingMessage(); + using Stream readStream = new NetBufferReadStream(rawMessage); + using BinaryReader reader = new BinaryReader(readStream); + + while (rawMessage.LengthBits - rawMessage.Position >= 8) { - while (rawMessage.LengthBits - rawMessage.Position >= 8) + message.Read(reader); + NetConnection connection = rawMessage.SenderConnection; // don't pass rawMessage into context because it gets reused + this.OnProcessingMessage(message, outgoing => this.sendMessage(connection, outgoing), () => { - message.Read(reader); - NetConnection connection = rawMessage.SenderConnection; // don't pass rawMessage into context because it gets reused - this.OnProcessingMessage(message, outgoing => this.sendMessage(connection, outgoing), () => + if (this.peers.ContainsLeft(message.FarmerID) && this.peers[message.FarmerID] == peer) + this.gameServer.processIncomingMessage(message); + else if (message.MessageType == StardewValley.Multiplayer.playerIntroduction) { - if (this.peers.ContainsLeft(message.FarmerID) && this.peers[message.FarmerID] == peer) - this.gameServer.processIncomingMessage(message); - else if (message.MessageType == StardewValley.Multiplayer.playerIntroduction) - { - NetFarmerRoot farmer = this.Multiplayer.readFarmer(message.Reader); - this.gameServer.checkFarmhandRequest("", this.getConnectionId(rawMessage.SenderConnection), farmer, msg => this.sendMessage(peer, msg), () => this.peers[farmer.Value.UniqueMultiplayerID] = peer); - } - }); - } + NetFarmerRoot farmer = this.Multiplayer.readFarmer(message.Reader); + this.gameServer.checkFarmhandRequest("", this.getConnectionId(rawMessage.SenderConnection), farmer, msg => this.sendMessage(peer, msg), () => this.peers[farmer.Value.UniqueMultiplayerID] = peer); + } + }); } } } diff --git a/src/SMAPI/Framework/PerformanceMonitoring/AlertContext.cs b/src/SMAPI/Framework/PerformanceMonitoring/AlertContext.cs index 01197f74..af630055 100644 --- a/src/SMAPI/Framework/PerformanceMonitoring/AlertContext.cs +++ b/src/SMAPI/Framework/PerformanceMonitoring/AlertContext.cs @@ -1,7 +1,7 @@ namespace StardewModdingAPI.Framework.PerformanceMonitoring { /// <summary>The context for an alert.</summary> - internal struct AlertContext + internal readonly struct AlertContext { /********* ** Accessors diff --git a/src/SMAPI/Framework/PerformanceMonitoring/AlertEntry.cs b/src/SMAPI/Framework/PerformanceMonitoring/AlertEntry.cs index f5b80189..d5a0b343 100644 --- a/src/SMAPI/Framework/PerformanceMonitoring/AlertEntry.cs +++ b/src/SMAPI/Framework/PerformanceMonitoring/AlertEntry.cs @@ -1,7 +1,7 @@ namespace StardewModdingAPI.Framework.PerformanceMonitoring { /// <summary>A single alert entry.</summary> - internal struct AlertEntry + internal readonly struct AlertEntry { /********* ** Accessors diff --git a/src/SMAPI/Framework/PerformanceMonitoring/PeakEntry.cs b/src/SMAPI/Framework/PerformanceMonitoring/PeakEntry.cs index cff502ad..1746e358 100644 --- a/src/SMAPI/Framework/PerformanceMonitoring/PeakEntry.cs +++ b/src/SMAPI/Framework/PerformanceMonitoring/PeakEntry.cs @@ -3,7 +3,7 @@ using System; namespace StardewModdingAPI.Framework.PerformanceMonitoring { /// <summary>A peak invocation time.</summary> - internal struct PeakEntry + internal readonly struct PeakEntry { /********* ** Accessors diff --git a/src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounterEntry.cs b/src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounterEntry.cs index 8adbd88d..18cca628 100644 --- a/src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounterEntry.cs +++ b/src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounterEntry.cs @@ -3,7 +3,7 @@ using System; namespace StardewModdingAPI.Framework.PerformanceMonitoring { /// <summary>A single performance counter entry.</summary> - internal struct PerformanceCounterEntry + internal readonly struct PerformanceCounterEntry { /********* ** Accessors diff --git a/src/SMAPI/Framework/Rendering/SDisplayDevice.cs b/src/SMAPI/Framework/Rendering/SDisplayDevice.cs index 382949bf..85e69ae6 100644 --- a/src/SMAPI/Framework/Rendering/SDisplayDevice.cs +++ b/src/SMAPI/Framework/Rendering/SDisplayDevice.cs @@ -2,7 +2,6 @@ using System; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Graphics; -using StardewValley; using xTile.Dimensions; using xTile.Layers; using xTile.ObjectModel; @@ -14,23 +13,13 @@ namespace StardewModdingAPI.Framework.Rendering internal class SDisplayDevice : SXnaDisplayDevice { /********* - ** Fields - *********/ - /// <summary>The origin to use when rotating tiles.</summary> - private readonly Vector2 RotationOrigin; - - - /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> /// <param name="contentManager">The content manager through which to load tiles.</param> /// <param name="graphicsDevice">The graphics device with which to render tiles.</param> public SDisplayDevice(ContentManager contentManager, GraphicsDevice graphicsDevice) - : base(contentManager, graphicsDevice) - { - this.RotationOrigin = new Vector2((Game1.tileSize * Game1.pixelZoom) / 2f); - } + : base(contentManager, graphicsDevice) { } /// <summary>Draw a tile to the screen.</summary> /// <param name="tile">The tile to draw.</param> diff --git a/src/SMAPI/Framework/Rendering/SXnaDisplayDevice.cs b/src/SMAPI/Framework/Rendering/SXnaDisplayDevice.cs index d4f62b4f..121e53bc 100644 --- a/src/SMAPI/Framework/Rendering/SXnaDisplayDevice.cs +++ b/src/SMAPI/Framework/Rendering/SXnaDisplayDevice.cs @@ -10,7 +10,7 @@ using xTile.Layers; using xTile.Tiles; using Rectangle = xTile.Dimensions.Rectangle; -namespace StardewModdingAPI.Framework +namespace StardewModdingAPI.Framework.Rendering { /// <summary>A map display device which reimplements the default logic.</summary> /// <remarks>This is an exact copy of <see cref="XnaDisplayDevice"/>, except that private fields are protected and all methods are virtual.</remarks> diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index de9c955d..cd292bfc 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -1129,67 +1129,115 @@ namespace StardewModdingAPI.Framework // log warnings if (modsWithWarnings.Any()) { - // issue block format logic - void LogWarningGroup(ModWarning warning, LogLevel logLevel, string heading, params string[] blurb) - { - IModMetadata[] matches = modsWithWarnings - .Where(mod => mod.HasUnsuppressWarning(warning)) - .ToArray(); - if (!matches.Any()) - return; - - this.Monitor.Log(" " + heading, logLevel); - this.Monitor.Log(" " + "".PadRight(50, '-'), logLevel); - foreach (string line in blurb) - this.Monitor.Log(" " + line, logLevel); - this.Monitor.Newline(); - foreach (IModMetadata match in matches) - this.Monitor.Log($" - {match.DisplayName}", logLevel); - this.Monitor.Newline(); - } - - // supported issues - LogWarningGroup(ModWarning.BrokenCodeLoaded, LogLevel.Error, "Broken mods", + // broken code + this.LogModWarningGroup(modsWithWarnings, ModWarning.BrokenCodeLoaded, LogLevel.Error, "Broken mods", "These mods have broken code, but you configured SMAPI to load them anyway. This may cause bugs,", "errors, or crashes in-game." ); - LogWarningGroup(ModWarning.ChangesSaveSerializer, LogLevel.Warn, "Changed save serializer", + + // changes serializer + this.LogModWarningGroup(modsWithWarnings, ModWarning.ChangesSaveSerializer, LogLevel.Warn, "Changed save serializer", "These mods change the save serializer. They may corrupt your save files, or make them unusable if", "you uninstall these mods." ); - if (this.Settings.ParanoidWarnings) - { - LogWarningGroup(ModWarning.AccessesConsole, LogLevel.Warn, "Accesses the console directly", - "These mods directly access the SMAPI console, and you enabled paranoid warnings. (Note that this may be", - "legitimate and innocent usage; this warning is meaningless without further investigation.)" - ); - LogWarningGroup(ModWarning.AccessesFilesystem, LogLevel.Warn, "Accesses filesystem directly", - "These mods directly access the filesystem, and you enabled paranoid warnings. (Note that this may be", - "legitimate and innocent usage; this warning is meaningless without further investigation.)" - ); - LogWarningGroup(ModWarning.AccessesShell, LogLevel.Warn, "Accesses shell/process directly", - "These mods directly access the OS shell or processes, and you enabled paranoid warnings. (Note that", - "this may be legitimate and innocent usage; this warning is meaningless without further investigation.)" - ); - } - LogWarningGroup(ModWarning.PatchesGame, LogLevel.Info, "Patched game code", + + // patched game code + this.LogModWarningGroup(modsWithWarnings, ModWarning.PatchesGame, LogLevel.Info, "Patched game code", "These mods directly change the game code. They're more likely to cause errors or bugs in-game; if", "your game has issues, try removing these first. Otherwise you can ignore this warning." ); - LogWarningGroup(ModWarning.UsesUnvalidatedUpdateTick, LogLevel.Info, "Bypassed safety checks", + + // unvalidated update tick + this.LogModWarningGroup(modsWithWarnings, ModWarning.UsesUnvalidatedUpdateTick, LogLevel.Info, "Bypassed safety checks", "These mods bypass SMAPI's normal safety checks, so they're more likely to cause errors or save", "corruption. If your game has issues, try removing these first." ); - LogWarningGroup(ModWarning.NoUpdateKeys, LogLevel.Debug, "No update keys", + + // paranoid warnings + if (this.Settings.ParanoidWarnings) + { + this.LogModWarningGroup( + modsWithWarnings, + match: mod => mod.HasUnsuppressedWarnings(ModWarning.AccessesConsole, ModWarning.AccessesFilesystem, ModWarning.AccessesShell), + level: LogLevel.Debug, + heading: "Direct system access", + blurb: new[] + { + "You enabled paranoid warnings and these mods directly access the filesystem, shells/processes, or", + "SMAPI console. (This is usually legitimate and innocent usage; this warning is only useful for", + "further investigation.)" + }, + modLabel: mod => + { + List<string> labels = new List<string>(); + if (mod.HasUnsuppressedWarnings(ModWarning.AccessesConsole)) + labels.Add("console"); + if (mod.HasUnsuppressedWarnings(ModWarning.AccessesFilesystem)) + labels.Add("files"); + if (mod.HasUnsuppressedWarnings(ModWarning.AccessesShell)) + labels.Add("shells/processes"); + + return $"{mod.DisplayName} ({string.Join(", ", labels)})"; + } + ); + } + + // no update keys + this.LogModWarningGroup(modsWithWarnings, ModWarning.NoUpdateKeys, LogLevel.Debug, "No update keys", "These mods have no update keys in their manifest. SMAPI may not notify you about updates for these", "mods. Consider notifying the mod authors about this problem." ); - LogWarningGroup(ModWarning.UsesDynamic, LogLevel.Debug, "Not crossplatform", + + // not crossplatform + this.LogModWarningGroup(modsWithWarnings, ModWarning.UsesDynamic, LogLevel.Debug, "Not crossplatform", "These mods use the 'dynamic' keyword, and won't work on Linux/Mac." ); } } + /// <summary>Write a mod warning group to the console and log.</summary> + /// <param name="mods">The mods to search.</param> + /// <param name="match">Matches mods to include in the warning group.</param> + /// <param name="level">The log level for the logged messages.</param> + /// <param name="heading">A brief heading label for the group.</param> + /// <param name="blurb">A detailed explanation of the warning, split into lines.</param> + /// <param name="modLabel">Formats the mod label, or <c>null</c> to use the <see cref="IModMetadata.DisplayName"/>.</param> + private void LogModWarningGroup(IModMetadata[] mods, Func<IModMetadata, bool> match, LogLevel level, string heading, string[] blurb, Func<IModMetadata, string> modLabel = null) + { + // get matching mods + string[] modLabels = mods + .Where(match) + .Select(mod => modLabel?.Invoke(mod) ?? mod.DisplayName) + .OrderBy(p => p) + .ToArray(); + if (!modLabels.Any()) + return; + + // log header/blurb + this.Monitor.Log(" " + heading, level); + this.Monitor.Log(" " + "".PadRight(50, '-'), level); + foreach (string line in blurb) + this.Monitor.Log(" " + line, level); + this.Monitor.Newline(); + + // log mod list + foreach (string label in modLabels) + this.Monitor.Log($" - {label}", level); + + this.Monitor.Newline(); + } + + /// <summary>Write a mod warning group to the console and log.</summary> + /// <param name="mods">The mods to search.</param> + /// <param name="warning">The mod warning to match.</param> + /// <param name="level">The log level for the logged messages.</param> + /// <param name="heading">A brief heading label for the group.</param> + /// <param name="blurb">A detailed explanation of the warning, split into lines.</param> + void LogModWarningGroup(IModMetadata[] mods, ModWarning warning, LogLevel level, string heading, params string[] blurb) + { + this.LogModWarningGroup(mods, mod => mod.HasUnsuppressedWarnings(warning), level, heading, blurb); + } + /// <summary>Load a mod's entry class.</summary> /// <param name="modAssembly">The mod assembly.</param> /// <param name="mod">The loaded instance.</param> diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs index 2a30b595..82db5857 100644 --- a/src/SMAPI/Framework/SGame.cs +++ b/src/SMAPI/Framework/SGame.cs @@ -1310,7 +1310,7 @@ namespace StardewModdingAPI.Framework } Game1.drawPlayerHeldObject(Game1.player); } - label_139: + label_139: if ((Game1.player.UsingTool || Game1.pickingTool) && Game1.player.CurrentTool != null && ((!Game1.player.CurrentTool.Name.Equals("Seeds") || Game1.pickingTool) && (Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.getStandingX(), (int)Game1.player.Position.Y - 38), Game1.viewport.Size) != null && Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.getStandingX(), Game1.player.getStandingY()), Game1.viewport.Size) == null))) Game1.drawTool(Game1.player); if (Game1.currentLocation.Map.GetLayer("AlwaysFront") != null) diff --git a/src/SMAPI/Framework/Serialization/ColorConverter.cs b/src/SMAPI/Framework/Serialization/ColorConverter.cs index 19979981..7315f1a5 100644 --- a/src/SMAPI/Framework/Serialization/ColorConverter.cs +++ b/src/SMAPI/Framework/Serialization/ColorConverter.cs @@ -35,7 +35,7 @@ namespace StardewModdingAPI.Framework.Serialization { string[] parts = str.Split(','); if (parts.Length != 4) - throw new SParseException($"Can't parse {typeof(Color).Name} from invalid value '{str}' (path: {path})."); + throw new SParseException($"Can't parse {nameof(Color)} from invalid value '{str}' (path: {path})."); int r = Convert.ToInt32(parts[0]); int g = Convert.ToInt32(parts[1]); diff --git a/src/SMAPI/Framework/Serialization/PointConverter.cs b/src/SMAPI/Framework/Serialization/PointConverter.cs index 3481c9b2..6cf795dc 100644 --- a/src/SMAPI/Framework/Serialization/PointConverter.cs +++ b/src/SMAPI/Framework/Serialization/PointConverter.cs @@ -33,7 +33,7 @@ namespace StardewModdingAPI.Framework.Serialization { string[] parts = str.Split(','); if (parts.Length != 2) - throw new SParseException($"Can't parse {typeof(Point).Name} from invalid value '{str}' (path: {path})."); + throw new SParseException($"Can't parse {nameof(Point)} from invalid value '{str}' (path: {path})."); int x = Convert.ToInt32(parts[0]); int y = Convert.ToInt32(parts[1]); diff --git a/src/SMAPI/Framework/Serialization/RectangleConverter.cs b/src/SMAPI/Framework/Serialization/RectangleConverter.cs index fbb2e253..a5780d8a 100644 --- a/src/SMAPI/Framework/Serialization/RectangleConverter.cs +++ b/src/SMAPI/Framework/Serialization/RectangleConverter.cs @@ -39,7 +39,7 @@ namespace StardewModdingAPI.Framework.Serialization var match = Regex.Match(str, @"^\{X:(?<x>\d+) Y:(?<y>\d+) Width:(?<width>\d+) Height:(?<height>\d+)\}$", RegexOptions.IgnoreCase); if (!match.Success) - throw new SParseException($"Can't parse {typeof(Rectangle).Name} from invalid value '{str}' (path: {path})."); + throw new SParseException($"Can't parse {nameof(Rectangle)} from invalid value '{str}' (path: {path})."); int x = Convert.ToInt32(match.Groups["x"].Value); int y = Convert.ToInt32(match.Groups["y"].Value); diff --git a/src/SMAPI/Framework/Serialization/Vector2Converter.cs b/src/SMAPI/Framework/Serialization/Vector2Converter.cs index 1d9b08e0..3e2ab776 100644 --- a/src/SMAPI/Framework/Serialization/Vector2Converter.cs +++ b/src/SMAPI/Framework/Serialization/Vector2Converter.cs @@ -33,7 +33,7 @@ namespace StardewModdingAPI.Framework.Serialization { string[] parts = str.Split(','); if (parts.Length != 2) - throw new SParseException($"Can't parse {typeof(Vector2).Name} from invalid value '{str}' (path: {path})."); + throw new SParseException($"Can't parse {nameof(Vector2)} from invalid value '{str}' (path: {path})."); float x = Convert.ToSingle(parts[0]); float y = Convert.ToSingle(parts[1]); |