using System; using System.Collections.Generic; 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.WebApi; using StardewModdingAPI.Web.Framework.Clients.Chucklefish; using StardewModdingAPI.Web.Framework.Clients.GitHub; using StardewModdingAPI.Web.Framework.Clients.Nexus; using StardewModdingAPI.Web.Framework.ConfigModels; using StardewModdingAPI.Web.Framework.ModRepositories; namespace StardewModdingAPI.Web.Controllers { /// Provides an API to perform mod update checks. [Produces("application/json")] [Route("api/v{version:semanticVersion}/mods")] internal class ModsApiController : Controller { /********* ** Properties *********/ /// The mod repositories which provide mod metadata. private readonly IDictionary Repositories; /// The cache in which to store mod metadata. private readonly IMemoryCache Cache; /// The number of minutes successful update checks should be cached before refetching them. private readonly int SuccessCacheMinutes; /// The number of minutes failed update checks should be cached before refetching them. private readonly int ErrorCacheMinutes; /// A regex which matches SMAPI-style semantic version. private readonly string VersionRegex; /********* ** Public methods *********/ /// Construct an instance. /// The cache in which to store mod metadata. /// The config settings for mod update checks. /// The Chucklefish API client. /// The GitHub API client. /// The Nexus API client. public ModsApiController(IMemoryCache cache, IOptions configProvider, IChucklefishClient chucklefish, IGitHubClient github, INexusClient nexus) { ModUpdateCheckConfig config = configProvider.Value; this.Cache = cache; this.SuccessCacheMinutes = config.SuccessCacheMinutes; this.ErrorCacheMinutes = config.ErrorCacheMinutes; this.VersionRegex = config.SemanticVersionRegex; this.Repositories = new IModRepository[] { new ChucklefishRepository(config.ChucklefishKey, chucklefish), new GitHubRepository(config.GitHubKey, github), new NexusRepository(config.NexusKey, nexus) } .ToDictionary(p => p.VendorKey, StringComparer.CurrentCultureIgnoreCase); } /// Fetch version metadata for the given mods. /// The mod search criteria. [HttpPost] public async Task> PostAsync([FromBody] ModSearchModel model) { ModSearchEntryModel[] searchMods = this.GetSearchMods(model).ToArray(); IDictionary mods = new Dictionary(StringComparer.CurrentCultureIgnoreCase); foreach (ModSearchEntryModel mod in searchMods) { if (string.IsNullOrWhiteSpace(mod.ID)) continue; // get latest versions ModEntryModel result = new ModEntryModel { ID = mod.ID }; IList errors = new List(); foreach (string updateKey in mod.UpdateKeys ?? new string[0]) { // fetch data ModInfoModel data = await this.GetInfoForUpdateKeyAsync(updateKey); if (data.Error != null) { errors.Add(data.Error); continue; } // handle main version if (data.Version != null) { if (!SemanticVersion.TryParse(data.Version, out ISemanticVersion version)) { errors.Add($"The update key '{updateKey}' matches a mod with invalid semantic version '{data.Version}'."); continue; } if (result.Version == null || version.IsNewerThan(new SemanticVersion(result.Version))) { result.Name = data.Name; result.Url = data.Url; result.Version = version.ToString(); } } // handle optional version if (data.PreviewVersion != null) { if (!SemanticVersion.TryParse(data.PreviewVersion, out ISemanticVersion version)) { errors.Add($"The update key '{updateKey}' matches a mod with invalid optional semantic version '{data.PreviewVersion}'."); continue; } if (result.PreviewVersion == null || version.IsNewerThan(new SemanticVersion(data.PreviewVersion))) { result.Name = result.Name ?? data.Name; result.PreviewUrl = data.Url; result.PreviewVersion = version.ToString(); } } } // fallback to preview if latest is invalid if (result.Version == null && result.PreviewVersion != null) { result.Version = result.PreviewVersion; result.Url = result.PreviewUrl; result.PreviewVersion = null; result.PreviewUrl = null; } // special cases if (mod.ID == "Pathoschild.SMAPI") { result.Name = "SMAPI"; result.Url = "https://smapi.io/"; if (result.PreviewUrl != null) result.PreviewUrl = "https://smapi.io/"; } // add result result.Errors = errors.ToArray(); mods[mod.ID] = result; } return mods; } /********* ** Private methods *********/ /// Parse a namespaced mod ID. /// The raw mod ID to parse. /// The parsed vendor key. /// The parsed mod ID. /// Returns whether the value could be parsed. private bool TryParseModKey(string raw, out string vendorKey, out string modID) { // split parts string[] parts = raw?.Split(':'); if (parts == null || parts.Length != 2) { vendorKey = null; modID = null; return false; } // parse vendorKey = parts[0].Trim(); modID = parts[1].Trim(); return true; } /// Get the mods for which the API should return data. /// The search model. private IEnumerable GetSearchMods(ModSearchModel model) { if (model == null) yield break; // yield standard entries if (model.Mods != null) { foreach (ModSearchEntryModel mod in model.Mods) yield return mod; } // yield mod update keys if backwards compatible if (model.ModKeys != null && model.ModKeys.Any() && this.ShouldBeBackwardsCompatible("2.6-beta.17")) { foreach (string updateKey in model.ModKeys.Distinct()) yield return new ModSearchEntryModel(updateKey, new[] { updateKey }); } } /// Get the mod info for an update key. /// The namespaced update key. private async Task GetInfoForUpdateKeyAsync(string updateKey) { // parse update key if (!this.TryParseModKey(updateKey, out string vendorKey, out string modID)) 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(vendorKey, out IModRepository repository)) return new ModInfoModel($"There's no mod site with key '{vendorKey}'. Expected one of [{string.Join(", ", this.Repositories.Keys)}]."); // fetch mod info return await this.Cache.GetOrCreateAsync($"{repository.VendorKey}:{modID}".ToLower(), async entry => { ModInfoModel result = await repository.GetModInfoAsync(modID); 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}'."; } entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(result.Error == null ? this.SuccessCacheMinutes : this.ErrorCacheMinutes); return result; }); } /// Get whether the API should return data in a backwards compatible way. /// The last version for which data should be backwards compatible. private bool ShouldBeBackwardsCompatible(string maxVersion) { string actualVersion = (string)this.RouteData.Values["version"]; return !new SemanticVersion(actualVersion).IsNewerThan(new SemanticVersion(maxVersion)); } } }