diff options
Diffstat (limited to 'src/SMAPI.Web')
77 files changed, 3902 insertions, 718 deletions
diff --git a/src/SMAPI.Web/BackgroundService.cs b/src/SMAPI.Web/BackgroundService.cs new file mode 100644 index 00000000..ee7a60f3 --- /dev/null +++ b/src/SMAPI.Web/BackgroundService.cs @@ -0,0 +1,108 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Hangfire; +using Microsoft.Extensions.Hosting; +using StardewModdingAPI.Toolkit; +using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; +using StardewModdingAPI.Web.Framework.Caching.Mods; +using StardewModdingAPI.Web.Framework.Caching.Wiki; + +namespace StardewModdingAPI.Web +{ + /// <summary>A hosted service which runs background data updates.</summary> + /// <remarks>Task methods need to be static, since otherwise Hangfire will try to serialize the entire instance.</remarks> + internal class BackgroundService : IHostedService, IDisposable + { + /********* + ** Fields + *********/ + /// <summary>The background task server.</summary> + private static BackgroundJobServer JobServer; + + /// <summary>The cache in which to store wiki metadata.</summary> + private static IWikiCacheRepository WikiCache; + + /// <summary>The cache in which to store mod data.</summary> + private static IModCacheRepository ModCache; + + + /********* + ** Public methods + *********/ + /**** + ** Hosted service + ****/ + /// <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) + { + BackgroundService.WikiCache = wikiCache; + BackgroundService.ModCache = modCache; + } + + /// <summary>Start the service.</summary> + /// <param name="cancellationToken">Tracks whether the start process has been aborted.</param> + public Task StartAsync(CancellationToken cancellationToken) + { + this.TryInit(); + + // set startup tasks + BackgroundJob.Enqueue(() => BackgroundService.UpdateWikiAsync()); + BackgroundJob.Enqueue(() => BackgroundService.RemoveStaleModsAsync()); + + // set recurring tasks + RecurringJob.AddOrUpdate(() => BackgroundService.UpdateWikiAsync(), "*/10 * * * *"); // every 10 minutes + RecurringJob.AddOrUpdate(() => BackgroundService.RemoveStaleModsAsync(), "0 * * * *"); // hourly + + return Task.CompletedTask; + } + + /// <summary>Triggered when the application host is performing a graceful shutdown.</summary> + /// <param name="cancellationToken">Tracks whether the shutdown process should no longer be graceful.</param> + public async Task StopAsync(CancellationToken cancellationToken) + { + if (BackgroundService.JobServer != null) + await BackgroundService.JobServer.WaitForShutdownAsync(cancellationToken); + } + + /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> + public void Dispose() + { + BackgroundService.JobServer?.Dispose(); + } + + /**** + ** Tasks + ****/ + /// <summary>Update the cached wiki metadata.</summary> + [AutomaticRetry(Attempts = 3, DelaysInSeconds = new[] { 30, 60, 120 })] + public static async Task UpdateWikiAsync() + { + WikiModList wikiCompatList = await new ModToolkit().GetWikiCompatibilityListAsync(); + BackgroundService.WikiCache.SaveWikiData(wikiCompatList.StableVersion, wikiCompatList.BetaVersion, wikiCompatList.Mods, out _, out _); + } + + /// <summary>Remove mods which haven't been requested in over 48 hours.</summary> + public static Task RemoveStaleModsAsync() + { + BackgroundService.ModCache.RemoveStaleMods(TimeSpan.FromHours(48)); + return Task.CompletedTask; + } + + + /********* + ** Private method + *********/ + /// <summary>Initialize the background service if it's not already initialized.</summary> + /// <exception cref="InvalidOperationException">The background service is already initialized.</exception> + private void TryInit() + { + if (BackgroundService.JobServer != null) + throw new InvalidOperationException("The scheduler service is already started."); + + BackgroundService.JobServer = new BackgroundJobServer(); + } + } +} diff --git a/src/SMAPI.Web/Controllers/JsonValidatorController.cs b/src/SMAPI.Web/Controllers/JsonValidatorController.cs new file mode 100644 index 00000000..b2eb9a87 --- /dev/null +++ b/src/SMAPI.Web/Controllers/JsonValidatorController.cs @@ -0,0 +1,349 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Schema; +using StardewModdingAPI.Web.Framework; +using StardewModdingAPI.Web.Framework.Clients.Pastebin; +using StardewModdingAPI.Web.Framework.Compression; +using StardewModdingAPI.Web.Framework.ConfigModels; +using StardewModdingAPI.Web.ViewModels.JsonValidator; + +namespace StardewModdingAPI.Web.Controllers +{ + /// <summary>Provides a web UI for validating JSON schemas.</summary> + internal class JsonValidatorController : Controller + { + /********* + ** Fields + *********/ + /// <summary>The site config settings.</summary> + private readonly SiteConfig Config; + + /// <summary>The underlying Pastebin client.</summary> + private readonly IPastebinClient Pastebin; + + /// <summary>The underlying text compression helper.</summary> + private readonly IGzipHelper GzipHelper; + + /// <summary>The section URL for the schema validator.</summary> + private string SectionUrl => this.Config.JsonValidatorUrl; + + /// <summary>The supported JSON schemas (names indexed by ID).</summary> + private readonly IDictionary<string, string> SchemaFormats = new Dictionary<string, string> + { + ["none"] = "None", + ["manifest"] = "Manifest", + ["content-patcher"] = "Content Patcher" + }; + + /// <summary>The schema ID to use if none was specified.</summary> + private string DefaultSchemaID = "manifest"; + + /// <summary>A token in an error message which indicates that the child errors should be displayed instead.</summary> + private readonly string TransparentToken = "$transparent"; + + + /********* + ** Public methods + *********/ + /*** + ** Constructor + ***/ + /// <summary>Construct an instance.</summary> + /// <param name="siteConfig">The context config settings.</param> + /// <param name="pastebin">The Pastebin API client.</param> + /// <param name="gzipHelper">The underlying text compression helper.</param> + public JsonValidatorController(IOptions<SiteConfig> siteConfig, IPastebinClient pastebin, IGzipHelper gzipHelper) + { + this.Config = siteConfig.Value; + this.Pastebin = pastebin; + this.GzipHelper = gzipHelper; + } + + /*** + ** Web UI + ***/ + /// <summary>Render the schema validator UI.</summary> + /// <param name="schemaName">The schema name with which to validate the JSON.</param> + /// <param name="id">The paste ID.</param> + [HttpGet] + [Route("json")] + [Route("json/{schemaName}")] + [Route("json/{schemaName}/{id}")] + public async Task<ViewResult> Index(string schemaName = null, string id = null) + { + schemaName = this.NormalizeSchemaName(schemaName); + + var result = new JsonValidatorModel(this.SectionUrl, id, schemaName, this.SchemaFormats); + if (string.IsNullOrWhiteSpace(id)) + return this.View("Index", result); + + // fetch raw JSON + PasteInfo paste = await this.GetAsync(id); + if (string.IsNullOrWhiteSpace(paste.Content)) + return this.View("Index", result.SetUploadError("The JSON file seems to be empty.")); + result.SetContent(paste.Content); + + // parse JSON + JToken parsed; + try + { + parsed = JToken.Parse(paste.Content, new JsonLoadSettings + { + DuplicatePropertyNameHandling = DuplicatePropertyNameHandling.Error, + CommentHandling = CommentHandling.Load + }); + } + catch (JsonReaderException ex) + { + return this.View("Index", result.AddErrors(new JsonValidatorErrorModel(ex.LineNumber, ex.Path, ex.Message, ErrorType.None))); + } + + // format JSON + result.SetContent(parsed.ToString(Formatting.Indented)); + + // skip if no schema selected + if (schemaName == "none") + return this.View("Index", result); + + // load schema + JSchema schema; + { + FileInfo schemaFile = this.FindSchemaFile(schemaName); + if (schemaFile == null) + return this.View("Index", result.SetParseError($"Invalid schema '{schemaName}'.")); + schema = JSchema.Parse(System.IO.File.ReadAllText(schemaFile.FullName)); + } + + // get format doc URL + result.FormatUrl = this.GetExtensionField<string>(schema, "@documentationUrl"); + + // validate JSON + parsed.IsValid(schema, out IList<ValidationError> rawErrors); + var errors = rawErrors + .SelectMany(this.GetErrorModels) + .ToArray(); + return this.View("Index", result.AddErrors(errors)); + } + + /*** + ** JSON + ***/ + /// <summary>Save raw JSON data.</summary> + [HttpPost, AllowLargePosts] + [Route("json")] + public async Task<ActionResult> PostAsync(JsonValidatorRequestModel request) + { + if (request == null) + return this.View("Index", new JsonValidatorModel(this.SectionUrl, null, null, this.SchemaFormats).SetUploadError("The request seems to be invalid.")); + + // normalize schema name + string schemaName = this.NormalizeSchemaName(request.SchemaName); + + // get raw log text + string input = request.Content; + if (string.IsNullOrWhiteSpace(input)) + return this.View("Index", new JsonValidatorModel(this.SectionUrl, null, schemaName, this.SchemaFormats).SetUploadError("The JSON file seems to be empty.")); + + // upload log + input = this.GzipHelper.CompressString(input); + SavePasteResult result = await this.Pastebin.PostAsync($"JSON validator {DateTime.UtcNow:s}", input); + + // handle errors + if (!result.Success) + return this.View("Index", new JsonValidatorModel(this.SectionUrl, result.ID, schemaName, this.SchemaFormats).SetUploadError($"Pastebin error: {result.Error ?? "unknown error"}")); + + // redirect to view + UriBuilder uri = new UriBuilder(new Uri(this.SectionUrl)); + uri.Path = $"{uri.Path.TrimEnd('/')}/{schemaName}/{result.ID}"; + return this.Redirect(uri.Uri.ToString()); + } + + + /********* + ** Private methods + *********/ + /// <summary>Fetch raw text from Pastebin.</summary> + /// <param name="id">The Pastebin paste ID.</param> + private async Task<PasteInfo> GetAsync(string id) + { + PasteInfo response = await this.Pastebin.GetAsync(id); + response.Content = this.GzipHelper.DecompressString(response.Content); + return response; + } + + /// <summary>Get a normalized schema name, or the <see cref="DefaultSchemaID"/> if blank.</summary> + /// <param name="schemaName">The raw schema name to normalize.</param> + private string NormalizeSchemaName(string schemaName) + { + schemaName = schemaName?.Trim().ToLower(); + return !string.IsNullOrWhiteSpace(schemaName) + ? schemaName + : this.DefaultSchemaID; + } + + /// <summary>Get the schema file given its unique ID.</summary> + /// <param name="id">The schema ID.</param> + private FileInfo FindSchemaFile(string id) + { + // normalize ID + id = id?.Trim().ToLower(); + if (string.IsNullOrWhiteSpace(id)) + return null; + + // get matching file + DirectoryInfo schemaDir = new DirectoryInfo(Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "schemas")); + foreach (FileInfo file in schemaDir.EnumerateFiles("*.json")) + { + if (file.Name.Equals($"{id}.json")) + return file; + } + + return null; + } + + /// <summary>Get view models representing a schema validation error and any child errors.</summary> + /// <param name="error">The error to represent.</param> + private IEnumerable<JsonValidatorErrorModel> GetErrorModels(ValidationError error) + { + // skip through transparent errors + if (this.IsTransparentError(error)) + { + foreach (var model in error.ChildErrors.SelectMany(this.GetErrorModels)) + yield return model; + yield break; + } + + // get message + string message = this.GetOverrideError(error); + if (message == null || message == this.TransparentToken) + message = this.FlattenErrorMessage(error); + + // build model + yield return new JsonValidatorErrorModel(error.LineNumber, error.Path, message, error.ErrorType); + } + + /// <summary>Get a flattened, human-readable message for a schema validation error and any child errors.</summary> + /// <param name="error">The error to represent.</param> + /// <param name="indent">The indentation level to apply for inner errors.</param> + private string FlattenErrorMessage(ValidationError error, int indent = 0) + { + // get override + string message = this.GetOverrideError(error); + if (message != null && message != this.TransparentToken) + return message; + + // skip through transparent errors + if (this.IsTransparentError(error)) + error = error.ChildErrors[0]; + + // get friendly representation of main error + message = error.Message; + switch (error.ErrorType) + { + case ErrorType.Const: + message = $"Invalid value. Found '{error.Value}', but expected '{error.Schema.Const}'."; + break; + + case ErrorType.Enum: + message = $"Invalid value. Found '{error.Value}', but expected one of '{string.Join("', '", error.Schema.Enum)}'."; + break; + + case ErrorType.Required: + message = $"Missing required fields: {string.Join(", ", (List<string>)error.Value)}."; + break; + } + + // add inner errors + foreach (ValidationError childError in error.ChildErrors) + message += "\n" + "".PadLeft(indent * 2, ' ') + $"==> {childError.Path}: " + this.FlattenErrorMessage(childError, indent + 1); + return message; + } + + /// <summary>Get whether a validation error should be omitted in favor of its child errors in user-facing error messages.</summary> + /// <param name="error">The error to check.</param> + private bool IsTransparentError(ValidationError error) + { + if (!error.ChildErrors.Any()) + return false; + + string @override = this.GetOverrideError(error); + return + @override == this.TransparentToken + || (error.ErrorType == ErrorType.Then && @override == null); + } + + /// <summary>Get an override error from the JSON schema, if any.</summary> + /// <param name="error">The schema validation error.</param> + private string GetOverrideError(ValidationError error) + { + string GetRawOverrideError() + { + // get override errors + IDictionary<string, string> errors = this.GetExtensionField<Dictionary<string, string>>(error.Schema, "@errorMessages"); + if (errors == null) + return null; + errors = new Dictionary<string, string>(errors, StringComparer.InvariantCultureIgnoreCase); + + // match error by type and message + foreach (var pair in errors) + { + if (!pair.Key.Contains(":")) + continue; + + string[] parts = pair.Key.Split(':', 2); + if (parts[0].Equals(error.ErrorType.ToString(), StringComparison.InvariantCultureIgnoreCase) && Regex.IsMatch(error.Message, parts[1])) + return pair.Value?.Trim(); + } + + // match by type + if (errors.TryGetValue(error.ErrorType.ToString(), out string message)) + return message?.Trim(); + + return null; + } + + return GetRawOverrideError() + ?.Replace("@value", this.FormatValue(error.Value)); + } + + /// <summary>Get an extension field from a JSON schema.</summary> + /// <typeparam name="T">The field type.</typeparam> + /// <param name="schema">The schema whose extension fields to search.</param> + /// <param name="key">The case-insensitive field key.</param> + private T GetExtensionField<T>(JSchema schema, string key) + { + if (schema.ExtensionData != null) + { + foreach (var pair in schema.ExtensionData) + { + if (pair.Key.Equals(key, StringComparison.InvariantCultureIgnoreCase)) + return pair.Value.ToObject<T>(); + } + } + + return default; + } + + /// <summary>Format a schema value for display.</summary> + /// <param name="value">The value to format.</param> + private string FormatValue(object value) + { + switch (value) + { + case List<string> list: + return string.Join(", ", list); + + default: + return value?.ToString() ?? "null"; + } + } + } +} diff --git a/src/SMAPI.Web/Controllers/LogParserController.cs b/src/SMAPI.Web/Controllers/LogParserController.cs index 21e4a56f..f7f19cd8 100644 --- a/src/SMAPI.Web/Controllers/LogParserController.cs +++ b/src/SMAPI.Web/Controllers/LogParserController.cs @@ -1,13 +1,12 @@ using System; -using System.IO; -using System.IO.Compression; using System.Linq; -using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; +using StardewModdingAPI.Toolkit.Utilities; using StardewModdingAPI.Web.Framework; using StardewModdingAPI.Web.Framework.Clients.Pastebin; +using StardewModdingAPI.Web.Framework.Compression; using StardewModdingAPI.Web.Framework.ConfigModels; using StardewModdingAPI.Web.Framework.LogParsing; using StardewModdingAPI.Web.Framework.LogParsing.Models; @@ -27,9 +26,8 @@ namespace StardewModdingAPI.Web.Controllers /// <summary>The underlying Pastebin client.</summary> private readonly IPastebinClient Pastebin; - /// <summary>The first bytes in a valid zip file.</summary> - /// <remarks>See <a href="https://en.wikipedia.org/wiki/Zip_(file_format)#File_headers"/>.</remarks> - private const uint GzipLeadBytes = 0x8b1f; + /// <summary>The underlying text compression helper.</summary> + private readonly IGzipHelper GzipHelper; /********* @@ -41,10 +39,12 @@ namespace StardewModdingAPI.Web.Controllers /// <summary>Construct an instance.</summary> /// <param name="siteConfig">The context config settings.</param> /// <param name="pastebin">The Pastebin API client.</param> - public LogParserController(IOptions<SiteConfig> siteConfig, IPastebinClient pastebin) + /// <param name="gzipHelper">The underlying text compression helper.</param> + public LogParserController(IOptions<SiteConfig> siteConfig, IPastebinClient pastebin, IGzipHelper gzipHelper) { this.Config = siteConfig.Value; this.Pastebin = pastebin; + this.GzipHelper = gzipHelper; } /*** @@ -60,14 +60,14 @@ namespace StardewModdingAPI.Web.Controllers { // fresh page if (string.IsNullOrWhiteSpace(id)) - return this.View("Index", new LogParserModel(this.Config.LogParserUrl, id)); + return this.View("Index", this.GetModel(id)); // log page PasteInfo paste = await this.GetAsync(id); ParsedLog log = paste.Success ? new LogParser().Parse(paste.Content) : new ParsedLog { IsValid = false, Error = "Pastebin error: " + paste.Error }; - return this.View("Index", new LogParserModel(this.Config.LogParserUrl, id, log, raw)); + return this.View("Index", this.GetModel(id).SetResult(log, raw)); } /*** @@ -81,15 +81,15 @@ namespace StardewModdingAPI.Web.Controllers // get raw log text string input = this.Request.Form["input"].FirstOrDefault(); if (string.IsNullOrWhiteSpace(input)) - return this.View("Index", new LogParserModel(this.Config.LogParserUrl, null) { UploadError = "The log file seems to be empty." }); + return this.View("Index", this.GetModel(null, uploadError: "The log file seems to be empty.")); // upload log - input = this.CompressString(input); - SavePasteResult result = await this.Pastebin.PostAsync(input); + input = this.GzipHelper.CompressString(input); + SavePasteResult result = await this.Pastebin.PostAsync($"SMAPI log {DateTime.UtcNow:s}", input); // handle errors if (!result.Success) - return this.View("Index", new LogParserModel(this.Config.LogParserUrl, result.ID) { UploadError = $"Pastebin error: {result.Error ?? "unknown error"}" }); + return this.View("Index", this.GetModel(result.ID, uploadError: $"Pastebin error: {result.Error ?? "unknown error"}")); // redirect to view UriBuilder uri = new UriBuilder(new Uri(this.Config.LogParserUrl)); @@ -106,74 +106,41 @@ namespace StardewModdingAPI.Web.Controllers private async Task<PasteInfo> GetAsync(string id) { PasteInfo response = await this.Pastebin.GetAsync(id); - response.Content = this.DecompressString(response.Content); + response.Content = this.GzipHelper.DecompressString(response.Content); return response; } - /// <summary>Compress a string.</summary> - /// <param name="text">The text to compress.</param> - /// <remarks>Derived from <a href="https://stackoverflow.com/a/17993002/262123"/>.</remarks> - private string CompressString(string text) + /// <summary>Build a log parser model.</summary> + /// <param name="pasteID">The paste ID.</param> + /// <param name="uploadError">An error which occurred while uploading the log to Pastebin.</param> + private LogParserModel GetModel(string pasteID, string uploadError = null) { - // get raw bytes - byte[] buffer = Encoding.UTF8.GetBytes(text); - - // compressed - byte[] compressedData; - using (MemoryStream stream = new MemoryStream()) - { - using (GZipStream zipStream = new GZipStream(stream, CompressionLevel.Optimal, leaveOpen: true)) - zipStream.Write(buffer, 0, buffer.Length); - - stream.Position = 0; - compressedData = new byte[stream.Length]; - stream.Read(compressedData, 0, compressedData.Length); - } - - // prefix length - byte[] zipBuffer = new byte[compressedData.Length + 4]; - Buffer.BlockCopy(compressedData, 0, zipBuffer, 4, compressedData.Length); - Buffer.BlockCopy(BitConverter.GetBytes(buffer.Length), 0, zipBuffer, 0, 4); - - // return string representation - return Convert.ToBase64String(zipBuffer); + string sectionUrl = this.Config.LogParserUrl; + Platform? platform = this.DetectClientPlatform(); + return new LogParserModel(sectionUrl, pasteID, platform) { UploadError = uploadError }; } - /// <summary>Decompress a string.</summary> - /// <param name="rawText">The compressed text.</param> - /// <remarks>Derived from <a href="https://stackoverflow.com/a/17993002/262123"/>.</remarks> - private string DecompressString(string rawText) + /// <summary>Detect the viewer's OS.</summary> + /// <returns>Returns the viewer OS if known, else null.</returns> + private Platform? DetectClientPlatform() { - // get raw bytes - byte[] zipBuffer; - try + string userAgent = this.Request.Headers["User-Agent"]; + switch (userAgent) { - zipBuffer = Convert.FromBase64String(rawText); - } - catch - { - return rawText; // not valid base64, wasn't compressed by the log parser - } + case string ua when ua.Contains("Windows"): + return Platform.Windows; - // skip if not gzip - if (BitConverter.ToUInt16(zipBuffer, 4) != LogParserController.GzipLeadBytes) - return rawText; + case string ua when ua.Contains("Android"): // check for Android before Linux because Android user agents also contain Linux + return Platform.Android; - // decompress - using (MemoryStream memoryStream = new MemoryStream()) - { - // read length prefix - int dataLength = BitConverter.ToInt32(zipBuffer, 0); - memoryStream.Write(zipBuffer, 4, zipBuffer.Length - 4); - - // read data - byte[] buffer = new byte[dataLength]; - memoryStream.Position = 0; - using (GZipStream gZipStream = new GZipStream(memoryStream, CompressionMode.Decompress)) - gZipStream.Read(buffer, 0, buffer.Length); - - // return original string - return Encoding.UTF8.GetString(buffer); + case string ua when ua.Contains("Linux"): + return Platform.Linux; + + case string ua when ua.Contains("Mac"): + return Platform.Mac; + + default: + return null; } } } diff --git a/src/SMAPI.Web/Controllers/ModsApiController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs index 7e6f592c..fe220eb5 100644 --- a/src/SMAPI.Web/Controllers/ModsApiController.cs +++ b/src/SMAPI.Web/Controllers/ModsApiController.cs @@ -2,18 +2,19 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; -using System.Text.RegularExpressions; using System.Threading.Tasks; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; using StardewModdingAPI.Toolkit; using StardewModdingAPI.Toolkit.Framework.Clients.WebApi; using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; using StardewModdingAPI.Toolkit.Framework.ModData; using StardewModdingAPI.Toolkit.Framework.UpdateData; +using StardewModdingAPI.Web.Framework.Caching.Mods; +using StardewModdingAPI.Web.Framework.Caching.Wiki; using StardewModdingAPI.Web.Framework.Clients.Chucklefish; +using StardewModdingAPI.Web.Framework.Clients.CurseForge; using StardewModdingAPI.Web.Framework.Clients.GitHub; using StardewModdingAPI.Web.Framework.Clients.ModDrop; using StardewModdingAPI.Web.Framework.Clients.Nexus; @@ -33,8 +34,11 @@ namespace StardewModdingAPI.Web.Controllers /// <summary>The mod repositories which provide mod metadata.</summary> private readonly IDictionary<ModRepositoryKey, IModRepository> Repositories; - /// <summary>The cache in which to store mod metadata.</summary> - private readonly IMemoryCache Cache; + /// <summary>The cache in which to store wiki data.</summary> + private readonly IWikiCacheRepository WikiCache; + + /// <summary>The cache in which to store mod data.</summary> + private readonly IModCacheRepository ModCache; /// <summary>The number of minutes successful update checks should be cached before refetching them.</summary> private readonly int SuccessCacheMinutes; @@ -42,9 +46,6 @@ namespace StardewModdingAPI.Web.Controllers /// <summary>The number of minutes failed update checks should be cached before refetching them.</summary> private readonly int ErrorCacheMinutes; - /// <summary>A regex which matches SMAPI-style semantic version.</summary> - private readonly string VersionRegex; - /// <summary>The internal mod metadata list.</summary> private readonly ModDatabase ModDatabase; @@ -57,26 +58,29 @@ namespace StardewModdingAPI.Web.Controllers *********/ /// <summary>Construct an instance.</summary> /// <param name="environment">The web hosting environment.</param> - /// <param name="cache">The cache in which to store mod metadata.</param> + /// <param name="wikiCache">The cache in which to store wiki data.</param> + /// <param name="modCache">The cache in which to store mod metadata.</param> /// <param name="configProvider">The config settings for mod update checks.</param> /// <param name="chucklefish">The Chucklefish API client.</param> + /// <param name="curseForge">The CurseForge API client.</param> /// <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, IMemoryCache cache, IOptions<ModUpdateCheckConfig> configProvider, IChucklefishClient chucklefish, IGitHubClient github, IModDropClient modDrop, INexusClient nexus) + public ModsApiController(IHostingEnvironment environment, IWikiCacheRepository wikiCache, IModCacheRepository modCache, IOptions<ModUpdateCheckConfig> configProvider, IChucklefishClient chucklefish, ICurseForgeClient curseForge, IGitHubClient github, IModDropClient modDrop, INexusClient nexus) { - this.ModDatabase = new ModToolkit().GetModDatabase(Path.Combine(environment.WebRootPath, "StardewModdingAPI.metadata.json")); + this.ModDatabase = new ModToolkit().GetModDatabase(Path.Combine(environment.WebRootPath, "SMAPI.metadata.json")); ModUpdateCheckConfig config = configProvider.Value; this.CompatibilityPageUrl = config.CompatibilityPageUrl; - this.Cache = cache; + this.WikiCache = wikiCache; + this.ModCache = modCache; this.SuccessCacheMinutes = config.SuccessCacheMinutes; this.ErrorCacheMinutes = config.ErrorCacheMinutes; - this.VersionRegex = config.SemanticVersionRegex; this.Repositories = new IModRepository[] { new ChucklefishRepository(chucklefish), + new CurseForgeRepository(curseForge), new GitHubRepository(github), new ModDropRepository(modDrop), new NexusRepository(nexus) @@ -86,21 +90,42 @@ namespace StardewModdingAPI.Web.Controllers /// <summary>Fetch version metadata for the given mods.</summary> /// <param name="model">The mod search criteria.</param> + /// <param name="version">The requested API version.</param> [HttpPost] - public async Task<IEnumerable<ModEntryModel>> PostAsync([FromBody] ModSearchModel model) + public async Task<IEnumerable<ModEntryModel>> PostAsync([FromBody] ModSearchModel model, [FromRoute] string version) { if (model?.Mods == null) return new ModEntryModel[0]; + bool legacyMode = SemanticVersion.TryParse(version, out ISemanticVersion parsedVersion) && parsedVersion.IsOlderThan("3.0.0-beta.20191109"); + // fetch wiki data - WikiModEntry[] wikiData = await this.GetWikiDataAsync(); + WikiModEntry[] wikiData = this.WikiCache.GetWikiMods().Select(p => p.GetModel()).ToArray(); IDictionary<string, ModEntryModel> mods = new Dictionary<string, ModEntryModel>(StringComparer.CurrentCultureIgnoreCase); foreach (ModSearchEntryModel mod in model.Mods) { if (string.IsNullOrWhiteSpace(mod.ID)) continue; - ModEntryModel result = await this.GetModData(mod, wikiData, model.IncludeExtendedMetadata); + ModEntryModel result = await this.GetModData(mod, wikiData, model.IncludeExtendedMetadata || legacyMode, model.ApiVersion); + if (legacyMode) + { + result.Main = result.Metadata.Main; + result.Optional = result.Metadata.Optional; + result.Unofficial = result.Metadata.Unofficial; + result.UnofficialForBeta = result.Metadata.UnofficialForBeta; + result.HasBetaInfo = result.Metadata.BetaCompatibilityStatus != null; + result.SuggestedUpdate = null; + if (!model.IncludeExtendedMetadata) + result.Metadata = null; + } + else if (!model.IncludeExtendedMetadata && (model.ApiVersion == null || mod.InstalledVersion == null)) + { + var errors = new List<string>(result.Errors); + errors.Add($"This API can't suggest an update because {nameof(model.ApiVersion)} or {nameof(mod.InstalledVersion)} are null, and you didn't specify {nameof(model.IncludeExtendedMetadata)} to get other info. See the SMAPI technical docs for usage."); + result.Errors = errors.ToArray(); + } + mods[mod.ID] = result; } @@ -116,19 +141,31 @@ namespace StardewModdingAPI.Web.Controllers /// <param name="search">The mod data to match.</param> /// <param name="wikiData">The wiki data.</param> /// <param name="includeExtendedMetadata">Whether to include extended metadata for each mod.</param> + /// <param name="apiVersion">The SMAPI version installed by the player.</param> /// <returns>Returns the mod data if found, else <c>null</c>.</returns> - private async Task<ModEntryModel> GetModData(ModSearchEntryModel search, WikiModEntry[] wikiData, bool includeExtendedMetadata) + private async Task<ModEntryModel> GetModData(ModSearchEntryModel search, WikiModEntry[] wikiData, bool includeExtendedMetadata, ISemanticVersion apiVersion) { - // crossreference data + // cross-reference data ModDataRecord record = this.ModDatabase.Get(search.ID); WikiModEntry wikiEntry = wikiData.FirstOrDefault(entry => entry.ID.Contains(search.ID.Trim(), StringComparer.InvariantCultureIgnoreCase)); - string[] updateKeys = this.GetUpdateKeys(search.UpdateKeys, record, wikiEntry).ToArray(); + UpdateKey[] updateKeys = this.GetUpdateKeys(search.UpdateKeys, record, wikiEntry).ToArray(); // get latest versions ModEntryModel result = new ModEntryModel { ID = search.ID }; IList<string> errors = new List<string>(); - foreach (string updateKey in updateKeys) + ModEntryVersionModel main = null; + ModEntryVersionModel optional = null; + ModEntryVersionModel unofficial = null; + ModEntryVersionModel unofficialForBeta = null; + foreach (UpdateKey updateKey in updateKeys) { + // validate update key + if (!updateKey.LooksValid) + { + errors.Add($"The update key '{updateKey}' isn't in a valid format. It should contain the site key and mod ID like 'Nexus:541'."); + continue; + } + // fetch data ModInfoModel data = await this.GetInfoForUpdateKeyAsync(updateKey); if (data.Error != null) @@ -140,76 +177,118 @@ namespace StardewModdingAPI.Web.Controllers // handle main version if (data.Version != null) { - if (!SemanticVersion.TryParse(data.Version, out ISemanticVersion version)) + ISemanticVersion version = this.GetMappedVersion(data.Version, wikiEntry?.MapRemoteVersions); + if (version == null) { errors.Add($"The update key '{updateKey}' matches a mod with invalid semantic version '{data.Version}'."); continue; } - if (this.IsNewer(version, result.Main?.Version)) - result.Main = new ModEntryVersionModel(version, data.Url); + if (this.IsNewer(version, main?.Version)) + main = new ModEntryVersionModel(version, data.Url); } // handle optional version if (data.PreviewVersion != null) { - if (!SemanticVersion.TryParse(data.PreviewVersion, out ISemanticVersion version)) + ISemanticVersion version = this.GetMappedVersion(data.PreviewVersion, wikiEntry?.MapRemoteVersions); + if (version == null) { errors.Add($"The update key '{updateKey}' matches a mod with invalid optional semantic version '{data.PreviewVersion}'."); continue; } - if (this.IsNewer(version, result.Optional?.Version)) - result.Optional = new ModEntryVersionModel(version, data.Url); + if (this.IsNewer(version, optional?.Version)) + optional = new ModEntryVersionModel(version, data.Url); } } // get unofficial version - if (wikiEntry?.Compatibility.UnofficialVersion != null && this.IsNewer(wikiEntry.Compatibility.UnofficialVersion, result.Main?.Version) && this.IsNewer(wikiEntry.Compatibility.UnofficialVersion, result.Optional?.Version)) - result.Unofficial = new ModEntryVersionModel(wikiEntry.Compatibility.UnofficialVersion, $"{this.CompatibilityPageUrl}/#{wikiEntry.Anchor}"); + if (wikiEntry?.Compatibility.UnofficialVersion != null && this.IsNewer(wikiEntry.Compatibility.UnofficialVersion, main?.Version) && this.IsNewer(wikiEntry.Compatibility.UnofficialVersion, optional?.Version)) + unofficial = new ModEntryVersionModel(wikiEntry.Compatibility.UnofficialVersion, $"{this.CompatibilityPageUrl}/#{wikiEntry.Anchor}"); // get unofficial version for beta if (wikiEntry?.HasBetaInfo == true) { - result.HasBetaInfo = true; if (wikiEntry.BetaCompatibility.Status == WikiCompatibilityStatus.Unofficial) { if (wikiEntry.BetaCompatibility.UnofficialVersion != null) { - result.UnofficialForBeta = (wikiEntry.BetaCompatibility.UnofficialVersion != null && this.IsNewer(wikiEntry.BetaCompatibility.UnofficialVersion, result.Main?.Version) && this.IsNewer(wikiEntry.BetaCompatibility.UnofficialVersion, result.Optional?.Version)) + unofficialForBeta = (wikiEntry.BetaCompatibility.UnofficialVersion != null && this.IsNewer(wikiEntry.BetaCompatibility.UnofficialVersion, main?.Version) && this.IsNewer(wikiEntry.BetaCompatibility.UnofficialVersion, optional?.Version)) ? new ModEntryVersionModel(wikiEntry.BetaCompatibility.UnofficialVersion, $"{this.CompatibilityPageUrl}/#{wikiEntry.Anchor}") : null; } else - result.UnofficialForBeta = result.Unofficial; + unofficialForBeta = unofficial; } } // fallback to preview if latest is invalid - if (result.Main == null && result.Optional != null) + if (main == null && optional != null) { - result.Main = result.Optional; - result.Optional = null; + main = optional; + optional = null; } // special cases if (result.ID == "Pathoschild.SMAPI") { - if (result.Main != null) - result.Main.Url = "https://smapi.io/"; - if (result.Optional != null) - result.Optional.Url = "https://smapi.io/"; + if (main != null) + main.Url = "https://smapi.io/"; + if (optional != null) + optional.Url = "https://smapi.io/"; + } + + // get recommended update (if any) + ISemanticVersion installedVersion = this.GetMappedVersion(search.InstalledVersion?.ToString(), wikiEntry?.MapLocalVersions); + if (apiVersion != null && installedVersion != null) + { + // get newer versions + List<ModEntryVersionModel> updates = new List<ModEntryVersionModel>(); + if (this.IsRecommendedUpdate(installedVersion, main?.Version, useBetaChannel: true)) + updates.Add(main); + if (this.IsRecommendedUpdate(installedVersion, optional?.Version, useBetaChannel: installedVersion.IsPrerelease())) + updates.Add(optional); + if (this.IsRecommendedUpdate(installedVersion, unofficial?.Version, useBetaChannel: search.IsBroken)) + updates.Add(unofficial); + if (this.IsRecommendedUpdate(installedVersion, unofficialForBeta?.Version, useBetaChannel: apiVersion.IsPrerelease())) + updates.Add(unofficialForBeta); + + // get newest version + ModEntryVersionModel newest = null; + foreach (ModEntryVersionModel update in updates) + { + if (newest == null || update.Version.IsNewerThan(newest.Version)) + newest = update; + } + + // set field + result.SuggestedUpdate = newest != null + ? new ModEntryVersionModel(newest.Version, newest.Url) + : null; } // add extended metadata - if (includeExtendedMetadata && (wikiEntry != null || record != null)) - result.Metadata = new ModExtendedMetadataModel(wikiEntry, record); + if (includeExtendedMetadata) + result.Metadata = new ModExtendedMetadataModel(wikiEntry, record, main: main, optional: optional, unofficial: unofficial, unofficialForBeta: unofficialForBeta); // add result result.Errors = errors.ToArray(); return result; } + /// <summary>Get whether a given version should be offered to the user as an update.</summary> + /// <param name="currentVersion">The current semantic version.</param> + /// <param name="newVersion">The target semantic version.</param> + /// <param name="useBetaChannel">Whether the user enabled the beta channel and should be offered prerelease updates.</param> + private bool IsRecommendedUpdate(ISemanticVersion currentVersion, ISemanticVersion newVersion, bool useBetaChannel) + { + return + newVersion != null + && newVersion.IsNewerThan(currentVersion) + && (useBetaChannel || !newVersion.IsPrerelease()); + } + /// <summary>Get whether a <paramref name="current"/> version is newer than an <paramref name="other"/> version.</summary> /// <param name="current">The current version.</param> /// <param name="other">The other version.</param> @@ -218,60 +297,38 @@ namespace StardewModdingAPI.Web.Controllers return current != null && (other == null || other.IsOlderThan(current)); } - /// <summary>Get mod data from the wiki compatibility list.</summary> - private async Task<WikiModEntry[]> GetWikiDataAsync() - { - ModToolkit toolkit = new ModToolkit(); - return await this.Cache.GetOrCreateAsync("_wiki", async entry => - { - try - { - WikiModEntry[] entries = (await toolkit.GetWikiCompatibilityListAsync()).Mods; - entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(this.SuccessCacheMinutes); - return entries; - } - catch - { - entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(this.ErrorCacheMinutes); - return new WikiModEntry[0]; - } - }); - } - /// <summary>Get the mod info for an update key.</summary> /// <param name="updateKey">The namespaced update key.</param> - private async Task<ModInfoModel> GetInfoForUpdateKeyAsync(string updateKey) + private async Task<ModInfoModel> GetInfoForUpdateKeyAsync(UpdateKey updateKey) { - // parse update key - UpdateKey parsed = UpdateKey.Parse(updateKey); - if (!parsed.LooksValid) - return new ModInfoModel($"The update key '{updateKey}' isn't in a valid format. It should contain the site key and mod ID like 'Nexus:541'."); - - // get matching repository - if (!this.Repositories.TryGetValue(parsed.Repository, out IModRepository repository)) - return new ModInfoModel($"There's no mod site with key '{parsed.Repository}'. Expected one of [{string.Join(", ", this.Repositories.Keys)}]."); - - // fetch mod info - return await this.Cache.GetOrCreateAsync($"{repository.VendorKey}:{parsed.ID}".ToLower(), async entry => + // get mod + if (!this.ModCache.TryGetMod(updateKey.Repository, updateKey.ID, out CachedMod mod) || this.ModCache.IsStale(mod.LastUpdated, mod.FetchStatus == RemoteModStatus.TemporaryError ? this.ErrorCacheMinutes : this.SuccessCacheMinutes)) { - ModInfoModel result = await repository.GetModInfoAsync(parsed.ID); + // get site + if (!this.Repositories.TryGetValue(updateKey.Repository, out IModRepository repository)) + return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, $"There's no mod site with key '{updateKey.Repository}'. Expected one of [{string.Join(", ", this.Repositories.Keys)}]."); + + // fetch mod + ModInfoModel result = await repository.GetModInfoAsync(updateKey.ID); if (result.Error == null) { if (result.Version == null) - result.Error = $"The update key '{updateKey}' matches a mod with no version number."; - else if (!Regex.IsMatch(result.Version, this.VersionRegex, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)) - result.Error = $"The update key '{updateKey}' matches a mod with invalid semantic version '{result.Version}'."; + result.SetError(RemoteModStatus.InvalidData, $"The update key '{updateKey}' matches a mod with no version number."); + else if (!SemanticVersion.TryParse(result.Version, out _)) + result.SetError(RemoteModStatus.InvalidData, $"The update key '{updateKey}' matches a mod with invalid semantic version '{result.Version}'."); } - entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(result.Error == null ? this.SuccessCacheMinutes : this.ErrorCacheMinutes); - return result; - }); + + // cache mod + this.ModCache.SaveMod(repository.VendorKey, updateKey.ID, result, out mod); + } + return mod.GetModel(); } /// <summary>Get update keys based on the available mod metadata, while maintaining the precedence order.</summary> /// <param name="specifiedKeys">The specified update keys.</param> /// <param name="record">The mod's entry in SMAPI's internal database.</param> /// <param name="entry">The mod's entry in the wiki list.</param> - public IEnumerable<string> GetUpdateKeys(string[] specifiedKeys, ModDataRecord record, WikiModEntry entry) + private IEnumerable<UpdateKey> GetUpdateKeys(string[] specifiedKeys, ModDataRecord record, WikiModEntry entry) { IEnumerable<string> GetRaw() { @@ -291,20 +348,70 @@ namespace StardewModdingAPI.Web.Controllers if (entry != null) { if (entry.NexusID.HasValue) - yield return $"Nexus:{entry.NexusID}"; - if (entry.ChucklefishID.HasValue) - yield return $"Chucklefish:{entry.ChucklefishID}"; + yield return $"{ModRepositoryKey.Nexus}:{entry.NexusID}"; if (entry.ModDropID.HasValue) - yield return $"ModDrop:{entry.ModDropID}"; + yield return $"{ModRepositoryKey.ModDrop}:{entry.ModDropID}"; + if (entry.CurseForgeID.HasValue) + yield return $"{ModRepositoryKey.CurseForge}:{entry.CurseForgeID}"; + if (entry.ChucklefishID.HasValue) + yield return $"{ModRepositoryKey.Chucklefish}:{entry.ChucklefishID}"; } } - HashSet<string> seen = new HashSet<string>(StringComparer.InvariantCulture); - foreach (string key in GetRaw()) + HashSet<UpdateKey> seen = new HashSet<UpdateKey>(); + foreach (string rawKey in GetRaw()) { - if (!string.IsNullOrWhiteSpace(key) && seen.Add(key)) + if (string.IsNullOrWhiteSpace(rawKey)) + continue; + + UpdateKey key = UpdateKey.Parse(rawKey); + if (seen.Add(key)) yield return key; } } + + /// <summary>Get a semantic local version for update checks.</summary> + /// <param name="version">The version to parse.</param> + /// <param name="map">A map of version replacements.</param> + private ISemanticVersion GetMappedVersion(string version, IDictionary<string, string> map) + { + // try mapped version + string rawNewVersion = this.GetRawMappedVersion(version, map); + if (SemanticVersion.TryParse(rawNewVersion, out ISemanticVersion parsedNew)) + return parsedNew; + + // return original version + return SemanticVersion.TryParse(version, out ISemanticVersion parsedOld) + ? parsedOld + : null; + } + + /// <summary>Get a semantic local version for update checks.</summary> + /// <param name="version">The version to map.</param> + /// <param name="map">A map of version replacements.</param> + private string GetRawMappedVersion(string version, IDictionary<string, string> map) + { + if (version == null || map == null || !map.Any()) + return version; + + // match exact raw version + if (map.ContainsKey(version)) + return map[version]; + + // match parsed version + if (SemanticVersion.TryParse(version, out ISemanticVersion parsed)) + { + if (map.ContainsKey(parsed.ToString())) + return map[parsed.ToString()]; + + foreach (var pair in map) + { + if (SemanticVersion.TryParse(pair.Key, out ISemanticVersion target) && parsed.Equals(target) && SemanticVersion.TryParse(pair.Value, out ISemanticVersion newVersion)) + return newVersion.ToString(); + } + } + + return version; + } } } diff --git a/src/SMAPI.Web/Controllers/ModsController.cs b/src/SMAPI.Web/Controllers/ModsController.cs index ca866a8d..b621ded0 100644 --- a/src/SMAPI.Web/Controllers/ModsController.cs +++ b/src/SMAPI.Web/Controllers/ModsController.cs @@ -1,12 +1,8 @@ -using System; using System.Linq; using System.Text.RegularExpressions; -using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; -using StardewModdingAPI.Toolkit; -using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; +using StardewModdingAPI.Web.Framework.Caching.Wiki; using StardewModdingAPI.Web.Framework.ConfigModels; using StardewModdingAPI.Web.ViewModels; @@ -19,10 +15,10 @@ namespace StardewModdingAPI.Web.Controllers ** Fields *********/ /// <summary>The cache in which to store mod metadata.</summary> - private readonly IMemoryCache Cache; + private readonly IWikiCacheRepository Cache; - /// <summary>The number of minutes successful update checks should be cached before refetching them.</summary> - private readonly int CacheMinutes; + /// <summary>The number of minutes before which wiki data should be considered old.</summary> + private readonly int StaleMinutes; /********* @@ -31,20 +27,20 @@ namespace StardewModdingAPI.Web.Controllers /// <summary>Construct an instance.</summary> /// <param name="cache">The cache in which to store mod metadata.</param> /// <param name="configProvider">The config settings for mod update checks.</param> - public ModsController(IMemoryCache cache, IOptions<ModCompatibilityListConfig> configProvider) + public ModsController(IWikiCacheRepository cache, IOptions<ModCompatibilityListConfig> configProvider) { ModCompatibilityListConfig config = configProvider.Value; this.Cache = cache; - this.CacheMinutes = config.CacheMinutes; + this.StaleMinutes = config.StaleMinutes; } /// <summary>Display information for all mods.</summary> [HttpGet] [Route("mods")] - public async Task<ViewResult> Index() + public ViewResult Index() { - return this.View("Index", await this.FetchDataAsync()); + return this.View("Index", this.FetchData()); } @@ -52,23 +48,23 @@ namespace StardewModdingAPI.Web.Controllers ** Private methods *********/ /// <summary>Asynchronously fetch mod metadata from the wiki.</summary> - public async Task<ModListModel> FetchDataAsync() + public ModListModel FetchData() { - return await this.Cache.GetOrCreateAsync($"{nameof(ModsController)}_mod_list", async entry => - { - WikiModList data = await new ModToolkit().GetWikiCompatibilityListAsync(); - ModListModel model = new ModListModel( - stableVersion: data.StableVersion, - betaVersion: data.BetaVersion, - mods: data - .Mods - .Select(mod => new ModModel(mod)) - .OrderBy(p => Regex.Replace(p.Name.ToLower(), "[^a-z0-9]", "")) // ignore case, spaces, and special characters when sorting - ); + // fetch cached data + if (!this.Cache.TryGetWikiMetadata(out CachedWikiMetadata metadata)) + return new ModListModel(); - entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(this.CacheMinutes); - return model; - }); + // build model + return new ModListModel( + stableVersion: metadata.StableVersion, + betaVersion: metadata.BetaVersion, + mods: this.Cache + .GetWikiMods() + .Select(mod => new ModModel(mod.GetModel())) + .OrderBy(p => Regex.Replace(p.Name.ToLower(), "[^a-z0-9]", "")), // ignore case, spaces, and special characters when sorting + lastUpdated: metadata.LastUpdated, + isStale: this.Cache.IsStale(metadata.LastUpdated, this.StaleMinutes) + ); } } } diff --git a/src/SMAPI.Web/Framework/AllowLargePostsAttribute.cs b/src/SMAPI.Web/Framework/AllowLargePostsAttribute.cs index 5dc0feb6..864aa215 100644 --- a/src/SMAPI.Web/Framework/AllowLargePostsAttribute.cs +++ b/src/SMAPI.Web/Framework/AllowLargePostsAttribute.cs @@ -36,7 +36,7 @@ namespace StardewModdingAPI.Web.Framework } /// <summary>Called early in the filter pipeline to confirm request is authorized.</summary> - /// <param name="context">The authorisation filter context.</param> + /// <param name="context">The authorization filter context.</param> public void OnAuthorization(AuthorizationFilterContext context) { IFeatureCollection features = context.HttpContext.Features; diff --git a/src/SMAPI.Web/Framework/Caching/BaseCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/BaseCacheRepository.cs new file mode 100644 index 00000000..f5354b93 --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/BaseCacheRepository.cs @@ -0,0 +1,19 @@ +using System; + +namespace StardewModdingAPI.Web.Framework.Caching +{ + /// <summary>The base logic for a cache repository.</summary> + internal abstract class BaseCacheRepository + { + /********* + ** Public methods + *********/ + /// <summary>Whether cached data is stale.</summary> + /// <param name="lastUpdated">The date when the data was updated.</param> + /// <param name="staleMinutes">The age in minutes before data is considered stale.</param> + public bool IsStale(DateTimeOffset lastUpdated, int staleMinutes) + { + return lastUpdated < DateTimeOffset.UtcNow.AddMinutes(-staleMinutes); + } + } +} diff --git a/src/SMAPI.Web/Framework/Caching/ICacheRepository.cs b/src/SMAPI.Web/Framework/Caching/ICacheRepository.cs new file mode 100644 index 00000000..5de7e731 --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/ICacheRepository.cs @@ -0,0 +1,13 @@ +using System; + +namespace StardewModdingAPI.Web.Framework.Caching +{ + /// <summary>Encapsulates logic for accessing data in the cache.</summary> + internal interface ICacheRepository + { + /// <summary>Whether cached data is stale.</summary> + /// <param name="lastUpdated">The date when the data was updated.</param> + /// <param name="staleMinutes">The age in minutes before data is considered stale.</param> + bool IsStale(DateTimeOffset lastUpdated, int staleMinutes); + } +} diff --git a/src/SMAPI.Web/Framework/Caching/Mods/CachedMod.cs b/src/SMAPI.Web/Framework/Caching/Mods/CachedMod.cs new file mode 100644 index 00000000..96eca847 --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/Mods/CachedMod.cs @@ -0,0 +1,107 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; +using StardewModdingAPI.Toolkit.Framework.UpdateData; +using StardewModdingAPI.Web.Framework.ModRepositories; + +namespace StardewModdingAPI.Web.Framework.Caching.Mods +{ + /// <summary>The model for cached mod data.</summary> + internal class CachedMod + { + /********* + ** Accessors + *********/ + /**** + ** Tracking + ****/ + /// <summary>The internal MongoDB ID.</summary> + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Named per MongoDB conventions.")] + [BsonIgnoreIfDefault] + public ObjectId _id { get; set; } + + /// <summary>When the data was last updated.</summary> + public DateTimeOffset LastUpdated { get; set; } + + /// <summary>When the data was last requested through the web API.</summary> + public DateTimeOffset LastRequested { get; set; } + + /**** + ** Metadata + ****/ + /// <summary>The mod site on which the mod is found.</summary> + public ModRepositoryKey Site { get; set; } + + /// <summary>The mod's unique ID within the <see cref="Site"/>.</summary> + public string ID { get; set; } + + /// <summary>The mod availability status on the remote site.</summary> + public RemoteModStatus FetchStatus { get; set; } + + /// <summary>The error message providing more info for the <see cref="FetchStatus"/>, if applicable.</summary> + public string FetchError { get; set; } + + + /**** + ** Mod info + ****/ + /// <summary>The mod's display name.</summary> + public string Name { get; set; } + + /// <summary>The mod's latest version.</summary> + public string MainVersion { get; set; } + + /// <summary>The mod's latest optional or prerelease version, if newer than <see cref="MainVersion"/>.</summary> + public string PreviewVersion { get; set; } + + /// <summary>The URL for the mod page.</summary> + public string Url { get; set; } + + /// <summary>The license URL, if available.</summary> + public string LicenseUrl { get; set; } + + /// <summary>The license name, if available.</summary> + public string LicenseName { get; set; } + + + /********* + ** Accessors + *********/ + /// <summary>Construct an instance.</summary> + public CachedMod() { } + + /// <summary>Construct an instance.</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> + public CachedMod(ModRepositoryKey site, string id, ModInfoModel mod) + { + // tracking + this.LastUpdated = DateTimeOffset.UtcNow; + this.LastRequested = DateTimeOffset.UtcNow; + + // metadata + this.Site = site; + this.ID = id; + this.FetchStatus = mod.Status; + this.FetchError = mod.Error; + + // mod info + this.Name = mod.Name; + this.MainVersion = mod.Version; + this.PreviewVersion = mod.PreviewVersion; + this.Url = mod.Url; + this.LicenseUrl = mod.LicenseUrl; + this.LicenseName = mod.LicenseName; + } + + /// <summary>Get the API model for the cached data.</summary> + public ModInfoModel GetModel() + { + return new ModInfoModel(name: this.Name, version: this.MainVersion, url: this.Url, previewVersion: this.PreviewVersion) + .SetLicense(this.LicenseUrl, this.LicenseName) + .SetError(this.FetchStatus, this.FetchError); + } + } +} diff --git a/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs new file mode 100644 index 00000000..bcec8b36 --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs @@ -0,0 +1,31 @@ +using System; +using StardewModdingAPI.Toolkit.Framework.UpdateData; +using StardewModdingAPI.Web.Framework.ModRepositories; + +namespace StardewModdingAPI.Web.Framework.Caching.Mods +{ + /// <summary>Encapsulates logic for accessing the mod data cache.</summary> + internal interface IModCacheRepository : ICacheRepository + { + /********* + ** 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> + bool TryGetMod(ModRepositoryKey site, string id, out CachedMod mod, bool markRequested = 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> + void SaveMod(ModRepositoryKey site, string id, ModInfoModel mod, out CachedMod cachedMod); + + /// <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> + void RemoveStaleMods(TimeSpan age); + } +} diff --git a/src/SMAPI.Web/Framework/Caching/Mods/ModCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/Mods/ModCacheRepository.cs new file mode 100644 index 00000000..2e7804a7 --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/Mods/ModCacheRepository.cs @@ -0,0 +1,104 @@ +using System; +using MongoDB.Driver; +using StardewModdingAPI.Toolkit.Framework.UpdateData; +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 + { + /********* + ** Fields + *********/ + /// <summary>The collection for cached mod data.</summary> + private readonly IMongoCollection<CachedMod> Mods; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="database">The authenticated MongoDB database.</param> + public ModCacheRepository(IMongoDatabase database) + { + // get collections + this.Mods = database.GetCollection<CachedMod>("mods"); + + // add indexes if needed + this.Mods.Indexes.CreateOne(new CreateIndexModel<CachedMod>(Builders<CachedMod>.IndexKeys.Ascending(p => p.ID).Ascending(p => p.Site))); + } + + /********* + ** 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 + id = this.NormalizeId(id); + mod = this.Mods.Find(entry => entry.ID == id && entry.Site == site).FirstOrDefault(); + if (mod == null) + 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) + { + id = this.NormalizeId(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); + var result = 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) + { + string id = this.NormalizeId(mod.ID); + + this.Mods.ReplaceOne( + entry => entry.ID == id && entry.Site == mod.Site, + mod, + new UpdateOptions { IsUpsert = true } + ); + + return mod; + } + + /// <summary>Normalize a mod ID for case-insensitive search.</summary> + /// <param name="id">The mod ID.</param> + public string NormalizeId(string id) + { + return id.Trim().ToLower(); + } + } +} diff --git a/src/SMAPI.Web/Framework/Caching/UtcDateTimeOffsetSerializer.cs b/src/SMAPI.Web/Framework/Caching/UtcDateTimeOffsetSerializer.cs new file mode 100644 index 00000000..6a103e37 --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/UtcDateTimeOffsetSerializer.cs @@ -0,0 +1,40 @@ +using System; +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Serializers; + +namespace StardewModdingAPI.Web.Framework.Caching +{ + /// <summary>Serializes <see cref="DateTimeOffset"/> to a UTC date field instead of the default array.</summary> + public class UtcDateTimeOffsetSerializer : StructSerializerBase<DateTimeOffset> + { + /********* + ** Fields + *********/ + /// <summary>The underlying date serializer.</summary> + private static readonly DateTimeSerializer DateTimeSerializer = new DateTimeSerializer(DateTimeKind.Utc, BsonType.DateTime); + + + /********* + ** Public methods + *********/ + /// <summary>Deserializes a value.</summary> + /// <param name="context">The deserialization context.</param> + /// <param name="args">The deserialization args.</param> + /// <returns>A deserialized value.</returns> + public override DateTimeOffset Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args) + { + DateTime date = UtcDateTimeOffsetSerializer.DateTimeSerializer.Deserialize(context, args); + return new DateTimeOffset(date, TimeSpan.Zero); + } + + /// <summary>Serializes a value.</summary> + /// <param name="context">The serialization context.</param> + /// <param name="args">The serialization args.</param> + /// <param name="value">The object.</param> + public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, DateTimeOffset value) + { + UtcDateTimeOffsetSerializer.DateTimeSerializer.Serialize(context, args, value.UtcDateTime); + } + } +} diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/CachedWikiMetadata.cs b/src/SMAPI.Web/Framework/Caching/Wiki/CachedWikiMetadata.cs new file mode 100644 index 00000000..6a560eb4 --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/Wiki/CachedWikiMetadata.cs @@ -0,0 +1,43 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using MongoDB.Bson; + +namespace StardewModdingAPI.Web.Framework.Caching.Wiki +{ + /// <summary>The model for cached wiki metadata.</summary> + internal class CachedWikiMetadata + { + /********* + ** Accessors + *********/ + /// <summary>The internal MongoDB ID.</summary> + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Named per MongoDB conventions.")] + public ObjectId _id { get; set; } + + /// <summary>When the data was last updated.</summary> + public DateTimeOffset LastUpdated { get; set; } + + /// <summary>The current stable Stardew Valley version.</summary> + public string StableVersion { get; set; } + + /// <summary>The current beta Stardew Valley version.</summary> + public string BetaVersion { get; set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public CachedWikiMetadata() { } + + /// <summary>Construct an instance.</summary> + /// <param name="stableVersion">The current stable Stardew Valley version.</param> + /// <param name="betaVersion">The current beta Stardew Valley version.</param> + public CachedWikiMetadata(string stableVersion, string betaVersion) + { + this.StableVersion = stableVersion; + this.BetaVersion = betaVersion; + this.LastUpdated = DateTimeOffset.UtcNow; + } + } +} diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/CachedWikiMod.cs b/src/SMAPI.Web/Framework/Caching/Wiki/CachedWikiMod.cs new file mode 100644 index 00000000..8569984a --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/Wiki/CachedWikiMod.cs @@ -0,0 +1,230 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; +using MongoDB.Bson.Serialization.Options; +using StardewModdingAPI.Toolkit; +using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; + +namespace StardewModdingAPI.Web.Framework.Caching.Wiki +{ + /// <summary>The model for cached wiki mods.</summary> + internal class CachedWikiMod + { + /********* + ** Accessors + *********/ + /**** + ** Tracking + ****/ + /// <summary>The internal MongoDB ID.</summary> + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Named per MongoDB conventions.")] + public ObjectId _id { get; set; } + + /// <summary>When the data was last updated.</summary> + public DateTimeOffset LastUpdated { get; set; } + + /**** + ** Mod info + ****/ + /// <summary>The mod's unique ID. If the mod has alternate/old IDs, they're listed in latest to newest order.</summary> + public string[] ID { get; set; } + + /// <summary>The mod's display name. If the mod has multiple names, the first one is the most canonical name.</summary> + public string[] Name { get; set; } + + /// <summary>The mod's author name. If the author has multiple names, the first one is the most canonical name.</summary> + public string[] Author { get; set; } + + /// <summary>The mod ID on Nexus.</summary> + public int? NexusID { get; set; } + + /// <summary>The mod ID in the Chucklefish mod repo.</summary> + public int? ChucklefishID { get; set; } + + /// <summary>The mod ID in the CurseForge mod repo.</summary> + public int? CurseForgeID { get; set; } + + /// <summary>The mod key in the CurseForge mod repo (used in mod page URLs).</summary> + public string CurseForgeKey { get; set; } + + /// <summary>The mod ID in the ModDrop mod repo.</summary> + public int? ModDropID { get; set; } + + /// <summary>The GitHub repository in the form 'owner/repo'.</summary> + public string GitHubRepo { get; set; } + + /// <summary>The URL to a non-GitHub source repo.</summary> + public string CustomSourceUrl { get; set; } + + /// <summary>The custom mod page URL (if applicable).</summary> + public string CustomUrl { get; set; } + + /// <summary>The name of the mod which loads this content pack, if applicable.</summary> + public string ContentPackFor { get; set; } + + /// <summary>The human-readable warnings for players about this mod.</summary> + public string[] Warnings { get; set; } + + /// <summary>Extra metadata links (usually for open pull requests).</summary> + public Tuple<Uri, string>[] MetadataLinks { get; set; } + + /// <summary>Special notes intended for developers who maintain unofficial updates or submit pull requests. </summary> + public string DevNote { get; set; } + + /// <summary>The link anchor for the mod entry in the wiki compatibility list.</summary> + public string Anchor { get; set; } + + /**** + ** Stable compatibility + ****/ + /// <summary>The compatibility status.</summary> + public WikiCompatibilityStatus MainStatus { get; set; } + + /// <summary>The human-readable summary of the compatibility status or workaround, without HTML formatting.</summary> + public string MainSummary { get; set; } + + /// <summary>The game or SMAPI version which broke this mod (if applicable).</summary> + public string MainBrokeIn { get; set; } + + /// <summary>The version of the latest unofficial update, if applicable.</summary> + public string MainUnofficialVersion { get; set; } + + /// <summary>The URL to the latest unofficial update, if applicable.</summary> + public string MainUnofficialUrl { get; set; } + + /**** + ** Beta compatibility + ****/ + /// <summary>The compatibility status.</summary> + public WikiCompatibilityStatus? BetaStatus { get; set; } + + /// <summary>The human-readable summary of the compatibility status or workaround, without HTML formatting.</summary> + public string BetaSummary { get; set; } + + /// <summary>The game or SMAPI version which broke this mod (if applicable).</summary> + public string BetaBrokeIn { get; set; } + + /// <summary>The version of the latest unofficial update, if applicable.</summary> + public string BetaUnofficialVersion { get; set; } + + /// <summary>The URL to the latest unofficial update, if applicable.</summary> + public string BetaUnofficialUrl { get; set; } + + /**** + ** Version maps + ****/ + /// <summary>Maps local versions to a semantic version for update checks.</summary> + [BsonDictionaryOptions(Representation = DictionaryRepresentation.ArrayOfArrays)] + public IDictionary<string, string> MapLocalVersions { get; set; } + + /// <summary>Maps remote versions to a semantic version for update checks.</summary> + [BsonDictionaryOptions(Representation = DictionaryRepresentation.ArrayOfArrays)] + public IDictionary<string, string> MapRemoteVersions { get; set; } + + + /********* + ** Accessors + *********/ + /// <summary>Construct an instance.</summary> + public CachedWikiMod() { } + + /// <summary>Construct an instance.</summary> + /// <param name="mod">The mod data.</param> + public CachedWikiMod(WikiModEntry mod) + { + // tracking + this.LastUpdated = DateTimeOffset.UtcNow; + + // mod info + this.ID = mod.ID; + this.Name = mod.Name; + this.Author = mod.Author; + this.NexusID = mod.NexusID; + this.ChucklefishID = mod.ChucklefishID; + this.CurseForgeID = mod.CurseForgeID; + this.CurseForgeKey = mod.CurseForgeKey; + this.ModDropID = mod.ModDropID; + this.GitHubRepo = mod.GitHubRepo; + this.CustomSourceUrl = mod.CustomSourceUrl; + this.CustomUrl = mod.CustomUrl; + this.ContentPackFor = mod.ContentPackFor; + this.MetadataLinks = mod.MetadataLinks; + this.Warnings = mod.Warnings; + this.DevNote = mod.DevNote; + this.Anchor = mod.Anchor; + + // stable compatibility + this.MainStatus = mod.Compatibility.Status; + this.MainSummary = mod.Compatibility.Summary; + this.MainBrokeIn = mod.Compatibility.BrokeIn; + this.MainUnofficialVersion = mod.Compatibility.UnofficialVersion?.ToString(); + this.MainUnofficialUrl = mod.Compatibility.UnofficialUrl; + + // beta compatibility + this.BetaStatus = mod.BetaCompatibility?.Status; + this.BetaSummary = mod.BetaCompatibility?.Summary; + this.BetaBrokeIn = mod.BetaCompatibility?.BrokeIn; + this.BetaUnofficialVersion = mod.BetaCompatibility?.UnofficialVersion?.ToString(); + this.BetaUnofficialUrl = mod.BetaCompatibility?.UnofficialUrl; + + // version maps + this.MapLocalVersions = mod.MapLocalVersions; + this.MapRemoteVersions = mod.MapRemoteVersions; + } + + /// <summary>Reconstruct the original model.</summary> + public WikiModEntry GetModel() + { + var mod = new WikiModEntry + { + ID = this.ID, + Name = this.Name, + Author = this.Author, + NexusID = this.NexusID, + ChucklefishID = this.ChucklefishID, + CurseForgeID = this.CurseForgeID, + CurseForgeKey = this.CurseForgeKey, + ModDropID = this.ModDropID, + GitHubRepo = this.GitHubRepo, + CustomSourceUrl = this.CustomSourceUrl, + CustomUrl = this.CustomUrl, + ContentPackFor = this.ContentPackFor, + Warnings = this.Warnings, + MetadataLinks = this.MetadataLinks, + DevNote = this.DevNote, + Anchor = this.Anchor, + + // stable compatibility + Compatibility = new WikiCompatibilityInfo + { + Status = this.MainStatus, + Summary = this.MainSummary, + BrokeIn = this.MainBrokeIn, + UnofficialVersion = this.MainUnofficialVersion != null ? new SemanticVersion(this.MainUnofficialVersion) : null, + UnofficialUrl = this.MainUnofficialUrl + }, + + // version maps + MapLocalVersions = this.MapLocalVersions, + MapRemoteVersions = this.MapRemoteVersions + }; + + // beta compatibility + if (this.BetaStatus != null) + { + mod.BetaCompatibility = new WikiCompatibilityInfo + { + Status = this.BetaStatus.Value, + Summary = this.BetaSummary, + BrokeIn = this.BetaBrokeIn, + UnofficialVersion = this.BetaUnofficialVersion != null ? new SemanticVersion(this.BetaUnofficialVersion) : null, + UnofficialUrl = this.BetaUnofficialUrl + }; + } + + return mod; + } + } +} diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/IWikiCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/Wiki/IWikiCacheRepository.cs new file mode 100644 index 00000000..b54c8a2f --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/Wiki/IWikiCacheRepository.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; + +namespace StardewModdingAPI.Web.Framework.Caching.Wiki +{ + /// <summary>Encapsulates logic for accessing the wiki data cache.</summary> + internal interface IWikiCacheRepository : ICacheRepository + { + /********* + ** Methods + *********/ + /// <summary>Get the cached wiki metadata.</summary> + /// <param name="metadata">The fetched metadata.</param> + bool TryGetWikiMetadata(out CachedWikiMetadata metadata); + + /// <summary>Get the cached wiki mods.</summary> + /// <param name="filter">A filter to apply, if any.</param> + IEnumerable<CachedWikiMod> GetWikiMods(Expression<Func<CachedWikiMod, bool>> filter = null); + + /// <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> + void SaveWikiData(string stableVersion, string betaVersion, IEnumerable<WikiModEntry> mods, out CachedWikiMetadata cachedMetadata, out CachedWikiMod[] cachedMods); + } +} diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheRepository.cs new file mode 100644 index 00000000..1ae9d38f --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheRepository.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using MongoDB.Driver; +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 + { + /********* + ** Fields + *********/ + /// <summary>The collection for wiki metadata.</summary> + private readonly IMongoCollection<CachedWikiMetadata> WikiMetadata; + + /// <summary>The collection for wiki mod data.</summary> + private readonly IMongoCollection<CachedWikiMod> WikiMods; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="database">The authenticated MongoDB database.</param> + public WikiCacheRepository(IMongoDatabase database) + { + // get collections + this.WikiMetadata = database.GetCollection<CachedWikiMetadata>("wiki-metadata"); + this.WikiMods = database.GetCollection<CachedWikiMod>("wiki-mods"); + + // add indexes if needed + this.WikiMods.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(); + 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.WikiMods.Find(filter).ToList() + : this.WikiMods.Find("{}").ToList(); + } + + /// <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) + { + cachedMetadata = new CachedWikiMetadata(stableVersion, betaVersion); + cachedMods = mods.Select(mod => new CachedWikiMod(mod)).ToArray(); + + this.WikiMods.DeleteMany("{}"); + this.WikiMods.InsertMany(cachedMods); + + this.WikiMetadata.DeleteMany("{}"); + this.WikiMetadata.InsertOne(cachedMetadata); + } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs b/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs index 2753e33a..939c32c6 100644 --- a/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs @@ -45,7 +45,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish .GetAsync(string.Format(this.ModPageUrlFormat, id)) .AsString(); } - catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) + catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound || ex.Status == HttpStatusCode.Forbidden) { return null; } diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs new file mode 100644 index 00000000..140b854e --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs @@ -0,0 +1,113 @@ +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Pathoschild.Http.Client; +using StardewModdingAPI.Toolkit; +using StardewModdingAPI.Web.Framework.Clients.CurseForge.ResponseModels; + +namespace StardewModdingAPI.Web.Framework.Clients.CurseForge +{ + /// <summary>An HTTP client for fetching mod metadata from the CurseForge API.</summary> + internal class CurseForgeClient : ICurseForgeClient + { + /********* + ** Fields + *********/ + /// <summary>The underlying HTTP client.</summary> + private readonly IClient Client; + + /// <summary>A regex pattern which matches a version number in a CurseForge mod file name.</summary> + private readonly Regex VersionInNamePattern = new Regex(@"^(?:.+? | *)v?(\d+\.\d+(?:\.\d+)?(?:-.+?)?) *(?:\.(?:zip|rar|7z))?$", RegexOptions.Compiled); + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="userAgent">The user agent for the API client.</param> + /// <param name="apiUrl">The base URL for the CurseForge API.</param> + public CurseForgeClient(string userAgent, string apiUrl) + { + this.Client = new FluentClient(apiUrl).SetUserAgent(userAgent); + } + + /// <summary>Get metadata about a mod.</summary> + /// <param name="id">The CurseForge mod ID.</param> + /// <returns>Returns the mod info if found, else <c>null</c>.</returns> + public async Task<CurseForgeMod> GetModAsync(long id) + { + // get raw data + ModModel mod = await this.Client + .GetAsync($"addon/{id}") + .As<ModModel>(); + if (mod == null) + return null; + + // get latest versions + string invalidVersion = null; + ISemanticVersion latest = null; + foreach (ModFileModel file in mod.LatestFiles) + { + // extract version + ISemanticVersion version; + { + string raw = this.GetRawVersion(file); + if (raw == null) + continue; + + if (!SemanticVersion.TryParse(raw, out version)) + { + if (invalidVersion == null) + invalidVersion = raw; + continue; + } + } + + // track latest version + if (latest == null || version.IsNewerThan(latest)) + latest = version; + } + + // get error + string error = null; + if (latest == null && invalidVersion == null) + { + error = mod.LatestFiles.Any() + ? $"CurseForge mod {id} has no downloads which specify the version in a recognised format." + : $"CurseForge mod {id} has no downloads."; + } + + // generate result + return new CurseForgeMod + { + Name = mod.Name, + LatestVersion = latest?.ToString() ?? invalidVersion, + Url = mod.WebsiteUrl, + Error = error + }; + } + + /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> + public void Dispose() + { + this.Client?.Dispose(); + } + + + /********* + ** Private methods + *********/ + /// <summary>Get a raw version string for a mod file, if available.</summary> + /// <param name="file">The file whose version to get.</param> + private string GetRawVersion(ModFileModel file) + { + Match match = this.VersionInNamePattern.Match(file.DisplayName); + if (!match.Success) + match = this.VersionInNamePattern.Match(file.FileName); + + return match.Success + ? match.Groups[1].Value + : null; + } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeMod.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeMod.cs new file mode 100644 index 00000000..e5bb8cf1 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeMod.cs @@ -0,0 +1,23 @@ +using Newtonsoft.Json; + +namespace StardewModdingAPI.Web.Framework.Clients.CurseForge +{ + /// <summary>Mod metadata from the CurseForge API.</summary> + internal class CurseForgeMod + { + /********* + ** Accessors + *********/ + /// <summary>The mod name.</summary> + public string Name { get; set; } + + /// <summary>The latest file version.</summary> + public string LatestVersion { get; set; } + + /// <summary>The mod's web URL.</summary> + public string Url { get; set; } + + /// <summary>A user-friendly error which indicates why fetching the mod info failed (if applicable).</summary> + public string Error { get; set; } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/ICurseForgeClient.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/ICurseForgeClient.cs new file mode 100644 index 00000000..907b4087 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/CurseForge/ICurseForgeClient.cs @@ -0,0 +1,17 @@ +using System; +using System.Threading.Tasks; + +namespace StardewModdingAPI.Web.Framework.Clients.CurseForge +{ + /// <summary>An HTTP client for fetching mod metadata from the CurseForge API.</summary> + internal interface ICurseForgeClient : IDisposable + { + /********* + ** Methods + *********/ + /// <summary>Get metadata about a mod.</summary> + /// <param name="id">The CurseForge mod ID.</param> + /// <returns>Returns the mod info if found, else <c>null</c>.</returns> + Task<CurseForgeMod> GetModAsync(long id); + } +} diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModFileModel.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModFileModel.cs new file mode 100644 index 00000000..9de74847 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModFileModel.cs @@ -0,0 +1,12 @@ +namespace StardewModdingAPI.Web.Framework.Clients.CurseForge.ResponseModels +{ + /// <summary>Metadata from the CurseForge API about a mod file.</summary> + public class ModFileModel + { + /// <summary>The file name as downloaded.</summary> + public string FileName { get; set; } + + /// <summary>The file display name.</summary> + public string DisplayName { get; set; } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModModel.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModModel.cs new file mode 100644 index 00000000..48cd185b --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModModel.cs @@ -0,0 +1,18 @@ +namespace StardewModdingAPI.Web.Framework.Clients.CurseForge.ResponseModels +{ + /// <summary>An mod from the CurseForge API.</summary> + public class ModModel + { + /// <summary>The mod's unique ID on CurseForge.</summary> + public int ID { get; set; } + + /// <summary>The mod name.</summary> + public string Name { get; set; } + + /// <summary>The web URL for the mod page.</summary> + public string WebsiteUrl { get; set; } + + /// <summary>The available file downloads.</summary> + public ModFileModel[] LatestFiles { get; set; } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs index 22950db9..84c20957 100644 --- a/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs +++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs @@ -12,12 +12,6 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub /********* ** Fields *********/ - /// <summary>The URL for a GitHub API query for the latest stable release, excluding the base URL, where {0} is the organisation and project name.</summary> - private readonly string StableReleaseUrlFormat; - - /// <summary>The URL for a GitHub API query for the latest release (including prerelease), excluding the base URL, where {0} is the organisation and project name.</summary> - private readonly string AnyReleaseUrlFormat; - /// <summary>The underlying HTTP client.</summary> private readonly IClient Client; @@ -27,17 +21,12 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub *********/ /// <summary>Construct an instance.</summary> /// <param name="baseUrl">The base URL for the GitHub API.</param> - /// <param name="stableReleaseUrlFormat">The URL for a GitHub API query for the latest stable release, excluding the <paramref name="baseUrl"/>, where {0} is the organisation and project name.</param> - /// <param name="anyReleaseUrlFormat">The URL for a GitHub API query for the latest release (including prerelease), excluding the <paramref name="baseUrl"/>, where {0} is the organisation and project name.</param> /// <param name="userAgent">The user agent for the API client.</param> /// <param name="acceptHeader">The Accept header value expected by the GitHub API.</param> /// <param name="username">The username with which to authenticate to the GitHub API.</param> /// <param name="password">The password with which to authenticate to the GitHub API.</param> - public GitHubClient(string baseUrl, string stableReleaseUrlFormat, string anyReleaseUrlFormat, string userAgent, string acceptHeader, string username, string password) + public GitHubClient(string baseUrl, string userAgent, string acceptHeader, string username, string password) { - this.StableReleaseUrlFormat = stableReleaseUrlFormat; - this.AnyReleaseUrlFormat = anyReleaseUrlFormat; - this.Client = new FluentClient(baseUrl) .SetUserAgent(userAgent) .AddDefault(req => req.WithHeader("Accept", acceptHeader)); @@ -45,25 +34,43 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub this.Client = this.Client.SetBasicAuthentication(username, password); } + /// <summary>Get basic metadata for a GitHub repository, if available.</summary> + /// <param name="repo">The repository key (like <c>Pathoschild/SMAPI</c>).</param> + /// <returns>Returns the repository info if it exists, else <c>null</c>.</returns> + public async Task<GitRepo> GetRepositoryAsync(string repo) + { + this.AssertKeyFormat(repo); + try + { + return await this.Client + .GetAsync($"repos/{repo}") + .As<GitRepo>(); + } + catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) + { + return null; + } + } + /// <summary>Get the latest release for a GitHub repository.</summary> /// <param name="repo">The repository key (like <c>Pathoschild/SMAPI</c>).</param> /// <param name="includePrerelease">Whether to return a prerelease version if it's latest.</param> /// <returns>Returns the release if found, else <c>null</c>.</returns> public async Task<GitRelease> GetLatestReleaseAsync(string repo, bool includePrerelease = false) { - this.AssetKeyFormat(repo); + this.AssertKeyFormat(repo); try { if (includePrerelease) { GitRelease[] results = await this.Client - .GetAsync(string.Format(this.AnyReleaseUrlFormat, repo)) + .GetAsync($"repos/{repo}/releases?per_page=2") // allow for draft release (only visible if GitHub repo is owned by same account as the update check credentials) .AsArray<GitRelease>(); return results.FirstOrDefault(p => !p.IsDraft); } return await this.Client - .GetAsync(string.Format(this.StableReleaseUrlFormat, repo)) + .GetAsync($"repos/{repo}/releases/latest") .As<GitRelease>(); } catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) @@ -85,7 +92,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub /// <summary>Assert that a repository key is formatted correctly.</summary> /// <param name="repo">The repository key (like <c>Pathoschild/SMAPI</c>).</param> /// <exception cref="ArgumentException">The repository key is invalid.</exception> - private void AssetKeyFormat(string repo) + private void AssertKeyFormat(string repo) { if (repo == null || !repo.Contains("/") || repo.IndexOf("/", StringComparison.InvariantCultureIgnoreCase) != repo.LastIndexOf("/", StringComparison.InvariantCultureIgnoreCase)) throw new ArgumentException($"The value '{repo}' isn't a valid GitHub repository key, must be a username and project name like 'Pathoschild/SMAPI'.", nameof(repo)); diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitLicense.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitLicense.cs new file mode 100644 index 00000000..736efbe6 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitLicense.cs @@ -0,0 +1,20 @@ +using Newtonsoft.Json; + +namespace StardewModdingAPI.Web.Framework.Clients.GitHub +{ + /// <summary>The license info for a GitHub project.</summary> + internal class GitLicense + { + /// <summary>The license display name.</summary> + [JsonProperty("name")] + public string Name { get; set; } + + /// <summary>The SPDX ID for the license.</summary> + [JsonProperty("spdx_id")] + public string SpdxId { get; set; } + + /// <summary>The URL for the license info.</summary> + [JsonProperty("url")] + public string Url { get; set; } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitRepo.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitRepo.cs new file mode 100644 index 00000000..7d80576e --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitRepo.cs @@ -0,0 +1,20 @@ +using Newtonsoft.Json; + +namespace StardewModdingAPI.Web.Framework.Clients.GitHub +{ + /// <summary>Basic metadata about a GitHub project.</summary> + internal class GitRepo + { + /// <summary>The full repository name, including the owner.</summary> + [JsonProperty("full_name")] + public string FullName { get; set; } + + /// <summary>The URL to the repository web page, if any.</summary> + [JsonProperty("html_url")] + public string WebUrl { get; set; } + + /// <summary>The code license, if any.</summary> + [JsonProperty("license")] + public GitLicense License { get; set; } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs b/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs index 9519c26f..a34f03bd 100644 --- a/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs +++ b/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs @@ -9,6 +9,11 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub /********* ** Methods *********/ + /// <summary>Get basic metadata for a GitHub repository, if available.</summary> + /// <param name="repo">The repository key (like <c>Pathoschild/SMAPI</c>).</param> + /// <returns>Returns the repository info if it exists, else <c>null</c>.</returns> + Task<GitRepo> GetRepositoryAsync(string repo); + /// <summary>Get the latest release for a GitHub repository.</summary> /// <param name="repo">The repository key (like <c>Pathoschild/SMAPI</c>).</param> /// <param name="includePrerelease">Whether to return a prerelease version if it's latest.</param> diff --git a/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropMod.cs b/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropMod.cs index 291fb353..def79106 100644 --- a/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropMod.cs +++ b/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropMod.cs @@ -17,8 +17,5 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop /// <summary>The mod's web URL.</summary> public string Url { get; set; } - - /// <summary>A user-friendly error which indicates why fetching the mod info failed (if applicable).</summary> - public string Error { get; set; } } } diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs b/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs new file mode 100644 index 00000000..753d3b4f --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs @@ -0,0 +1,225 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using HtmlAgilityPack; +using Pathoschild.FluentNexus.Models; +using Pathoschild.Http.Client; +using StardewModdingAPI.Toolkit; +using FluentNexusClient = Pathoschild.FluentNexus.NexusClient; + +namespace StardewModdingAPI.Web.Framework.Clients.Nexus +{ + /// <summary>An HTTP client for fetching mod metadata from the Nexus website.</summary> + internal class NexusClient : INexusClient + { + /********* + ** Fields + *********/ + /// <summary>The URL for a Nexus mod page for the user, excluding the base URL, where {0} is the mod ID.</summary> + private readonly string WebModUrlFormat; + + /// <summary>The URL for a Nexus mod page to scrape for versions, excluding the base URL, where {0} is the mod ID.</summary> + public string WebModScrapeUrlFormat { get; set; } + + /// <summary>The underlying HTTP client for the Nexus Mods website.</summary> + private readonly IClient WebClient; + + /// <summary>The underlying HTTP client for the Nexus API.</summary> + private readonly FluentNexusClient ApiClient; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="webUserAgent">The user agent for the Nexus Mods web client.</param> + /// <param name="webBaseUrl">The base URL for the Nexus Mods site.</param> + /// <param name="webModUrlFormat">The URL for a Nexus Mods mod page for the user, excluding the <paramref name="webBaseUrl"/>, where {0} is the mod ID.</param> + /// <param name="webModScrapeUrlFormat">The URL for a Nexus mod page to scrape for versions, excluding the base URL, where {0} is the mod ID.</param> + /// <param name="apiAppVersion">The app version to show in API user agents.</param> + /// <param name="apiKey">The Nexus API authentication key.</param> + public NexusClient(string webUserAgent, string webBaseUrl, string webModUrlFormat, string webModScrapeUrlFormat, string apiAppVersion, string apiKey) + { + this.WebModUrlFormat = webModUrlFormat; + this.WebModScrapeUrlFormat = webModScrapeUrlFormat; + this.WebClient = new FluentClient(webBaseUrl).SetUserAgent(webUserAgent); + this.ApiClient = new FluentNexusClient(apiKey, "SMAPI", apiAppVersion); + } + + /// <summary>Get metadata about a mod.</summary> + /// <param name="id">The Nexus mod ID.</param> + /// <returns>Returns the mod info if found, else <c>null</c>.</returns> + public async Task<NexusMod> GetModAsync(uint id) + { + // Fetch from the Nexus website when possible, since it has no rate limits. Mods with + // adult content are hidden for anonymous users, so fall back to the API in that case. + // Note that the API has very restrictive rate limits which means we can't just use it + // for all cases. + NexusMod mod = await this.GetModFromWebsiteAsync(id); + if (mod?.Status == NexusModStatus.AdultContentForbidden) + mod = await this.GetModFromApiAsync(id); + + return mod; + } + + /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> + public void Dispose() + { + this.WebClient?.Dispose(); + } + + + /********* + ** Private methods + *********/ + /// <summary>Get metadata about a mod by scraping the Nexus website.</summary> + /// <param name="id">The Nexus mod ID.</param> + /// <returns>Returns the mod info if found, else <c>null</c>.</returns> + private async Task<NexusMod> GetModFromWebsiteAsync(uint id) + { + // fetch HTML + string html; + try + { + html = await this.WebClient + .GetAsync(string.Format(this.WebModScrapeUrlFormat, id)) + .AsString(); + } + catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) + { + return null; + } + + // parse HTML + var doc = new HtmlDocument(); + doc.LoadHtml(html); + + // handle Nexus error message + HtmlNode node = doc.DocumentNode.SelectSingleNode("//div[contains(@class, 'site-notice')][contains(@class, 'warning')]"); + if (node != null) + { + string[] errorParts = node.InnerText.Trim().Split(new[] { '\n' }, 2, System.StringSplitOptions.RemoveEmptyEntries); + string errorCode = errorParts[0]; + string errorText = errorParts.Length > 1 ? errorParts[1] : null; + switch (errorCode.Trim().ToLower()) + { + case "not found": + return null; + + default: + return new NexusMod { Error = $"Nexus error: {errorCode} ({errorText}).", Status = this.GetWebStatus(errorCode) }; + } + } + + // extract mod info + string url = this.GetModUrl(id); + string name = doc.DocumentNode.SelectSingleNode("//h1")?.InnerText.Trim(); + string version = doc.DocumentNode.SelectSingleNode("//ul[contains(@class, 'stats')]//li[@class='stat-version']//div[@class='stat']")?.InnerText.Trim(); + SemanticVersion.TryParse(version, out ISemanticVersion parsedVersion); + + // extract file versions + List<string> rawVersions = new List<string>(); + foreach (var fileSection in doc.DocumentNode.SelectNodes("//div[contains(@class, 'files-tabs')]")) + { + string sectionName = fileSection.Descendants("h2").First().InnerText; + if (sectionName != "Main files" && sectionName != "Optional files") + continue; + + rawVersions.AddRange( + from statBox in fileSection.Descendants().Where(p => p.HasClass("stat-version")) + from versionStat in statBox.Descendants().Where(p => p.HasClass("stat")) + select versionStat.InnerText.Trim() + ); + } + + // choose latest file version + ISemanticVersion latestFileVersion = null; + foreach (string rawVersion in rawVersions) + { + if (!SemanticVersion.TryParse(rawVersion, out ISemanticVersion cur)) + continue; + if (parsedVersion != null && !cur.IsNewerThan(parsedVersion)) + continue; + if (latestFileVersion != null && !cur.IsNewerThan(latestFileVersion)) + continue; + + latestFileVersion = cur; + } + + // yield info + return new NexusMod + { + Name = name, + Version = parsedVersion?.ToString() ?? version, + LatestFileVersion = latestFileVersion, + Url = url + }; + } + + /// <summary>Get metadata about a mod from the Nexus API.</summary> + /// <param name="id">The Nexus mod ID.</param> + /// <returns>Returns the mod info if found, else <c>null</c>.</returns> + private async Task<NexusMod> GetModFromApiAsync(uint id) + { + // fetch mod + Mod mod = await this.ApiClient.Mods.GetMod("stardewvalley", (int)id); + ModFileList files = await this.ApiClient.ModFiles.GetModFiles("stardewvalley", (int)id, FileCategory.Main, FileCategory.Optional); + + // get versions + if (!SemanticVersion.TryParse(mod.Version, out ISemanticVersion mainVersion)) + mainVersion = null; + ISemanticVersion latestFileVersion = null; + foreach (string rawVersion in files.Files.Select(p => p.FileVersion)) + { + if (!SemanticVersion.TryParse(rawVersion, out ISemanticVersion cur)) + continue; + if (mainVersion != null && !cur.IsNewerThan(mainVersion)) + continue; + if (latestFileVersion != null && !cur.IsNewerThan(latestFileVersion)) + continue; + + latestFileVersion = cur; + } + + // yield info + return new NexusMod + { + Name = mod.Name, + Version = SemanticVersion.TryParse(mod.Version, out ISemanticVersion version) ? version?.ToString() : mod.Version, + LatestFileVersion = latestFileVersion, + Url = this.GetModUrl(id) + }; + } + + /// <summary>Get the full mod page URL for a given ID.</summary> + /// <param name="id">The mod ID.</param> + private string GetModUrl(uint id) + { + UriBuilder builder = new UriBuilder(this.WebClient.BaseClient.BaseAddress); + builder.Path += string.Format(this.WebModUrlFormat, id); + return builder.Uri.ToString(); + } + + /// <summary>Get the mod status for a web error code.</summary> + /// <param name="errorCode">The Nexus error code.</param> + private NexusModStatus GetWebStatus(string errorCode) + { + switch (errorCode.Trim().ToLower()) + { + case "adult content": + return NexusModStatus.AdultContentForbidden; + + case "hidden mod": + return NexusModStatus.Hidden; + + case "not published": + return NexusModStatus.NotPublished; + + default: + return NexusModStatus.Other; + } + } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs b/src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs index f4909155..0f1b29d5 100644 --- a/src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs +++ b/src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs @@ -21,6 +21,10 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus [JsonProperty("mod_page_uri")] public string Url { get; set; } + /// <summary>The mod's publication status.</summary> + [JsonIgnore] + public NexusModStatus Status { get; set; } = NexusModStatus.Ok; + /// <summary>A user-friendly error which indicates why fetching the mod info failed (if applicable).</summary> [JsonIgnore] public string Error { get; set; } diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/NexusModStatus.cs b/src/SMAPI.Web/Framework/Clients/Nexus/NexusModStatus.cs new file mode 100644 index 00000000..9ef314cd --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/Nexus/NexusModStatus.cs @@ -0,0 +1,21 @@ +namespace StardewModdingAPI.Web.Framework.Clients.Nexus +{ + /// <summary>The status of a Nexus mod.</summary> + internal enum NexusModStatus + { + /// <summary>The mod is published and valid.</summary> + Ok, + + /// <summary>The mod is hidden by the author.</summary> + Hidden, + + /// <summary>The mod hasn't been published yet.</summary> + NotPublished, + + /// <summary>The mod contains adult content which is hidden for anonymous web users.</summary> + AdultContentForbidden, + + /// <summary>The Nexus API returned an unhandled error.</summary> + Other + } +} diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/NexusWebScrapeClient.cs b/src/SMAPI.Web/Framework/Clients/Nexus/NexusWebScrapeClient.cs deleted file mode 100644 index e83a6041..00000000 --- a/src/SMAPI.Web/Framework/Clients/Nexus/NexusWebScrapeClient.cs +++ /dev/null @@ -1,146 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Threading.Tasks; -using HtmlAgilityPack; -using Pathoschild.Http.Client; -using StardewModdingAPI.Toolkit; - -namespace StardewModdingAPI.Web.Framework.Clients.Nexus -{ - /// <summary>An HTTP client for fetching mod metadata from the Nexus website.</summary> - internal class NexusWebScrapeClient : INexusClient - { - /********* - ** Fields - *********/ - /// <summary>The URL for a Nexus mod page for the user, excluding the base URL, where {0} is the mod ID.</summary> - private readonly string ModUrlFormat; - - /// <summary>The URL for a Nexus mod page to scrape for versions, excluding the base URL, where {0} is the mod ID.</summary> - public string ModScrapeUrlFormat { get; set; } - - /// <summary>The underlying HTTP client.</summary> - private readonly IClient Client; - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="userAgent">The user agent for the Nexus Mods API client.</param> - /// <param name="baseUrl">The base URL for the Nexus Mods site.</param> - /// <param name="modUrlFormat">The URL for a Nexus Mods mod page for the user, excluding the <paramref name="baseUrl"/>, where {0} is the mod ID.</param> - /// <param name="modScrapeUrlFormat">The URL for a Nexus mod page to scrape for versions, excluding the base URL, where {0} is the mod ID.</param> - public NexusWebScrapeClient(string userAgent, string baseUrl, string modUrlFormat, string modScrapeUrlFormat) - { - this.ModUrlFormat = modUrlFormat; - this.ModScrapeUrlFormat = modScrapeUrlFormat; - this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent); - } - - /// <summary>Get metadata about a mod.</summary> - /// <param name="id">The Nexus mod ID.</param> - /// <returns>Returns the mod info if found, else <c>null</c>.</returns> - public async Task<NexusMod> GetModAsync(uint id) - { - // fetch HTML - string html; - try - { - html = await this.Client - .GetAsync(string.Format(this.ModScrapeUrlFormat, id)) - .AsString(); - } - catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) - { - return null; - } - - // parse HTML - var doc = new HtmlDocument(); - doc.LoadHtml(html); - - // handle Nexus error message - HtmlNode node = doc.DocumentNode.SelectSingleNode("//div[contains(@class, 'site-notice')][contains(@class, 'warning')]"); - if (node != null) - { - string[] errorParts = node.InnerText.Trim().Split(new[] { '\n' }, 2, System.StringSplitOptions.RemoveEmptyEntries); - string errorCode = errorParts[0]; - string errorText = errorParts.Length > 1 ? errorParts[1] : null; - switch (errorCode.Trim().ToLower()) - { - case "not found": - return null; - - default: - return new NexusMod { Error = $"Nexus error: {errorCode} ({errorText})." }; - } - } - - // extract mod info - string url = this.GetModUrl(id); - string name = doc.DocumentNode.SelectSingleNode("//h1")?.InnerText.Trim(); - string version = doc.DocumentNode.SelectSingleNode("//ul[contains(@class, 'stats')]//li[@class='stat-version']//div[@class='stat']")?.InnerText.Trim(); - SemanticVersion.TryParse(version, out ISemanticVersion parsedVersion); - - // extract file versions - List<string> rawVersions = new List<string>(); - foreach (var fileSection in doc.DocumentNode.SelectNodes("//div[contains(@class, 'files-tabs')]")) - { - string sectionName = fileSection.Descendants("h2").First().InnerText; - if (sectionName != "Main files" && sectionName != "Optional files") - continue; - - rawVersions.AddRange( - from statBox in fileSection.Descendants().Where(p => p.HasClass("stat-version")) - from versionStat in statBox.Descendants().Where(p => p.HasClass("stat")) - select versionStat.InnerText.Trim() - ); - } - - // choose latest file version - ISemanticVersion latestFileVersion = null; - foreach (string rawVersion in rawVersions) - { - if (!SemanticVersion.TryParse(rawVersion, out ISemanticVersion cur)) - continue; - if (parsedVersion != null && !cur.IsNewerThan(parsedVersion)) - continue; - if (latestFileVersion != null && !cur.IsNewerThan(latestFileVersion)) - continue; - - latestFileVersion = cur; - } - - // yield info - return new NexusMod - { - Name = name, - Version = parsedVersion?.ToString() ?? version, - LatestFileVersion = latestFileVersion, - Url = url - }; - } - - /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> - public void Dispose() - { - this.Client?.Dispose(); - } - - - /********* - ** Private methods - *********/ - /// <summary>Get the full mod page URL for a given ID.</summary> - /// <param name="id">The mod ID.</param> - private string GetModUrl(uint id) - { - UriBuilder builder = new UriBuilder(this.Client.BaseClient.BaseAddress); - builder.Path += string.Format(this.ModUrlFormat, id); - return builder.Uri.ToString(); - } - } -} diff --git a/src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs b/src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs index 630dfb76..a635abe3 100644 --- a/src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs @@ -11,7 +11,8 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin Task<PasteInfo> GetAsync(string id); /// <summary>Save a paste to Pastebin.</summary> + /// <param name="name">The paste name.</param> /// <param name="content">The paste content.</param> - Task<SavePasteResult> PostAsync(string content); + Task<SavePasteResult> PostAsync(string name, string content); } } diff --git a/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs b/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs index 12c3e83f..2e8a8c68 100644 --- a/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs @@ -1,11 +1,8 @@ using System; -using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; -using System.Text; using System.Threading.Tasks; -using System.Web; using Pathoschild.Http.Client; namespace StardewModdingAPI.Web.Framework.Clients.Pastebin @@ -70,8 +67,9 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin } /// <summary>Save a paste to Pastebin.</summary> + /// <param name="name">The paste name.</param> /// <param name="content">The paste content.</param> - public async Task<SavePasteResult> PostAsync(string content) + public async Task<SavePasteResult> PostAsync(string name, string content) { try { @@ -82,15 +80,15 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin // post to API string response = await this.Client .PostAsync("api/api_post.php") - .WithBodyContent(this.GetFormUrlEncodedContent(new Dictionary<string, string> + .WithBody(p => p.FormUrlEncoded(new { - ["api_option"] = "paste", - ["api_user_key"] = this.UserKey, - ["api_dev_key"] = this.DevKey, - ["api_paste_private"] = "1", // unlisted - ["api_paste_name"] = $"SMAPI log {DateTime.UtcNow:s}", - ["api_paste_expire_date"] = "N", // never expire - ["api_paste_code"] = content + api_option = "paste", + api_user_key = this.UserKey, + api_dev_key = this.DevKey, + api_paste_private = 1, // unlisted + api_paste_name = name, + api_paste_expire_date = "N", // never expire + api_paste_code = content })) .AsString(); @@ -117,18 +115,5 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin { this.Client.Dispose(); } - - - /********* - ** Private methods - *********/ - /// <summary>Build an HTTP content body with form-url-encoded content.</summary> - /// <param name="data">The content to encode.</param> - /// <remarks>This bypasses an issue where <see cref="FormUrlEncodedContent"/> restricts the body length to the maximum size of a URL, which isn't applicable here.</remarks> - private HttpContent GetFormUrlEncodedContent(IDictionary<string, string> data) - { - string body = string.Join("&", from arg in data select $"{HttpUtility.UrlEncode(arg.Key)}={HttpUtility.UrlEncode(arg.Value)}"); - return new StringContent(body, Encoding.UTF8, "application/x-www-form-urlencoded"); - } } } diff --git a/src/SMAPI.Web/Framework/Compression/GzipHelper.cs b/src/SMAPI.Web/Framework/Compression/GzipHelper.cs new file mode 100644 index 00000000..cc8f4737 --- /dev/null +++ b/src/SMAPI.Web/Framework/Compression/GzipHelper.cs @@ -0,0 +1,89 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Text; + +namespace StardewModdingAPI.Web.Framework.Compression +{ + /// <summary>Handles GZip compression logic.</summary> + internal class GzipHelper : IGzipHelper + { + /********* + ** Fields + *********/ + /// <summary>The first bytes in a valid zip file.</summary> + /// <remarks>See <a href="https://en.wikipedia.org/wiki/Zip_(file_format)#File_headers"/>.</remarks> + private const uint GzipLeadBytes = 0x8b1f; + + + /********* + ** Public methods + *********/ + /// <summary>Compress a string.</summary> + /// <param name="text">The text to compress.</param> + /// <remarks>Derived from <a href="https://stackoverflow.com/a/17993002/262123"/>.</remarks> + public string CompressString(string text) + { + // get raw bytes + byte[] buffer = Encoding.UTF8.GetBytes(text); + + // compressed + byte[] compressedData; + using (MemoryStream stream = new MemoryStream()) + { + using (GZipStream zipStream = new GZipStream(stream, CompressionLevel.Optimal, leaveOpen: true)) + zipStream.Write(buffer, 0, buffer.Length); + + stream.Position = 0; + compressedData = new byte[stream.Length]; + stream.Read(compressedData, 0, compressedData.Length); + } + + // prefix length + byte[] zipBuffer = new byte[compressedData.Length + 4]; + Buffer.BlockCopy(compressedData, 0, zipBuffer, 4, compressedData.Length); + Buffer.BlockCopy(BitConverter.GetBytes(buffer.Length), 0, zipBuffer, 0, 4); + + // return string representation + return Convert.ToBase64String(zipBuffer); + } + + /// <summary>Decompress a string.</summary> + /// <param name="rawText">The compressed text.</param> + /// <remarks>Derived from <a href="https://stackoverflow.com/a/17993002/262123"/>.</remarks> + public string DecompressString(string rawText) + { + // get raw bytes + byte[] zipBuffer; + try + { + zipBuffer = Convert.FromBase64String(rawText); + } + catch + { + return rawText; // not valid base64, wasn't compressed by the log parser + } + + // skip if not gzip + if (BitConverter.ToUInt16(zipBuffer, 4) != GzipHelper.GzipLeadBytes) + return rawText; + + // decompress + using (MemoryStream memoryStream = new MemoryStream()) + { + // read length prefix + int dataLength = BitConverter.ToInt32(zipBuffer, 0); + memoryStream.Write(zipBuffer, 4, zipBuffer.Length - 4); + + // read data + byte[] buffer = new byte[dataLength]; + memoryStream.Position = 0; + using (GZipStream gZipStream = new GZipStream(memoryStream, CompressionMode.Decompress)) + gZipStream.Read(buffer, 0, buffer.Length); + + // return original string + return Encoding.UTF8.GetString(buffer); + } + } + } +} diff --git a/src/SMAPI.Web/Framework/Compression/IGzipHelper.cs b/src/SMAPI.Web/Framework/Compression/IGzipHelper.cs new file mode 100644 index 00000000..a000865e --- /dev/null +++ b/src/SMAPI.Web/Framework/Compression/IGzipHelper.cs @@ -0,0 +1,17 @@ +namespace StardewModdingAPI.Web.Framework.Compression +{ + /// <summary>Handles GZip compression logic.</summary> + internal interface IGzipHelper + { + /********* + ** Methods + *********/ + /// <summary>Compress a string.</summary> + /// <param name="text">The text to compress.</param> + string CompressString(string text); + + /// <summary>Decompress a string.</summary> + /// <param name="rawText">The compressed text.</param> + string DecompressString(string rawText); + } +} diff --git a/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs index c27cadab..121690c5 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs @@ -24,17 +24,18 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels /**** + ** CurseForge + ****/ + /// <summary>The base URL for the CurseForge API.</summary> + public string CurseForgeBaseUrl { get; set; } + + + /**** ** GitHub ****/ /// <summary>The base URL for the GitHub API.</summary> public string GitHubBaseUrl { get; set; } - /// <summary>The URL for a GitHub API query for the latest stable release, excluding the <see cref="GitHubBaseUrl"/>, where {0} is the organisation and project name.</summary> - public string GitHubStableReleaseUrlFormat { get; set; } - - /// <summary>The URL for a GitHub API query for the latest release (including prerelease), excluding the <see cref="GitHubBaseUrl"/>, where {0} is the organisation and project name.</summary> - public string GitHubAnyReleaseUrlFormat { get; set; } - /// <summary>The Accept header value expected by the GitHub API.</summary> public string GitHubAcceptHeader { get; set; } @@ -65,6 +66,9 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels /// <summary>The URL for a Nexus mod page to scrape for versions, excluding the <see cref="NexusBaseUrl"/>, where {0} is the mod ID.</summary> public string NexusModScrapeUrlFormat { get; set; } + /// <summary>The Nexus API authentication key.</summary> + public string NexusApiKey { get; set; } + /**** ** Pastebin ****/ diff --git a/src/SMAPI.Web/Framework/ConfigModels/BackgroundServicesConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/BackgroundServicesConfig.cs new file mode 100644 index 00000000..de871c9a --- /dev/null +++ b/src/SMAPI.Web/Framework/ConfigModels/BackgroundServicesConfig.cs @@ -0,0 +1,12 @@ +namespace StardewModdingAPI.Web.Framework.ConfigModels +{ + /// <summary>The config settings for background services.</summary> + internal class BackgroundServicesConfig + { + /********* + ** Accessors + *********/ + /// <summary>Whether to enable background update services.</summary> + public bool Enabled { get; set; } + } +} diff --git a/src/SMAPI.Web/Framework/ConfigModels/ModCompatibilityListConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ModCompatibilityListConfig.cs index d9ac9f02..24b540cd 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/ModCompatibilityListConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/ModCompatibilityListConfig.cs @@ -1,12 +1,12 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels { - /// <summary>The config settings for mod compatibility list.</summary> + /// <summary>The config settings for the mod compatibility list.</summary> internal class ModCompatibilityListConfig { /********* ** Accessors *********/ - /// <summary>The number of minutes data from the wiki should be cached before refetching it.</summary> - public int CacheMinutes { get; set; } + /// <summary>The number of minutes before which wiki data should be considered old.</summary> + public int StaleMinutes { get; set; } } } diff --git a/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs index bde566c0..ab935bb3 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs @@ -12,10 +12,6 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels /// <summary>The number of minutes failed update checks should be cached before refetching them.</summary> public int ErrorCacheMinutes { get; set; } - /// <summary>A regex which matches SMAPI-style semantic version.</summary> - /// <remarks>Derived from SMAPI's SemanticVersion implementation.</remarks> - public string SemanticVersionRegex { get; set; } - /// <summary>The web URL for the wiki compatibility list.</summary> public string CompatibilityPageUrl { get; set; } } diff --git a/src/SMAPI.Web/Framework/ConfigModels/MongoDbConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/MongoDbConfig.cs new file mode 100644 index 00000000..3c508300 --- /dev/null +++ b/src/SMAPI.Web/Framework/ConfigModels/MongoDbConfig.cs @@ -0,0 +1,38 @@ +using System; + +namespace StardewModdingAPI.Web.Framework.ConfigModels +{ + /// <summary>The config settings for mod compatibility list.</summary> + internal class MongoDbConfig + { + /********* + ** Accessors + *********/ + /// <summary>The MongoDB hostname.</summary> + public string Host { get; set; } + + /// <summary>The MongoDB username (if any).</summary> + public string Username { get; set; } + + /// <summary>The MongoDB password (if any).</summary> + public string Password { get; set; } + + /// <summary>The database name.</summary> + public string Database { get; set; } + + + /********* + ** Public method + *********/ + /// <summary>Get the MongoDB connection string.</summary> + public string GetConnectionString() + { + bool isLocal = this.Host == "localhost"; + bool hasLogin = !string.IsNullOrWhiteSpace(this.Username) && !string.IsNullOrWhiteSpace(this.Password); + + return $"mongodb{(isLocal ? "" : "+srv")}://" + + (hasLogin ? $"{Uri.EscapeDataString(this.Username)}:{Uri.EscapeDataString(this.Password)}@" : "") + + $"{this.Host}/{this.Database}?retryWrites=true&w=majority"; + } + } +} diff --git a/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs index d89a4260..bc6e868a 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs @@ -12,6 +12,9 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels /// <summary>The root URL for the log parser.</summary> public string LogParserUrl { get; set; } + /// <summary>The root URL for the JSON validator.</summary> + public string JsonValidatorUrl { get; set; } + /// <summary>The root URL for the mod list.</summary> public string ModListUrl { get; set; } diff --git a/src/SMAPI.Web/Framework/JobDashboardAuthorizationFilter.cs b/src/SMAPI.Web/Framework/JobDashboardAuthorizationFilter.cs new file mode 100644 index 00000000..385c0c91 --- /dev/null +++ b/src/SMAPI.Web/Framework/JobDashboardAuthorizationFilter.cs @@ -0,0 +1,34 @@ +using Hangfire.Dashboard; + +namespace StardewModdingAPI.Web.Framework +{ + /// <summary>Authorizes requests to access the Hangfire job dashboard.</summary> + internal class JobDashboardAuthorizationFilter : IDashboardAuthorizationFilter + { + /********* + ** Fields + *********/ + /// <summary>An authorization filter that allows local requests.</summary> + private static readonly LocalRequestsOnlyAuthorizationFilter LocalRequestsOnlyFilter = new LocalRequestsOnlyAuthorizationFilter(); + + + /********* + ** Public methods + *********/ + /// <summary>Authorize a request.</summary> + /// <param name="context">The dashboard context.</param> + public bool Authorize(DashboardContext context) + { + return + context.IsReadOnly // always allow readonly access + || JobDashboardAuthorizationFilter.IsLocalRequest(context); // else allow access from localhost + } + + /// <summary>Get whether a request originated from a user on the server machine.</summary> + /// <param name="context">The dashboard context.</param> + public static bool IsLocalRequest(DashboardContext context) + { + return JobDashboardAuthorizationFilter.LocalRequestsOnlyFilter.Authorize(context); + } + } +} diff --git a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs index 595e6b49..66a3687f 100644 --- a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs +++ b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs @@ -221,7 +221,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing } } - // finalise log + // finalize log gameMod.Version = log.GameVersion; log.Mods = new[] { gameMod, smapiMod }.Concat(mods.Values.OrderBy(p => p.Name)).ToArray(); return log; diff --git a/src/SMAPI.Web/Framework/ModRepositories/BaseRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/BaseRepository.cs index 94256005..f9f9f47d 100644 --- a/src/SMAPI.Web/Framework/ModRepositories/BaseRepository.cs +++ b/src/SMAPI.Web/Framework/ModRepositories/BaseRepository.cs @@ -34,9 +34,9 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories this.VendorKey = vendorKey; } - /// <summary>Normalise a version string.</summary> - /// <param name="version">The version to normalise.</param> - protected string NormaliseVersion(string version) + /// <summary>Normalize a version string.</summary> + /// <param name="version">The version to normalize.</param> + protected string NormalizeVersion(string version) { if (string.IsNullOrWhiteSpace(version)) return null; diff --git a/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs index 87e29a2f..0945735a 100644 --- a/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs +++ b/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs @@ -32,21 +32,19 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories { // validate ID format if (!uint.TryParse(id, out uint realID)) - return new ModInfoModel($"The value '{id}' isn't a valid Chucklefish mod ID, must be an integer ID."); + return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid Chucklefish mod ID, must be an integer ID."); // fetch info try { var mod = await this.Client.GetModAsync(realID); - if (mod == null) - return new ModInfoModel("Found no mod with this ID."); - - // create model - return new ModInfoModel(name: mod.Name, version: this.NormaliseVersion(mod.Version), url: mod.Url); + return mod != null + ? new ModInfoModel(name: mod.Name, version: this.NormalizeVersion(mod.Version), url: mod.Url) + : new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, "Found no Chucklefish mod with this ID."); } catch (Exception ex) { - return new ModInfoModel(ex.ToString()); + return new ModInfoModel().SetError(RemoteModStatus.TemporaryError, ex.ToString()); } } diff --git a/src/SMAPI.Web/Framework/ModRepositories/CurseForgeRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/CurseForgeRepository.cs new file mode 100644 index 00000000..93ddc1eb --- /dev/null +++ b/src/SMAPI.Web/Framework/ModRepositories/CurseForgeRepository.cs @@ -0,0 +1,63 @@ +using System; +using System.Threading.Tasks; +using StardewModdingAPI.Toolkit.Framework.UpdateData; +using StardewModdingAPI.Web.Framework.Clients.CurseForge; + +namespace StardewModdingAPI.Web.Framework.ModRepositories +{ + /// <summary>An HTTP client for fetching mod metadata from CurseForge.</summary> + internal class CurseForgeRepository : RepositoryBase + { + /********* + ** Fields + *********/ + /// <summary>The underlying CurseForge API client.</summary> + private readonly ICurseForgeClient Client; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="client">The underlying CurseForge API client.</param> + public CurseForgeRepository(ICurseForgeClient client) + : base(ModRepositoryKey.CurseForge) + { + this.Client = client; + } + + /// <summary>Get metadata about a mod in the repository.</summary> + /// <param name="id">The mod ID in this repository.</param> + public override async Task<ModInfoModel> GetModInfoAsync(string id) + { + // validate ID format + if (!uint.TryParse(id, out uint curseID)) + return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid CurseForge mod ID, must be an integer ID."); + + // fetch info + try + { + CurseForgeMod mod = await this.Client.GetModAsync(curseID); + if (mod == null) + return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, "Found no CurseForge mod with this ID."); + if (mod.Error != null) + { + RemoteModStatus remoteStatus = RemoteModStatus.InvalidData; + return new ModInfoModel().SetError(remoteStatus, mod.Error); + } + + return new ModInfoModel(name: mod.Name, version: this.NormalizeVersion(mod.LatestVersion), url: mod.Url); + } + catch (Exception ex) + { + return new ModInfoModel().SetError(RemoteModStatus.TemporaryError, ex.ToString()); + } + } + + /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> + public override void Dispose() + { + this.Client.Dispose(); + } + } +} diff --git a/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs index 14f44dc0..c62cb73f 100644 --- a/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs +++ b/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs @@ -30,36 +30,46 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories /// <param name="id">The mod ID in this repository.</param> public override async Task<ModInfoModel> GetModInfoAsync(string id) { + ModInfoModel result = new ModInfoModel().SetBasicInfo(id, $"https://github.com/{id}/releases"); + // validate ID format if (!id.Contains("/") || id.IndexOf("/", StringComparison.InvariantCultureIgnoreCase) != id.LastIndexOf("/", StringComparison.InvariantCultureIgnoreCase)) - return new ModInfoModel($"The value '{id}' isn't a valid GitHub mod ID, must be a username and project name like 'Pathoschild/LookupAnything'."); + return result.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid GitHub mod ID, must be a username and project name like 'Pathoschild/LookupAnything'."); // fetch info try { + // fetch repo info + GitRepo repository = await this.Client.GetRepositoryAsync(id); + if (repository == null) + return result.SetError(RemoteModStatus.DoesNotExist, "Found no GitHub repository for this ID."); + result + .SetBasicInfo(repository.FullName, $"{repository.WebUrl}/releases") + .SetLicense(url: repository.License?.Url, name: repository.License?.SpdxId ?? repository.License?.Name); + // get latest release (whether preview or stable) GitRelease latest = await this.Client.GetLatestReleaseAsync(id, includePrerelease: true); if (latest == null) - return new ModInfoModel("Found no mod with this ID."); + return result.SetError(RemoteModStatus.DoesNotExist, "Found no GitHub release for this ID."); // split stable/prerelease if applicable GitRelease preview = null; if (latest.IsPrerelease) { - GitRelease result = await this.Client.GetLatestReleaseAsync(id, includePrerelease: false); - if (result != null) + GitRelease release = await this.Client.GetLatestReleaseAsync(id, includePrerelease: false); + if (release != null) { preview = latest; - latest = result; + latest = release; } } // return data - return new ModInfoModel(name: id, version: this.NormaliseVersion(latest.Tag), previewVersion: this.NormaliseVersion(preview?.Tag), url: $"https://github.com/{id}/releases"); + return result.SetVersions(version: this.NormalizeVersion(latest.Tag), previewVersion: this.NormalizeVersion(preview?.Tag)); } catch (Exception ex) { - return new ModInfoModel(ex.ToString()); + return result.SetError(RemoteModStatus.TemporaryError, ex.ToString()); } } diff --git a/src/SMAPI.Web/Framework/ModRepositories/ModDropRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/ModDropRepository.cs index 1994f515..62142668 100644 --- a/src/SMAPI.Web/Framework/ModRepositories/ModDropRepository.cs +++ b/src/SMAPI.Web/Framework/ModRepositories/ModDropRepository.cs @@ -32,21 +32,19 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories { // validate ID format if (!long.TryParse(id, out long modDropID)) - return new ModInfoModel($"The value '{id}' isn't a valid ModDrop mod ID, must be an integer ID."); + return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid ModDrop mod ID, must be an integer ID."); // fetch info try { ModDropMod mod = await this.Client.GetModAsync(modDropID); - if (mod == null) - return new ModInfoModel("Found no mod with this ID."); - if (mod.Error != null) - return new ModInfoModel(mod.Error); - return new ModInfoModel(name: mod.Name, version: mod.LatestDefaultVersion?.ToString(), previewVersion: mod.LatestOptionalVersion?.ToString(), url: mod.Url); + return mod != null + ? new ModInfoModel(name: mod.Name, version: mod.LatestDefaultVersion?.ToString(), previewVersion: mod.LatestOptionalVersion?.ToString(), url: mod.Url) + : new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, "Found no ModDrop mod with this ID."); } catch (Exception ex) { - return new ModInfoModel(ex.ToString()); + return new ModInfoModel().SetError(RemoteModStatus.TemporaryError, ex.ToString()); } } diff --git a/src/SMAPI.Web/Framework/ModRepositories/ModInfoModel.cs b/src/SMAPI.Web/Framework/ModRepositories/ModInfoModel.cs index 18252298..46b98860 100644 --- a/src/SMAPI.Web/Framework/ModRepositories/ModInfoModel.cs +++ b/src/SMAPI.Web/Framework/ModRepositories/ModInfoModel.cs @@ -9,15 +9,24 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories /// <summary>The mod name.</summary> public string Name { get; set; } - /// <summary>The mod's latest release number.</summary> + /// <summary>The mod's latest version.</summary> public string Version { get; set; } - /// <summary>The mod's latest optional release, if newer than <see cref="Version"/>.</summary> + /// <summary>The mod's latest optional or prerelease version, if newer than <see cref="Version"/>.</summary> public string PreviewVersion { get; set; } /// <summary>The mod's web URL.</summary> public string Url { get; set; } + /// <summary>The license URL, if available.</summary> + public string LicenseUrl { get; set; } + + /// <summary>The license name, if available.</summary> + public string LicenseName { get; set; } + + /// <summary>The mod availability status on the remote site.</summary> + public RemoteModStatus Status { get; set; } = RemoteModStatus.Ok; + /// <summary>The error message indicating why the mod is invalid (if applicable).</summary> public string Error { get; set; } @@ -26,31 +35,62 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories ** Public methods *********/ /// <summary>Construct an empty instance.</summary> - public ModInfoModel() - { - // needed for JSON deserialising - } + public ModInfoModel() { } /// <summary>Construct an instance.</summary> /// <param name="name">The mod name.</param> /// <param name="version">The semantic version for the mod's latest release.</param> /// <param name="previewVersion">The semantic version for the mod's latest preview release, if available and different from <see cref="Version"/>.</param> /// <param name="url">The mod's web URL.</param> - /// <param name="error">The error message indicating why the mod is invalid (if applicable).</param> - public ModInfoModel(string name, string version, string url, string previewVersion = null, string error = null) + public ModInfoModel(string name, string version, string url, string previewVersion = null) + { + this + .SetBasicInfo(name, url) + .SetVersions(version, previewVersion); + } + + /// <summary>Set the basic mod info.</summary> + /// <param name="name">The mod name.</param> + /// <param name="url">The mod's web URL.</param> + public ModInfoModel SetBasicInfo(string name, string url) { this.Name = name; + this.Url = url; + + return this; + } + + /// <summary>Set the mod version info.</summary> + /// <param name="version">The semantic version for the mod's latest release.</param> + /// <param name="previewVersion">The semantic version for the mod's latest preview release, if available and different from <see cref="Version"/>.</param> + public ModInfoModel SetVersions(string version, string previewVersion = null) + { this.Version = version; this.PreviewVersion = previewVersion; - this.Url = url; - this.Error = error; + + return this; } - /// <summary>Construct an instance.</summary> - /// <param name="error">The error message indicating why the mod is invalid.</param> - public ModInfoModel(string error) + /// <summary>Set the license info, if available.</summary> + /// <param name="url">The license URL.</param> + /// <param name="name">The license name.</param> + public ModInfoModel SetLicense(string url, string name) { + this.LicenseUrl = url; + this.LicenseName = name; + + return this; + } + + /// <summary>Set a mod error.</summary> + /// <param name="status">The mod availability status on the remote site.</param> + /// <param name="error">The error message indicating why the mod is invalid (if applicable).</param> + public ModInfoModel SetError(RemoteModStatus status, string error) + { + this.Status = status; this.Error = error; + + return this; } } } diff --git a/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs index 4c5fe9bf..9551258c 100644 --- a/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs +++ b/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs @@ -32,21 +32,27 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories { // validate ID format if (!uint.TryParse(id, out uint nexusID)) - return new ModInfoModel($"The value '{id}' isn't a valid Nexus mod ID, must be an integer ID."); + return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid Nexus mod ID, must be an integer ID."); // fetch info try { NexusMod mod = await this.Client.GetModAsync(nexusID); if (mod == null) - return new ModInfoModel("Found no mod with this ID."); + return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, "Found no Nexus mod with this ID."); if (mod.Error != null) - return new ModInfoModel(mod.Error); - return new ModInfoModel(name: mod.Name, version: this.NormaliseVersion(mod.Version), previewVersion: mod.LatestFileVersion?.ToString(), url: mod.Url); + { + RemoteModStatus remoteStatus = mod.Status == NexusModStatus.Hidden || mod.Status == NexusModStatus.NotPublished + ? RemoteModStatus.DoesNotExist + : RemoteModStatus.TemporaryError; + return new ModInfoModel().SetError(remoteStatus, mod.Error); + } + + return new ModInfoModel(name: mod.Name, version: this.NormalizeVersion(mod.Version), previewVersion: mod.LatestFileVersion?.ToString(), url: mod.Url); } catch (Exception ex) { - return new ModInfoModel(ex.ToString()); + return new ModInfoModel().SetError(RemoteModStatus.TemporaryError, ex.ToString()); } } diff --git a/src/SMAPI.Web/Framework/ModRepositories/RemoteModStatus.cs b/src/SMAPI.Web/Framework/ModRepositories/RemoteModStatus.cs new file mode 100644 index 00000000..02876556 --- /dev/null +++ b/src/SMAPI.Web/Framework/ModRepositories/RemoteModStatus.cs @@ -0,0 +1,18 @@ +namespace StardewModdingAPI.Web.Framework.ModRepositories +{ + /// <summary>The mod availability status on a remote site.</summary> + internal enum RemoteModStatus + { + /// <summary>The mod is valid.</summary> + Ok, + + /// <summary>The mod data was fetched, but the data is not valid (e.g. version isn't semantic).</summary> + InvalidData, + + /// <summary>The mod does not exist.</summary> + DoesNotExist, + + /// <summary>The mod was temporarily unavailable (e.g. the site could not be reached or an unknown error occurred).</summary> + TemporaryError + } +} diff --git a/src/SMAPI.Web/Properties/AssemblyInfo.cs b/src/SMAPI.Web/Properties/AssemblyInfo.cs deleted file mode 100644 index 31e6fc30..00000000 --- a/src/SMAPI.Web/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,4 +0,0 @@ -using System.Reflection; - -[assembly: AssemblyTitle("SMAPI.Web")] -[assembly: AssemblyDescription("")] diff --git a/src/SMAPI.Web/StardewModdingAPI.Web.csproj b/src/SMAPI.Web/SMAPI.Web.csproj index d47361bd..8a7ca741 100644 --- a/src/SMAPI.Web/StardewModdingAPI.Web.csproj +++ b/src/SMAPI.Web/SMAPI.Web.csproj @@ -1,8 +1,9 @@ <Project Sdk="Microsoft.NET.Sdk.Web"> <PropertyGroup> + <AssemblyName>SMAPI.Web</AssemblyName> + <RootNamespace>StardewModdingAPI.Web</RootNamespace> <TargetFramework>netcoreapp2.0</TargetFramework> - <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <LangVersion>latest</LangVersion> </PropertyGroup> @@ -11,25 +12,30 @@ </ItemGroup> <ItemGroup> - <Compile Include="..\..\build\GlobalAssemblyInfo.cs" Link="Properties\GlobalAssemblyInfo.cs" /> - </ItemGroup> - - <ItemGroup> - <PackageReference Include="HtmlAgilityPack" Version="1.8.9" /> - <PackageReference Include="Markdig" Version="0.15.4" /> - <PackageReference Include="Microsoft.AspNetCore" Version="2.1.4" /> - <PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.1.3" /> - <PackageReference Include="Microsoft.AspNetCore.Rewrite" Version="2.1.1" /> - <PackageReference Include="Microsoft.AspNetCore.StaticFiles" Version="2.1.1" /> - <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="2.1.1" /> - <PackageReference Include="Pathoschild.Http.FluentClient" Version="3.2.0" /> + <PackageReference Include="Hangfire.AspNetCore" Version="1.7.7" /> + <PackageReference Include="Hangfire.Mongo" Version="0.6.5" /> + <PackageReference Include="HtmlAgilityPack" Version="1.11.16" /> + <PackageReference Include="Humanizer.Core" Version="2.7.9" /> + <PackageReference Include="Markdig" Version="0.18.0" /> + <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="Microsoft.Extensions.Logging.Debug" Version="3.0.1" /> + <PackageReference Include="MongoDB.Driver" Version="2.9.3" /> + <PackageReference Include="Newtonsoft.Json.Schema" Version="3.0.11" /> + <PackageReference Include="Pathoschild.FluentNexus" Version="0.8.0" /> + <PackageReference Include="Pathoschild.Http.FluentClient" Version="3.3.1" /> </ItemGroup> <ItemGroup> <DotNetCliToolReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Tools" Version="1.0.1" /> </ItemGroup> <Import Project="..\SMAPI.Internal\SMAPI.Internal.projitems" Label="Shared" /> <ItemGroup> - <ProjectReference Include="..\SMAPI.Toolkit\StardewModdingAPI.Toolkit.csproj" /> + <None Include="..\..\docs\technical\web.md" Link="web.md" /> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\SMAPI.Toolkit\SMAPI.Toolkit.csproj" /> </ItemGroup> <ItemGroup> <Content Update="Views\Index\Privacy.cshtml"> @@ -38,9 +44,11 @@ <Content Update="Views\Mods\Index.cshtml"> <Pack>$(IncludeRazorContentInPack)</Pack> </Content> - <Content Update="wwwroot\StardewModdingAPI.metadata.json"> + <Content Update="wwwroot\SMAPI.metadata.json"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </Content> </ItemGroup> + <Import Project="..\..\build\common.targets" /> + </Project> diff --git a/src/SMAPI.Web/Startup.cs b/src/SMAPI.Web/Startup.cs index a2e47482..8110b696 100644 --- a/src/SMAPI.Web/Startup.cs +++ b/src/SMAPI.Web/Startup.cs @@ -1,19 +1,27 @@ using System.Collections.Generic; +using Hangfire; +using Hangfire.Mongo; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Rewrite; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; +using MongoDB.Bson.Serialization; +using MongoDB.Driver; using Newtonsoft.Json; -using StardewModdingAPI.Toolkit.Serialisation; +using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Web.Framework; +using StardewModdingAPI.Web.Framework.Caching; +using StardewModdingAPI.Web.Framework.Caching.Mods; +using StardewModdingAPI.Web.Framework.Caching.Wiki; using StardewModdingAPI.Web.Framework.Clients.Chucklefish; +using StardewModdingAPI.Web.Framework.Clients.CurseForge; using StardewModdingAPI.Web.Framework.Clients.GitHub; using StardewModdingAPI.Web.Framework.Clients.ModDrop; 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; @@ -48,12 +56,15 @@ namespace StardewModdingAPI.Web /// <param name="services">The service injection container.</param> public void ConfigureServices(IServiceCollection services) { - // init configuration + // init basic services services + .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<SiteConfig>(this.Configuration.GetSection("Site")) .Configure<RouteOptions>(options => options.ConstraintMap.Add("semanticVersion", typeof(VersionConstraint))) + .AddLogging() .AddMemoryCache() .AddMvc() .ConfigureApplicationPartManager(manager => manager.FeatureProviders.Add(new InternalControllerFeatureProvider())) @@ -65,6 +76,38 @@ namespace StardewModdingAPI.Web options.SerializerSettings.Formatting = Formatting.Indented; options.SerializerSettings.NullValueHandling = NullValueHandling.Ignore; }); + MongoDbConfig mongoConfig = this.Configuration.GetSection("MongoDB").Get<MongoDbConfig>(); + + // init background service + { + BackgroundServicesConfig config = this.Configuration.GetSection("BackgroundServices").Get<BackgroundServicesConfig>(); + if (config.Enabled) + services.AddHostedService<BackgroundService>(); + } + + // init MongoDB + services.AddSingleton<IMongoDatabase>(serv => + { + BsonSerializer.RegisterSerializer(new UtcDateTimeOffsetSerializer()); + return new MongoClient(mongoConfig.GetConnectionString()).GetDatabase(mongoConfig.Database); + }); + services.AddSingleton<IModCacheRepository>(serv => new ModCacheRepository(serv.GetRequiredService<IMongoDatabase>())); + services.AddSingleton<IWikiCacheRepository>(serv => new WikiCacheRepository(serv.GetRequiredService<IMongoDatabase>())); + + // init Hangfire + services + .AddHangfire(config => + { + config + .SetDataCompatibilityLevel(CompatibilityLevel.Version_170) + .UseSimpleAssemblyNameTypeSerializer() + .UseRecommendedSerializerSettings() + .UseMongoStorage(mongoConfig.GetConnectionString(), $"{mongoConfig.Database}-hangfire", new MongoStorageOptions + { + MigrationOptions = new MongoMigrationOptions(MongoMigrationStrategy.Drop), + CheckConnection = false // error on startup takes down entire process + }); + }); // init API clients { @@ -77,11 +120,13 @@ namespace StardewModdingAPI.Web baseUrl: api.ChucklefishBaseUrl, modPageUrlFormat: api.ChucklefishModPageUrlFormat )); + services.AddSingleton<ICurseForgeClient>(new CurseForgeClient( + userAgent: userAgent, + apiUrl: api.CurseForgeBaseUrl + )); services.AddSingleton<IGitHubClient>(new GitHubClient( baseUrl: api.GitHubBaseUrl, - stableReleaseUrlFormat: api.GitHubStableReleaseUrlFormat, - anyReleaseUrlFormat: api.GitHubAnyReleaseUrlFormat, userAgent: userAgent, acceptHeader: api.GitHubAcceptHeader, username: api.GitHubUsername, @@ -94,11 +139,13 @@ namespace StardewModdingAPI.Web modUrlFormat: api.ModDropModPageUrl )); - services.AddSingleton<INexusClient>(new NexusWebScrapeClient( - userAgent: userAgent, - baseUrl: api.NexusBaseUrl, - modUrlFormat: api.NexusModUrlFormat, - modScrapeUrlFormat: api.NexusModScrapeUrlFormat + services.AddSingleton<INexusClient>(new NexusClient( + webUserAgent: userAgent, + webBaseUrl: api.NexusBaseUrl, + webModUrlFormat: api.NexusModUrlFormat, + webModScrapeUrlFormat: api.NexusModScrapeUrlFormat, + apiAppVersion: version, + apiKey: api.NexusApiKey )); services.AddSingleton<IPastebinClient>(new PastebinClient( @@ -108,20 +155,19 @@ namespace StardewModdingAPI.Web devKey: api.PastebinDevKey )); } + + // init helpers + services.AddSingleton<IGzipHelper>(new GzipHelper()); } /// <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> - /// <param name="loggerFactory">The logger factory.</param> - public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory) + public void Configure(IApplicationBuilder app, IHostingEnvironment env) { - loggerFactory.AddConsole(this.Configuration.GetSection("Logging")); - loggerFactory.AddDebug(); - + // basic config if (env.IsDevelopment()) app.UseDeveloperExceptionPage(); - app .UseCors(policy => policy .AllowAnyHeader() @@ -132,6 +178,13 @@ namespace StardewModdingAPI.Web .UseRewriter(this.GetRedirectRules()) .UseStaticFiles() // wwwroot folder .UseMvc(); + + // enable Hangfire dashboard + app.UseHangfireDashboard("/tasks", new DashboardOptions + { + IsReadOnlyFunc = context => !JobDashboardAuthorizationFilter.IsLocalRequest(context), + Authorization = new[] { new JobDashboardAuthorizationFilter() } + }); } @@ -155,14 +208,15 @@ namespace StardewModdingAPI.Web redirects.Add(new ConditionalRewriteSubdomainRule( shouldRewrite: req => req.Host.Host != "localhost" - && (req.Host.Host.StartsWith("api.") || req.Host.Host.StartsWith("log.") || req.Host.Host.StartsWith("mods.")) + && (req.Host.Host.StartsWith("api.") || req.Host.Host.StartsWith("json.") || req.Host.Host.StartsWith("log.") || req.Host.Host.StartsWith("mods.")) && !req.Path.StartsWithSegments("/content") && !req.Path.StartsWithSegments("/favicon.ico") )); // shortcut redirects redirects.Add(new RedirectToUrlRule(@"^/3\.0\.?$", "https://stardewvalleywiki.com/Modding:Migrate_to_SMAPI_3.0")); - redirects.Add(new RedirectToUrlRule(@"^/buildmsg(?:/?(.*))$", "https://github.com/Pathoschild/SMAPI/blob/develop/docs/mod-build-config.md#$1")); + 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://mods.smapi.io")); 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")); diff --git a/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorErrorModel.cs b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorErrorModel.cs new file mode 100644 index 00000000..62b95501 --- /dev/null +++ b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorErrorModel.cs @@ -0,0 +1,43 @@ +using Newtonsoft.Json.Schema; + +namespace StardewModdingAPI.Web.ViewModels.JsonValidator +{ + /// <summary>The view model for a JSON validator error.</summary> + public class JsonValidatorErrorModel + { + /********* + ** Accessors + *********/ + /// <summary>The line number on which the error occurred.</summary> + public int Line { get; set; } + + /// <summary>The field path in the JSON file where the error occurred.</summary> + public string Path { get; set; } + + /// <summary>A human-readable description of the error.</summary> + public string Message { get; set; } + + /// <summary>The schema error type.</summary> + public ErrorType SchemaErrorType { get; set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public JsonValidatorErrorModel() { } + + /// <summary>Construct an instance.</summary> + /// <param name="line">The line number on which the error occurred.</param> + /// <param name="path">The field path in the JSON file where the error occurred.</param> + /// <param name="message">A human-readable description of the error.</param> + /// <param name="schemaErrorType">The schema error type.</param> + public JsonValidatorErrorModel(int line, string path, string message, ErrorType schemaErrorType) + { + this.Line = line; + this.Path = path; + this.Message = message; + this.SchemaErrorType = schemaErrorType; + } + } +} diff --git a/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs new file mode 100644 index 00000000..2d13bf23 --- /dev/null +++ b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs @@ -0,0 +1,95 @@ +using System.Collections.Generic; +using System.Linq; + +namespace StardewModdingAPI.Web.ViewModels.JsonValidator +{ + /// <summary>The view model for the JSON validator page.</summary> + public class JsonValidatorModel + { + /********* + ** Accessors + *********/ + /// <summary>The root URL for the log parser controller.</summary> + public string SectionUrl { get; set; } + + /// <summary>The paste ID.</summary> + public string PasteID { get; set; } + + /// <summary>The schema name with which the JSON was validated.</summary> + public string SchemaName { get; set; } + + /// <summary>The supported JSON schemas (names indexed by ID).</summary> + public readonly IDictionary<string, string> SchemaFormats; + + /// <summary>The validated content.</summary> + public string Content { get; set; } + + /// <summary>The schema validation errors, if any.</summary> + public JsonValidatorErrorModel[] Errors { get; set; } = new JsonValidatorErrorModel[0]; + + /// <summary>An error which occurred while uploading the JSON to Pastebin.</summary> + public string UploadError { get; set; } + + /// <summary>An error which occurred while parsing the JSON.</summary> + public string ParseError { get; set; } + + /// <summary>A web URL to the user-facing format documentation.</summary> + public string FormatUrl { get; set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public JsonValidatorModel() { } + + /// <summary>Construct an instance.</summary> + /// <param name="sectionUrl">The root URL for the log parser controller.</param> + /// <param name="pasteID">The paste ID.</param> + /// <param name="schemaName">The schema name with which the JSON was validated.</param> + /// <param name="schemaFormats">The supported JSON schemas (names indexed by ID).</param> + public JsonValidatorModel(string sectionUrl, string pasteID, string schemaName, IDictionary<string, string> schemaFormats) + { + this.SectionUrl = sectionUrl; + this.PasteID = pasteID; + this.SchemaName = schemaName; + this.SchemaFormats = schemaFormats; + } + + /// <summary>Set the validated content.</summary> + /// <param name="content">The validated content.</param> + public JsonValidatorModel SetContent(string content) + { + this.Content = content; + + return this; + } + + /// <summary>Set the error which occurred while uploading the log to Pastebin.</summary> + /// <param name="error">The error message.</param> + public JsonValidatorModel SetUploadError(string error) + { + this.UploadError = error; + + return this; + } + + /// <summary>Set the error which occurred while parsing the JSON.</summary> + /// <param name="error">The error message.</param> + public JsonValidatorModel SetParseError(string error) + { + this.ParseError = error; + + return this; + } + + /// <summary>Add validation errors to the response.</summary> + /// <param name="errors">The schema validation errors.</param> + public JsonValidatorModel AddErrors(params JsonValidatorErrorModel[] errors) + { + this.Errors = this.Errors.Concat(errors).ToArray(); + + return this; + } + } +} diff --git a/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorRequestModel.cs b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorRequestModel.cs new file mode 100644 index 00000000..c8e851bf --- /dev/null +++ b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorRequestModel.cs @@ -0,0 +1,15 @@ +namespace StardewModdingAPI.Web.ViewModels.JsonValidator +{ + /// <summary>The view model for a JSON validation request.</summary> + public class JsonValidatorRequestModel + { + /********* + ** Accessors + *********/ + /// <summary>The schema name with which to validate the JSON.</summary> + public string SchemaName { get; set; } + + /// <summary>The raw content to validate.</summary> + public string Content { get; set; } + } +} diff --git a/src/SMAPI.Web/ViewModels/LogParserModel.cs b/src/SMAPI.Web/ViewModels/LogParserModel.cs index 41864c99..f4c5214b 100644 --- a/src/SMAPI.Web/ViewModels/LogParserModel.cs +++ b/src/SMAPI.Web/ViewModels/LogParserModel.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; +using StardewModdingAPI.Toolkit.Utilities; using StardewModdingAPI.Web.Framework.LogParsing.Models; namespace StardewModdingAPI.Web.ViewModels @@ -24,6 +25,9 @@ namespace StardewModdingAPI.Web.ViewModels /// <summary>The paste ID.</summary> public string PasteID { get; set; } + /// <summary>The viewer's detected OS, if known.</summary> + public Platform? DetectedPlatform { get; set; } + /// <summary>The parsed log info.</summary> public ParsedLog ParsedLog { get; set; } @@ -46,24 +50,25 @@ namespace StardewModdingAPI.Web.ViewModels /// <summary>Construct an instance.</summary> /// <param name="sectionUrl">The root URL for the log parser controller.</param> /// <param name="pasteID">The paste ID.</param> - public LogParserModel(string sectionUrl, string pasteID) + /// <param name="platform">The viewer's detected OS, if known.</param> + public LogParserModel(string sectionUrl, string pasteID, Platform? platform) { this.SectionUrl = sectionUrl; this.PasteID = pasteID; + this.DetectedPlatform = platform; this.ParsedLog = null; this.ShowRaw = false; } - /// <summary>Construct an instance.</summary> - /// <param name="sectionUrl">The root URL for the log parser controller.</param> - /// <param name="pasteID">The paste ID.</param> + /// <summary>Set the log parser result.</summary> /// <param name="parsedLog">The parsed log info.</param> /// <param name="showRaw">Whether to show the raw unparsed log.</param> - public LogParserModel(string sectionUrl, string pasteID, ParsedLog parsedLog, bool showRaw) - : this(sectionUrl, pasteID) + public LogParserModel SetResult(ParsedLog parsedLog, bool showRaw) { this.ParsedLog = parsedLog; this.ShowRaw = showRaw; + + return this; } /// <summary>Get all content packs in the log grouped by the mod they're for.</summary> @@ -81,7 +86,7 @@ namespace StardewModdingAPI.Web.ViewModels .ToDictionary(group => group.Key, group => group.ToArray()); } - /// <summary>Get a sanitised mod name that's safe to use in anchors, attributes, and URLs.</summary> + /// <summary>Get a sanitized mod name that's safe to use in anchors, attributes, and URLs.</summary> /// <param name="modName">The mod name.</param> public string GetSlug(string modName) { diff --git a/src/SMAPI.Web/ViewModels/ModListModel.cs b/src/SMAPI.Web/ViewModels/ModListModel.cs index 3b87d393..ff7513bc 100644 --- a/src/SMAPI.Web/ViewModels/ModListModel.cs +++ b/src/SMAPI.Web/ViewModels/ModListModel.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; @@ -18,19 +19,35 @@ namespace StardewModdingAPI.Web.ViewModels /// <summary>The mods to display.</summary> public ModModel[] Mods { get; set; } + /// <summary>When the data was last updated.</summary> + public DateTimeOffset LastUpdated { get; set; } + + /// <summary>Whether the data hasn't been updated in a while.</summary> + public bool IsStale { get; set; } + + /// <summary>Whether the mod metadata is available.</summary> + public bool HasData => this.Mods != null; + /********* ** Public methods *********/ + /// <summary>Construct an empty instance.</summary> + public ModListModel() { } + /// <summary>Construct an instance.</summary> /// <param name="stableVersion">The current stable version of the game.</param> /// <param name="betaVersion">The current beta version of the game (if any).</param> /// <param name="mods">The mods to display.</param> - public ModListModel(string stableVersion, string betaVersion, IEnumerable<ModModel> mods) + /// <param name="lastUpdated">When the data was last updated.</param> + /// <param name="isStale">Whether the data hasn't been updated in a while.</param> + public ModListModel(string stableVersion, string betaVersion, IEnumerable<ModModel> mods, DateTimeOffset lastUpdated, bool isStale) { this.StableVersion = stableVersion; this.BetaVersion = betaVersion; this.Mods = mods.ToArray(); + this.LastUpdated = lastUpdated; + this.IsStale = isStale; } } } diff --git a/src/SMAPI.Web/ViewModels/ModModel.cs b/src/SMAPI.Web/ViewModels/ModModel.cs index 8668f67b..2b478c81 100644 --- a/src/SMAPI.Web/ViewModels/ModModel.cs +++ b/src/SMAPI.Web/ViewModels/ModModel.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; @@ -37,6 +38,12 @@ namespace StardewModdingAPI.Web.ViewModels /// <summary>The human-readable warnings for players about this mod.</summary> public string[] Warnings { get; set; } + /// <summary>Extra metadata links (usually for open pull requests).</summary> + public Tuple<Uri, string>[] MetadataLinks { get; set; } + + /// <summary>Special notes intended for developers who maintain unofficial updates or submit pull requests. </summary> + public string DevNote { get; set; } + /// <summary>A unique identifier for the mod that can be used in an anchor URL.</summary> public string Slug { get; set; } @@ -61,6 +68,8 @@ namespace StardewModdingAPI.Web.ViewModels this.BetaCompatibility = entry.BetaCompatibility != null ? new ModCompatibilityModel(entry.BetaCompatibility) : null; this.ModPages = this.GetModPageUrls(entry).ToArray(); this.Warnings = entry.Warnings; + this.MetadataLinks = entry.MetadataLinks; + this.DevNote = entry.DevNote; this.Slug = entry.Anchor; } @@ -91,15 +100,20 @@ namespace StardewModdingAPI.Web.ViewModels anyFound = true; yield return new ModLinkModel($"https://www.nexusmods.com/stardewvalley/mods/{entry.NexusID}", "Nexus"); } - if (entry.ChucklefishID.HasValue) + if (entry.ModDropID.HasValue) { anyFound = true; - yield return new ModLinkModel($"https://community.playstarbound.com/resources/{entry.ChucklefishID}", "Chucklefish"); + yield return new ModLinkModel($"https://www.moddrop.com/sdv/mod/{entry.ModDropID}", "ModDrop"); } - if (entry.ModDropID.HasValue) + if (!string.IsNullOrWhiteSpace(entry.CurseForgeKey)) { anyFound = true; - yield return new ModLinkModel($"https://www.moddrop.com/sdv/mod/{entry.ModDropID}", "ModDrop"); + yield return new ModLinkModel($"https://www.curseforge.com/stardewvalley/mods/{entry.CurseForgeKey}", "CurseForge"); + } + if (entry.ChucklefishID.HasValue) + { + anyFound = true; + yield return new ModLinkModel($"https://community.playstarbound.com/resources/{entry.ChucklefishID}", "Chucklefish"); } // fallback diff --git a/src/SMAPI.Web/Views/Index/Index.cshtml b/src/SMAPI.Web/Views/Index/Index.cshtml index 249dc9d1..f42dde3b 100644 --- a/src/SMAPI.Web/Views/Index/Index.cshtml +++ b/src/SMAPI.Web/Views/Index/Index.cshtml @@ -52,7 +52,7 @@ <h2 id="help">Get help</h2> <ul> <li><a href="@SiteConfig.Value.ModListUrl">Mod compatibility list</a></li> - <li>Get help <a href="https://stardewvalleywiki.com/Modding:Community#Discord">on Discord</a> or <a href="https://community.playstarbound.com/threads/smapi-stardew-modding-api.108375/">in the forums</a></li> + <li>Get help <a href="https://smapi.io/community">on Discord or in the forums</a></li> </ul> @if (Model.BetaVersion == null) @@ -105,13 +105,12 @@ else <p> Special thanks to - <a href="https://www.nexusmods.com/stardewvalley/users/31393530">ChefRude</a>, - <a href="https://github.com/dittusch">dittusch</a>, hawkfalcon, <a href="https://twitter.com/iKeychain">iKeychain</a>, jwdred, <a href="https://www.nexusmods.com/users/12252523">Karmylla</a>, Pucklynn, + Renorien, Robby LaFarge, and a few anonymous users for their ongoing support on Patreon; you're awesome! </p> @@ -124,5 +123,5 @@ else <li><a href="@Model.BetaVersion.DevDownloadUrl">SMAPI @Model.BetaVersion.Version for developers</a> (includes <a href="https://docs.microsoft.com/en-us/visualstudio/ide/using-intellisense">intellisense</a> and full console output)</li> } <li><a href="https://stardewvalleywiki.com/Modding:Index">Modding documentation</a></li> - <li>Need help? Come <a href="https://stardewvalleywiki.com/Modding:Community#Discord">chat on Discord</a>.</li> + <li>Need help? Come <a href="https://smapi.io/community">chat on Discord</a>.</li> </ul> diff --git a/src/SMAPI.Web/Views/Index/Privacy.cshtml b/src/SMAPI.Web/Views/Index/Privacy.cshtml index ca99eef6..914384a8 100644 --- a/src/SMAPI.Web/Views/Index/Privacy.cshtml +++ b/src/SMAPI.Web/Views/Index/Privacy.cshtml @@ -24,12 +24,12 @@ <p>This website and SMAPI's web API are hosted by Amazon Web Services. Their servers may automatically collect diagnostics like your IP address, but this information is not visible to SMAPI's web application or developers. For more information, see the <a href="https://aws.amazon.com/privacy/">Amazon Privacy Notice</a>.</p> <h3>Update checks</h3> -<p>SMAPI notifies you when there's a new version of SMAPI or your mods available. To do so, it sends your SMAPI and mod versions to its web API. No personal information is stored by the web application, but see <em><a href="#web-logging">web logging</a></em>.</p> +<p>SMAPI notifies you when there's a new version of SMAPI or your mods available. To do so, it sends your game/SMAPI/mod versions and platform type to its web API. No personal information is stored by the web application, but see <em><a href="#web-logging">web logging</a></em>.</p> <p>You can disable update checks, and no information will be transmitted to the web API. To do so:</p> <ol> <li><a href="https://stardewvalleywiki.com/Modding:Game_folder">find your game folder</a>;</li> - <li>open the <code>smapi-internal/StardewModdingAPI.config.json</code> file in a text editor;</li> + <li>open the <code>smapi-internal/config.json</code> file in a text editor;</li> <li>change <code>"CheckForUpdates": true</code> to <code>"CheckForUpdates": false</code>.</li> </ol> diff --git a/src/SMAPI.Web/Views/JsonValidator/Index.cshtml b/src/SMAPI.Web/Views/JsonValidator/Index.cshtml new file mode 100644 index 00000000..3143fad9 --- /dev/null +++ b/src/SMAPI.Web/Views/JsonValidator/Index.cshtml @@ -0,0 +1,151 @@ +@using StardewModdingAPI.Web.ViewModels.JsonValidator +@model JsonValidatorModel + +@{ + // get view data + string curPageUrl = new Uri(new Uri(Model.SectionUrl), $"{Model.SchemaName}/{Model.PasteID}").ToString(); + string newUploadUrl = Model.SchemaName != null ? new Uri(new Uri(Model.SectionUrl), Model.SchemaName).ToString() : Model.SectionUrl; + string schemaDisplayName = null; + bool isValidSchema = Model.SchemaName != null && Model.SchemaFormats.TryGetValue(Model.SchemaName, out schemaDisplayName) && schemaDisplayName != "None"; + + // build title + ViewData["Title"] = "JSON validator"; + @if (Model.PasteID != null) + { + ViewData["ViewTitle"] = ViewData["Title"]; + ViewData["Title"] += + " (" + + string.Join(", ", new[] { isValidSchema ? schemaDisplayName : null, Model.PasteID }.Where(p => p != null)) + + ")"; + } +} + +@section Head { + @if (Model.PasteID != null) + { + <meta name="robots" content="noindex" /> + } + <link rel="stylesheet" href="~/Content/css/json-validator.css" /> + <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/themes/sunlight.default.min.css" /> + + <script src="https://cdn.jsdelivr.net/npm/jquery@3.3.1/dist/jquery.min.js" crossorigin="anonymous"></script> + <script src="https://cdn.jsdelivr.net/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> + <script src="~/Content/js/json-validator.js"></script> + <script> + $(function () { + smapi.jsonValidator(@Json.Serialize(Model.SectionUrl), @Json.Serialize(Model.PasteID)); + }); + </script> +} + +@* upload result banner *@ +@if (Model.UploadError != null) +{ + <div class="banner error"> + <strong>Oops, the server ran into trouble saving that file.</strong><br /> + <small>Error details: @Model.UploadError</small> + </div> +} +else if (Model.ParseError != null) +{ + <div class="banner error"> + <strong>Oops, couldn't parse that JSON.</strong><br /> + Share this link to let someone see this page: <code>@curPageUrl</code><br /> + (Or <a href="@newUploadUrl">validate a new file</a>.)<br /> + <br /> + <small v-pre>Error details: @Model.ParseError</small> + </div> +} +else if (Model.PasteID != null) +{ + <div class="banner success"> + <strong>Share this link to let someone else see this page:</strong> <code>@curPageUrl</code><br /> + (Or <a href="@newUploadUrl">validate a new file</a>.) + </div> +} + +@* upload new file *@ +@if (Model.Content == null) +{ + <h2>Upload a JSON file</h2> + <form action="@Model.SectionUrl" method="post"> + <ol> + <li> + Choose the JSON format:<br /> + <select id="format" name="SchemaName"> + @foreach (var pair in Model.SchemaFormats) + { + <option value="@pair.Key" selected="@(Model.SchemaName == pair.Key)">@pair.Value</option> + } + </select> + </li> + <li> + Drag the file onto this textbox (or paste the text in):<br /> + <textarea id="input" name="Content" placeholder="paste file here"></textarea> + </li> + <li> + Click this button:<br /> + <input type="submit" id="submit" value="save & validate file" /> + </li> + </ol> + </form> +} + +@* validation results *@ +@if (Model.Content != null) +{ + <div id="output"> + @if (Model.UploadError == null) + { + <div> + Change JSON format: + <select id="format" name="format"> + @foreach (var pair in Model.SchemaFormats) + { + <option value="@pair.Key" selected="@(Model.SchemaName == pair.Key)">@pair.Value</option> + } + </select> + </div> + + <h2>Validation errors</h2> + @if (Model.FormatUrl != null) + { + <p>See <a href="@Model.FormatUrl">format documentation</a>.</p> + } + + @if (Model.Errors.Any()) + { + <table id="metadata" class="table"> + <tr> + <th>Line</th> + <th>Field</th> + <th>Error</th> + </tr> + + @foreach (JsonValidatorErrorModel error in Model.Errors) + { + <tr data-schema-error="@error.SchemaErrorType"> + <td><a href="#L@(error.Line)">@error.Line</a></td> + <td>@error.Path</td> + <td>@error.Message</td> + </tr> + } + </table> + } + else + { + <p>No errors found.</p> + } + } + + <h2>Content</h2> + <pre id="raw-content" class="sunlight-highlight-javascript">@Model.Content</pre> + + @if (isValidSchema) + { + <p class="footer-tip">(Tip: you can <a href="https://github.com/Pathoschild/SMAPI/blob/develop/docs/technical/web.md#using-a-schema-file-directly">validate directly in your text editor</a> if it supports JSON Schema.)</p> + } + </div> +} diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml index 1b40cfa9..f98ffdf9 100644 --- a/src/SMAPI.Web/Views/LogParser/Index.cshtml +++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml @@ -1,4 +1,5 @@ @using Newtonsoft.Json +@using StardewModdingAPI.Toolkit.Utilities @using StardewModdingAPI.Web.Framework.LogParsing.Models @model StardewModdingAPI.Web.ViewModels.LogParserModel @@ -67,12 +68,15 @@ else if (Model.ParsedLog?.IsValid == true) <h2>Where do I find my SMAPI log?</h2> <div>What system do you use?</div> <ul id="os-list"> - <li><input type="radio" name="os" value="android" id="os-android" /> <label for="os-android">Android</label></li> - <li><input type="radio" name="os" value="linux" id="os-linux" /> <label for="os-linux">Linux</label></li> - <li><input type="radio" name="os" value="mac" id="os-mac" /> <label for="os-mac">Mac</label></li> - <li><input type="radio" name="os" value="windows" id="os-windows" /> <label for="os-windows">Windows</label></li> + @foreach (Platform platform in new[] { Platform.Android, Platform.Linux, Platform.Mac, Platform.Windows }) + { + <li> + <input type="radio" name="os" value="@platform" id="os-@platform" checked="@(Model.DetectedPlatform == platform)"/> + <label for="os-@platform">@platform</label> + </li> + } </ul> - <div data-os="android"> + <div data-os="@Platform.Android"> On Android: <ol> <li>Open a file app (like My Files or MT Manager).</li> @@ -81,7 +85,7 @@ else if (Model.ParsedLog?.IsValid == true) <li>The log file is <code>SMAPI-crash.txt</code> if it exists, otherwise <code>SMAPI-latest.txt</code>.</li> </ol> </div> - <div data-os="linux"> + <div data-os="@Platform.Linux"> On Linux: <ol> <li>Open the Files app.</li> @@ -91,7 +95,7 @@ else if (Model.ParsedLog?.IsValid == true) <li>The log file is <code>SMAPI-crash.txt</code> if it exists, otherwise <code>SMAPI-latest.txt</code>.</li> </ol> </div> - <div data-os="mac"> + <div data-os="@Platform.Mac"> On Mac: <ol> <li>Open the Finder app.</li> @@ -100,7 +104,7 @@ else if (Model.ParsedLog?.IsValid == true) <li>The log file is <code>SMAPI-crash.txt</code> if it exists, otherwise <code>SMAPI-latest.txt</code>.</li> </ol> </div> - <div data-os="windows"> + <div data-os="@Platform.Windows"> On Windows: <ol> <li>Press the <code>Windows</code> and <code>R</code> buttons at the same time.</li> @@ -118,7 +122,7 @@ else if (Model.ParsedLog?.IsValid == true) </li> <li> Click this button:<br /> - <input type="submit" id="submit" value="save log" /> + <input type="submit" id="submit" value="save & parse log" /> </li> <li>On the new page, copy the URL and send it to the person helping you.</li> </ol> diff --git a/src/SMAPI.Web/Views/Mods/Index.cshtml b/src/SMAPI.Web/Views/Mods/Index.cshtml index 8293fbe2..50b59b45 100644 --- a/src/SMAPI.Web/Views/Mods/Index.cshtml +++ b/src/SMAPI.Web/Views/Mods/Index.cshtml @@ -1,7 +1,11 @@ +@using Humanizer +@using Humanizer.Localisation @using Newtonsoft.Json @model StardewModdingAPI.Web.ViewModels.ModListModel @{ ViewData["Title"] = "Mod compatibility"; + + TimeSpan staleAge = DateTimeOffset.UtcNow - Model.LastUpdated; } @section Head { <link rel="stylesheet" href="~/Content/css/mods.css?r=20190302" /> @@ -18,83 +22,104 @@ </script> } -<div id="app"> - <div id="intro"> - <p>This page shows all known SMAPI mods and (incompatible) content packs, whether they work with the latest versions of Stardew Valley and SMAPI, and how to fix them if not. If a mod doesn't work after following the instructions below, check <a href="https://stardewvalleywiki.com/Modding:Player_Guide/Troubleshooting">the troubleshooting guide</a> or <a href="https://stardewvalleywiki.com/Modding:Player_Guide/Troubleshooting#Ask_for_help">ask for help</a>.</p> +@if (!Model.HasData) +{ + <div class="error">↻ The mod data hasn't been fetched yet; please try again in a few minutes.</div> +} +else +{ + @if (Model.IsStale) + { + <div class="error">Showing data from @staleAge.Humanize(maxUnit: TimeUnit.Hour, minUnit: TimeUnit.Minute) ago. (Couldn't fetch newer data; the wiki API may be offline.)</div> + } - <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> + <div id="app"> + <div id="intro"> + <p>This page shows all known SMAPI mods and (incompatible) content packs, whether they work with the latest versions of Stardew Valley and SMAPI, and how to fix them if not. If a mod doesn't work after following the instructions below, check <a href="https://stardewvalleywiki.com/Modding:Player_Guide/Troubleshooting">the troubleshooting guide</a> or <a href="https://stardewvalleywiki.com/Modding:Player_Guide/Troubleshooting#Ask_for_help">ask for help</a>.</p> - @if (Model.BetaVersion != null) - { - <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> - } - </div> + <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> - <div id="options"> - <div> - <label for="search-box">Search: </label> - <input type="text" id="search-box" v-model="search" v-on:input="applyFilters" /> + @if (Model.BetaVersion != null) + { + <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> + } </div> - <div id="filter-area"> - <input type="checkbox" id="show-advanced" v-model="showAdvanced" /> - <label for="show-advanced">show advanced info and options</label> - <div id="filters" v-show="showAdvanced"> - <div v-for="(filterGroup, key) in filters"> - {{filterGroup.label}}: <span v-for="filter in filterGroup.value" v-bind:class="{ active: filter.value }"><input type="checkbox" v-bind:id="filter.id" v-model="filter.value" v-on:change="applyFilters" /> <label v-bind:for="filter.id">{{filter.label}}</label></span> + + <div id="options"> + <div> + <label for="search-box">Search: </label> + <input type="text" id="search-box" v-model="search" v-on:input="applyFilters" /> + </div> + <div id="filter-area"> + <input type="checkbox" id="show-advanced" v-model="showAdvanced" /> + <label for="show-advanced">show advanced info and options</label> + <div id="filters" v-show="showAdvanced"> + <div v-for="(filterGroup, key) in filters"> + {{filterGroup.label}}: <span v-for="filter in filterGroup.value" v-bind:class="{ active: filter.value }"><input type="checkbox" v-bind:id="filter.id" v-model="filter.value" v-on:change="applyFilters" /> <label v-bind:for="filter.id">{{filter.label}}</label></span> + </div> </div> </div> </div> - </div> - <div id="mod-count" v-show="showAdvanced"> - <div v-if="visibleStats.total > 0"> - {{visibleStats.total}} mods shown ({{Math.round((visibleStats.compatible + visibleStats.workaround) / visibleStats.total * 100)}}% compatible or have a workaround, {{Math.round((visibleStats.soon + visibleStats.broken) / visibleStats.total * 100)}}% broken, {{Math.round(visibleStats.abandoned / visibleStats.total * 100)}}% obsolete). + <div id="mod-count" v-show="showAdvanced"> + <div v-if="visibleStats.total > 0"> + {{visibleStats.total}} mods shown ({{Math.round((visibleStats.compatible + visibleStats.workaround) / visibleStats.total * 100)}}% compatible or have a workaround, {{Math.round((visibleStats.soon + visibleStats.broken) / visibleStats.total * 100)}}% broken, {{Math.round(visibleStats.abandoned / visibleStats.total * 100)}}% obsolete). + </div> + <span v-else>No matching mods found.</span> </div> - <span v-else>No matching mods found.</span> + <table class="wikitable" id="mod-list"> + <thead> + <tr> + <th>mod name</th> + <th>links</th> + <th>author</th> + <th>compatibility</th> + <th v-show="showAdvanced">broke in</th> + <th v-show="showAdvanced">code</th> + <th> </th> + </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"> + <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 ? ', ' : ''}} + </span> + </td> + <td> + {{mod.Author}} + <small class="mod-alt-authors" v-if="mod.AlternateAuthors">(aka {{mod.AlternateAuthors}})</small> + </td> + <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> + <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-else class="mod-closed-source">no source</span> + </td> + <td> + <small> + <a v-bind:href="'#' + mod.Slug">#</a> + <span v-show="showAdvanced"> + <template v-for="(link, i) in mod.MetadataLinks"> + <a v-bind:href="link.Item1">{{link.Item2}}</a> + </template> + + <abbr v-bind:title="mod.DevNote" v-show="mod.DevNote">[dev note]</abbr> + </span> + </small> + </td> + </tr> + </tbody> + </table> </div> - <table class="wikitable" id="mod-list"> - <thead> - <tr> - <th>mod name</th> - <th>links</th> - <th>author</th> - <th>compatibility</th> - <th v-show="showAdvanced">broke in</th> - <th v-show="showAdvanced">code</th> - <th> </th> - </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"> - <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 ? ', ' : ''}} - </span> - </td> - <td> - {{mod.Author}} - <small class="mod-alt-authors" v-if="mod.AlternateAuthors">(aka {{mod.AlternateAuthors}})</small> - </td> - <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> - <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-else class="mod-closed-source">no source</span> - </td> - <td> - <small><a v-bind:href="'#' + mod.Slug">#</a></small> - </td> - </tr> - </tbody> - </table> -</div> +} diff --git a/src/SMAPI.Web/Views/Shared/_Layout.cshtml b/src/SMAPI.Web/Views/Shared/_Layout.cshtml index 4c602b29..87a22f06 100644 --- a/src/SMAPI.Web/Views/Shared/_Layout.cshtml +++ b/src/SMAPI.Web/Views/Shared/_Layout.cshtml @@ -16,14 +16,19 @@ <h4>SMAPI</h4> <ul> <li><a href="@SiteConfig.Value.RootUrl">About SMAPI</a></li> + <li><a href="https://stardewvalleywiki.com/Modding:Index">Modding docs</a></li> + </ul> + + <h4>Tools</h4> + <ul> <li><a href="@SiteConfig.Value.ModListUrl">Mod compatibility</a></li> <li><a href="@SiteConfig.Value.LogParserUrl">Log parser</a></li> - <li><a href="https://stardewvalleywiki.com/Modding:Index">Docs</a></li> + <li><a href="@SiteConfig.Value.JsonValidatorUrl">JSON validator</a></li> </ul> </div> <div id="content-column"> <div id="content"> - <h1>@ViewData["Title"]</h1> + <h1>@(ViewData["ViewTitle"] ?? ViewData["Title"])</h1> @RenderBody() </div> <div id="footer"> diff --git a/src/SMAPI.Web/appsettings.Development.json b/src/SMAPI.Web/appsettings.Development.json index 49234a3b..baf7efb7 100644 --- a/src/SMAPI.Web/appsettings.Development.json +++ b/src/SMAPI.Web/appsettings.Development.json @@ -8,19 +8,11 @@ */ { - "Logging": { - "IncludeScopes": false, - "LogLevel": { - "Default": "Debug", - "System": "Information", - "Microsoft": "Information" - } - }, - "Site": { "RootUrl": "http://localhost:59482/", "ModListUrl": "http://localhost:59482/mods/", "LogParserUrl": "http://localhost:59482/log/", + "JsonValidatorUrl": "http://localhost:59482/json/", "BetaEnabled": false, "BetaBlurb": null }, @@ -29,7 +21,16 @@ "GitHubUsername": null, "GitHubPassword": null, + "NexusApiKey": null, + "PastebinUserKey": null, "PastebinDevKey": null + }, + + "MongoDB": { + "Host": "localhost", + "Username": null, + "Password": null, + "Database": "smapi-edge" } } diff --git a/src/SMAPI.Web/appsettings.json b/src/SMAPI.Web/appsettings.json index 9e15aa97..674bb672 100644 --- a/src/SMAPI.Web/appsettings.json +++ b/src/SMAPI.Web/appsettings.json @@ -10,7 +10,8 @@ "Logging": { "IncludeScopes": false, "LogLevel": { - "Default": "Warning" + "Default": "Warning", + "Hangfire": "Information" } }, @@ -18,6 +19,7 @@ "RootUrl": null, // see top note "ModListUrl": null, // see top note "LogParserUrl": null, // see top note + "JsonValidatorUrl": null, // see top note "BetaEnabled": null, // see top note "BetaBlurb": null // see top note }, @@ -28,9 +30,9 @@ "ChucklefishBaseUrl": "https://community.playstarbound.com", "ChucklefishModPageUrlFormat": "resources/{0}", + "CurseForgeBaseUrl": "https://addons-ecs.forgesvc.net/api/v2/", + "GitHubBaseUrl": "https://api.github.com", - "GitHubStableReleaseUrlFormat": "repos/{0}/releases/latest", - "GitHubAnyReleaseUrlFormat": "repos/{0}/releases?per_page=2", // allow for draft release (only visible if GitHub repo is owned by same account as the update check credentials) "GitHubAcceptHeader": "application/vnd.github.v3+json", "GitHubUsername": null, // see top note "GitHubPassword": null, // see top note @@ -38,6 +40,7 @@ "ModDropApiUrl": "https://www.moddrop.com/api/mods/data", "ModDropModPageUrl": "https://www.moddrop.com/sdv/mod/{0}", + "NexusApiKey": null, // see top note "NexusBaseUrl": "https://www.nexusmods.com/stardewvalley/", "NexusModUrlFormat": "mods/{0}", "NexusModScrapeUrlFormat": "mods/{0}?tab=files", @@ -47,14 +50,24 @@ "PastebinDevKey": null // see top note }, + "MongoDB": { + "Host": null, // see top note + "Username": null, // see top note + "Password": null, // see top note + "Database": null // see top note + }, + "ModCompatibilityList": { - "CacheMinutes": 10 + "StaleMinutes": 15 + }, + + "BackgroundServices": { + "Enabled": true }, "ModUpdateCheck": { "SuccessCacheMinutes": 60, "ErrorCacheMinutes": 5, - "SemanticVersionRegex": "^(?>(?<major>0|[1-9]\\d*))\\.(?>(?<minor>0|[1-9]\\d*))(?>(?:\\.(?<patch>0|[1-9]\\d*))?)(?:-(?<prerelease>(?>[a-z0-9]+[\\-\\.]?)+))?$", "CompatibilityPageUrl": "https://mods.smapi.io" } } diff --git a/src/SMAPI.Web/wwwroot/Content/css/json-validator.css b/src/SMAPI.Web/wwwroot/Content/css/json-validator.css new file mode 100644 index 00000000..cd117694 --- /dev/null +++ b/src/SMAPI.Web/wwwroot/Content/css/json-validator.css @@ -0,0 +1,111 @@ +/********* +** Main layout +*********/ +#content { + max-width: 100%; +} + +#output { + padding: 10px; + overflow: auto; +} + +#output table td { + font-family: monospace; +} + +#output table tr th, +#output table tr td { + padding: 0 0.75rem; + white-space: pre-wrap; +} + + +/********* +** Result banner +*********/ +.banner { + border: 2px solid gray; + border-radius: 5px; + margin-top: 1em; + padding: 1em; +} + +.banner.success { + border-color: green; + background: #CFC; +} + +.banner.error { + border-color: red; + background: #FCC; +} + +/********* +** Validation results +*********/ +.table { + border-bottom: 1px dashed #888888; + margin-bottom: 5px; +} + +#metadata th, #metadata td { + text-align: left; + padding-right: 0.7em; +} + +.table { + border: 1px solid #000000; + background: #ffffff; + border-radius: 5px; + border-spacing: 1px; + overflow: hidden; + cursor: default; + box-shadow: 1px 1px 1px 1px #dddddd; +} + +.table tr { + background: #eee; +} + +.table tr:nth-child(even) { + background: #fff; +} + +#output div.sunlight-line-highlight-active { + background-color: #eeeacc; +} + +.footer-tip { + color: gray; + font-size: 0.9em; +} + +.footer-tip a { + color: gray; +} + +/********* +** Upload form +*********/ +#input { + width: 100%; + height: 20em; + max-height: 70%; + margin: auto; + box-sizing: border-box; + border-radius: 5px; + border: 1px solid #000088; + outline: none; + box-shadow: inset 0px 0px 1px 1px rgba(0, 0, 192, .2); +} + +#submit { + font-size: 1.5em; + border-radius: 5px; + outline: none; + box-shadow: inset 0 0 1px 1px rgba(0, 0, 0, .2); + cursor: pointer; + border: 1px solid #008800; + background-color: #cfc; +} diff --git a/src/SMAPI.Web/wwwroot/Content/css/main.css b/src/SMAPI.Web/wwwroot/Content/css/main.css index 57eeee88..dcc7a798 100644 --- a/src/SMAPI.Web/wwwroot/Content/css/main.css +++ b/src/SMAPI.Web/wwwroot/Content/css/main.css @@ -73,7 +73,7 @@ a { } #sidebar h4 { - margin: 0 0 0.2em 0; + margin: 1.5em 0 0.2em 0; width: 10em; border-bottom: 1px solid #CCC; font-size: 0.8em; diff --git a/src/SMAPI.Web/wwwroot/Content/css/mods.css b/src/SMAPI.Web/wwwroot/Content/css/mods.css index fc5fff47..1c2b8056 100644 --- a/src/SMAPI.Web/wwwroot/Content/css/mods.css +++ b/src/SMAPI.Web/wwwroot/Content/css/mods.css @@ -15,30 +15,6 @@ border: 3px solid darkgreen; } -table.wikitable { - background-color:#f8f9fa; - color:#222; - border:1px solid #a2a9b1; - border-collapse:collapse -} - -table.wikitable > tr > th, -table.wikitable > tr > td, -table.wikitable > * > tr > th, -table.wikitable > * > tr > td { - border:1px solid #a2a9b1; - padding:0.2em 0.4em -} - -table.wikitable > tr > th, -table.wikitable > * > tr > th { - background-color:#eaecf0; -} - -table.wikitable > caption { - font-weight:bold -} - #options { margin-bottom: 1em; } @@ -73,6 +49,39 @@ table.wikitable > caption { opacity: 0.5; } +div.error { + padding: 2em 0; + color: red; + font-weight: bold; +} + +/********* +** Mod list +*********/ +table.wikitable { + background-color:#f8f9fa; + color:#222; + border:1px solid #a2a9b1; + border-collapse:collapse +} + +table.wikitable > tr > th, +table.wikitable > tr > td, +table.wikitable > * > tr > th, +table.wikitable > * > tr > td { + border:1px solid #a2a9b1; + padding:0.2em 0.4em +} + +table.wikitable > tr > th, +table.wikitable > * > tr > th { + background-color:#eaecf0; +} + +table.wikitable > caption { + font-weight:bold +} + #mod-list { font-size: 0.9em; } diff --git a/src/SMAPI.Web/wwwroot/Content/js/json-validator.js b/src/SMAPI.Web/wwwroot/Content/js/json-validator.js new file mode 100644 index 00000000..5499cef6 --- /dev/null +++ b/src/SMAPI.Web/wwwroot/Content/js/json-validator.js @@ -0,0 +1,179 @@ +/* globals $ */ + +var smapi = smapi || {}; + +/** + * Manages the logic for line range selections. + * @param {int} maxLines The maximum number of lines in the content. + */ +smapi.LineNumberRange = function (maxLines) { + var self = this; + + /** + * @var {int} minLine The first line in the selection, or null if no lines selected. + */ + self.minLine = null; + + /** + * @var {int} maxLine The last line in the selection, or null if no lines selected. + */ + self.maxLine = null; + + /** + * Parse line numbers from a URL hash. + * @param {string} hash the URL hash to parse. + */ + self.parseFromUrlHash = function (hash) { + self.minLine = null; + self.maxLine = null; + + // parse hash + var hashParts = hash.match(/^#L(\d+)(?:-L(\d+))?$/); + if (!hashParts || hashParts.length <= 1) + return; + + // extract min/max lines + self.minLine = parseInt(hashParts[1]); + self.maxLine = parseInt(hashParts[2]) || self.minLine; + }; + + /** + * Generate a URL hash for the current line range. + * @returns {string} The generated URL hash. + */ + self.buildHash = function() { + if (!self.minLine) + return ""; + else if (self.minLine === self.maxLine) + return "#L" + self.minLine; + else + return "#L" + self.minLine + "-L" + self.maxLine; + } + + /** + * Get a list of all selected lines. + * @returns {Array<int>} The selected line numbers. + */ + self.getLinesSelected = function() { + // format + if (!self.minLine) + return []; + + var lines = []; + for (var i = self.minLine; i <= self.maxLine; i++) + lines.push(i); + return lines; + }; + + return self; +}; + +/** + * UI logic for the JSON validator page. + * @param {any} sectionUrl The base JSON validator page URL. + * @param {any} pasteID The Pastebin paste ID for the content being viewed, if any. + */ +smapi.jsonValidator = function (sectionUrl, pasteID) { + /** + * The original content element. + */ + var originalContent = $("#raw-content").clone(); + + /** + * The currently highlighted lines. + */ + var selection = new smapi.LineNumberRange(); + + /** + * Rebuild the syntax-highlighted element. + */ + var formatCode = function () { + // reset if needed + $(".sunlight-container").replaceWith(originalContent.clone()); + + // apply default highlighting + Sunlight.highlightAll({ + lineHighlight: selection.getLinesSelected() + }); + + // fix line links + $(".sunlight-line-number-margin a").each(function() { + var link = $(this); + var lineNumber = parseInt(link.text()); + link + .attr("id", "L" + lineNumber) + .attr("href", "#L" + lineNumber) + .removeAttr("name") + .data("line-number", lineNumber); + }); + }; + + /** + * Scroll the page so the selected range is visible. + */ + var scrollToRange = function() { + if (!selection.minLine) + return; + + var targetLine = Math.max(1, selection.minLine - 5); + $("#L" + targetLine).get(0).scrollIntoView(); + }; + + /** + * Initialize the JSON validator page. + */ + var init = function () { + // set initial code formatting + selection.parseFromUrlHash(location.hash); + formatCode(); + scrollToRange(); + + // update code formatting on hash change + $(window).on("hashchange", function() { + selection.parseFromUrlHash(location.hash); + formatCode(); + scrollToRange(); + }); + + // change format + $("#output #format").on("change", function() { + var schemaName = $(this).val(); + location.href = new URL(schemaName + "/" + pasteID, sectionUrl).toString(); + }); + + // upload form + var input = $("#input"); + if (input.length) { + // disable submit if it's empty + var toggleSubmit = function () { + var hasText = !!input.val().trim(); + submit.prop("disabled", !hasText); + }; + input.on("input", toggleSubmit); + toggleSubmit(); + + // drag & drop file + input.on({ + 'dragover dragenter': function (e) { + e.preventDefault(); + e.stopPropagation(); + }, + 'drop': function (e) { + var dataTransfer = e.originalEvent.dataTransfer; + if (dataTransfer && dataTransfer.files.length) { + e.preventDefault(); + e.stopPropagation(); + var file = dataTransfer.files[0]; + var reader = new FileReader(); + reader.onload = $.proxy(function (file, $input, event) { + $input.val(event.target.result); + toggleSubmit(); + }, this, file, $("#input")); + reader.readAsText(file); + } + } + }); + } + }; + init(); +}; diff --git a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js index e87a1a5c..e6c7591c 100644 --- a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js +++ b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js @@ -23,7 +23,7 @@ smapi.logParser = function (data, sectionUrl) { } // set local time started - if(data) + if (data) data.localTimeStarted = ("0" + data.logStarted.getHours()).slice(-2) + ":" + ("0" + data.logStarted.getMinutes()).slice(-2); // init app @@ -100,7 +100,7 @@ smapi.logParser = function (data, sectionUrl) { updateModFilters(); }, - filtersAllow: function(modId, level) { + filtersAllow: function (modId, level) { return this.showMods[modId] !== false && this.showLevels[level] !== false; }, @@ -121,16 +121,15 @@ smapi.logParser = function (data, sectionUrl) { var submit = $("#submit"); // instruction OS chooser - var chooseSystem = function() { + var chooseSystem = function () { systemInstructions.hide(); systemInstructions.filter("[data-os='" + $("input[name='os']:checked").val() + "']").show(); - } + }; systemOptions.on("click", chooseSystem); chooseSystem(); // disable submit if it's empty - var toggleSubmit = function() - { + var toggleSubmit = function () { var hasText = !!input.val().trim(); submit.prop("disabled", !hasText); } @@ -139,18 +138,18 @@ smapi.logParser = function (data, sectionUrl) { // drag & drop file input.on({ - 'dragover dragenter': function(e) { + 'dragover dragenter': function (e) { e.preventDefault(); e.stopPropagation(); }, - 'drop': function(e) { + 'drop': function (e) { var dataTransfer = e.originalEvent.dataTransfer; if (dataTransfer && dataTransfer.files.length) { e.preventDefault(); e.stopPropagation(); var file = dataTransfer.files[0]; var reader = new FileReader(); - reader.onload = $.proxy(function(file, $input, event) { + reader.onload = $.proxy(function (file, $input, event) { $input.val(event.target.result); toggleSubmit(); }, this, file, $("#input")); diff --git a/src/SMAPI.Web/wwwroot/Content/js/mods.js b/src/SMAPI.Web/wwwroot/Content/js/mods.js index 130f60be..0394ac4f 100644 --- a/src/SMAPI.Web/wwwroot/Content/js/mods.js +++ b/src/SMAPI.Web/wwwroot/Content/js/mods.js @@ -44,6 +44,7 @@ smapi.modList = function (mods, enableBeta) { download: { value: { chucklefish: { value: true, label: "Chucklefish" }, + curseforge: { value: true, label: "CurseForge" }, moddrop: { value: true, label: "ModDrop" }, nexus: { value: true, label: "Nexus" }, custom: { value: true } @@ -180,6 +181,8 @@ smapi.modList = function (mods, enableBeta) { if (!filters.download.value.chucklefish.value) ignoreSites.push("Chucklefish"); + if (!filters.download.value.curseforge.value) + ignoreSites.push("CurseForge"); if (!filters.download.value.moddrop.value) ignoreSites.push("ModDrop"); if (!filters.download.value.nexus.value) diff --git a/src/SMAPI.Web/wwwroot/StardewModdingAPI.metadata.json b/src/SMAPI.Web/wwwroot/SMAPI.metadata.json index d0c55552..78918bac 100644 --- a/src/SMAPI.Web/wwwroot/StardewModdingAPI.metadata.json +++ b/src/SMAPI.Web/wwwroot/SMAPI.metadata.json @@ -14,11 +14,6 @@ * other fields if no ID was specified. This doesn't include the latest ID, if any. Multiple * variants can be separated with '|'. * - * - MapLocalVersions and MapRemoteVersions correct local manifest versions and remote versions - * during update checks. For example, if the API returns version '1.1-1078' where '1078' is - * intended to be a build number, MapRemoteVersions can map it to '1.1' when comparing to the - * mod's current version. This is only meant to support legacy mods with injected update keys. - * * Versioned metadata * ================== * Each record can also specify extra metadata using the field keys below. @@ -59,15 +54,15 @@ "Default | UpdateKey": "Nexus:2270" }, - "Content Patcher": { - "ID": "Pathoschild.ContentPatcher", - "Default | UpdateKey": "Nexus:1915" - }, + //"Content Patcher": { + // "ID": "Pathoschild.ContentPatcher", + // "Default | UpdateKey": "Nexus:1915" + //}, - "Custom Farming Redux": { - "ID": "Platonymous.CustomFarming", - "Default | UpdateKey": "Nexus:991" - }, + //"Custom Farming Redux": { + // "ID": "Platonymous.CustomFarming", + // "Default | UpdateKey": "Nexus:991" + //}, "Custom Shirts": { "ID": "Platonymous.CustomShirts", @@ -122,116 +117,175 @@ "Default | UpdateKey": "Nexus:1820" }, + /********* + ** Obsolete + *********/ + "Animal Mood Fix": { + "ID": "GPeters-AnimalMoodFix", + "~ | Status": "Obsolete", + "~ | StatusReasonPhrase": "the animal mood bugs were fixed in Stardew Valley 1.2." + }, + + "Bee House Flower Range Fix": { + "ID": "kirbylink.beehousefix", + "~ | Status": "Obsolete", + "~ | StatusReasonPhrase": "the bee house flower range was fixed in Stardew Valley 1.4." + }, + + "Colored Chests": { + "ID": "4befde5c-731c-4853-8e4b-c5cdf946805f", + "~ | Status": "Obsolete", + "~ | StatusReasonPhrase": "colored chests were added in Stardew Valley 1.1." + }, + + "Modder Serialization Utility": { + "ID": "SerializerUtils-0-1", + "~ | Status": "Obsolete", + "~ | StatusReasonPhrase": "it's no longer maintained or used." + }, + + "No Debug Mode": { + "ID": "NoDebugMode", + "~ | Status": "Obsolete", + "~ | StatusReasonPhrase": "debug mode was removed in SMAPI 1.0." + }, /********* - ** Map versions + ** Broke in SDV 1.4 *********/ - "Adjust Artisan Prices": { - "ID": "ThatNorthernMonkey.AdjustArtisanPrices", - "FormerIDs": "1e36d4ca-c7ef-4dfb-9927-d27a6c3c8bdc", // changed in 0.0.2-pathoschild-update - "MapRemoteVersions": { "0.01": "0.0.1" } + "Fix Dice": { + "ID": "ashley.fixdice", + "~1.1.2 | Status": "AssumeBroken" // crashes game on startup + }, + + "Fix Dice": { + "ID": "ashley.fixdice", + "~1.1.2 | Status": "AssumeBroken" // crashes game on startup + }, + + "Grass Growth": { + "ID": "bcmpinc.GrassGrowth", + "~1.0 | Status": "AssumeBroken" + }, + + "Invite Code Mod": { + "ID": "KOREJJamJar.InviteCodeMod", + "~1.0.1 | Status": "AssumeBroken" + }, + + "Loved Labels": { + "ID": "Advize.LovedLabels", + "~2.2.1-unofficial.2-pathoschild | Status": "AssumeBroken" }, - "Almighty Farming Tool": { - "ID": "439", - "MapRemoteVersions": { - "1.21": "1.2.1", - "1.22-unofficial.3.mizzion": "1.2.2-unofficial.3.mizzion" - } + "Neat Additions": { + "ID": "ilyaki.neatadditions", + "~1.0.3 | Status": "AssumeBroken" }, - "Basic Sprinkler Improved": { - "ID": "lrsk_sdvm_bsi.0117171308", - "MapRemoteVersions": { "1.0.2": "1.0.1-release" } // manifest not updated + "Remote Fridge Storage": { + "ID": "EternalSoap.RemoteFridgeStorage", + "~1.5 | Status": "AssumeBroken" }, - "Better Shipping Box": { - "ID": "Kithio:BetterShippingBox", - "MapLocalVersions": { "1.0.1": "1.0.2" } + "Stack Everything": { + "ID": "cat.stackeverything", + "~2.15 | Status": "AssumeBroken" }, - "Chefs Closet": { - "ID": "Duder.ChefsCloset", - "MapLocalVersions": { "1.3-1": "1.3" } + "Yet Another Harvest With Scythe Mod": { + "ID": "bcmpinc.HarvestWithScythe", + "~1.1 | Status": "AssumeBroken" }, - "Configurable Machines": { - "ID": "21da6619-dc03-4660-9794-8e5b498f5b97", - "MapLocalVersions": { "1.2-beta": "1.2" } + /********* + ** Broke in SMAPI 3.0 (runtime errors due to lifecycle changes) + *********/ + "Advancing Sprinklers": { + "ID": "warix3.advancingsprinklers", + "~1.0.0 | Status": "AssumeBroken" }, - "Crafting Counter": { - "ID": "lolpcgaming.CraftingCounter", - "MapRemoteVersions": { "1.1": "1.0" } // not updated in manifest + "Arcade 2048": { + "ID": "Platonymous.2048", + "~1.0.6 | Status": "AssumeBroken" // possibly due to PyTK }, - "Custom Linens": { - "ID": "Mevima.CustomLinens", - "MapRemoteVersions": { "1.1": "1.0" } // manifest not updated + "Arcade Snake": { + "ID": "Platonymous.Snake", + "~1.1.0 | Status": "AssumeBroken" // possibly due to PyTK }, - "Dynamic Horses": { - "ID": "Bpendragon-DynamicHorses", - "MapRemoteVersions": { "1.2": "1.1-release" } // manifest not updated + "Better Sprinklers": { + "ID": "Speeder.BetterSprinklers", + "~2.3.1-unofficial.7-pathoschild | Status": "AssumeBroken" }, - "Dynamic Machines": { - "ID": "DynamicMachines", - "MapLocalVersions": { "1.1": "1.1.1" } + "Content Patcher": { + "ID": "Pathoschild.ContentPatcher", + "Default | UpdateKey": "Nexus:1915", + "~1.6.4 | Status": "AssumeBroken" }, - "Multiple Sprites and Portraits On Rotation (File Loading)": { - "ID": "FileLoading", - "MapLocalVersions": { "1.1": "1.12" } + "Current Location (Vrakyas)": { + "ID": "Vrakyas.CurrentLocation", + "~1.5.4 | Status": "AssumeBroken" }, - "Relationship Status": { - "ID": "relationshipstatus", - "MapRemoteVersions": { "1.0.5": "1.0.4" } // not updated in manifest + "Custom Adventure Guild Challenges": { + "ID": "DefenTheNation.CustomGuildChallenges", + "~1.8 | Status": "AssumeBroken" }, - "ReRegeneration": { - "ID": "lrsk_sdvm_rerg.0925160827", - "MapLocalVersions": { "1.1.2-release": "1.1.2" } + "Custom Farming Redux": { + "ID": "Platonymous.CustomFarming", + "Default | UpdateKey": "Nexus:991", + "~2.10.10 | Status": "AssumeBroken" // possibly due to PyTK }, - "Showcase Mod": { - "ID": "Igorious.Showcase", - "MapLocalVersions": { "0.9-500": "0.9" } + "Decrafting Mod": { + "ID": "MSCFC.DecraftingMod", + "~1.0 | Status": "AssumeBroken" // NRE in ModEntry }, - "Siv's Marriage Mod": { - "ID": "6266959802", // official version - "FormerIDs": "Siv.MarriageMod | medoli900.Siv's Marriage Mod", // 1.2.3-unofficial versions - "MapLocalVersions": { "0.0": "1.4" } + "JoJaBan - Arcade Sokoban": { + "ID": "Platonymous.JoJaBan", + "~0.4.3 | Status": "AssumeBroken" // possibly due to PyTK }, + "Level Extender": { + "ID": "DevinLematty.LevelExtender", + "~3.1 | Status": "AssumeBroken" + }, - /********* - ** Obsolete - *********/ - "Animal Mood Fix": { - "ID": "GPeters-AnimalMoodFix", - "~ | Status": "Obsolete", - "~ | StatusReasonPhrase": "the animal mood bugs were fixed in Stardew Valley 1.2." + "Mod Update Menu": { + "ID": "cat.modupdatemenu", + "~1.4 | Status": "AssumeBroken" }, - "Colored Chests": { - "ID": "4befde5c-731c-4853-8e4b-c5cdf946805f", - "~ | Status": "Obsolete", - "~ | StatusReasonPhrase": "colored chests were added in Stardew Valley 1.1." + "Quick Start": { + "ID": "WuestMan.QuickStart", + "~1.5 | Status": "AssumeBroken" }, - "Modder Serialization Utility": { - "ID": "SerializerUtils-0-1", - "~ | Status": "Obsolete", - "~ | StatusReasonPhrase": "it's no longer maintained or used." + "Seed Bag": { + "ID": "Platonymous.SeedBag", + "~1.2.7 | Status": "AssumeBroken" // possibly due to PyTK }, - "No Debug Mode": { - "ID": "NoDebugMode", - "~ | Status": "Obsolete", - "~ | StatusReasonPhrase": "debug mode was removed in SMAPI 1.0." + "Stardew Valley ESP": { + "ID": "reimu.sdv-helper", + "~1.1 | Status": "AssumeBroken" + }, + + "Underdark Krobus": { + "ID": "melnoelle.underdarkkrobus", + "~1.0.0 | Status": "AssumeBroken" // NRE in ModEntry + }, + + "Underdark Sewer": { + "ID": "melnoelle.underdarksewer", + "~1.1.0 | Status": "AssumeBroken" // NRE in ModEntry }, /********* @@ -349,11 +403,6 @@ "~0.3 | Status": "AssumeBroken" // broke in 1.3: Exception from HarmonyInstance "bcmpinc.FixScytheExp" [...] Bad label content in ILGenerator. }, - "Grass Growth": { - "ID": "bcmpinc.GrassGrowth", - "~0.6 | Status": "AssumeBroken" // breaks newer versions of bcmpinc mods (per bcmpinc's request) - }, - "More Silo Storage": { "ID": "OrneryWalrus.MoreSiloStorage", "~1.0.1 | Status": "AssumeBroken" // broke in SDV 1.3 @@ -374,12 +423,6 @@ "~1.0.0 | Status": "AssumeBroken" // broke in Stardew Valley 1.3.29 (runtime errors) }, - "Skill Prestige: Cooking Adapter": { - "ID": "Alphablackwolf.CookingSkillPrestigeAdapter", - "FormerIDs": "20d6b8a3-b6e7-460b-a6e4-07c2b0cb6c63", // changed circa 1.1 - "MapRemoteVersions": { "1.2.3": "1.1" } // manifest not updated - }, - "Skull Cave Saver": { "ID": "cantorsdust.SkullCaveSaver", "FormerIDs": "8ac06349-26f7-4394-806c-95d48fd35774 | community.SkullCaveSaver", // changed in 1.1 and 1.2.2 @@ -398,7 +441,6 @@ "Stephan's Lots of Crops": { "ID": "stephansstardewcrops", - "MapRemoteVersions": { "1.41": "1.1" }, // manifest not updated "~1.1 | Status": "AssumeBroken" // broke in SDV 1.3 (overwrites vanilla items) }, @@ -418,11 +460,6 @@ "~0.6 | Status": "AssumeBroken" // breaks newer versions of bcmpinc mods (per bcmpinc's request) }, - "Yet Another Harvest With Scythe Mod": { - "ID": "bcmpinc.HarvestWithScythe", - "~0.6 | Status": "AssumeBroken" // breaks newer versions of bcmpinc mods (per bcmpinc's request) - }, - /********* ** Broke circa SDV 1.2 *********/ diff --git a/src/SMAPI.Web/wwwroot/schemas/content-patcher.json b/src/SMAPI.Web/wwwroot/schemas/content-patcher.json new file mode 100644 index 00000000..61a633cb --- /dev/null +++ b/src/SMAPI.Web/wwwroot/schemas/content-patcher.json @@ -0,0 +1,389 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://smapi.io/schemas/content-patcher.json", + "title": "Content Patcher content pack", + "description": "Content Patcher content file for mods", + "@documentationUrl": "https://github.com/Pathoschild/StardewMods/tree/develop/ContentPatcher#readme", + "type": "object", + + "properties": { + "Format": { + "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.9", + "@errorMessages": { + "const": "Incorrect value '@value'. This should be set to the latest format version, currently '1.9'." + } + }, + "ConfigSchema": { + "title": "Config schema", + "description": "Defines the config.json format, to support more complex mods.", + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "AllowValues": { + "title": "Allowed values", + "description": "The values the player can provide, as a comma-delimited string. If omitted, any value is allowed.\nTip: for a boolean flag, use \"true, false\".", + "type": "string" + }, + "AllowBlank": { + "title": "Allow blank", + "description": "Whether the field can be left blank. If false or omitted, blank fields will be replaced with the default value.", + "type": "boolean" + }, + "AllowMultiple": { + "title": "Allow multiple values", + "description": "Whether the player can specify multiple comma-delimited values. Default false.", + "type": "boolean" + }, + "Default": { + "title": "Default value", + "description": "The default values when the field is missing. Can contain multiple comma-delimited values if AllowMultiple is true. If omitted, blank fields are left blank.", + "type": "string" + }, + + "additionalProperties": false + }, + "allOf": [ + { + "if": { + "properties": { + "AllowBlank": { "const": false } + }, + "required": [ "AllowBlank" ] + }, + "then": { + "required": [ "Default" ] + } + } + ], + + "@errorMessages": { + "allOf": "If 'AllowBlank' is false, the 'Default' field is required." + } + } + }, + "DynamicTokens": { + "title": "Dynamic tokens", + "description": "Custom tokens that you can use.", + "type": "array", + "items": { + "type": "object", + "properties": { + "Name": { + "title": "Name", + "description": "The name of the token to use for tokens & conditions.", + "type": "string" + }, + "Value": { + "title": "Token value", + "description": "The value(s) to set. This can be a comma-delimited value to give it multiple values. If any block for a token name has multiple values, it will only be usable in conditions. This field supports tokens, including dynamic tokens defined before this entry.", + "type": "string" + }, + "When": { + "title": "When", + "description": "Only set the value if the given conditions match. If not specified, always matches.", + "$ref": "#/definitions/Condition" + } + }, + + "required": [ "Name", "Value" ], + "additionalProperties": false + } + }, + "Changes": { + "title": "Changes", + "description": "The changes you want to make. Each entry is called a patch, and describes a specific action to perform: replace this file, copy this image into the file, etc. You can list any number of patches.", + "type": "array", + "items": { + "properties": { + "Action": { + "title": "Action", + "description": "The kind of change to make.", + "type": "string", + "enum": [ "Load", "EditImage", "EditData", "EditMap" ] + }, + "Target": { + "title": "Target asset", + "description": "The game asset you want to patch (or multiple comma-delimited assets). This is the file path inside your game's Content folder, without the file extension or language (like Animals/Dinosaur to edit Content/Animals/Dinosaur.xnb). This field supports tokens and capitalization doesn't matter. Your changes are applied in all languages unless you specify a language condition.", + "type": "string", + "not": { + "pattern": "^ *[cC][oO][nN][tT][eE][nN][tT]/|\\.[xX][nN][bB] *$|\\.[a-zA-Z][a-zA-Z]-[a-zA-Z][a-zA-Z](?:.xnb)? *$" + }, + "@errorMessages": { + "not": "Invalid target; it shouldn't include the 'Content/' folder, '.xnb' extension, or locale code." + } + }, + "LogName": { + "title": "Patch log name", + "description": "A name for this patch shown in log messages. This is very useful for understanding errors; if not specified, will default to a name like 'entry #14 (EditImage Animals/Dinosaurs)'.", + "type": "string" + }, + "Enabled": { + "title": "Enabled", + "description": "Whether to apply this patch. Default true. This fields supports immutable tokens (e.g. config tokens) if they return true/false.", + "anyOf": [ + { + "type": "string", + "enum": [ "true", "false" ] + }, + { + "type": "string", + "pattern": "\\{\\{[^{}]+\\}\\}" + }, + { + "type": "boolean" + } + ], + "@errorMessages": { + "anyOf": "Invalid value; must be true, false, or a single token which evaluates to true or false." + } + }, + "FromFile": { + "title": "Source file", + "description": "The relative file path in your content pack folder to load instead (like 'assets/dinosaur.png'). This can be a .json (data), .png (image), .tbin (map), or .xnb file. This field supports tokens and capitalization doesn't matter.", + "type": "string", + "allOf": [ + { + "not": { + "pattern": "\b\\.\\.[/\\]" + } + }, + { + "pattern": "\\.(json|png|tbin|xnb) *$" + } + ], + "@errorMessages": { + "allOf:indexes: 0": "Invalid value; must not contain directory climbing (like '../').", + "allOf:indexes: 1": "Invalid value; must be a file path ending with .json, .png, .tbin, or .xnb." + } + }, + "FromArea": { + "title": "Source area", + "description": "The part of the source image to copy. Defaults to the whole source image.", + "$ref": "#/definitions/Rectangle" + }, + "ToArea": { + "title": "Destination area", + "description": "The part of the target image to replace. Defaults to the FromArea size starting from the top-left corner.", + "$ref": "#/definitions/Rectangle" + }, + "PatchMode": { + "title": "Patch mode", + "description": "How to apply FromArea to ToArea. Defaults to Replace.", + "type": "string", + "enum": [ "Replace", "Overlay" ], + "default": "Replace" + }, + "Fields": { + "title": "Fields", + "description": "The individual fields you want to change for existing entries. This field supports tokens in field keys and values. The key for each field is the field index (starting at zero) for a slash-delimited string, or the field name for an object.", + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "Entries": { + "title": "Entries", + "description": "The entries in the data file you want to add, replace, or delete. If you only want to change a few fields, use Fields instead for best compatibility with other mods. To add an entry, just specify a key that doesn't exist; to delete an entry, set the value to null (like \"some key\": null). This field supports tokens in entry keys and values.\nCaution: some XNB files have extra fields at the end for translations; when adding or replacing an entry for all locales, make sure you include the extra fields to avoid errors for non-English players.", + "type": "object", + "additionalProperties": { + "type": [ "object", "string" ] + } + }, + "MoveEntries": { + "type": "array", + "items": { + "type": "object", + "properties": { + "ID": { + "title": "ID", + "description": "The ID of the entry to move", + "type": "string" + }, + "BeforeID": { + "title": "Before ID", + "description": "Move entry so it's right before this ID", + "type": "string" + }, + "AfterID": { + "title": "After ID", + "description": "Move entry so it's right after this ID", + "type": "string" + }, + "ToPosition": { + "title": "To position", + "description": "Move entry so it's right at this position", + "enum": [ "Top", "Bottom" ] + } + }, + + "anyOf": [ + { + "required": [ "BeforeID" ] + }, + { + "required": [ "AfterID" ] + }, + { + "required": [ "ToPosition" ] + } + ], + + "dependencies": { + "BeforeID": { + "propertyNames": { + "enum": [ "ID", "BeforeID" ] + } + }, + "AfterID": { + "propertyNames": { + "enum": [ "ID", "AfterID" ] + } + }, + "ToPosition": { + "propertyNames": { + "enum": [ "ID", "ToPosition" ] + } + } + }, + + "required": [ "ID" ], + "@errorMessages": { + "anyOf": "You must specify one of 'AfterID', 'BeforeID', or 'ToPosition'.", + "dependencies:BeforeID": "If 'BeforeID' is specified, only 'ID' and 'BeforeID' fields are valid.", + "dependencies:AfterID": "If 'AfterID' is specified, only 'ID' and 'AfterID' fields are valid.", + "dependencies:ToPosition": "If 'ToPosition' is specified, only 'ID' and 'ToPosition' fields are valid." + } + } + }, + "When": { + "title": "When", + "description": "Only apply the patch if the given conditions match.", + "$ref": "#/definitions/Condition" + } + }, + "allOf": [ + { + "if": { + "properties": { + "Action": { "const": "Load" } + } + }, + "then": { + "required": [ "FromFile" ], + "propertyNames": { + "enum": [ "Action", "Target", "LogName", "Enabled", "When", "FromFile" ] + } + } + }, + { + "if": { + "properties": { + "Action": { "const": "EditImage" } + } + }, + "then": { + "required": [ "FromFile" ], + "propertyNames": { + "enum": [ "Action", "Target", "LogName", "Enabled", "When", "FromFile", "FromArea", "ToArea", "PatchMode" ] + } + } + }, + { + "if": { + "properties": { + "Action": { "const": "EditData" } + } + }, + "then": { + "propertyNames": { + "enum": [ "Action", "Target", "LogName", "Enabled", "When", "Fields", "Entries", "MoveEntries" ] + } + } + }, + { + "if": { + "properties": { + "Action": { "const": "EditMap" } + } + }, + "then": { + "properties": { + "FromFile": { + "description": "The relative path to the map in your content pack folder from which to copy (like assets/town.tbin). This can be a .tbin or .xnb file. This field supports tokens and capitalization doesn't matter.\nContent Patcher will handle tilesheets referenced by the FromFile map for you if it's a .tbin file:\n - If a tilesheet isn't referenced by the target map, Content Patcher will add it for you (with a z_ ID prefix to avoid conflicts with hardcoded game logic). If the source map has a custom version of a tilesheet that's already referenced, it'll be added as a separate tilesheet only used by your tiles.\n - If you include the tilesheet file in your mod folder, Content Patcher will use that one automatically; otherwise it will be loaded from the game's Content/Maps folder." + }, + "FromArea": { + "description": "The part of the source map to copy. Defaults to the whole source map." + }, + "ToArea": { + "description": "The part of the target map to replace." + } + }, + "propertyNames": { + "enum": [ "Action", "Target", "LogName", "Enabled", "When", "FromFile", "FromArea", "ToArea" ] + }, + "required": [ "FromFile", "ToArea" ] + } + } + ], + + "required": [ "Action", "Target" ], + "@errorMessages": { + "allOf": "$transparent" + } + } + }, + "$schema": { + "title": "Schema", + "description": "A reference to this JSON schema. Not part of the actual format, but useful for validation tools.", + "type": "string", + "const": "https://smapi.io/schemas/content-patcher.json" + } + }, + "definitions": { + "Condition": { + "type": "object", + "additionalProperties": { + "type": [ "boolean", "string" ] + } + }, + "Rectangle": { + "type": "object", + "properties": { + "X": { + "title": "X-Coordinate", + "description": "Location in pixels of the top-left of the rectangle", + "type": "integer", + "minimum:": 0 + }, + "Y": { + "title": "Y-Coordinate", + "description": "Location in pixels of the top-left of the rectangle", + "type": "integer", + "minimum:": 0 + }, + "Width": { + "title": "Width", + "description": "The width of the rectangle", + "type": "integer", + "minimum:": 0 + }, + "Height": { + "title": "Height", + "description": "The height of the rectangle", + "type": "integer", + "minimum:": 0 + } + }, + + "required": [ "X", "Y", "Width", "Height" ], + "additionalProperties": false + } + }, + + "required": [ "Format", "Changes" ], + "additionalProperties": false +} diff --git a/src/SMAPI.Web/wwwroot/schemas/manifest.json b/src/SMAPI.Web/wwwroot/schemas/manifest.json new file mode 100644 index 00000000..685b515b --- /dev/null +++ b/src/SMAPI.Web/wwwroot/schemas/manifest.json @@ -0,0 +1,147 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://smapi.io/schemas/manifest.json", + "title": "SMAPI manifest", + "description": "Manifest file for a SMAPI mod or content pack", + "@documentationUrl": "https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Manifest", + "type": "object", + "properties": { + "Name": { + "title": "Mod name", + "description": "The mod's display name. SMAPI uses this in player messages, logs, and errors.", + "type": "string", + "examples": [ "Lookup Anything" ] + }, + "Author": { + "title": "Mod author", + "description": "The name of the person who created the mod. Ideally this should include the username used to publish mods.", + "type": "string", + "examples": [ "Pathoschild" ] + }, + "Version": { + "title": "Mod version", + "description": "The mod's semantic version. Make sure you update this for each release! SMAPI uses this for update checks, mod dependencies, and compatibility blacklists (if the mod breaks in a future version of the game).", + "$ref": "#/definitions/SemanticVersion" + }, + "Description": { + "title": "Mod description", + "description": "A short explanation of what your mod does (one or two sentences), shown in the SMAPI log.", + "type": "string", + "examples": [ "View metadata about anything by pressing a button." ] + }, + "UniqueID": { + "title": "Mod unique ID", + "description": "A unique identifier for your mod. The recommended format is \"Username.ModName\", with no spaces or special characters. SMAPI uses this for update checks, mod dependencies, and compatibility blacklists (if the mod breaks in a future version of the game). When another mod needs to reference this mod, it uses the unique ID.", + "$ref": "#/definitions/ModID" + }, + "EntryDll": { + "title": "Mod entry DLL", + "description": "The DLL filename SMAPI should load for this mod. Mutually exclusive with ContentPackFor.", + "type": "string", + "pattern": "^[a-zA-Z0-9_.-]+\\.dll$", + "examples": "LookupAnything.dll", + "@errorMessages": { + "pattern": "Invalid value; must be a filename ending with .dll." + } + }, + "ContentPackFor": { + "title": "Content pack for", + "description": "Specifies the mod which can read this content pack.", + "type": "object", + "properties": { + "UniqueID": { + "title": "Required unique ID", + "description": "The unique ID of the mod which can read this content pack.", + "$ref": "#/definitions/ModID" + }, + "MinimumVersion": { + "title": "Required minimum version", + "description": "The minimum semantic version of the mod which can read this content pack, if applicable.", + "$ref": "#/definitions/SemanticVersion" + } + }, + + "required": [ "UniqueID" ] + }, + "MinimumApiVersion": { + "title": "Minimum API version", + "description": "The minimum SMAPI version needed to use this mod. If a player tries to use the mod with an older SMAPI version, they'll see a friendly message saying they need to update SMAPI. This also serves as a proxy for the minimum game version, since SMAPI itself enforces a minimum game version.", + "$ref": "#/definitions/SemanticVersion" + }, + "Dependencies": { + "title": "Mod dependencies", + "description": "Specifies other mods to load before this mod. If a dependency is required and a player tries to use the mod without the dependency installed, the mod won't be loaded and they'll see a friendly message saying they need to install those.", + "type": "array", + "items": { + "type": "object", + "properties": { + "UniqueID": { + "title": "Dependency unique ID", + "description": "The unique ID of the mod to load first.", + "$ref": "#/definitions/ModID" + }, + "MinimumVersion": { + "title": "Dependency minimum version", + "description": "The minimum semantic version of the mod to load first, if applicable.", + "$ref": "#/definitions/SemanticVersion" + }, + "IsRequired": { + "title": "Dependency is required", + "description": "Whether the dependency is required. Default true if not specified." + } + }, + "required": [ "UniqueID" ] + } + }, + "UpdateKeys": { + "title": "Mod update keys", + "description": "Specifies where SMAPI should check for mod updates, so it can alert the user with a link to your mod page. See https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Manifest#Update_checks for more info.", + "type": "array", + "items": { + "type": "string", + "pattern": "^(Chucklefish:\\d+|Nexus:\\d+|GitHub:[A-Za-z0-9_]+/[A-Za-z0-9_]+|ModDrop:\\d+)$", + "@errorMessages": { + "pattern": "Invalid update key; see https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Manifest#Update_checks for more info." + } + } + }, + "$schema": { + "title": "Schema", + "description": "A reference to this JSON schema. Not part of the actual format, but useful for validation tools.", + "type": "string", + "const": "https://smapi.io/schemas/manifest.json" + } + }, + "definitions": { + "SemanticVersion": { + "type": "string", + "pattern": "^(?>(?:0|[1-9]\\d*))\\.(?>(?:0|[1-9]\\d*))(?>(?:\\.(?:0|[1-9]\\d*))?)(?:-(?:(?>[a-zA-Z0-9]+[\\-\\.]?)+))?$", + "$comment": "derived from SMAPI.Toolkit.SemanticVersion", + "examples": [ "1.0.0", "1.0.1-beta.2" ], + "@errorMessages": { + "pattern": "Invalid semantic version; must be formatted like 1.2.0 or 1.2.0-prerelease.tags. See https://semver.org/ for more info." + } + }, + "ModID": { + "type": "string", + "pattern": "^[a-zA-Z0-9_.-]+$", + "$comment": "derived from SMAPI.Toolkit.Utilities.PathUtilities.IsSlug", + "examples": [ "Pathoschild.LookupAnything" ] + } + }, + + "required": [ "Name", "Author", "Version", "Description", "UniqueID" ], + "oneOf": [ + { + "required": [ "EntryDll" ] + }, + { + "required": [ "ContentPackFor" ] + } + ], + "additionalProperties": false, + "@errorMessages": { + "oneOf:valid against no schemas": "Missing required field: EntryDll or ContentPackFor.", + "oneOf:valid against more than one schema": "Can't specify both EntryDll and ContentPackFor, they're mutually exclusive." + } +} |