using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; 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; using StardewModdingAPI.Web.Framework.Caching; using StardewModdingAPI.Web.Framework.Caching.Mods; using StardewModdingAPI.Web.Framework.Caching.Wiki; using StardewModdingAPI.Web.Framework.Clients; 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.UpdateManifest; using StardewModdingAPI.Web.Framework.ConfigModels; 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 { /********* ** Fields *********/ /// The mod sites which provide mod metadata. private readonly ModSiteManager ModSites; /// The cache in which to store wiki data. private readonly IWikiCacheRepository WikiCache; /// The cache in which to store mod data. private readonly IModCacheRepository ModCache; /// The config settings for mod update checks. private readonly IOptions Config; /// The internal mod metadata list. private readonly ModDatabase ModDatabase; /********* ** Public methods *********/ /// Construct an instance. /// The web hosting environment. /// The cache in which to store wiki data. /// The cache in which to store mod metadata. /// The config settings for mod update checks. /// The Chucklefish API client. /// The CurseForge API client. /// The GitHub API client. /// The ModDrop API client. /// The Nexus API client. /// The API client for arbitrary update manifest URLs. public ModsApiController(IWebHostEnvironment environment, IWikiCacheRepository wikiCache, IModCacheRepository modCache, IOptions config, IChucklefishClient chucklefish, ICurseForgeClient curseForge, IGitHubClient github, IModDropClient modDrop, INexusClient nexus, IUpdateManifestClient updateManifest) { this.ModDatabase = new ModToolkit().GetModDatabase(Path.Combine(environment.WebRootPath, "SMAPI.metadata.json")); this.WikiCache = wikiCache; this.ModCache = modCache; this.Config = config; this.ModSites = new ModSiteManager(new IModSiteClient[] { chucklefish, curseForge, github, modDrop, nexus, updateManifest }); } /// Fetch version metadata for the given mods. /// The mod search criteria. /// The requested API version. [HttpPost] public async Task> PostAsync([FromBody] ModSearchModel? model, [FromRoute] string version) { if (model?.Mods == null) return Array.Empty(); ModUpdateCheckConfig config = this.Config.Value; // fetch wiki data WikiModEntry[] wikiData = this.WikiCache.GetWikiMods().Select(p => p.Data).ToArray(); IDictionary mods = new Dictionary(StringComparer.CurrentCultureIgnoreCase); foreach (ModSearchEntryModel mod in model.Mods) { if (string.IsNullOrWhiteSpace(mod.ID)) continue; // special case: if this is an update check for the official SMAPI repo, check the Nexus mod page for beta versions if (mod.ID == config.SmapiInfo.ID && mod.UpdateKeys.Any(key => key == config.SmapiInfo.DefaultUpdateKey) && mod.InstalledVersion?.IsPrerelease() == true) mod.AddUpdateKeys(config.SmapiInfo.AddBetaUpdateKeys); // fetch result ModEntryModel result = await this.GetModData(mod, wikiData, model.IncludeExtendedMetadata, model.ApiVersion); if (!model.IncludeExtendedMetadata && (model.ApiVersion == null || mod.InstalledVersion == null)) { result.Errors = result.Errors .Concat(new[] { $"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." }) .ToArray(); } mods[mod.ID] = result; } // return data return mods.Values; } /********* ** Private methods *********/ /// Get the metadata for a mod. /// The mod data to match. /// The wiki data. /// Whether to include extended metadata for each mod. /// The SMAPI version installed by the player. /// Returns the mod data if found, else null. private async Task GetModData(ModSearchEntryModel search, WikiModEntry[] wikiData, bool includeExtendedMetadata, ISemanticVersion? apiVersion) { // cross-reference data ModDataRecord? record = this.ModDatabase.Get(search.ID); WikiModEntry? wikiEntry = wikiData.FirstOrDefault(entry => entry.ID.Contains(search.ID.Trim(), StringComparer.OrdinalIgnoreCase)); UpdateKey[] updateKeys = this.GetUpdateKeys(search.UpdateKeys, record, wikiEntry).ToArray(); ModOverrideConfig? overrides = this.Config.Value.ModOverrides.FirstOrDefault(p => p.ID.Equals(search.ID.Trim(), StringComparison.OrdinalIgnoreCase)); bool allowNonStandardVersions = overrides?.AllowNonStandardVersions ?? false; // SMAPI versions with a '-beta' tag indicate major changes that may need beta mod versions. // This doesn't apply to normal prerelease versions which have an '-alpha' tag. bool isSmapiBeta = apiVersion != null && apiVersion.IsPrerelease() && apiVersion.PrereleaseTag.StartsWith("beta"); // get latest versions ModEntryModel result = new(search.ID); IList errors = new List(); 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', with an optional subkey like 'Nexus:541@subkey'."); continue; } // fetch data ModInfoModel data = await this.GetInfoForUpdateKeyAsync(updateKey, allowNonStandardVersions, wikiEntry?.Overrides?.ChangeRemoteVersions); if (data.Status != RemoteModStatus.Ok) { errors.Add(data.Error ?? data.Status.ToString()); continue; } // if there's only a prerelease version (e.g. from GitHub), don't override the main version ISemanticVersion? curMain = data.Version; ISemanticVersion? curPreview = data.PreviewVersion; string? curMainUrl = data.MainModPageUrl; string? curPreviewUrl = data.PreviewModPageUrl; if (curPreview == null && curMain?.IsPrerelease() == true) { curPreview = curMain; curPreviewUrl = curMainUrl; curMain = null; curMainUrl = null; } // handle versions if (this.IsNewer(curMain, main?.Version)) main = new ModEntryVersionModel(curMain, curMainUrl ?? data.Url!); if (this.IsNewer(curPreview, optional?.Version)) optional = new ModEntryVersionModel(curPreview, curPreviewUrl ?? data.Url!); } // get unofficial version 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.Url.PlainAction("Index", "Mods", absoluteUrl: true)}#{wikiEntry.Anchor}"); // get unofficial version for beta if (wikiEntry is { HasBetaInfo: true }) { if (wikiEntry.BetaCompatibility.Status == WikiCompatibilityStatus.Unofficial) { if (wikiEntry.BetaCompatibility.UnofficialVersion != null) { 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.Url.PlainAction("Index", "Mods", absoluteUrl: true)}#{wikiEntry.Anchor}") : null; } else unofficialForBeta = unofficial; } } // fallback to preview if latest is invalid if (main == null && optional != null) { main = optional; optional = null; } // special cases if (overrides?.SetUrl != null) { if (main != null) main = new(main.Version, overrides.SetUrl); if (optional != null) optional = new(optional.Version, overrides.SetUrl); } // get recommended update (if any) ISemanticVersion? installedVersion = this.ModSites.GetMappedVersion(search.InstalledVersion?.ToString(), wikiEntry?.Overrides?.ChangeLocalVersions, allowNonStandard: allowNonStandardVersions); if (apiVersion != null && installedVersion != null) { // get newer versions List updates = new List(); if (this.IsRecommendedUpdate(installedVersion, main?.Version, useBetaChannel: true)) updates.Add(main); if (this.IsRecommendedUpdate(installedVersion, optional?.Version, useBetaChannel: isSmapiBeta || installedVersion.IsPrerelease() || search.IsBroken)) updates.Add(optional); if (this.IsRecommendedUpdate(installedVersion, unofficial?.Version, useBetaChannel: true)) 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) result.Metadata = new ModExtendedMetadataModel(wikiEntry, record, main: main, optional: optional, unofficial: unofficial, unofficialForBeta: unofficialForBeta); // add result result.Errors = errors.ToArray(); return result; } /// Get whether a given version should be offered to the user as an update. /// The current semantic version. /// The target semantic version. /// Whether the user enabled the beta channel and should be offered prerelease updates. private bool IsRecommendedUpdate(ISemanticVersion currentVersion, [NotNullWhen(true)] ISemanticVersion? newVersion, bool useBetaChannel) { return newVersion != null && newVersion.IsNewerThan(currentVersion) && (useBetaChannel || !newVersion.IsPrerelease()); } /// Get whether a version is newer than an version. /// The current version. /// The other version. private bool IsNewer([NotNullWhen(true)] ISemanticVersion? current, ISemanticVersion? other) { return current != null && (other == null || other.IsOlderThan(current)); } /// Get the mod info for an update key. /// The namespaced update key. /// Whether to allow non-standard versions. /// The changes to apply to remote versions for update checks. private async Task GetInfoForUpdateKeyAsync(UpdateKey updateKey, bool allowNonStandardVersions, ChangeDescriptor? mapRemoteVersions) { if (!updateKey.LooksValid) return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, $"Invalid update key '{updateKey}'."); // get mod page IModPage page; { bool isCached = this.ModCache.TryGetMod(updateKey.Site, updateKey.ID, out Cached? cachedMod) && !this.ModCache.IsStale(cachedMod.LastUpdated, cachedMod.Data.Status == RemoteModStatus.TemporaryError ? this.Config.Value.ErrorCacheMinutes : this.Config.Value.SuccessCacheMinutes); if (isCached) page = cachedMod!.Data; else { page = await this.ModSites.GetModPageAsync(updateKey); this.ModCache.SaveMod(updateKey.Site, updateKey.ID, page); } } // get version info return this.ModSites.GetPageVersions(page, updateKey.Subkey, allowNonStandardVersions, mapRemoteVersions); } /// Get update keys based on the available mod metadata, while maintaining the precedence order. /// The specified update keys. /// The mod's entry in SMAPI's internal database. /// The mod's entry in the wiki list. private IEnumerable GetUpdateKeys(string[]? specifiedKeys, ModDataRecord? record, WikiModEntry? entry) { // get unique update keys List updateKeys = this.GetUnfilteredUpdateKeys(specifiedKeys, record, entry) .Select(UpdateKey.Parse) .Distinct() .ToList(); // apply overrides from wiki if (entry?.Overrides?.ChangeUpdateKeys?.HasChanges == true) { List newKeys = updateKeys.Select(p => p.ToString()).ToList(); entry.Overrides.ChangeUpdateKeys.Apply(newKeys); updateKeys = newKeys.Select(UpdateKey.Parse).ToList(); } // if the list has both an update key (like "Nexus:2400") and subkey (like "Nexus:2400@subkey") for the same page, the subkey takes priority { var removeKeys = new HashSet(); foreach (UpdateKey key in updateKeys) { if (key.Subkey != null) removeKeys.Add(new UpdateKey(key.Site, key.ID, null)); } if (removeKeys.Any()) updateKeys.RemoveAll(removeKeys.Contains); } return updateKeys; } /// Get every available update key based on the available mod metadata, including duplicates and keys which should be filtered. /// The specified update keys. /// The mod's entry in SMAPI's internal database. /// The mod's entry in the wiki list. private IEnumerable GetUnfilteredUpdateKeys(string[]? specifiedKeys, ModDataRecord? record, WikiModEntry? entry) { // specified update keys foreach (string key in specifiedKeys ?? Array.Empty()) { if (!string.IsNullOrWhiteSpace(key)) yield return key.Trim(); } // default update key { string? defaultKey = record?.GetDefaultUpdateKey(); if (!string.IsNullOrWhiteSpace(defaultKey)) yield return defaultKey; } // wiki metadata if (entry != null) { if (entry.NexusID.HasValue) yield return UpdateKey.GetString(ModSiteKey.Nexus, entry.NexusID.ToString()); if (entry.ModDropID.HasValue) yield return UpdateKey.GetString(ModSiteKey.ModDrop, entry.ModDropID.ToString()); if (entry.CurseForgeID.HasValue) yield return UpdateKey.GetString(ModSiteKey.CurseForge, entry.CurseForgeID.ToString()); if (entry.ChucklefishID.HasValue) yield return UpdateKey.GetString(ModSiteKey.Chucklefish, entry.ChucklefishID.ToString()); } } } }